From c078dbb92fd45c95c10ccaf360ae58e1a685cbb3 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:43:14 +0100 Subject: [PATCH 01/10] Snap to Point draws no Smart Guides labels or lines with Smart Guides off With Smart Guides off, View > Snap to Point still pulls points onto anchors and ruler guides, but it showed the magenta "anchor" label while drawing, since its targets kept the default guide style. Its targets now draw no labels or lines. --- crates/tools/src/guides/targets.rs | 6 ++++-- crates/tools/src/guides/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 41b9f517ee..f2a451f110 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -568,8 +568,10 @@ impl Targets { } /// View → Snap to Point: only these targets' anchors pull, and the ruler guides (shown and - /// unlocked, as the selection tools pick them) pull the pointer into line where they run. - pub(super) fn for_snap_to_point(self, cx: &ToolContext) -> Self { + /// unlocked, as the selection tools pick them) pull the pointer into line where they run. It + /// snaps without Smart Guides, so it draws none of their labels and lines. + pub(super) fn for_snap_to_point(mut self, cx: &ToolContext) -> Self { + self.style = GuideStyle { lines: false, labels: false, ..GuideStyle::of(cx) }; self.anchors_only().with_rulers(cx) } diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index a4f3bc49fd..1b6ddbaa12 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -952,3 +952,26 @@ fn a_drawn_point_lands_on_a_corner_of_a_glyphs_bounds() { assert!((q - tr).cross(dir).abs() < 1e-6, "angle {angle}: {q:?} is off the right edge"); } } + +/// With Smart Guides off, View → Snap to Point still pulls a drawn point onto an anchor, but +/// draws no Smart Guides: no "anchor" label, no lines. +#[test] +fn snap_to_point_without_smart_guides_shows_no_guides() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + // Within Snap to Point's 2 px of the anchor at (200, 200). + let at = Point::new(201.0, 199.0); + let off = ToolContext { smart_guides: false, snap_to_point: true, ..cx(&d, &s, &p) }; + let mut snap = DrawSnap::default(); + assert_eq!(snap.hover(&off, at, &[], None), Point::new(200.0, 200.0)); + assert!(snap.guides().is_empty(), "{:?}", snap.guides()); + assert_eq!(snap.press(&off, at, &[], None), Point::new(200.0, 200.0)); + assert!(snap.guides().is_empty(), "{:?}", snap.guides()); + // A dragged selection's grabbed point too. + let (q, ov) = Targets::snap_to_point(&off, &[]).unwrap().snap_point(at, 4.0); + assert_eq!((q, ov.len()), (Point::new(200.0, 200.0), 0), "{ov:?}"); + // Smart Guides on: the same point says "anchor". + let mut snap = DrawSnap::default(); + assert_eq!(snap.hover(&cx(&d, &s, &p), at, &[], None), Point::new(200.0, 200.0)); + assert_eq!(labels(snap.guides()), ["anchor"]); +} From e1a918636eccd216eb6ac64e922995942c8e3227 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:42:24 -0800 Subject: [PATCH 02/10] Document Info: values show in the interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel showed the values Document Info makes in English in every language. It now shows the Units, the Raster Effects resolution, each font's file, substitution and embedding permission, each image's size and linked file, each Repeat object's kind and counts (in the language's plural forms) and gradients named by their kind in the interface language. Font, face, file and swatch names stay as they are, a gradient swatch named like a kind included. document.info keeps its English rows and adds the values the panel builds them from: document.unitId, fontDetails, imageObjects and a kinds list on each gradientObjects entry. Save… and Package's report stay in English. --- crates/engine/src/cmd/docinfo.rs | 279 +++++++++++++++++++++----- crates/engine/src/tests/package.rs | 10 + crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 23 +++ crates/ui-egui/src/i18n/bs.tsv | 23 +++ crates/ui-egui/src/i18n/ca.tsv | 23 +++ crates/ui-egui/src/i18n/cs.tsv | 23 +++ crates/ui-egui/src/i18n/da.tsv | 23 +++ crates/ui-egui/src/i18n/de.tsv | 23 +++ crates/ui-egui/src/i18n/el.tsv | 23 +++ crates/ui-egui/src/i18n/es.tsv | 23 +++ crates/ui-egui/src/i18n/fi.tsv | 23 +++ crates/ui-egui/src/i18n/fr.tsv | 23 +++ crates/ui-egui/src/i18n/hi.tsv | 23 +++ crates/ui-egui/src/i18n/hu.tsv | 23 +++ crates/ui-egui/src/i18n/id.tsv | 23 +++ crates/ui-egui/src/i18n/it.tsv | 23 +++ crates/ui-egui/src/i18n/ja.tsv | 23 +++ crates/ui-egui/src/i18n/ka.tsv | 23 +++ crates/ui-egui/src/i18n/ko.tsv | 23 +++ crates/ui-egui/src/i18n/nl.tsv | 23 +++ crates/ui-egui/src/i18n/pl.tsv | 23 +++ crates/ui-egui/src/i18n/pt-br.tsv | 23 +++ crates/ui-egui/src/i18n/ru.tsv | 23 +++ crates/ui-egui/src/i18n/sv.tsv | 23 +++ crates/ui-egui/src/i18n/tests.rs | 40 ++++ crates/ui-egui/src/i18n/tr.tsv | 23 +++ crates/ui-egui/src/i18n/uk.tsv | 23 +++ crates/ui-egui/src/i18n/vi.tsv | 23 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 23 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 23 +++ crates/ui-egui/src/panels/doc_info.rs | 258 +++++++++++++++++++++++- docs/mcp.md | 15 +- docs/target-app-parity.md | 1 + 34 files changed, 1165 insertions(+), 61 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a9fb07bdf8..04ed66445e 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document: {…, units (English), unitId: the units' Preferences › Units id (points, millimeters, feetInches, …), rasterEffectsPpi}, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), fontDetails: [{font, match: exact|style|missingWithFallback|missing, family, style, file, embedding: allowed|editing|previewPrint|forbidden|null}] (each font of fonts, in order: how it was found, the face shown (null when none), that face's file name (null for a built-in face) and what its license allows), images, imageObjects: [{name, width, height, linked, file}] (the image objects in the order Linked Images and Embedded Images list them; file: what a linked image's row shows, null for an embedded one), swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients, kinds}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else its kind's name, such as Linear gradient; kinds gives each one's kind, linear|radial|conic|diamond|freeform, when it is named by its kind, else null; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", has_doc, info ), @@ -85,17 +85,65 @@ pub struct Section { /// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the /// most permissive usage bit wins, and bitmap-only fonts may not be embedded. pub fn embedding_label(fs_type: u16) -> &'static str { - if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { - "embedding not allowed" - } else if fs_type & 0x0008 != 0 { - "embedding for editing" - } else if fs_type & 0x0004 != 0 { - "embedding for preview and print" - } else { - "embedding allowed" + match embedding_id(fs_type) { + "forbidden" => "embedding not allowed", + "editing" => "embedding for editing", + "previewPrint" => "embedding for preview and print", + _ => "embedding allowed", } } +/// [`embedding_label`] as the `embedding` value of a `fontDetails` entry: `forbidden`, `editing`, +/// `previewPrint` or `allowed`. +pub fn embedding_id(fs_type: u16) -> &'static str { + if Embedding::from_fs_type(fs_type) == Embedding::Forbidden { + "forbidden" + } else if fs_type & 0x0008 != 0 { + "editing" + } else if fs_type & 0x0004 != 0 { + "previewPrint" + } else { + "allowed" + } +} + +/// A font as Font Details lists it: its `fontDetails` entry and its row. The row gives the file +/// the font is drawn from (`built in` for a face with no file) and whether its license lets it be +/// embedded; for a style the family lacks, the face shown in its place; for a font that is not +/// installed, the face shown instead, or `missing`. The entry holds the same facts as values +/// (`match`: `exact`, `style`, `missingWithFallback` or `missing`), so the panel can show them in +/// the interface language. +fn font_detail(used: &super::fonts::UsedFont) -> (Value, (String, String)) { + let db = vectorcraft_text::FontDb::global(); + let (family, style, version) = used; + let label = super::fonts::font_label(used); + // Found by any of its names (in the version the type names); a style the family lacks shows in + // another. + let resolved = db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); + let (entry, detail) = match resolved { + Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { + let file = f.path().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + let found = format!("{}; {}", file.as_deref().unwrap_or("built in"), embedding_label(f.fs_type())); + let substituted = m == vectorcraft_text::FontMatch::Style; + let entry = json!({ + "font": label, + "match": if substituted { "style" } else { "exact" }, + "family": f.family, + "style": f.style, + "file": file, + "embedding": embedding_id(f.fs_type()), + }); + (entry, if substituted { format!("substituted: shown in {} {} ({found})", f.family, f.style) } else { found }) + } + Some((f, _)) => ( + json!({ "font": label, "match": "missingWithFallback", "family": f.family, "style": f.style, "file": null, "embedding": null }), + format!("missing: shown in {} {}", f.family, f.style), + ), + None => (json!({ "font": label, "match": "missing", "family": null, "style": null, "file": null, "embedding": null }), "missing".into()), + }; + (entry, (label, detail)) +} + /// An image object as Document Info lists it: (name, width, height, linked file). type ImageRow = (String, u32, u32, Option); @@ -131,14 +179,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the -/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a -/// pattern and with a gradient. +/// `document.info` reports), its fonts and their Font Details rows, its image objects, the rows of +/// the brushes its art uses, the rows of its Repeat objects and the rows of its objects filled or +/// stroked with a spot color, with a pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, + font_details: &[(String, String)], images: &[ImageRow], brushes: &[(String, String)], repeats: &[(String, String)], @@ -192,34 +241,7 @@ fn sections( "gradients" => gradient_objects.to_vec(), "symbols" => names("symbols"), "fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(), - "fontDetails" => { - let db = vectorcraft_text::FontDb::global(); - fonts - .iter() - .map(|used| { - let (family, style, version) = used; - // Found by any of its names (in the version the type names); a style the - // family lacks shows in another. - let resolved = - db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m)); - let detail = match resolved { - Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => { - let file = - f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned()); - let found = format!("{file}; {}", embedding_label(f.fs_type())); - if m == vectorcraft_text::FontMatch::Style { - format!("substituted: shown in {} {} ({found})", f.family, f.style) - } else { - found - } - } - Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style), - None => "missing".into(), - }; - (super::fonts::font_label(used), detail) - }) - .collect() - } + "fontDetails" => font_details.to_vec(), "linkedImages" => { images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect() } @@ -277,6 +299,39 @@ fn gradient_label(p: &Paint) -> Option { } } +/// Where [`gradient_label`]'s name for gradient paint `p` comes from: `swatch:` for a +/// gradient linked to a swatch, else `kind:` (`linear`, `radial`, `conic`, `diamond` or +/// `freeform`). The two prefixes keep a swatch's name apart from a kind's name it reads like. +fn gradient_source(p: &Paint) -> Option { + match p { + Paint::Gradient(g) => Some(match g.swatch.as_deref().filter(|s| !s.is_empty()) { + Some(swatch) => format!("swatch:{swatch}"), + None => format!("kind:{}", g.gradient.kind.label().to_ascii_lowercase()), + }), + _ => None, + } +} + +/// For each of `names` (an object's [`gradient_label`]s), the kind it names when only a kind +/// gives that name: a name one of `sources` (the object's [`gradient_source`]s) gives as a kind's +/// and none gives as a swatch's, else null. A swatch named like a kind keeps its name even where +/// a gradient of that kind is listed under the same name. +fn gradient_kinds(names: &[String], sources: &[String]) -> Vec { + names + .iter() + .map(|name| { + if sources.iter().any(|s| s.strip_prefix("swatch:") == Some(name.as_str())) { + return Value::Null; + } + sources + .iter() + .filter_map(|s| s.strip_prefix("kind:")) + .find(|k| vectorcraft_color::GradientKind::parse(k).is_some_and(|kind| *name == format!("{} gradient", kind.label()))) + .map_or(Value::Null, |k| json!(k)) + }) + .collect() +} + /// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own /// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as /// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not @@ -447,9 +502,11 @@ fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool /// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n` /// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of /// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with. -fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 3] { +/// The fourth list gives where each gradient comes from: a swatch or a kind ([`gradient_source`]). +fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 4] { let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label)); - let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient]; + let gradient_from = one_label(gradient_source); + let labels: [&PaintNames<'_>; 4] = [&spot, &pattern, &gradient, &gradient_from]; let painted_runs = painted_runs(d, spots, n); let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label)); // The characters' own paints: an inline graphic draws no outline and does not take its run's @@ -615,8 +672,10 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw. - let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; + // The spot colors, the patterns and the gradients this object paints, each once, and where each gradient comes from; + // none when it does not draw. + let [used_spots, used_patterns, used_gradients, used_sources] = + if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -636,7 +695,8 @@ fn info(s: &mut Session, p: &Value) -> Result { let name = n.display_name(); gradients.extend(used_gradients.iter().cloned()); gradient_rows.push((name.clone(), used_gradients.join(", "))); - gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients })); + let kinds = gradient_kinds(&used_gradients, &used_sources); + gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients, "kinds": kinds })); *counts.entry("gradients").or_default() += 1; } if n.extras().mask.is_some() { @@ -671,6 +731,14 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); + let (font_entries, font_rows): (Vec, Vec<(String, String)>) = fonts.iter().map(font_detail).unzip(); + // The image objects in walk order, as Linked Images and Embedded Images list them: `file` is a + // linked image's row value (its path, or its file name alone when the request may not read it), + // and null for an embedded one. + let image_objects: Vec = image_rows + .iter() + .map(|(name, w, h, file)| json!({ "name": name, "width": w, "height": h, "linked": file.is_some(), "file": file })) + .collect(); // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. @@ -688,7 +756,9 @@ fn info(s: &mut Session, p: &Value) -> Result { "objects": counts, "pathLength": path_length, "fonts": font_names, + "fontDetails": font_entries, "images": images, + "imageObjects": image_objects, "symbols": symbols, "spotColors": spot, "spotColorNames": spot, @@ -710,7 +780,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); + let sections = sections(d, &out, &fonts, &font_rows, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -724,6 +794,7 @@ fn document_summary(d: &Document) -> Value { "title": d.title, "colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" }, "units": d.units.label(), + "unitId": d.units.key(), "artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::>(), "rasterEffectsPpi": d.raster_effects_ppi, }) @@ -1250,11 +1321,11 @@ mod tests { assert_eq!( i["gradientObjects"], json!([ - {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]}, - {"id": b, "name": "", "gradients": ["Radial gradient"]}, - {"id": f, "name": "", "gradients": ["Freeform gradient"]}, - {"id": t, "name": "Type", "gradients": ["Ramp"]}, - {"id": bar, "name": "Bar", "gradients": ["Ramp"]}, + {"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"], "kinds": [null, "linear"]}, + {"id": b, "name": "", "gradients": ["Radial gradient"], "kinds": ["radial"]}, + {"id": f, "name": "", "gradients": ["Freeform gradient"], "kinds": ["freeform"]}, + {"id": t, "name": "Type", "gradients": ["Ramp"], "kinds": [null]}, + {"id": bar, "name": "Bar", "gradients": ["Ramp"], "kinds": [null]}, ]) ); assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"])); @@ -1672,6 +1743,112 @@ mod tests { assert_eq!(embedding_label(0x0003), "embedding not allowed"); assert_eq!(embedding_label(0x0006), "embedding for preview and print"); assert_eq!(embedding_label(0x000c), "embedding for editing"); + // `fontDetails` names each permission by id. + assert_eq!( + [0, 0x0002, 0x0104, 0x0008, 0x0200, 0x000c].map(embedding_id), + ["allowed", "forbidden", "previewPrint", "editing", "forbidden", "editing"] + ); + } + + /// `fontDetails` gives each Font Details row's facts as values, in the rows' order: how the + /// font was found, the face shown, its file (null for a built-in face) and its embedding + /// permission. The rows and the report stay in English. + #[test] + fn info_exposes_structured_font_details_without_changing_english_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Gone", "font": "Qx Absent Family"})).unwrap(); + let i = s.execute("document.info", &json!({"category": "fontDetails"})).unwrap(); + let (entries, rows) = (i["fontDetails"].as_array().unwrap(), i["sections"][0]["rows"].as_array().unwrap()); + assert_eq!(entries.iter().map(|e| e["font"].clone()).collect::>(), rows.iter().map(|r| r[0].clone()).collect::>()); + let at = |prefix: &str| entries.iter().position(|e| e["font"].as_str().unwrap().starts_with(prefix)).unwrap(); + let regular = at("Source Sans 3 Regular"); + assert_eq!( + entries[regular], + json!({"font": "Source Sans 3 Regular", "match": "exact", "family": "Source Sans 3", "style": "Regular", "file": null, "embedding": "allowed"}) + ); + assert_eq!(rows[regular][1], "built in; embedding allowed"); + let (wide, e) = (at("Source Sans 3 Black Wide"), &entries[at("Source Sans 3 Black Wide")]); + assert_eq!((e["match"].as_str(), e["family"].as_str()), (Some("style"), Some("Source Sans 3")), "{e}"); + let file = e["file"].as_str().unwrap_or("built in"); + let fs_type = [("allowed", 0), ("forbidden", 0x0002), ("previewPrint", 0x0004), ("editing", 0x0008)] + .into_iter() + .find_map(|(id, bits)| (e["embedding"] == id).then_some(bits)) + .unwrap(); + let found = format!("{file}; {}", embedding_label(fs_type)); + assert_eq!(rows[wide][1], format!("substituted: shown in Source Sans 3 {} ({found})", e["style"].as_str().unwrap())); + let (gone, e) = (at("Qx Absent Family"), &entries[at("Qx Absent Family")]); + assert!(e["file"].is_null() && e["embedding"].is_null(), "{e}"); + match e["match"].as_str() { + Some("missingWithFallback") => { + assert_eq!(rows[gone][1], format!("missing: shown in {} {}", e["family"].as_str().unwrap(), e["style"].as_str().unwrap())) + } + Some("missing") => assert_eq!((rows[gone][1].as_str(), e["family"].is_null()), (Some("missing"), true)), + m => panic!("{m:?}"), + } + let text = s.execute("document.info", &json!({"category": "fontDetails", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("Source Sans 3 Regular: built in; embedding allowed\n"), "{text}"); + } + + /// `document.unitId` names the document's units as Preferences › Units does, beside the English + /// `units`, and `imageObjects` lists the image objects in the order Embedded Images and Linked + /// Images list them, with the file a linked one shows (null for an embedded one). + #[test] + fn info_exposes_ordered_images_and_unit_id() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.edit("Units", |d, _| { + d.units = vectorcraft_doc::Unit::Millimeters; + Ok(()) + }) + .unwrap(); + for x in [0.0, 50.0] { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + } + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!((i["document"]["units"].as_str(), i["document"]["unitId"].as_str()), (Some("Millimeters"), Some("millimeters"))); + let section = |id: &str| i["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone(); + assert!(section("document").iter().any(|r| *r == json!(["Units", "Millimeters"]))); + let embedded = section("embeddedImages"); + let objects = i["imageObjects"].as_array().unwrap(); + assert_eq!(objects.len(), 2, "{objects:?}"); + for (o, r) in objects.iter().zip(&embedded) { + assert_eq!((o["linked"].clone(), o["file"].clone(), o["name"].clone()), (json!(false), Value::Null, r[0].clone()), "{o}"); + assert_eq!(r[1], format!("{} × {} px", o["width"], o["height"])); + } + assert!(section("linkedImages").is_empty()); + } + + /// A `gradientObjects` entry's `kinds` gives the kind of each gradient listed by its kind + /// rather than by a swatch, and null for a swatch's name, even one that reads like a kind and + /// one an object's gradient of that kind shares; a type object's characters count as its own. + #[test] + fn gradient_kinds_tell_kinds_from_swatch_names() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + s.execute("swatch.new", &json!({"name": "Radial gradient", "gradient": ramp("linear")})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [r], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [r], "gradient": ramp("conic")})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [t], "gradient": ramp("diamond")})).unwrap(); + // The swatch's name and a radial gradient's kind name are listed once, as the name. + let both = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [both], "swatch": "Radial gradient"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [both], "gradient": ramp("radial")})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + let entry = |id: &Value| i["gradientObjects"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap().clone(); + assert_eq!( + (entry(&r)["gradients"].clone(), entry(&r)["kinds"].clone()), + (json!(["Radial gradient", "Conic gradient"]), json!([null, "conic"])) + ); + assert_eq!((entry(&t)["gradients"].clone(), entry(&t)["kinds"].clone()), (json!(["Diamond gradient"]), json!(["diamond"]))); + assert_eq!((entry(&both)["gradients"].clone(), entry(&both)["kinds"].clone()), (json!(["Radial gradient"]), json!([null]))); } #[test] diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index 2e57ed048c..e61c4bb4a4 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -534,6 +534,16 @@ fn the_document_info_report_has_every_category() { let i = run(&mut s, "document.info", json!({"category": "linkedImages"})); assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("linkedImages"))); assert_eq!(i["sections"][0]["rows"].as_array().unwrap().len(), 2); + // `imageObjects` lists the linked images in the rows' order with the files they show, and the + // embedded one with no file. + let linked: Vec = i["imageObjects"].as_array().unwrap().iter().filter(|o| o["linked"] == true).cloned().collect(); + for (o, r) in linked.iter().zip(i["sections"][0]["rows"].as_array().unwrap()) { + let shown = format!("{} × {} px — {}", o["width"], o["height"], o["file"].as_str().unwrap()); + assert_eq!((&o["name"], shown.as_str()), (&r[0], r[1].as_str().unwrap())); + } + assert_eq!(linked.len(), 2); + let embedded: Vec<&Value> = i["imageObjects"].as_array().unwrap().iter().filter(|o| o["linked"] == false).collect(); + assert_eq!((embedded.len(), &embedded[0]["file"], &embedded[0]["width"]), (1, &Value::Null, &json!(600))); let all = run(&mut s, "document.info", json!({})); assert_eq!(all["sections"].as_array().unwrap().len(), 13); let i = run(&mut s, "document.info", json!({"category": "brushes"})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index a6bacfc1d7..9d15012784 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -688,7 +688,7 @@ 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. `{{` and `}}` are a literal `{` and `}`. -fn fill_once(template: &str, args: &[(&str, &str)]) -> String { +pub(crate) 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(['{', '}']) { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e5e4f653ca..26f6b4e10a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2120,6 +2120,29 @@ font-technology Variable Променливи Bristle Brush Четка с косъм Pattern Brush Четка с мостра missing from the Brushes panel липсва в панела „Четки“ + built in вграден + embedding not allowed вграждането не е разрешено + embedding for editing вграждане за редактиране + embedding for preview and print вграждане за преглед и печат + embedding allowed вграждането е разрешено + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) заместен: показан с {family} {style} ({detail}) + missing: shown in {family} {style} липсва: показан с {family} {style} + missing липсва + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Линеен градиент + Radial gradient Радиален градиент + Conic gradient Конусен градиент + Diamond gradient Ромбовиден градиент + Freeform gradient Градиент със свободна форма +@plural {n} instance|{n} instances {n} екземпляр|{n} екземпляра +@plural {n} row|{n} rows {n} ред|{n} реда +@plural {n} column|{n} columns {n} колона|{n} колони Select art to make a scatter, art or pattern brush. Изберете графика, за да създадете разпръсната четка, артистична четка или четка с мостра. Brush Art Графика на четка New Calligraphic Brush Нова калиграфска четка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 42be604f40..186b750309 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3351,6 +3351,29 @@ theme Appearance Izgled Bristle Brush Četkica s čekinjama Pattern Brush Četkica uzorka missing from the Brushes panel nedostaje u panelu Četkice + built in ugrađen + embedding not allowed ugrađivanje nije dozvoljeno + embedding for editing ugrađivanje za uređivanje + embedding for preview and print ugrađivanje za pregled i štampu + embedding allowed ugrađivanje dozvoljeno + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) zamijenjen: prikazan fontom {family} {style} ({detail}) + missing: shown in {family} {style} nedostaje: prikazan fontom {family} {style} + missing nedostaje + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Linearni gradijent + Radial gradient Radijalni gradijent + Conic gradient Konusni gradijent + Diamond gradient Rombni gradijent + Freeform gradient Gradijent slobodnog oblika +@plural {n} instance|{n} instances {n} instanca|{n} instance|{n} instanci +@plural {n} row|{n} rows {n} red|{n} reda|{n} redova +@plural {n} column|{n} columns {n} kolona|{n} kolone|{n} kolona Select art to make a scatter, art or pattern brush. Odaberite crtež da napravite raspršenu, umjetničku četkicu ili četkicu uzorka. Brush Art Crtež četkice New Symbol Novi simbol diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5b5c3b49d5..65b110b64a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3115,6 +3115,29 @@ digits Malayalam Malaiàlam Pattern Borders Vores de motiu Pattern Brush Pinzell de motiu missing from the Brushes panel absent del tauler Pinzells + built in integrat + embedding not allowed incrustació no permesa + embedding for editing incrustació per a l’edició + embedding for preview and print incrustació per a la visualització i la impressió + embedding allowed incrustació permesa + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) substituït: es mostra amb {family} {style} ({detail}) + missing: shown in {family} {style} absent: es mostra amb {family} {style} + missing absent + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppp + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Degradat lineal + Radial gradient Degradat radial + Conic gradient Degradat cònic + Diamond gradient Degradat de rombe + Freeform gradient Degradat de forma lliure +@plural {n} instance|{n} instances {n} instància|{n} instàncies +@plural {n} row|{n} rows {n} fila|{n} files +@plural {n} column|{n} columns {n} columna|{n} columnes Pattern Brush Options Opcions del pinzell de motiu Pattern Editing Edició de motius Pattern Options Opcions del motiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index dcf0ad6ed4..2e48d927f7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3401,6 +3401,29 @@ theme Appearance Motiv Bristle Brush Štětinový štětec Pattern Brush Vzorkový štětec missing from the Brushes panel chybí v panelu Štětce + built in vestavěné + embedding not allowed vkládání není povoleno + embedding for editing vkládání pro úpravy + embedding for preview and print vkládání pro náhled a tisk + embedding allowed vkládání povoleno + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) nahrazeno: zobrazeno písmem {family} {style} ({detail}) + missing: shown in {family} {style} chybí: zobrazeno písmem {family} {style} + missing chybí + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Lineární přechod + Radial gradient Kruhový přechod + Conic gradient Kuželový přechod + Diamond gradient Kosočtverečný přechod + Freeform gradient Volný přechod +@plural {n} instance|{n} instances {n} instance|{n} instance|{n} instancí +@plural {n} row|{n} rows {n} řádek|{n} řádky|{n} řádků +@plural {n} column|{n} columns {n} sloupec|{n} sloupce|{n} sloupců Select art to make a scatter, art or pattern brush. Vyberte kresbu, abyste mohli vytvořit rozptylový, obrazový nebo vzorkový štětec. Brush Art Kresba štětce New Symbol Nový symbol diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 79303a36a1..4e4211551b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3134,6 +3134,29 @@ theme Appearance Udseende Bristle Brush Børstepensel Pattern Brush Mønsterpensel missing from the Brushes panel mangler i panelet Pensler + built in indbygget + embedding not allowed indlejring ikke tilladt + embedding for editing indlejring til redigering + embedding for preview and print indlejring til visning og udskrivning + embedding allowed indlejring tilladt + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) erstattet: vist med {family} {style} ({detail}) + missing: shown in {family} {style} mangler: vist med {family} {style} + missing mangler + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Lineær gradient + Radial gradient Radial gradient + Conic gradient Konisk gradient + Diamond gradient Diamantgradient + Freeform gradient Freeform-gradient +@plural {n} instance|{n} instances {n} forekomst|{n} forekomster +@plural {n} row|{n} rows {n} række|{n} rækker +@plural {n} column|{n} columns {n} kolonne|{n} kolonner Select art to make a scatter, art or pattern brush. Markér grafik for at oprette en spredningspensel, grafikpensel eller mønsterpensel. Brush Art Penselgrafik New Symbol Nyt symbol diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3f754195c8..d6df2c4f97 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2154,6 +2154,29 @@ file-filter Templates Vorlagen Bristle Brush Borstenpinsel Pattern Brush Musterpinsel missing from the Brushes panel fehlt im Bedienfeld „Pinsel“ + built in integriert + embedding not allowed Einbetten nicht erlaubt + embedding for editing Einbetten zum Bearbeiten + embedding for preview and print Einbetten für Vorschau und Druck + embedding allowed Einbetten erlaubt + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) ersetzt: angezeigt in {family} {style} ({detail}) + missing: shown in {family} {style} fehlt: angezeigt in {family} {style} + missing fehlt + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Linearer Verlauf + Radial gradient Radialer Verlauf + Conic gradient Konischer Verlauf + Diamond gradient Rautenverlauf + Freeform gradient Freiformverlauf +@plural {n} instance|{n} instances {n} Instanz|{n} Instanzen +@plural {n} row|{n} rows {n} Zeile|{n} Zeilen +@plural {n} column|{n} columns {n} Spalte|{n} Spalten Select art to make a scatter, art or pattern brush. Wählen Sie eine Grafik aus, um einen Spezial-, Bild- oder Musterpinsel zu erstellen. Brush Art Pinselgrafik New Calligraphic Brush Neuer Kalligrafiepinsel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 284944e8f9..7befe03ee8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1827,6 +1827,29 @@ font-technology Variable Μεταβλητές Bristle Brush Πινέλο με τρίχες Pattern Brush Πινέλο μοτίβου missing from the Brushes panel λείπει από τον πίνακα Πινέλα + built in ενσωματωμένη + embedding not allowed η ενσωμάτωση δεν επιτρέπεται + embedding for editing ενσωμάτωση για επεξεργασία + embedding for preview and print ενσωμάτωση για προεπισκόπηση και εκτύπωση + embedding allowed η ενσωμάτωση επιτρέπεται + {file}; {embedding} {file}· {embedding} + substituted: shown in {family} {style} ({detail}) αντικαταστάθηκε: εμφανίζεται με {family} {style} ({detail}) + missing: shown in {family} {style} λείπει: εμφανίζεται με {family} {style} + missing λείπει + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Γραμμικό ντεγκραντέ + Radial gradient Ακτινικό ντεγκραντέ + Conic gradient Κωνικό ντεγκραντέ + Diamond gradient Ρομβοειδές ντεγκραντέ + Freeform gradient Ντεγκραντέ ελεύθερης μορφής +@plural {n} instance|{n} instances {n} παρουσία|{n} παρουσίες +@plural {n} row|{n} rows {n} γραμμή|{n} γραμμές +@plural {n} column|{n} columns {n} στήλη|{n} στήλες Select art to make a scatter, art or pattern brush. Επιλέξτε σχέδιο για να δημιουργήσετε πινέλο διασποράς, σχεδίου ή μοτίβου. Brush Art Σχέδιο πινέλου New Layer… Νέο επίπεδο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28253260..fdd0e3ea5a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2192,6 +2192,29 @@ font-technology Variable Variables Bristle Brush Pincel de cerdas Pattern Brush Pincel de motivo missing from the Brushes panel falta en el panel Pinceles + built in integrada + embedding not allowed incrustación no permitida + embedding for editing incrustación para edición + embedding for preview and print incrustación para vista previa e impresión + embedding allowed incrustación permitida + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) sustituida: se muestra con {family} {style} ({detail}) + missing: shown in {family} {style} falta: se muestra con {family} {style} + missing falta + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppp + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Degradado lineal + Radial gradient Degradado radial + Conic gradient Degradado cónico + Diamond gradient Degradado de rombo + Freeform gradient Degradado libre +@plural {n} instance|{n} instances {n} instancia|{n} instancias +@plural {n} row|{n} rows {n} fila|{n} filas +@plural {n} column|{n} columns {n} columna|{n} columnas Select art to make a scatter, art or pattern brush. Selecciona una ilustración para crear un pincel de dispersión, de arte o de motivo. Brush Art Ilustración del pincel New Calligraphic Brush Nuevo pincel caligráfico diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d5aa68e741..cb52d10419 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3340,6 +3340,29 @@ theme Appearance Ulkoasu Bristle Brush Harjassivellin Pattern Brush Kuviosivellin missing from the Brushes panel puuttuu Siveltimet-paneelista + built in sisäänrakennettu + embedding not allowed upottaminen ei sallittu + embedding for editing upottaminen muokkausta varten + embedding for preview and print upottaminen esikatselua ja tulostusta varten + embedding allowed upottaminen sallittu + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) korvattu: näytetään fontilla {family} {style} ({detail}) + missing: shown in {family} {style} puuttuu: näytetään fontilla {family} {style} + missing puuttuu + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Lineaarinen liukuväri + Radial gradient Säteittäinen liukuväri + Conic gradient Kartiomainen liukuväri + Diamond gradient Vinoneliöliukuväri + Freeform gradient Vapaamuotoinen liukuväri +@plural {n} instance|{n} instances {n} esiintymä|{n} esiintymää +@plural {n} row|{n} rows {n} rivi|{n} riviä +@plural {n} column|{n} columns {n} sarake|{n} saraketta Select art to make a scatter, art or pattern brush. Valitse kuvitus, jotta voit luoda sirote-, taide- tai kuviosiveltimen. Brush Art Siveltimen kuvitus New Symbol Uusi symboli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a88d99fc8b..602cf6ae8a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2184,6 +2184,29 @@ font-technology Variable Variables Bristle Brush Forme Soies Pattern Brush Forme de motif missing from the Brushes panel absente du panneau Formes + built in intégrée + embedding not allowed incorporation interdite + embedding for editing incorporation pour la modification + embedding for preview and print incorporation pour l’aperçu et l’impression + embedding allowed incorporation autorisée + {file}; {embedding} {file}, {embedding} + substituted: shown in {family} {style} ({detail}) remplacée, affichée en {family} {style} ({detail}) + missing: shown in {family} {style} absente, affichée en {family} {style} + missing absente + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppp + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Dégradé linéaire + Radial gradient Dégradé radial + Conic gradient Dégradé conique + Diamond gradient Dégradé en losange + Freeform gradient Dégradé de forme libre +@plural {n} instance|{n} instances {n} occurrence|{n} occurrences +@plural {n} row|{n} rows {n} ligne|{n} lignes +@plural {n} column|{n} columns {n} colonne|{n} colonnes Select art to make a scatter, art or pattern brush. Sélectionnez une illustration pour créer une forme diffuse, artistique ou de motif. Brush Art Illustration de la forme New Calligraphic Brush Nouvelle forme calligraphique diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3c4a7715de..d19029a81d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3397,6 +3397,29 @@ antiAlias Sharp शार्प Pattern Borders पैटर्न सीमा Pattern Brush पैटर्न ब्रश missing from the Brushes panel ब्रश पैनल में अनुपलब्ध + built in अंतर्निहित + embedding not allowed एम्बेडिंग की अनुमति नहीं + embedding for editing संपादन के लिए एम्बेडिंग + embedding for preview and print पूर्वावलोकन और प्रिंट के लिए एम्बेडिंग + embedding allowed एम्बेडिंग की अनुमति है + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) प्रतिस्थापित: {family} {style} में दिखाया गया ({detail}) + missing: shown in {family} {style} अनुपलब्ध: {family} {style} में दिखाया गया + missing अनुपलब्ध + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient लीनियर ग्रेडिएंट + Radial gradient रेडियल ग्रेडिएंट + Conic gradient कोनिक ग्रेडिएंट + Diamond gradient डायमंड ग्रेडिएंट + Freeform gradient फ्रीफॉर्म ग्रेडिएंट +@plural {n} instance|{n} instances {n} इंस्टेंस|{n} इंस्टेंस +@plural {n} row|{n} rows {n} पंक्ति|{n} पंक्तियाँ +@plural {n} column|{n} columns {n} कॉलम|{n} कॉलम Pattern Brush Options पैटर्न ब्रश ऑप्शन्स Pattern Editing पैटर्न संपादन Pattern Options पैटर्न ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index dfe9c1a803..00ae44885b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3009,6 +3009,29 @@ file-filter Templates Sablonok Pattern Mintázat Pattern Brush Mintázatecset missing from the Brushes panel hiányzik az Ecsetek panelről + built in beépített + embedding not allowed beágyazás nem engedélyezett + embedding for editing beágyazás szerkesztéshez + embedding for preview and print beágyazás előnézethez és nyomtatáshoz + embedding allowed beágyazás engedélyezett + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) helyettesítve: megjelenítés ezzel: {family} {style} ({detail}) + missing: shown in {family} {style} hiányzik: megjelenítés ezzel: {family} {style} + missing hiányzik + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Lineáris színátmenet + Radial gradient Körkörös színátmenet + Conic gradient Kúpos színátmenet + Diamond gradient Rombusz színátmenet + Freeform gradient Szabad formájú színátmenet +@plural {n} instance|{n} instances {n} példány +@plural {n} row|{n} rows {n} sor +@plural {n} column|{n} columns {n} oszlop Pattern Brush Options Mintázatecset beállításai Pattern Editing Mintázatszerkesztés Pattern Options Mintázat beállításai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 07c762997d..3ba3dfc413 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3134,6 +3134,29 @@ theme Appearance Tampilan Bristle Brush Kuas bristle Pattern Brush Kuas pola missing from the Brushes panel tidak ada di panel Kuas + built in bawaan + embedding not allowed penyematan tidak diizinkan + embedding for editing penyematan untuk pengeditan + embedding for preview and print penyematan untuk pratinjau dan cetak + embedding allowed penyematan diizinkan + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) diganti: ditampilkan dengan {family} {style} ({detail}) + missing: shown in {family} {style} tidak ada: ditampilkan dengan {family} {style} + missing tidak ada + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Gradien linear + Radial gradient Gradien radial + Conic gradient Gradien kerucut + Diamond gradient Gradien wajik + Freeform gradient Gradien bentuk bebas +@plural {n} instance|{n} instances {n} instans +@plural {n} row|{n} rows {n} baris +@plural {n} column|{n} columns {n} kolom Select art to make a scatter, art or pattern brush. Pilih karya seni untuk membuat kuas sebar, seni, atau pola. Brush Art Karya seni kuas New Symbol Simbol baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1e12d62295..22b030d775 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2230,6 +2230,29 @@ file-filter Templates Modelli Bristle Brush Pennello a setole Pattern Brush Pennello motivo missing from the Brushes panel mancante nel pannello Pennelli + built in integrato + embedding not allowed incorporamento non consentito + embedding for editing incorporamento per la modifica + embedding for preview and print incorporamento per anteprima e stampa + embedding allowed incorporamento consentito + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) sostituito: mostrato con {family} {style} ({detail}) + missing: shown in {family} {style} mancante: mostrato con {family} {style} + missing mancante + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Sfumatura lineare + Radial gradient Sfumatura radiale + Conic gradient Sfumatura conica + Diamond gradient Sfumatura a rombo + Freeform gradient Sfumatura libera +@plural {n} instance|{n} instances {n} istanza|{n} istanze +@plural {n} row|{n} rows {n} riga|{n} righe +@plural {n} column|{n} columns {n} colonna|{n} colonne Select art to make a scatter, art or pattern brush. Seleziona la grafica per creare un pennello a dispersione, artistico o motivo. Brush Art Grafica del pennello New Calligraphic Brush Nuovo pennello calligrafico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5a976660bf..e53cc93bb9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2439,6 +2439,29 @@ antiAlias Sharp シャープ Bristle Brush 剛毛ブラシ Pattern Brush パターンブラシ missing from the Brushes panel ブラシパネルにありません + built in 内蔵 + embedding not allowed 埋め込み不可 + embedding for editing 編集用に埋め込み可 + embedding for preview and print プレビューと印刷用に埋め込み可 + embedding allowed 埋め込み可 + {file}; {embedding} {file}、{embedding} + substituted: shown in {family} {style} ({detail}) 代替: {family} {style} で表示 ({detail}) + missing: shown in {family} {style} 見つかりません: {family} {style} で表示 + missing 見つかりません + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}、{instances} + {kind}, {rows} × {cols} {kind}、{rows} × {cols} + {grid}, {instances} {grid}、{instances} + Linear gradient 線形グラデーション + Radial gradient 円形グラデーション + Conic gradient 円錐グラデーション + Diamond gradient ひし形グラデーション + Freeform gradient フリーグラデーション +@plural {n} instance|{n} instances インスタンス {n} 個 +@plural {n} row|{n} rows {n} 行 +@plural {n} column|{n} columns {n} 列 Select art to make a scatter, art or pattern brush. 散布ブラシ、アートブラシ、パターンブラシを作成するには、アートを選択します。 Brush Art ブラシのアート New Calligraphic Brush 新規絵筆ブラシ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e28903effc..0deaf80280 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2977,6 +2977,29 @@ theme Appearance იერსახე Bristle Brush ჯაგრის ფუნჯი Pattern Brush პატერნის ფუნჯი missing from the Brushes panel ფუნჯების პანელში არ არის + built in ჩაშენებული + embedding not allowed ჩაშენება არ არის დაშვებული + embedding for editing ჩაშენება რედაქტირებისთვის + embedding for preview and print ჩაშენება გადახედვისა და ბეჭდვისთვის + embedding allowed ჩაშენება დაშვებულია + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) ჩანაცვლებულია: ნაჩვენებია შრიფტით {family} {style} ({detail}) + missing: shown in {family} {style} აკლია: ნაჩვენებია შრიფტით {family} {style} + missing აკლია + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient წრფივი გრადიენტი + Radial gradient რადიალური გრადიენტი + Conic gradient კონუსური გრადიენტი + Diamond gradient რომბისებრი გრადიენტი + Freeform gradient თავისუფალი ფორმის გრადიენტი +@plural {n} instance|{n} instances {n} ეგზემპლარი +@plural {n} row|{n} rows {n} მწკრივი +@plural {n} column|{n} columns {n} სვეტი Select art to make a scatter, art or pattern brush. მონიშნეთ ნახატი გაფანტვის, მხატვრული ან პატერნის ფუნჯის შესაქმნელად. Brush Art ფუნჯის ნახატი New Symbol ახალი სიმბოლო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0cf9100b0b..4d44b1212c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2429,6 +2429,29 @@ antiAlias Sharp 날카롭게 Bristle Brush 강모 브러시 Pattern Brush 패턴 브러시 missing from the Brushes panel 브러시 패널에 없음 + built in 내장 + embedding not allowed 포함 허용 안 됨 + embedding for editing 편집용 포함 + embedding for preview and print 미리 보기 및 인쇄용 포함 + embedding allowed 포함 허용 + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) 대체됨: {family} {style}(으)로 표시({detail}) + missing: shown in {family} {style} 없음: {family} {style}(으)로 표시 + missing 없음 + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient 선형 그레이디언트 + Radial gradient 방사형 그레이디언트 + Conic gradient 원뿔형 그레이디언트 + Diamond gradient 다이아몬드 그레이디언트 + Freeform gradient 자유 형태 그레이디언트 +@plural {n} instance|{n} instances 인스턴스 {n}개 +@plural {n} row|{n} rows {n}행 +@plural {n} column|{n} columns {n}열 Select art to make a scatter, art or pattern brush. 흩뿌리기, 아트 또는 패턴 브러시를 만들려면 아트웍을 선택하세요. Brush Art 브러시 아트 New Calligraphic Brush 새 캘리그래피 브러시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3cb3c7c5ed..317942dc26 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3650,6 +3650,29 @@ theme Appearance Vormgeving Bristle Brush Borstelpenseel Pattern Brush Patroonpenseel missing from the Brushes panel ontbreekt in het deelvenster Penselen + built in ingebouwd + embedding not allowed insluiten niet toegestaan + embedding for editing insluiten voor bewerken + embedding for preview and print insluiten voor voorvertoning en afdrukken + embedding allowed insluiten toegestaan + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) vervangen: weergegeven in {family} {style} ({detail}) + missing: shown in {family} {style} ontbreekt: weergegeven in {family} {style} + missing ontbreekt + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Lineair verloop + Radial gradient Radiaal verloop + Conic gradient Conisch verloop + Diamond gradient Ruitverloop + Freeform gradient Vrijvormverloop +@plural {n} instance|{n} instances {n} exemplaar|{n} exemplaren +@plural {n} row|{n} rows {n} rij|{n} rijen +@plural {n} column|{n} columns {n} kolom|{n} kolommen Select art to make a scatter, art or pattern brush. Selecteer illustratie om een verspreidings-, art- of patroonpenseel te maken. Brush Art Penseelillustratie New Symbol Nieuw symbool diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 838d4c2719..2d864b1604 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3280,6 +3280,29 @@ theme Appearance Wygląd Bristle Brush Pędzel włosiankowy Pattern Brush Pędzel wzorka missing from the Brushes panel brak w panelu Pędzle + built in wbudowany + embedding not allowed osadzanie niedozwolone + embedding for editing osadzanie do edycji + embedding for preview and print osadzanie do podglądu i drukowania + embedding allowed osadzanie dozwolone + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) zastąpiony: wyświetlany krojem {family} {style} ({detail}) + missing: shown in {family} {style} brak: wyświetlany krojem {family} {style} + missing brak + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Gradient liniowy + Radial gradient Gradient promienisty + Conic gradient Gradient stożkowy + Diamond gradient Gradient rombowy + Freeform gradient Gradient dowolny +@plural {n} instance|{n} instances {n} wystąpienie|{n} wystąpienia|{n} wystąpień +@plural {n} row|{n} rows {n} wiersz|{n} wiersze|{n} wierszy +@plural {n} column|{n} columns {n} kolumna|{n} kolumny|{n} kolumn Select art to make a scatter, art or pattern brush. Zaznacz kompozycję, aby utworzyć pędzel rozproszony, artystyczny lub wzorka. Brush Art Kompozycja pędzla New Symbol Nowy symbol diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c3160ec6e1..3be2cfe45b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1801,6 +1801,29 @@ antiAlias Sharp Nítido Bristle Brush Pincel de Cerdas Pattern Brush Pincel de Padrão missing from the Brushes panel ausente do painel Pincéis + built in integrada + embedding not allowed incorporação não permitida + embedding for editing incorporação para edição + embedding for preview and print incorporação para visualização e impressão + embedding allowed incorporação permitida + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) substituída: exibida em {family} {style} ({detail}) + missing: shown in {family} {style} ausente: exibida em {family} {style} + missing ausente + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppp + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Gradiente linear + Radial gradient Gradiente radial + Conic gradient Gradiente cônico + Diamond gradient Gradiente losango + Freeform gradient Gradiente de forma livre +@plural {n} instance|{n} instances {n} instância|{n} instâncias +@plural {n} row|{n} rows {n} linha|{n} linhas +@plural {n} column|{n} columns {n} coluna|{n} colunas Select art to make a scatter, art or pattern brush. Selecione arte para criar um pincel de dispersão, de arte ou de padrão. Brush Art Arte do Pincel New Calligraphic Brush Novo Pincel Caligráfico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fd32a373a8..ca829ca2bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2217,6 +2217,29 @@ file-filter Templates Шаблоны Bristle Brush Щетинная кисть Pattern Brush Узорная кисть missing from the Brushes panel отсутствует в панели «Кисти» + built in встроенный + embedding not allowed внедрение запрещено + embedding for editing внедрение для редактирования + embedding for preview and print внедрение для просмотра и печати + embedding allowed внедрение разрешено + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) заменен: показан шрифтом {family} {style} ({detail}) + missing: shown in {family} {style} отсутствует: показан шрифтом {family} {style} + missing отсутствует + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Линейный градиент + Radial gradient Радиальный градиент + Conic gradient Конический градиент + Diamond gradient Ромбовидный градиент + Freeform gradient Произвольный градиент +@plural {n} instance|{n} instances {n} экземпляр|{n} экземпляра|{n} экземпляров +@plural {n} row|{n} rows {n} строка|{n} строки|{n} строк +@plural {n} column|{n} columns {n} столбец|{n} столбца|{n} столбцов Select art to make a scatter, art or pattern brush. Выделите рисунок, чтобы создать кисть-распыление, художественную или узорную кисть. Brush Art Рисунок кисти New Calligraphic Brush Новая каллиграфическая кисть diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fe938da2fd..359a4b30ed 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2131,6 +2131,29 @@ font-technology Variable Variabla Bristle Brush Borstpensel Pattern Brush Mönsterpensel missing from the Brushes panel saknas i panelen Penslar + built in inbyggt + embedding not allowed inbäddning inte tillåten + embedding for editing inbäddning för redigering + embedding for preview and print inbäddning för förhandsvisning och utskrift + embedding allowed inbäddning tillåten + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) ersatt: visas med {family} {style} ({detail}) + missing: shown in {family} {style} saknas: visas med {family} {style} + missing saknas + {width} × {height} px {width} × {height} px + {size} — {file} {size} – {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Linjär gradient + Radial gradient Radiell gradient + Conic gradient Konisk gradient + Diamond gradient Rombgradient + Freeform gradient Fri gradient +@plural {n} instance|{n} instances {n} instans|{n} instanser +@plural {n} row|{n} rows {n} rad|{n} rader +@plural {n} column|{n} columns {n} kolumn|{n} kolumner Select art to make a scatter, art or pattern brush. Markera grafik för att skapa en spridnings-, konst- eller mönsterpensel. Brush Art Penselgrafik New Calligraphic Brush Ny kalligrafisk pensel diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4ac03011ac..95f729da90 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -364,6 +364,46 @@ fn document_info_labels_are_in_complete_catalogs() { } } +/// The values Document Info makes and shows in the interface language: the font states and +/// embedding permissions, the templates of the font, image, raster effects and Repeat object +/// values, the names of gradients named by their kind, and the counted instances, rows and columns. +#[test] +fn document_info_values_are_in_complete_catalogs() { + const VALUES: [&str; 20] = [ + "built in", + "embedding not allowed", + "embedding for editing", + "embedding for preview and print", + "embedding allowed", + "{file}; {embedding}", + "substituted: shown in {family} {style} ({detail})", + "missing: shown in {family} {style}", + "missing", + "{width} × {height} px", + "{size} — {file}", + "{n} ppi", + "{kind}, {instances}", + "{kind}, {rows} × {cols}", + "{grid}, {instances}", + "Linear gradient", + "Radial gradient", + "Conic gradient", + "Diamond gradient", + "Freeform gradient", + ]; + const COUNTED: [(&str, &str); 3] = [("{n} instance", "{n} instances"), ("{n} row", "{n} rows"), ("{n} column", "{n} columns")]; + let gradient_kinds: Vec = vectorcraft_color::GradientKind::ALL.iter().map(|k| format!("{} gradient", k.label())).collect(); + assert!(gradient_kinds.iter().all(|k| VALUES.contains(&k.as_str())), "{gradient_kinds:?}"); + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + for value in VALUES { + assert!(lang.catalog().plain(value).is_some(), "{} is missing {value}", lang.code); + } + for (one, other) in COUNTED { + assert!(lang.catalog().plural(one, other, 0).is_some(), "{} is missing the row for {other}", lang.code); + } + } +} + /// The Character panel's Anti-aliasing Method label and its choices, with the Sharp method's /// own `antiAlias` row apart from the plain "Sharp" corner row. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95f8b7f128..8b189ba943 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6118,6 +6118,29 @@ theme Appearance Görünüm New Brush Yeni Fırça Pattern Brush Desen Fırçası missing from the Brushes panel Fırçalar panelinde yok + built in yerleşik + embedding not allowed gömmeye izin verilmiyor + embedding for editing düzenleme için gömme + embedding for preview and print önizleme ve yazdırma için gömme + embedding allowed gömmeye izin veriliyor + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) yerine konan: {family} {style} ile gösteriliyor ({detail}) + missing: shown in {family} {style} eksik: {family} {style} ile gösteriliyor + missing eksik + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Doğrusal degrade + Radial gradient Radyal degrade + Conic gradient Konik degrade + Diamond gradient Elmas degrade + Freeform gradient Serbest biçimli degrade +@plural {n} instance|{n} instances {n} örnek +@plural {n} row|{n} rows {n} satır +@plural {n} column|{n} columns {n} sütun Pick Color From: Rengi Buradan Al: Scatter Brush Serpme Fırçası Select art to make a scatter, art or pattern brush. Bir serpme, resim veya desen fırçası oluşturmak için çizim seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a62048dbe6..6568fcde9a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2222,6 +2222,29 @@ file-filter Templates Шаблони Bristle Brush Щетинний пензель Pattern Brush Візерунковий пензель missing from the Brushes panel відсутній у панелі «Пензлі» + built in вбудований + embedding not allowed вбудовування заборонено + embedding for editing вбудовування для редагування + embedding for preview and print вбудовування для перегляду й друку + embedding allowed вбудовування дозволено + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) замінено: показано шрифтом {family} {style} ({detail}) + missing: shown in {family} {style} відсутній: показано шрифтом {family} {style} + missing відсутній + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Лінійний градієнт + Radial gradient Радіальний градієнт + Conic gradient Конічний градієнт + Diamond gradient Ромбоподібний градієнт + Freeform gradient Довільний градієнт +@plural {n} instance|{n} instances {n} екземпляр|{n} екземпляри|{n} екземплярів +@plural {n} row|{n} rows {n} рядок|{n} рядки|{n} рядків +@plural {n} column|{n} columns {n} стовпець|{n} стовпці|{n} стовпців Select art to make a scatter, art or pattern brush. Виділіть малюнок, щоб створити пензель-розпорошення, художній або візерунковий пензель. Brush Art Малюнок пензля New Calligraphic Brush Новий каліграфічний пензель diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5727398643..42039308aa 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2826,6 +2826,29 @@ digits Hindi Hindi Pattern Họa tiết Pattern Brush Cọ họa tiết missing from the Brushes panel không có trong bảng Cọ + built in tích hợp sẵn + embedding not allowed không cho phép nhúng + embedding for editing nhúng để chỉnh sửa + embedding for preview and print nhúng để xem trước và in + embedding allowed cho phép nhúng + {file}; {embedding} {file}; {embedding} + substituted: shown in {family} {style} ({detail}) đã thay thế: hiển thị bằng {family} {style} ({detail}) + missing: shown in {family} {style} thiếu: hiển thị bằng {family} {style} + missing thiếu + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind}, {instances} + {kind}, {rows} × {cols} {kind}, {rows} × {cols} + {grid}, {instances} {grid}, {instances} + Linear gradient Chuyển sắc tuyến tính + Radial gradient Chuyển sắc hướng tâm + Conic gradient Chuyển sắc hình nón + Diamond gradient Chuyển sắc hình thoi + Freeform gradient Chuyển sắc tự do +@plural {n} instance|{n} instances {n} phiên bản +@plural {n} row|{n} rows {n} hàng +@plural {n} column|{n} columns {n} cột Pattern Brush Options Tùy chọn cọ họa tiết Pattern Editing Chỉnh sửa họa tiết Pattern Options Tùy chọn 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 cf2b6c9312..819c40a8ae 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2087,6 +2087,29 @@ file-filter Templates 模板 Bristle Brush 鬃毛画笔 Pattern Brush 图案画笔 missing from the Brushes panel “画笔”面板中没有 + built in 内置 + embedding not allowed 不允许嵌入 + embedding for editing 允许嵌入以供编辑 + embedding for preview and print 允许嵌入以供预览和打印 + embedding allowed 允许嵌入 + {file}; {embedding} {file};{embedding} + substituted: shown in {family} {style} ({detail}) 已替换:以 {family} {style} 显示 ({detail}) + missing: shown in {family} {style} 缺失:以 {family} {style} 显示 + missing 缺失 + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind},{instances} + {kind}, {rows} × {cols} {kind},{rows} × {cols} + {grid}, {instances} {grid},{instances} + Linear gradient 线性渐变 + Radial gradient 径向渐变 + Conic gradient 锥形渐变 + Diamond gradient 菱形渐变 + Freeform gradient 自由渐变 +@plural {n} instance|{n} instances {n} 个实例 +@plural {n} row|{n} rows {n} 行 +@plural {n} column|{n} columns {n} 列 Select art to make a scatter, art or pattern brush. 选择图稿以创建散布画笔、艺术画笔或图案画笔。 Brush Art 画笔图稿 New Calligraphic Brush 新建书法画笔 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6bf831faa1..a51877a8cc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3286,6 +3286,29 @@ file-filter Templates 範本 Bristle Brush 鬃毛筆刷 Pattern Brush 圖樣筆刷 missing from the Brushes panel 「筆刷」面板中沒有 + built in 內建 + embedding not allowed 不允許內嵌 + embedding for editing 允許內嵌以供編輯 + embedding for preview and print 允許內嵌以供預覽和列印 + embedding allowed 允許內嵌 + {file}; {embedding} {file};{embedding} + substituted: shown in {family} {style} ({detail}) 已替代:以 {family} {style} 顯示 ({detail}) + missing: shown in {family} {style} 遺失:以 {family} {style} 顯示 + missing 遺失 + {width} × {height} px {width} × {height} px + {size} — {file} {size} — {file} + {n} ppi {n} ppi + {kind}, {instances} {kind},{instances} + {kind}, {rows} × {cols} {kind},{rows} × {cols} + {grid}, {instances} {grid},{instances} + Linear gradient 線性漸層 + Radial gradient 放射狀漸層 + Conic gradient 錐形漸層 + Diamond gradient 菱形漸層 + Freeform gradient 自由漸層 +@plural {n} instance|{n} instances {n} 個實例 +@plural {n} row|{n} rows {n} 列 +@plural {n} column|{n} columns {n} 欄 Select art to make a scatter, art or pattern brush. 選取圖稿以製作散佈筆刷、藝術筆刷或圖樣筆刷。 Brush Art 筆刷圖稿 New Calligraphic Brush 新增書法筆刷 diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index 1d4db19979..64144a2150 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -72,16 +72,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if rows.is_empty() { widgets::dim_label(ui, tl!("None")); } - let named = named_rows(s["id"].as_str().unwrap_or_default(), rows); + let id = s["id"].as_str().unwrap_or_default(); + let named = named_rows(id, rows); for (k, r) in rows.iter().enumerate() { let (label, value) = (r[0].as_str().unwrap_or_default(), r[1].as_str().unwrap_or_default()); - let label = super::label_or_name(label, !named.contains(&k)); if value.is_empty() { - widgets::dim_name(ui, label); - } else if s["id"] == "brushes" { - row(ui, label, brush_value(value).into()); + widgets::dim_name(ui, super::label_or_name(label, !named.contains(&k))); } else { - row(ui, label, value.into()); + let shown = shown_value(&i, id, k, (label, value), named.contains(&k)).unwrap_or_else(|| value.to_string()); + row(ui, super::label_or_name(label, !named.contains(&k)), shown); } } } @@ -102,6 +101,127 @@ fn brush_value(value: &str) -> &str { } } +/// Row `k` of Document Info section `id` (`label` and `value` as `document.info` gives them, the +/// label a name when `named`) with its value in the interface language, built from what +/// `document.info` (`info`) gives as values: the +/// units, the raster effects resolution, each brush's type, each font's file, match and +/// embedding permission, each image's size and file, each Repeat object's kind and counts, and the +/// kinds of the gradients no swatch names. Names (fonts, files, faces, swatches) stay as they are. +/// None when the row's value is shown as `document.info` gives it, or the values do not match the +/// row. +fn shown_value(info: &Value, id: &str, k: usize, (label, value): (&str, &str), named: bool) -> Option { + let entry = |list: &str| info[list].get(k).filter(|e| e["name"] == label || e["font"] == label); + match id { + "document" if !named => match label { + "Units" => { + let unit = info["document"]["unitId"].as_str()?; + vectorcraft_doc::Unit::EAST_ASIAN.into_iter().find(|u| u.key() == unit).map(|u| tl!(u.label()).to_string()) + } + "Raster Effects" => { + let ppi = info["document"]["rasterEffectsPpi"].as_f64()?; + Some(fill(tl!("{n} ppi"), &[("n", &ppi.to_string())])) + } + _ => None, + }, + "brushes" => Some(brush_value(value).to_string()), + "fontDetails" => font_detail_value(entry("fontDetails")?), + "linkedImages" | "embeddedImages" => { + let linked = id == "linkedImages"; + let image = info["imageObjects"].as_array()?.iter().filter(|o| o["linked"] == linked).nth(k).filter(|o| o["name"] == label)?; + image_value(image) + } + "repeats" => repeat_value(entry("repeatObjects")?), + "gradients" => gradient_value(entry("gradientObjects")?), + _ => None, + } +} + +/// [`crate::i18n::fill_once`]: a name that holds `{…}` text stays as it is. +fn fill(template: &str, args: &[(&str, &str)]) -> String { + crate::i18n::fill_once(template, args) +} + +/// A `fontDetails` entry as Font Details shows it: the font's file (or that it is built in) and +/// what its license allows, the face shown in place of a style the family lacks, the face shown in +/// place of a font that is not installed, or that it is missing. +fn font_detail_value(e: &Value) -> Option { + let found = || { + let embedding = match e["embedding"].as_str()? { + "forbidden" => tl!("embedding not allowed"), + "editing" => tl!("embedding for editing"), + "previewPrint" => tl!("embedding for preview and print"), + "allowed" => tl!("embedding allowed"), + _ => return None, + }; + let file = e["file"].as_str().unwrap_or(tl!("built in")); + Some(fill(tl!("{file}; {embedding}"), &[("file", file), ("embedding", embedding)])) + }; + let face = || Some((e["family"].as_str()?, e["style"].as_str()?)); + match e["match"].as_str()? { + "exact" => found(), + "style" => { + let ((family, style), detail) = (face()?, found()?); + Some(fill(tl!("substituted: shown in {family} {style} ({detail})"), &[("family", family), ("style", style), ("detail", &detail)])) + } + "missingWithFallback" => { + let (family, style) = face()?; + Some(fill(tl!("missing: shown in {family} {style}"), &[("family", family), ("style", style)])) + } + "missing" => Some(tl!("missing").to_string()), + _ => None, + } +} + +/// An `imageObjects` entry as Linked Images and Embedded Images show it: its size in pixels, and +/// for a linked image the file it shows. +fn image_value(o: &Value) -> Option { + let size = fill(tl!("{width} × {height} px"), &[("width", &o["width"].as_u64()?.to_string()), ("height", &o["height"].as_u64()?.to_string())]); + if o["linked"] == true { Some(fill(tl!("{size} — {file}"), &[("size", &size), ("file", o["file"].as_str()?)])) } else { Some(size) } +} + +/// A `repeatObjects` entry as Repeat Objects shows it: its kind and the number of instances it +/// draws, or for a grid its rows and columns, followed by the number it draws when that is fewer. +fn repeat_value(e: &Value) -> Option { + let kind = match e["type"].as_str()? { + "radial" => tl!("Radial Repeat"), + "grid" => tl!("Grid Repeat"), + "mirror" => tl!("Mirror Repeat"), + _ => return None, + }; + let n = e["instances"].as_u64()?; + let instances = crate::i18n::tn(n, "{n} instance", "{n} instances"); + if e["type"] != "grid" { + return Some(fill(tl!("{kind}, {instances}"), &[("kind", kind), ("instances", &instances)])); + } + let (rows, cols) = (e["rows"].as_u64()?, e["cols"].as_u64()?); + let (shown_rows, shown_cols) = (crate::i18n::tn(rows, "{n} row", "{n} rows"), crate::i18n::tn(cols, "{n} column", "{n} columns")); + let grid = fill(tl!("{kind}, {rows} × {cols}"), &[("kind", kind), ("rows", &shown_rows), ("cols", &shown_cols)]); + Some(if n < rows.saturating_mul(cols) { fill(tl!("{grid}, {instances}"), &[("grid", &grid), ("instances", &instances)]) } else { grid }) +} + +/// A `gradientObjects` entry as Gradient Objects shows it: its gradients, a swatch's name as it +/// is and a gradient named by its kind in the interface language. +fn gradient_value(e: &Value) -> Option { + let (names, kinds) = (e["gradients"].as_array()?, e["kinds"].as_array()?); + if names.len() != kinds.len() { + return None; + } + let shown: Option> = names + .iter() + .zip(kinds) + .map(|(name, kind)| match kind.as_str() { + None => name.as_str(), + Some("linear") => Some(tl!("Linear gradient")), + Some("radial") => Some(tl!("Radial gradient")), + Some("conic") => Some(tl!("Conic gradient")), + Some("diamond") => Some(tl!("Diamond gradient")), + Some("freeform") => Some(tl!("Freeform gradient")), + Some(_) => name.as_str(), + }) + .collect(); + Some(shown?.join(", ")) +} + /// The rows of Document Info section `id` labelled by a name (an artboard, a style, a swatch, a /// font, an image, a repeat object) rather than an interface label: every row of the lists, and in Document the /// artboards, which follow the Artboards count. Names are shown as they are. @@ -337,4 +457,130 @@ mod tests { let report = app.session.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].to_string(); assert!(report.contains("Charcoal: Art Brush"), "{report}"); } + + /// Section `id`'s rows as the panel shows them for `info`: each label and its shown value. + fn shown_rows(info: &Value, id: &str) -> Vec<(String, String)> { + let section = info["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap_or_else(|| panic!("no {id}")); + let rows = section["rows"].as_array().unwrap(); + let named = named_rows(id, rows); + rows.iter() + .enumerate() + .map(|(k, r)| { + let (label, value) = (r[0].as_str().unwrap(), r[1].as_str().unwrap()); + (label.to_string(), shown_value(info, id, k, (label, value), named.contains(&k)).unwrap_or_else(|| value.to_string())) + }) + .collect() + } + + /// The values Document Info makes (the units, the raster effects resolution, each font's file + /// and state, each image's size, each Repeat object's kind and counts, and gradients named by + /// their kind) show in the interface language, from the same `document.info` after a language + /// change; font, face, file and swatch names stay as they are, also with Selection Only, and + /// `document.info` and the saved report stay in English. + #[test] + fn values_follow_interface_language_and_keep_names() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 400, "units": "Millimeters"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 120, "text": "Gone", "font": "Qx Absent Family"})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 200, "width": 20, "height": 10})).unwrap()["id"].clone() + }; + let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}); + app.session.execute("swatch.new", &json!({"name": "Linear gradient", "gradient": ramp("radial")})).unwrap(); + let glow = rect(&mut app, 0.0); + app.session.execute("paint.setFill", &json!({"ids": [glow], "swatch": "Linear gradient"})).unwrap(); + app.session.execute("paint.setStroke", &json!({"ids": [glow], "gradient": ramp("radial")})).unwrap(); + app.session.execute("layer.setProps", &json!({"id": glow, "name": "Glow"})).unwrap(); + let star = rect(&mut app, 50.0); + app.session.execute("object.repeat.radial", &json!({"ids": [star], "instances": 6})).unwrap(); + let tile = rect(&mut app, 100.0); + app.session.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 1})).unwrap(); + let pic = rect(&mut app, 150.0); + app.session.execute("select.set", &json!({"ids": [pic]})).unwrap(); + app.session.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + app.session.execute("select.set", &json!({"ids": []})).unwrap(); + let info = app.session.execute("document.info", &json!({})).unwrap(); + let value = |rows: &[(String, String)], label: &str| { + rows.iter().find(|(l, _)| l == label).unwrap_or_else(|| panic!("no {label} in {rows:?}")).1.clone() + }; + let english = |id: &str| shown_rows(&info, id); + assert_eq!((value(&english("document"), "Units"), value(&english("document"), "Raster Effects")), ("Millimeters".into(), "72 ppi".into())); + assert_eq!(value(&english("fontDetails"), "Source Sans 3 Regular"), "built in; embedding allowed"); + assert_eq!(value(&english("gradients"), "Glow"), "Linear gradient, Radial gradient"); + let repeats = english("repeats"); + assert_eq!((repeats[0].1.as_str(), repeats[1].1.as_str()), ("Radial Repeat, 6 instances", "Grid Repeat, 2 rows × 1 column")); + // Every value the panel builds reads as `document.info`'s English row. + for s in info["sections"].as_array().unwrap() { + let id = s["id"].as_str().unwrap(); + let rows: Vec<(String, String)> = + s["rows"].as_array().unwrap().iter().map(|r| (r[0].as_str().unwrap().into(), r[1].as_str().unwrap().into())).collect(); + assert_eq!(shown_rows(&info, id), rows, "{id}"); + } + let Some(de) = crate::i18n::Lang::from_code("de") else { panic!("no German catalog") }; + crate::i18n::set_current(de); + let german = |id: &str| shown_rows(&info, id); + let (document, fonts, images, gradients, repeats) = + (german("document"), german("fontDetails"), german("embeddedImages"), german("gradients"), german("repeats")); + let painted = crate::tests_labels::painted_text(&mut app, show); + app.session.execute("select.set", &json!({"ids": [glow]})).unwrap(); + let selected = shown_rows(&app.session.execute("document.info", &json!({"selectionOnly": true})).unwrap(), "gradients"); + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + app.services.write = Some(Box::new(move |_: &str, b: &[u8]| { + w.borrow_mut().extend_from_slice(b); + Ok(()) + })); + app.run("docInfo.save", json!({"path": "/tmp/info.txt", "category": "gradients", "selectionOnly": true})).unwrap(); + crate::i18n::set_current(crate::i18n::Lang::EN); + let saved = String::from_utf8(written.borrow().clone()).unwrap(); + assert_eq!((value(&document, "Units"), value(&document, "Raster Effects")), ("Millimeter".into(), "72 ppi".into())); + assert!(painted.contains("Millimeter") && !painted.contains("Millimeters"), "{painted}"); + assert_eq!(value(&fonts, "Source Sans 3 Regular"), "integriert; Einbetten erlaubt"); + let wide = value(&fonts, "Source Sans 3 Black Wide"); + assert!(wide.starts_with("ersetzt: angezeigt in Source Sans 3 ") && wide.ends_with("Einbetten erlaubt)"), "{wide}"); + let gone = fonts.iter().find(|(l, _)| l.starts_with("Qx Absent Family")).unwrap_or_else(|| panic!("{fonts:?}")).1.clone(); + assert!(gone == "fehlt" || gone.starts_with("fehlt: angezeigt in "), "{gone}"); + assert_eq!(images.len(), 1); + assert!(images[0].1.ends_with(" px") && images[0].1.contains(" × "), "{images:?}"); + // A swatch named like a kind keeps its name; the gradient named by its kind is translated. + assert_eq!(value(&gradients, "Glow"), "Linear gradient, Radialer Verlauf"); + assert_eq!(value(&selected, "Glow"), "Linear gradient, Radialer Verlauf"); + assert_eq!((repeats[0].1.as_str(), repeats[1].1.as_str()), ("Radiale Wiederholung, 6 Instanzen", "Rasterwiederholung, 2 Zeilen × 1 Spalte")); + assert!(saved.contains("(the selection only)") && saved.contains("\nGRADIENT OBJECTS\nGlow: Linear gradient, Radial gradient\n"), "{saved}"); + let report = app.session.execute("document.info", &json!({"format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(report.contains("Units: Millimeters\n") && report.contains("Radial Repeat, 6 instances"), "{report}"); + } + + /// A Repeat object's counts take each language's plural forms, and a grid that draws fewer + /// instances than its rows and columns give is followed by the number it draws. + #[test] + fn repeat_value_uses_locale_plural_forms_and_drawn_count() { + let radial = |n: u64| json!({"name": "R", "type": "radial", "instances": n}); + let grid = json!({"name": "G", "type": "grid", "rows": 500, "cols": 500, "instances": 125_000}); + let in_lang = |code: &str, e: &Value| { + let Some(lang) = crate::i18n::Lang::from_code(code) else { panic!("no {code} catalog") }; + crate::i18n::set_current(lang); + let shown = repeat_value(e); + crate::i18n::set_current(crate::i18n::Lang::EN); + shown.unwrap_or_else(|| panic!("{e}")) + }; + assert_eq!(in_lang("en", &radial(1)), "Radial Repeat, 1 instance"); + assert_eq!(in_lang("en", &grid), "Grid Repeat, 500 rows × 500 columns, 125000 instances"); + assert_eq!( + [1, 3, 5, 21].map(|n| in_lang("ru", &radial(n))), + [ + "Радиальный повтор, 1 экземпляр", + "Радиальный повтор, 3 экземпляра", + "Радиальный повтор, 5 экземпляров", + "Радиальный повтор, 21 экземпляр" + ] + ); + assert_eq!(in_lang("ru", &grid), "Повтор сеткой, 500 строк × 500 столбцов, 125000 экземпляров"); + assert_eq!([1, 2].map(|n| in_lang("ja", &radial(n))), ["ラジアルリピート、インスタンス 1 個", "ラジアルリピート、インスタンス 2 個"]); + // A full grid shows its rows and columns alone; an unknown kind is shown as `document.info` gives it. + assert_eq!(in_lang("de", &json!({"type": "grid", "rows": 1, "cols": 3, "instances": 3})), "Rasterwiederholung, 1 Zeile × 3 Spalten"); + assert_eq!(repeat_value(&json!({"type": "spiral", "instances": 2})), None); + } } diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..e732813e86 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7154,7 +7154,14 @@ result carries the same files as a zip (`{name: ".zip", dataBase64}`, entr document, objects, graphicStyles, brushes, spotColors, patterns, gradients, symbols, fonts, fontDetails, linkedImages, embeddedImages, repeats), `fonts` (the fonts the document's type uses, the type in opacity masks and in the art of its symbols and pattern swatches included, which the fonts and fontDetails categories list; with `selectionOnly`, the -fonts of the selected type), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no +fonts of the selected type), `fontDetails` (`[{font, match, family, style, file, embedding}]`: each font of `fonts`, +in order, as the fontDetails category's rows give it: `match` is `exact`, `style` (a style the family lacks, shown in +`family` `style`), `missingWithFallback` (not installed, shown in `family` `style`) or `missing`; `file` is the face's +file name, null for a built-in face; `embedding` is `allowed`, `editing`, `previewPrint` or `forbidden`, null when no +face is found), `imageObjects` (`[{name, width, height, linked, file}]`: the image objects in the order the +linkedImages and embeddedImages categories list them, with the file a linked image's row shows and null for an +embedded one), `document.unitId` (the document's units as Preferences › Units names them, such as `millimeters`, beside +the English `document.units`), `brushNames` (the brushes the art's strokes use, each once, hidden strokes, strokes at an opacity of 0, strokes with no paint or width, guides, paths with no segment, type that draws no characters, a group's or layer's own strokes, a compound path member's own strokes and an image's strokes without a shape left out; with `selectionOnly`, the brushes the selected objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, @@ -7167,9 +7174,11 @@ whose drawn characters use a pattern included, hidden fills and strokes, fills a member's own fills and strokes, an image's fills and strokes that no Convert to Shape or Outline Object effect gives a shape, and characters that draw nothing (spaces and line breaks without an underline or strikethrough) left out, with the patterns it uses, which the patterns category lists as Pattern Objects and `objects.patterns` counts; with `selectionOnly`, those in the selection), -`patternNames` (the patterns those objects use), `gradientObjects` (`[{id, name, gradients}]`: every object filled or +`patternNames` (the patterns those objects use), `gradientObjects` (`[{id, name, gradients, kinds}]`: every object filled or stroked with a gradient, by the same rules as `patternObjects`, with the gradients it uses: the name of the gradient -swatch a gradient is linked to, else `Linear gradient`, `Radial gradient` or `Freeform gradient`; the gradients category +swatch a gradient is linked to, else its kind's name, such as `Linear gradient`, `Radial gradient` or `Freeform gradient`; +`kinds` gives, for each of `gradients`, the kind (`linear`, `radial`, `conic`, `diamond` or `freeform`) of a gradient +named by its kind and null for a swatch's name; the gradients category lists them as Gradient Objects and `objects.gradients` counts them; gradient swatches no art uses are not listed; with `selectionOnly`, those in the selection), `gradientNames` (the gradients those objects use), `spotObjects` (`[{id, name, spotColors}]`: every object filled or stroked with a spot color swatch, as a solid color or in the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ec074e63a1..73153cb0fa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1487,6 +1487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Brushes, Spot Color Objects, Pattern Objects and Gradient Objects categories leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0, their characters' paints and colorized images included. With Selection Only an object selected inside such a group or layer is left out too, and Objects still counts every object as before. This is VectorCraft's own rule. Test: `cmd::docinfo::tests::info_skips_paints_on_hidden_and_clear_objects_and_their_members` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. Document Info's category menu and headings show every category title in the interface language, Font Details, Linked Images and Embedded Images included, and the Objects category shows every count label in it too: Compound Paths, Text Objects, Placed Documents, Symbol Instances, Gradient Meshes, Blends, Envelopes, Intertwine Groups, Opacity Masks and Objects with Effects. The Document category's Pattern Swatches row and the Objects category's Path Length row are translated in every catalog that has every menu label. `document.info` and the saved report keep the English labels. Showing these labels in the interface language is VectorCraft's own rule. Test: `i18n::tests::document_info_labels_are_in_complete_catalogs` (vectorcraft-ui-egui). + Document Info shows the values it makes in the interface language too: the Units, the Raster Effects resolution, each font's file, substitution and embedding permission (or that it is built in or missing), each image's size and linked file, each Repeat object's kind with its instances, rows and columns in the language's plural forms, and gradients named by their kind. Font, face, file, swatch and other names stay as they are, a gradient swatch named like a kind included, and `document.info`, Save… and Package's report keep the English values (`document.unitId`, `fontDetails`, `imageObjects`, `gradientObjects` `kinds`). Showing these values in the interface language is VectorCraft's own rule. Tests: `panels::doc_info::tests::values_follow_interface_language_and_keep_names`, `repeat_value_uses_locale_plural_forms_and_drawn_count`, `i18n::tests::document_info_values_are_in_complete_catalogs` (vectorcraft-ui-egui), `cmd::docinfo::tests::info_exposes_structured_font_details_without_changing_english_rows`, `info_exposes_ordered_images_and_unit_id` and `gradient_kinds_tell_kinds_from_swatch_names` (vectorcraft-engine). The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). From 15679574826df6fc4b71fa89faf564b2be6ee51e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:51:02 -0800 Subject: [PATCH 03/10] Smooth tool: an open path's end turns with the smoothing when a drag reaches it When a Smooth tool drag refits the stretch at the first or last anchor of an open path, the curve there now leaves or arrives along the smoothed path within the tool's reach instead of along the first or last segment, so a jagged end no longer keeps its jag. The end point stays where it is. An end where the smoothed path turns back by 90 degrees or more keeps its segment's direction, and a stretch that ends inside the path keeps the direction of the part outside the drag. - pathops: SimplifyOptions gains free_start and free_end, off by default; closed subpaths and simplify_part ignore them, so Simplify and Object > Path > Smooth give the same output as before. - engine: path.smoothRegion sets them for the ends of an open path that a run's refit reaches. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/draw2/smooth.rs | 37 +++++--- crates/engine/src/tests/draw2.rs | 77 +++++++++++++++++ crates/pathops/src/edit.rs | 116 ++++++++++++++++++++++++-- crates/pathops/tests/it/ops.rs | 55 ++++++++++++ docs/mcp.md | 7 ++ docs/target-app-parity.md | 1 + 6 files changed, 275 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 0ac4fc8d6b..828b7a4f88 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0; /// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of /// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the -/// smoothing moved it, keeping a corner only where the path turns back on itself. -fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions { - SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true } +/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether +/// the start and the end of the stretch refitted are ends of an open path, whose direction then +/// follows the smoothed path within the reach rather than the first or last segment. +fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions { + SimplifyOptions { + tolerance: tol, + corner_angle_deg: 179.0, + straight_lines: false, + smooth: sigma, + smooth_moved_only: true, + free_start: free[0], + free_end: free[1], + } } /// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles. -fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec { +/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or +/// arriving at such an end can turn. +fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec { let piece = PathData::single(SubPath::new(anchors.to_vec(), false)); - let fitted = po::simplify_with(&piece, &refit_options(tol, sigma)); + let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free)); let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() }; let (f, l) = (anchors[0], anchors[anchors.len() - 1]); let last = out.len() - 1; @@ -46,7 +58,7 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f if sp.closed { let Some(k) = marked.iter().position(|m| !m) else { // Everything is in range: smooth the whole closed path, across its start too. - let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma)); + let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2])); return f.subpaths.into_iter().next(); }; // Rotate so an untouched anchor comes first, duplicate it at the end and treat as open. @@ -57,14 +69,14 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f open.anchors.push(open.anchors[0]); m.push(false); open.closed = false; - let mut res = smooth_open(&open.anchors, &m, near, tol, sigma); + let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false); let l = res.pop()?; if let Some(r0) = res.first_mut() { r0.h_in = l.h_in; } return Some(SubPath::new(res, true)); } - Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false)) + Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false)) } /// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it @@ -96,8 +108,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez /// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted /// within `tol`, together with the stretch of the segments leading into the run that is in the /// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next -/// to it stays as it was (#1512). -fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec { +/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first +/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends, +/// and those of the open run a closed path is cut into, keep their direction. +fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec { let n = anchors.len(); let mut runs = vec![]; let mut i = 0; @@ -137,7 +151,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool head = Some(Anchor { h_out: far.p1, ..*outer }); *outer = Anchor::with_handles(far.p3, far.p2, reach.p1); } - let new = refit_run(&piece, tol, sigma); + let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()]; + let new = refit_run(&piece, tol, sigma, free); out.splice(lo..=hi, head.into_iter().chain(new).chain(tail)); } out diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index f29df7ff23..0b89944212 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1606,6 +1606,83 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() { assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt"); } +/// A straight line along y = 200 from x = 100 to 400, one anchor every 5 pt, whose second and +/// second-to-last anchors are 1.5 pt off the line: its first and last segments leave and arrive at +/// 16.7° to it. +fn smooth_jagged_ends(s: &mut Session) -> NodeId { + let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": if i == 1 || i == 59 { 201.5 } else { 200.0 }})).collect(); + smooth_test_path(s, &anchors, false) +} + +/// The degrees between the direction `sp` leaves its first anchor along (`at_end` false), or arrives +/// at its last anchor along, and the x axis. +fn smooth_end_angle(sp: &vectorcraft_geom::SubPath, at_end: bool) -> f64 { + let c = sp.segment(if at_end { sp.segment_count() - 1 } else { 0 }); + let d = if at_end { + [c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9) + } else { + [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9) + }; + d.map_or(f64::NAN, |d| d.y.atan2(d.x).to_degrees().abs()) +} + +/// The largest turn, in degrees, at any anchor of `sp` (other than its ends) whose x is in `xs`. +fn smooth_largest_turn(sp: &vectorcraft_geom::SubPath, xs: std::ops::Range) -> f64 { + let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord); + (1..sp.segment_count()) + .filter(|&i| xs.contains(&sp.anchors[i].p.x)) + .map(|i| { + let (prev, next) = (sp.segment(i - 1), sp.segment(i)); + let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0); + let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0); + arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees() + }) + .fold(0.0, f64::max) +} + +/// A Smooth drag over the whole of an open path turns the curve at each end to follow the smoothed +/// path rather than the jag of its first or last segment, and leaves both end points where they were. +#[test] +fn smoothing_an_open_path_turns_its_jagged_ends_with_the_path() { + let mut s = session(); + let id = smooth_jagged_ends(&mut s); + let before = path(&s, id); + assert!(smooth_end_angle(&before.subpaths[0], false) > 16.0 && smooth_end_angle(&before.subpaths[0], true) > 16.0); + smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 410.0)); + let p = path(&s, id); + assert_ne!(p, before, "the path is smoothed"); + let sp = &p.subpaths[0]; + let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); + assert!(first == Point::new(100.0, 200.0) && last == Point::new(400.0, 200.0), "the ends stay: {first:?} {last:?}"); + let (start, end) = (smooth_end_angle(sp, false), smooth_end_angle(sp, true)); + assert!(start <= 5.0, "the path leaves its start at {start}° to the line"); + assert!(end <= 5.0, "and arrives at its end at {end}°"); + let off = smooth_test_samples(&p).iter().map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max); + assert!(off <= 1.5, "the path stays within {off} pt of the line"); +} + +/// A Smooth drag that reaches only the start of an open path turns the curve there, and the rest of +/// the path, its far end with its jag included, stays as it was, joining the refitted part without a +/// corner. +#[test] +fn a_smooth_drag_turns_only_the_end_it_reaches() { + let mut s = session(); + let id = smooth_jagged_ends(&mut s); + let before = path(&s, id); + smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 200.0)); + let p = path(&s, id); + assert_ne!(p, before, "the start is smoothed"); + let sp = &p.subpaths[0]; + assert_eq!(sp.anchors[0].p, Point::new(100.0, 200.0), "the start stays"); + let start = smooth_end_angle(sp, false); + assert!(start <= 5.0, "the path leaves its start at {start}° to the line"); + let kept = |sp: &vectorcraft_geom::SubPath| sp.anchors.iter().filter(|a| a.p.x >= 215.0).copied().collect::>(); + assert_eq!(kept(sp), kept(&before.subpaths[0]), "the anchors out of reach stay as they were"); + assert!(smooth_end_angle(sp, true) > 16.0, "the far end keeps its jag"); + let turn = smooth_largest_turn(sp, 101.0..300.0); + assert!(turn <= 1.0, "the refitted part joins the rest with a {turn}° turn"); +} + #[test] fn join_tool_joins_two_lines() { let mut s = session(); diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 960f4036d8..87b4cb000d 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -28,11 +28,26 @@ pub struct SimplifyOptions { /// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of /// its points far, all of it is refitted within up to `tolerance`, as without this option. pub smooth_moved_only: bool, + /// With [`smooth`](Self::smooth), the curve at an open subpath's first anchor leaves it along the + /// direction of the smoothed path over the 3 spreads next to it rather than along its first + /// segment. The anchor stays where it is. A closed subpath has no such end, and [`simplify_part`] + /// ignores this. + pub free_start: bool, + /// [`free_start`](Self::free_start) for an open subpath's last anchor. + pub free_end: bool, } impl Default for SimplifyOptions { fn default() -> Self { - Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false } + Self { + tolerance: 0.5, + corner_angle_deg: 30.0, + straight_lines: false, + smooth: 0.0, + smooth_moved_only: false, + free_start: false, + free_end: false, + } } } @@ -107,6 +122,8 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option { if n == 0 || sp.closed && n < 2 { return Some(sp.clone()); } + let closed_opts = SimplifyOptions { free_start: false, free_end: false, ..*opts }; + let opts = if sp.closed { &closed_opts } else { opts }; if sp.closed { // Start the loop at its first corner, so no fit runs across one. let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0); @@ -282,6 +299,12 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge cuts.insert(0, 0); cuts.push(run.len()); let mut result: Vec = Vec::new(); + // Whether `part` starts where `run` does and whether it ends where `run` does, for the free ends. + let free = |part: &[(&Seg, usize)]| { + let starts = part.first().zip(run.first()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r)); + let ends = part.last().zip(run.last()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r)); + [opts.free_start && starts, opts.free_end && ends] + }; for w in cuts.windows(2) { let piece = &run[w[0]..w[1]]; let mut part: Vec<(&Seg, usize)> = Vec::new(); @@ -290,7 +313,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge let steps = sample_steps(&s.c, tol); if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT { if part_samples + steps > MAX_FIT_SAMPLES { - refit_part(&part, opts, tol, None, &mut result); + refit_part(&part, opts, tol, None, free(&part), &mut result); part.clear(); part_samples = 0; } @@ -299,7 +322,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge part.push((s, steps)); } else { part_samples = 0; - refit_part(&part, opts, tol, None, &mut result); + refit_part(&part, opts, tol, None, free(&part), &mut result); part.clear(); if opts.straight_lines { let room = MAX_RUN_RESULT.saturating_sub(result.len()); @@ -313,7 +336,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge } } } - refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result); + refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), free(&part), &mut result); } result } @@ -321,14 +344,18 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge /// Adds the refit of `part`, consecutive segments with no corner between them and the steps to /// sample each one with, to `result`. `ends`, given only when `part` is a whole closed loop, is the /// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its -/// start and the direction comes from the smoothed samples there. -fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option, result: &mut Vec) { +/// start and the direction comes from the smoothed samples there. `free` says whether the start and +/// the end of `part` are free ends of an open subpath: when it is smoothed, the refit leaves such a +/// start and arrives at such an end along [`free_end_direction`], or along its first or last segment +/// when that gives none or turns from the segment by 90° or more. +fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option, free: [bool; 2], result: &mut Vec) { let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return }; let mut pts = Vec::new(); for (k, (s, steps)) in part.iter().enumerate() { sample(&s.c, *steps, &mut pts, k == 0); } let mut tol = tol; + let (mut t0, mut t1) = (start_tangent(&first.c), end_tangent(&last.c)); if opts.smooth > 0.0 { let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) }; if ends.is_some() @@ -336,6 +363,18 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends { ends = unit(next - prev).or(ends); } + if ends.is_none() { + if free[0] + && let Some(d) = free_end_direction(&smoothed, opts.smooth, false).filter(|d| d.dot(t0) > 0.0) + { + t0 = d; + } + if free[1] + && let Some(d) = free_end_direction(&smoothed, opts.smooth, true).filter(|d| d.dot(t1) > 0.0) + { + t1 = d; + } + } if opts.smooth_moved_only { tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR)); } @@ -351,7 +390,7 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last // curve of a loop fitted across its start leaves along the loop's first segment. - let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); + let (t0, t1) = ends.map_or((t0, t1), |d| (d, d)); let fitted = fit_cubics(&pts, t0, t1, tol * 0.98); let from = result.len(); let mut arrive = t0; @@ -451,6 +490,66 @@ fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 { .fold(0.0, f64::max) } +/// How far along the path, in spreads of the smoothing, [`free_end_direction`] looks from the end: +/// as far as the Gaussian reaches. VectorCraft's own value. +const FREE_END_SPREADS: f64 = 3.0; + +/// [`free_end_direction`] gives no direction when the path near the end spreads out from it more +/// evenly than this: the ratio of the least to the most squared distance from a line through the end, +/// over all such lines. VectorCraft's own value. +const FREE_END_MAX_SPREAD: f64 = 1.0 / 3.0; + +/// The direction the samples `pts` of an open run leave their first point along, or with `at_end` +/// arrive at their last point along: the direction of the line through that point that the path +/// within [`FREE_END_SPREADS`] spreads `sigma` of it along its length, and at most half its length, +/// lies closest to by the least sum of squared distances, pointing along the path. `None` when the +/// path there has no length or is not finite, when its mean point is within a tenth of its length +/// there of that point (it doubles back), or when it spreads from that point more evenly than +/// [`FREE_END_MAX_SPREAD`]. +fn free_end_direction(pts: &[Point], sigma: f64, at_end: bool) -> Option { + let n = pts.len(); + let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum(); + let reach = (FREE_END_SPREADS * sigma).min(total / 2.0); + if !(reach.is_finite() && reach > 0.0) { + return None; + } + let at = |i: usize| pts.get(if at_end { n - 1 - i } else { i }).copied(); + let origin = at(0)?; + // The integrals along the path of v vᵀ and of v, for v from the end to each point. + let (mut sxx, mut sxy, mut syy, mut sum, mut along) = (0.0, 0.0, 0.0, Vec2::ZERO, 0.0); + let mut a = Vec2::ZERO; + for i in 1..n { + let Some(p) = at(i) else { break }; + let mut b = p - origin; + let mut len = (b - a).hypot(); + if along + len > reach { + b = a.lerp(b, (reach - along) / len); + len = reach - along; + } + sxx += len * (a.x * a.x + a.x * b.x + b.x * b.x) / 3.0; + syy += len * (a.y * a.y + a.y * b.y + b.y * b.y) / 3.0; + sxy += len * (2.0 * a.x * a.y + a.x * b.y + b.x * a.y + 2.0 * b.x * b.y) / 6.0; + sum += (a + b) * (len / 2.0); + along += len; + a = b; + if along >= reach { + break; + } + } + let (trace, apart) = (sxx + syy, (sxx - syy).hypot(2.0 * sxy)); + // The two principal values are (trace ± apart) / 2. + if !(trace.is_finite() && apart.is_finite() && trace > 0.0) || trace - apart > FREE_END_MAX_SPREAD * (trace + apart) { + return None; + } + let mean = sum / along; + if !mean.hypot().is_finite() || mean.hypot() < 0.1 * along { + return None; + } + let d = Vec2::from_angle(0.5 * (2.0 * sxy).atan2(sxx - syy)); + let d = if d.dot(mean) >= 0.0 { d } else { -d }; + unit(if at_end { -d } else { d }) +} + /// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups /// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one /// by one. @@ -597,6 +696,7 @@ pub struct SimplifiedPart { /// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more /// segments than it had is left as it is (except with Convert to Straight Lines). pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart { + let opts = &SimplifyOptions { free_start: false, free_end: false, ..*opts }; let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new()); let mut subpaths = Vec::with_capacity(path.subpaths.len()); for (si, sp) in path.subpaths.iter().enumerate() { @@ -784,6 +884,8 @@ pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions { straight_lines: false, smooth: size * 0.05 * level / 100.0, smooth_moved_only: false, + free_start: false, + free_end: false, } } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 93f6d4c29c..0b5b0bbf25 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -488,6 +488,61 @@ fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() { } } +/// The degrees between the x axis and the direction `sp` leaves its first anchor along. +fn start_angle(sp: &SubPath) -> f64 { + let c = sp.segment(0); + let d = [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default(); + d.y.atan2(d.x).to_degrees() +} + +/// With `free_start` and `free_end`, a smoothed open subpath leaves its first anchor and arrives at +/// its last one along the smoothed path, not along a jagged first or last segment, and its end points +/// stay. Only a flagged end turns, and a closed subpath, Object › Path › Smooth's selected segments +/// (`simplify_part`) and a refit without smoothing give the same result with the flags as without. +#[test] +fn free_ends_turn_only_open_smoothed_ends() { + let pts: Vec = (0..=40).map(|i| Point::new(5.0 * f64::from(i), if i == 1 || i == 39 { 1.5 } else { 0.0 })).collect(); + let path = PathData::single(SubPath::polyline(&pts, false)); + let base = SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, ..Default::default() }; + let both = SimplifyOptions { free_start: true, free_end: true, ..base }; + let s = simplify_with(&path, &both); + let sp = &s.subpaths[0]; + assert_eq!((sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p), (pts[0], pts[40]), "{s:?}"); + let mut back = sp.clone(); + back.reverse(); + assert!(start_angle(sp).abs() <= 5.0 && (start_angle(&back).abs() - 180.0).abs() <= 5.0, "{s:?}"); + let start_only = simplify_with(&path, &SimplifyOptions { free_start: true, ..base }); + let plain = simplify_with(&path, &base); + assert!(start_angle(&start_only.subpaths[0]).abs() <= 5.0, "{start_only:?}"); + assert!(start_angle(&plain.subpaths[0]) > 16.0, "{plain:?}"); + let (mut a, mut b) = (start_only.subpaths[0].clone(), plain.subpaths[0].clone()); + a.reverse(); + b.reverse(); + assert!((start_angle(&a) - start_angle(&b)).abs() <= 1e-9, "the last anchor keeps its direction: {start_only:?}"); + // Flags that must have no effect. + let ring = PathData::single(SubPath::polyline(&pts[..39], true)); + assert_eq!(simplify_with(&ring, &both), simplify_with(&ring, &base)); + let unsmoothed = SimplifyOptions { smooth: 0.0, ..base }; + assert_eq!(simplify_with(&path, &SimplifyOptions { free_start: true, free_end: true, ..unsmoothed }), simplify_with(&path, &unsmoothed)); + let picked: std::collections::BTreeSet<(usize, usize)> = (0..20).map(|i| (0, i)).collect(); + assert_eq!(simplify_part(&path, &picked, &both).path, simplify_part(&path, &picked, &base).path); +} + +/// A free end whose smoothed path turns back by more than 90° from its first segment keeps that +/// segment's direction: a path that leaves its start along x for 2 pt, then turns by 170°. +#[test] +fn a_free_end_that_turns_back_keeps_its_first_direction() { + let back = kurbo::Vec2::from_angle(170f64.to_radians()); + let mut pts = vec![Point::ORIGIN, Point::new(2.0, 0.0)]; + pts.extend((1..=10).map(|i| Point::new(2.0, 0.0) + back * (3.0 * f64::from(i)))); + let path = PathData::single(SubPath::polyline(&pts, false)); + let opts = + SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, free_start: true, ..Default::default() }; + let s = simplify_with(&path, &opts); + let angle = start_angle(&s.subpaths[0]); + assert!(angle.abs() <= 1.0, "the path leaves its start at {angle}°: {s:?}"); +} + #[test] fn simplify_straight_lines_option() { let c = dense_circle(50.0, 200); diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..a8c5f0784b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5177,6 +5177,13 @@ round, such as a rectangle, stays as it is. Closed paths stay closed and open pa {"name":"run_command","arguments":{"command":"object.path.smooth","params":{"level":80}}} ``` +`path.smoothRegion` (the Smooth tool) keeps the end points of an open path where they are. When the stretch it refits +reaches the first or last anchor of an open path, the curve there leaves or arrives along the smoothed path within +`radius` of that end rather than along the first or last segment, so a jagged end turns with the rest of the path; it +keeps the segment's direction when the smoothed path there turns back by 90° or more. Where the stretch ends inside a +path, the curve keeps the direction of the part outside the drag. `object.path.smooth` and Simplify keep each end's +direction. + In the desktop app, Object › Path › Smooth and the canvas context menu's Smooth… (listed while the selection holds a path) open the slider (`ui.menuDialog {command: "object.path.smooth"}`, dialog `smoothSlider` with field `level`) at the Auto-Smooth level, and the slider previews the result until `ui.dialog.confirm`; `ui.smoothAuto` moves the open diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..e5662b7a52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1118,6 +1118,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. + When a Smooth tool drag reaches the first or last anchor of an open path, that anchor stays where it is and the curve there leaves or arrives along the smoothed path within the reach, so a jagged first or last segment no longer sets the end's direction; an end where the smoothed path turns back by 90° or more keeps its segment's direction, and where the refitted stretch ends inside the path the curve keeps the direction of the part outside the drag. This rule is VectorCraft's own (`tests/draw2.rs`, `smoothing_an_open_path_turns_its_jagged_ends_with_the_path` and `a_smooth_drag_turns_only_the_end_it_reaches`). Before a press that draws a new path, the Pencil cursor shows a small star, as [Draw freeform paths with the Pencil tool](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-freeform-paths-with-the-pencil-tool.html) describes; over a selected path within Edit selected paths' distance, which the press continues from an end or redraws, the star is not shown ([Reshape paths with the Pencil tool](https://web.archive.org/web/20260106004407/https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/reshape-paths-with-the-pencil-tool.html)). Keep selected in the Pencil and Paintbrush Tool Options is on by default: while it is on, a stroke selects the path it draws or continues and replaces the earlier selection. While it is off, a new stroke leaves nothing selected, the earlier selection included, as the reference application starts the stroke by dropping it (#5752); a continued or redrawn path is deselected while the rest of the selection stays (`path.freehand` `keepSelected`). With two open paths selected, a Pencil stroke from an end of one that ends within Edit selected paths' distance of an end of the other connects them into one path with the first path's appearance, in one undo step, as [Connect two paths with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/connect-two-paths-with-the-pencil-tool.html) describes (`path.freehand` `connect`). From 1577ef3f76ff8e138fce695d1a577ffad6a26678 Mon Sep 17 00:00:00 2001 From: ncic <1848265+ncic@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:11:54 +0100 Subject: [PATCH 04/10] Docs: record Snap to Point without Smart Guides labels (#7250) --- docs/mcp.md | 5 +++-- docs/target-app-parity.md | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index cb5c40501c..faa0e3dc42 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-13 · **Change:** Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. Previously: Save location-refusal error (#6146); existing response shapes retained. +> **Last updated:** 2026-11-13 · **Change:** Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -5742,7 +5742,7 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture - `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair. - `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler - guide that near. + guide that near, showing none of the Smart Guides labels or lines. - `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and hidden art, and art that is also on a locked artboard, with the artboard too, and `artboard.setProps {scaleArt: true}` scales it too; off (the default) it stays where it is. `artboard.duplicate`, `artboard.copy` and `artboard.cut` copy or @@ -11327,5 +11327,6 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). | | 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e309f72409..e7df6941c8 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 (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously 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 (Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250). > > 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 | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 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 | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | From 395dd5b2945bb3fcc81ab97dd2c0ef5277af48b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:19:43 -0800 Subject: [PATCH 05/10] Text Wrap: a negative Offset lets text run into the wrap object The help page says a wrap object's Offset may be positive or negative (https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307). Text Wrap Options, the commands, the MCP tool and .ai import already kept a negative value, but layout set it to 0. - Layout keeps the signed offset, within -1000 to 1000 pt like the commands and .ai import (a value that is not a number counts as 0). - Around a wrap object, a negative offset shrinks the object by that distance from each edge, holes included, so lines beside, above and below it run that far into it. A shape narrower than twice the distance no longer moves text aside. A line may get less room than the exact shrunk shape leaves, for example where several edges come close to one piece of the band, or where a band holds more than 64 anchor heights. It never gets less room than an offset of 0 leaves. - With Invert Wrap, a negative offset keeps the text that far inside the object. - Zero and positive offsets up to 1000 pt lay out as before. A larger positive offset lays out as 1000 pt. - The params text of object.textWrap.make and object.textWrap.options and the MCP text_wrap offset description give the range and what a negative value does. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/textwrap.rs | 31 ++++- crates/mcp/src/tools/definitions.rs | 2 +- crates/text/src/layout/region.rs | 185 +++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 4 files changed, 210 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs index caeb1f8fcf..c7c9adb261 100644 --- a/crates/engine/src/cmd/textwrap.rs +++ b/crates/engine/src/cmd/textwrap.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Text Wrap"], None, - "{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}", + "{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}", has_selection, make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Text Wrap Options…", ["Object", "Text Wrap"], None, - "{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}", + "{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}", has_selection, options ), @@ -186,6 +186,33 @@ mod tests { assert_eq!(lines(&s, t), plain); } + /// A negative offset from Text Wrap Options lets the lines beside a wrap object run that far + /// into it, the options report it, and one undo puts back the offset before it. + #[test] + fn text_wrap_options_negative_offset_reflows_and_round_trips() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let t = s + .execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(200), "size": 12, "area": {"width": 400, "height": 400}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": -50, "y": 50, "width": 250, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + let beside = |s: &Session| lines(s, t).into_iter().filter(|l| l.0 > 1.0).map(|l| l.0).collect::>(); + assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s)); + assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -20})).unwrap()["offset"], json!(-20.0)); + assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 180.0).abs() < 1e-6), "{:?}", beside(&s)); + assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(-20.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0)); + assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s)); + // An offset past -1000 is kept at -1000: the whole object lets text through. + assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0)); + assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0)); + } + #[test] fn text_flows_on_both_sides_of_a_centred_object() { let mut s = Session::new(); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index d4d69c945f..e583753e4d 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -511,7 +511,7 @@ pub fn tool_definitions() -> Vec { obj( json!({ "ids": {"type": "array", "items": {"type": "integer"}}, - "offset": num("Gap between text and object (default 6 pt)"), + "offset": num("Gap between text and object in pt, -1000 to 1000 (default 6 pt); a negative gap lets text that far into the object"), "invert": {"type": "boolean"}, "release": {"type": "boolean"}, }), diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index d7293c1109..a4a342535d 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -368,6 +368,80 @@ fn band_boundaries(polys: &[Vec], top: f64, bottom: f64) -> (Vec<(f64, f6 (union_intervals(edges), paired) } +/// The largest magnitude a text wrap offset may have (points), the bound the Text Wrap commands +/// and `.ai` import keep too. +const MAX_WRAP_OFFSET: f64 = 1000.0; + +/// A wrap object's offset as layout uses it: within [`MAX_WRAP_OFFSET`] either way, and 0 when it +/// is not a number. +fn wrap_offset(offset: f64) -> f64 { + if offset.is_nan() { 0.0 } else { offset.clamp(-MAX_WRAP_OFFSET, MAX_WRAP_OFFSET) } +} + +/// The x at which some point of the band `top..bottom` (`top <= bottom`) is inside `polys` +/// (even-odd) and farther than `d` (> 0) from every edge of it, holes included: the cover of the +/// shape shrunk by `d`. The band is cut into pieces (at most [`INVERT_WRAP_PIECES`] even ones, and +/// at every anchor's height and `d` above and below it when there are at most that many of those); +/// in each piece, an x is left out when the shape is nowhere in the piece there, or when the whole +/// piece there lies within `d` of one edge. A piece where several edges together cover it keeps +/// its x, so the result can be wider than the shrunk shape, never narrower, and never wider than +/// the shape's own cover of the band. +fn eroded_cover(polys: &[Vec], top: f64, bottom: f64, d: f64) -> Vec<(f64, f64)> { + // A point `d` from an edge, up to rounding, counts as near it, so a band that only touches the + // shrunk shape is left whole. + let r = d * (1.0 + 1e-12); + let span = |p: &[Point]| p.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y))); + // The shrunk shape lies more than `d` below the shape's top and above its bottom, so a band + // outside that holds none of it; within it, only contours that come within `d` of the band + // count (the others neither cross it nor bound the shape near it). + let (lo, hi) = polys.iter().map(|p| span(p)).fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (c, e)| (a.min(c), b.max(e))); + if bottom <= lo + r || top >= hi - r { + return vec![]; + } + let near_band: Vec> = polys + .iter() + .filter(|p| { + let (lo, hi) = span(p); + hi >= top - r && lo <= bottom + r + }) + .cloned() + .collect(); + let polys = near_band.as_slice(); + let pieces = ((bottom - top) * 4.0 / d).ceil().clamp(1.0, INVERT_WRAP_PIECES); + let mut cuts: Vec = (0..=pieces as usize).map(|k| top + (bottom - top) * k as f64 / pieces).collect(); + if bottom > top { + let mut anchors: Vec = polys.iter().flatten().flat_map(|p| [p.y - d, p.y, p.y + d]).filter(|&y| y > top && y < bottom).collect(); + anchors.sort_by(f64::total_cmp); + anchors.dedup(); + if anchors.len() <= INVERT_WRAP_PIECES as usize { + cuts.extend(anchors); + cuts.sort_by(f64::total_cmp); + cuts.dedup(); + } + } + let mut out = Vec::new(); + // A band of no height keeps its two equal cuts: one piece of no height, measured at its y. + for w in cuts.windows(2) { + let (a, b) = (w[0], w[1]); + let cover = band_cover(polys, a, b, false); + if cover.is_empty() { + continue; + } + // The points closer than `d` to an edge make a convex shape, so a vertical line within + // the piece lies in it when both its ends do. + let near = polys.iter().flat_map(|p| { + (0..p.len()).filter_map(move |i| { + let (s, t) = (p[i], p[(i + 1) % p.len()]); + let (u, v) = (near_segment(s, t, a, r)?, near_segment(s, t, b, r)?); + let (x, y) = (u.0.max(v.0), u.1.min(v.1)); + (y > x).then_some((x, y)) + }) + }); + out.extend(subtract_intervals(&cover, &union_intervals(near.collect()))); + } + union_intervals(out) +} + impl Region { pub(super) fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { // A frame out of range has no cells, and a wrap shape out of range is left out. @@ -376,7 +450,7 @@ impl Region { let polys: std::sync::Arc<[Vec]> = polys.into(); let wraps: std::sync::Arc<[FlatWrap]> = wrap .iter() - .filter_map(|w| Some((polygons(&w.path.to_bezpath())?, w.wrap.offset.max(0.0), w.wrap.invert))) + .filter_map(|w| Some((polygons(&w.path.to_bezpath())?, wrap_offset(w.wrap.offset), w.wrap.invert))) .filter(|w| !w.0.is_empty()) .collect(); let bbox = path.bounding_box(); @@ -415,8 +489,10 @@ impl Region { } /// `iv` minus every x at which a wrap object lies somewhere in the band `top..bottom` (or the x - /// inside it over the whole band, for Invert Wrap). Offsets grow each wrap shape vertically - /// and horizontally. Along the line, the text also keeps the inset beside each wrap object + /// inside it over the whole band, for Invert Wrap). A positive offset grows each wrap shape + /// vertically and horizontally; a negative one shrinks it by that distance from its edges + /// (holes included), so text runs that far into the object, or stays that far inside it for + /// Invert Wrap. Along the line, the text also keeps the inset beside each wrap object /// (outside it, or inside it for Invert Wrap); above and below a wrap object only its offset /// applies. fn wrapped(&self, mut iv: Vec<(f64, f64)>, top: f64, bottom: f64) -> Vec<(f64, f64)> { @@ -428,7 +504,17 @@ impl Region { } let grow = |v: Vec<(f64, f64)>| union_intervals(v.into_iter().map(|(a, b)| (a - off, b + off)).collect()); let shrink = |v: Vec<(f64, f64)>| v.into_iter().map(|(a, b)| (a + inset, b - inset)).filter(|(a, b)| b > a).collect::>(); - iv = if *invert { + let pad = |v: Vec<(f64, f64)>| union_intervals(v.into_iter().map(|(a, b)| (a - inset, b + inset)).collect()); + iv = if *off < 0.0 { + // Invert Wrap keeps the x at which the shape shrunk by the offset holds the whole + // band; otherwise the text leaves out the x at which the shrunk shape lies + // somewhere in the band. + if *invert { + intersect_intervals(&iv, &shrink(band_inset_intervals(polys, top, bottom, -off))) + } else { + subtract_intervals(&iv, &pad(eroded_cover(polys, top, bottom, -off))) + } + } else if *invert { // The band is cut into pieces. Each height of a piece `y0..y1` is within the offset // of some height from `y0` plus the offset to `y1` less the offset, so an x the // shape holds at all of those heights is inside the grown shape over the piece. In @@ -469,8 +555,7 @@ impl Region { } iv } else { - let w = grow(band_cover(polys, top - off, bottom + off, false)); - subtract_intervals(&iv, &union_intervals(w.into_iter().map(|(a, b)| (a - inset, b + inset)).collect())) + subtract_intervals(&iv, &pad(grow(band_cover(polys, top - off, bottom + off, false)))) }; } iv @@ -624,6 +709,94 @@ fn a_line_stays_clear_of_a_wrap_object_that_reaches_into_its_band() { assert_spans(&Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(50.0, 50.0), &[(15.0, 45.0)]); } +/// The spans of a 100 pt square frame over the band `top..bottom` with one wrap object through +/// `contours` (even-odd). +#[cfg(test)] +fn wrap_spans(contours: &[&[(f64, f64)]], offset: f64, invert: bool, top: f64, bottom: f64) -> Vec<(f64, f64)> { + let frame = Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1); + let wrap = vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&closed(contours)), wrap: vectorcraft_doc::TextWrap { offset, invert } }; + Region::cells(&frame, &LayoutOptions::default(), &[wrap])[0].spans(top, bottom) +} + +/// A negative offset lets text run into a wrap object by that distance, across and along the +/// line: beside a square, the line reaches 5 pt into it, and a line whose band reaches no more than +/// 5 pt into its top or bottom runs across it. +#[cfg(test)] +#[test] +fn negative_wrap_offset_insets_rectangle_on_both_axes() { + let square: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&wrap_spans(&[square], -5.0, false, 44.0, 50.0), &[(0.0, 45.0), (55.0, 100.0)]); + assert_spans(&wrap_spans(&[square], -5.0, false, 30.0, 45.0), &[(0.0, 100.0)]); + assert_spans(&wrap_spans(&[square], -5.0, false, 55.0, 70.0), &[(0.0, 100.0)]); + assert_spans(&wrap_spans(&[square], -5.0, false, 30.0, 46.0), &[(0.0, 45.0), (55.0, 100.0)]); + // The most an offset may take away is 1000 pt, and an offset that is not a number is 0. + assert_spans(&wrap_spans(&[square], f64::NEG_INFINITY, false, 44.0, 50.0), &[(0.0, 100.0)]); + assert_spans(&wrap_spans(&[square], f64::NAN, false, 44.0, 50.0), &[(0.0, 40.0), (60.0, 100.0)]); +} + +/// A negative offset is measured from a sloped edge itself (10 pt across the edge of a triangle is +/// 10√2 pt along the line), and from the edges of a hole too. +#[cfg(test)] +#[test] +fn negative_wrap_offset_insets_sloped_shape_and_hole() { + let triangle: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&wrap_spans(&[triangle], -10.0, false, 40.0, 60.0), &[(0.0, 10.0), (60.0 - 10.0 * std::f64::consts::SQRT_2, 100.0)]); + let outer: &[(f64, f64)] = &[(20.0, 20.0), (80.0, 20.0), (80.0, 80.0), (20.0, 80.0)]; + let hole: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&wrap_spans(&[outer, hole], -5.0, false, 48.0, 52.0), &[(0.0, 25.0), (35.0, 65.0), (75.0, 100.0)]); +} + +/// With Invert Wrap, a negative offset keeps the text that far inside the object, along and across +/// the line. +#[cfg(test)] +#[test] +fn negative_invert_wrap_keeps_only_eroded_interior() { + let square: &[(f64, f64)] = &[(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0)]; + assert_spans(&wrap_spans(&[square], -5.0, true, 46.0, 54.0), &[(45.0, 55.0)]); + assert!(wrap_spans(&[square], -5.0, true, 42.0, 48.0).is_empty()); + let triangle: &[(f64, f64)] = &[(0.0, 0.0), (100.0, 100.0), (0.0, 100.0)]; + assert_spans(&wrap_spans(&[triangle], -10.0, true, 40.0, 60.0), &[(10.0, 40.0 - 10.0 * std::f64::consts::SQRT_2)]); +} + +/// A negative offset at least half as large as a wrap object is wide leaves nothing of it: text +/// runs across it, and with Invert Wrap no text fits inside it. +#[cfg(test)] +#[test] +fn negative_offset_erases_narrow_wrap_shape() { + let strip: &[(f64, f64)] = &[(40.0, 0.0), (48.0, 0.0), (48.0, 100.0), (40.0, 100.0)]; + for offset in [-4.0, -5.0, -1000.0] { + assert_spans(&wrap_spans(&[strip], offset, false, 40.0, 60.0), &[(0.0, 100.0)]); + assert!(wrap_spans(&[strip], offset, true, 40.0, 60.0).is_empty(), "offset {offset}"); + } + // A band of no height. + assert_spans(&wrap_spans(&[strip], -1.0, false, 50.0, 50.0), &[(0.0, 41.0), (47.0, 100.0)]); +} + +/// A negative offset never keeps text farther from a wrap object than an offset of 0 does, on a +/// shape whose edges meet at many angles. +#[cfg(test)] +#[test] +fn negative_offset_never_wraps_more_than_no_offset() { + let star: Vec<(f64, f64)> = (0..40) + .map(|k| { + let (r, a) = (if k % 2 == 0 { 40.0 } else { 15.0 }, f64::from(k) * std::f64::consts::PI / 20.0); + (50.0 + r * a.cos(), 50.0 + r * a.sin()) + }) + .collect(); + for (top, bottom) in [(10.0, 22.0), (44.0, 56.0), (30.0, 31.0), (70.0, 90.0)] { + for invert in [false, true] { + let zero = wrap_spans(&[star.as_slice()], 0.0, invert, top, bottom); + let neg = wrap_spans(&[star.as_slice()], -3.0, invert, top, bottom); + let width = |v: &[(f64, f64)]| v.iter().map(|(a, b)| b - a).sum::(); + if invert { + assert!(width(&neg) <= width(&zero) + 1e-9, "{top}..{bottom}: {neg:?} {zero:?}"); + } else { + assert!(width(&neg) >= width(&zero) - 1e-9, "{top}..{bottom}: {neg:?} {zero:?}"); + } + } + } +} + /// A hole that starts and ends inside a line's band splits the line, and a band below the hole, or /// one that only touches it, keeps the whole width. #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..9ad6c941e0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1269,6 +1269,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in area type of any shape, rows, columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured where the lines above it put it, across the line's own height, so a taller line gets the room of the narrower part of the frame, or of the span beside a wrap object, that it reaches. Such a paragraph is broken with the Single-line Composer when the frame has no room for all of it or when the search passes VectorCraft's own limits. Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/set-hyphenation-and-line-breaks.html). + A text wrap object's Offset may be negative as well as positive ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=307)). A negative Offset (to -1000 pt) shrinks the wrap object by that distance, measured from each edge itself and from the edges of its holes, so lines beside it, above it and below it run that far into it, and an object narrower than twice the distance no longer moves text aside. With Invert Wrap, a negative Offset keeps the text that far inside the object. These are VectorCraft's own rules for the shape of the shrunk object. Text Wrap Options, `object.textWrap.make` and `object.textWrap.options` and the MCP `text_wrap` tool take the negative value, and layout uses it (`crates/text/src/layout/region.rs` tests, `cmd::textwrap::tests::text_wrap_options_negative_offset_reflows_and_round_trips`). In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests). The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. From d005ff6472cb6ab1ea0f5a2bb9c94b028fdfb6ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:28:42 -0800 Subject: [PATCH 06/10] Type: thread checks look up the threads once per command Dynamic Text, Dynamic Text Presets, Center on Top and Center on Bottom, and Area Type Options with Block on checked each object for a thread with its own search of the document's threads. A selection of many objects in a document with many threads repeated that search for each object, in the commands' enabled checks as well as when they ran. The checks now build one lookup from frame to thread for the whole selection. An object counts as threaded by the first thread that lists it, as before, so the commands refuse the same frames, list them in the same order and leave the document and the undo history as they were. --- crates/engine/src/cmd/threads.rs | 47 ++++++++++ crates/engine/src/cmd/threads/tests.rs | 22 +++++ crates/engine/src/cmd/typecmd.rs | 4 +- crates/engine/src/cmd/typemenu.rs | 23 +++-- crates/engine/src/tests/dynamic_text.rs | 113 ++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index c1c3801e40..b364e877f1 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -361,14 +361,61 @@ pub(crate) fn mark_continued(doc: &mut Document) { } } +#[cfg(test)] +thread_local! { + /// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's + /// threads on this thread. + static THREAD_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's +/// threads on this thread. +#[cfg(test)] +pub(crate) fn thread_scans() -> usize { + THREAD_SCANS.with(std::cell::Cell::get) +} + /// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a /// path; empty when `id` is in no thread or the thread has fewer than two such frames. pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { + #[cfg(test)] + THREAD_SCANS.with(|c| c.set(c.get() + 1)); let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; let frames: Vec = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect(); if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] } } +/// A document's text threads looked up by frame, built in one pass over them, for a command that +/// asks [`ThreadIndex::threaded`] of each of many objects. +pub(crate) struct ThreadIndex<'a> { + doc: &'a Document, + /// Each object the threads list, with the index of the first thread that lists it. + first: std::collections::HashMap, + /// Each thread's count of entries that are area type or type on a path. + live: Vec, +} + +impl<'a> ThreadIndex<'a> { + pub(crate) fn of(doc: &'a Document) -> Self { + #[cfg(test)] + THREAD_SCANS.with(|c| c.set(c.get() + 1)); + let mut first = std::collections::HashMap::new(); + let mut live = Vec::with_capacity(doc.text_threads.len()); + for (i, thread) in doc.text_threads.iter().enumerate() { + for id in thread { + first.entry(*id).or_insert(i); + } + live.push(thread.iter().filter(|id| text_of(doc, **id).is_some()).count()); + } + Self { doc, first, live } + } + + /// Whether `id` is a frame of a thread: [`story_of`] is not empty for it. + pub(crate) fn threaded(&self, id: NodeId) -> bool { + self.first.get(&id).and_then(|i| self.live.get(*i)).is_some_and(|n| *n >= 2) && text_of(self.doc, id).is_some() + } +} + /// `ids` followed by the other frames of the threads they are in, each once, in thread order. pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { let mut out = ids.to_vec(); diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index a622901c01..c490977814 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -245,6 +245,28 @@ fn a_frame_left_alone_by_a_dropped_repeat_stops_continuing() { assert_eq!(t.plain_text(), "Tail"); } +/// [`ThreadIndex::threaded`] answers as [`story_of`] does, by the first thread that lists an +/// object, also for threads listed more than once, with point type, other art and missing ids. +#[test] +fn thread_index_matches_story_of() { + let (mut s, a, b) = setup(); + s.execute("text.thread.create", &json!({})).unwrap(); + let c = s.execute("text.create", &json!({"x": 400, "y": 10, "text": "C", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let p = s.execute("text.create", &json!({"x": 10, "y": 250, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let [fa, fb, fc, fp, fr, fx] = [a, b, c, p, r, 9_999_999].map(NodeId); + // a's first thread has one frame beside point type, c's has one beside a rectangle, and b's + // lists a twice. + d.text_threads = vec![vec![fp, fa], vec![fa, fb, fa], vec![fc, fr], vec![fc, fb], vec![fx, fb]]; + let index = ThreadIndex::of(&d); + let got: Vec<(bool, bool)> = [fa, fb, fc, fp, fr, fx].iter().map(|id| (index.threaded(*id), !story_of(&d, *id).is_empty())).collect(); + let want: Vec<(bool, bool)> = [false, true, false, false, false, false].iter().map(|t| (*t, *t)).collect(); + assert_eq!(got, want); + d.text_threads.clear(); + assert!(!ThreadIndex::of(&d).threaded(fb)); +} + #[test] fn edits_copy_only_the_stories_of_threads_whose_frames_changed() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index e996591f91..94ed16ebd8 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1225,8 +1225,8 @@ fn area_options(s: &mut Session, p: &Value) -> Result { .map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o))) .collect::>()?; // Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it. - let frames: Vec = - planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect(); + let threads = super::threads::ThreadIndex::of(d); + let frames: Vec = planned.iter().filter(|(id, (_, block))| *block && threads.threaded(*id)).map(|(id, _)| id.0.to_string()).collect(); if !frames.is_empty() { let frames = frames.join(", "); return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}"))); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index faada86d5f..87c16bb9b9 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; +use super::threads::ThreadIndex; use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, @@ -556,7 +557,7 @@ fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option { let open = open.join(", "); return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}")); } - let frames = threaded_ids(d, ids); + let frames = threaded_ids(&ThreadIndex::of(d), ids); (!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}")) } @@ -783,11 +784,6 @@ fn path_options(s: &mut Session, p: &Value) -> Result { options(s) } -/// Whether type `id` is a frame of a thread. -fn threaded(d: &Document, id: NodeId) -> bool { - !super::threads::story_of(d, id).is_empty() -} - /// Type on a path or area type: the type Dynamic Text works on. fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { path_text(n).or_else(|| area_text(n)) @@ -820,17 +816,18 @@ fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { /// path first; None when none is. fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option { let (paths, areas): (Vec, Vec) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some()); - let frames = threaded_ids(d, &paths); + let threads = ThreadIndex::of(d); + let frames = threaded_ids(&threads, &paths); if !frames.is_empty() { return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); } - let frames = threaded_ids(d, &areas); + let frames = threaded_ids(&threads, &areas); (!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}")) } -/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none. -fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { - ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::>().join(", ") +/// The ids among `ids` of frames of `threads`, as a list for a message; empty when there are none. +fn threaded_ids(threads: &ThreadIndex, ids: &[NodeId]) -> String { + ids.iter().filter(|id| threads.threaded(**id)).map(|id| id.0.to_string()).collect::>().join(", ") } /// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on @@ -969,7 +966,7 @@ fn has_preset_type(s: &Session) -> std::result::Result<(), String> { if ids.is_empty() { return Err("select point type or type on a path".into()); } - let frames = threaded_ids(d, &ids); + let frames = threaded_ids(&ThreadIndex::of(d), &ids); if !frames.is_empty() { return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); } @@ -1108,7 +1105,7 @@ fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select point type or type on a path")); } - let frames = threaded_ids(d, &ids); + let frames = threaded_ids(&ThreadIndex::of(d), &ids); if !frames.is_empty() { return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); } diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 8605a7e052..0bc13dba7d 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -456,3 +456,116 @@ fn first_text(s: &Session) -> NodeId { }); found.unwrap() } + +/// Whether command `id` is enabled for `s`'s selection, with the reason when it is not. +fn enabled(s: &Session, id: &str) -> std::result::Result<(), String> { + command_specs().iter().find(|c| c.id == id).map_or_else(|| Err(format!("no command {id}")), |c| (c.enabled)(s)) +} + +/// What `f` returned, and how many times it went through the document's text threads to tell +/// which objects are frames of one. +fn scans(f: impl FnOnce() -> T) -> (T, usize) { + let before = crate::cmd::threads::thread_scans(); + let out = f(); + (out, crate::cmd::threads::thread_scans() - before) +} + +/// Threads `a` with a new 100 pt square frame at (`x`, `y`); the thread's frames. +fn thread_with_frame(s: &mut Session, a: NodeId, x: f64, y: f64) -> Vec { + let frame = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a.0, frame]})).unwrap(); + let r = s.execute("text.thread.create", &json!({})).unwrap(); + r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +/// `ids` as a type command's refusal lists them. +fn listed(ids: &[u64]) -> String { + ids.iter().map(u64::to_string).collect::>().join(", ") +} + +/// Dynamic Text, its presets, Center on Top and Bottom and Area Type Options look up the threads +/// once for a whole selection, not once for each object, and refuse the same frames in the same +/// order. +#[test] +fn type_commands_look_up_threads_once_for_the_whole_selection() { + const N: usize = 24; + let mut s = session(); + let area = |s: &mut Session, i: usize| { + let x = 10.0 + 30.0 * i as f64; + let r = s.execute("text.create", &json!({"x": x, "y": 10, "text": format!("Area {i}"), "area": {"width": 25, "height": 40}})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + }; + // Area type: every other one is the first frame of a thread of two. + let (mut areas, mut threaded) = (vec![], vec![]); + for i in 0..N { + let a = area(&mut s, i); + areas.push(a.0); + if i % 2 == 0 { + let frames = thread_with_frame(&mut s, a, 10.0 + 30.0 * i as f64, 500.0); + assert_eq!(frames.len(), 2, "{frames:?}"); + areas.push(frames[1]); + threaded.extend(frames); + } + } + // The refusal lists the threaded frames in the order the objects are given. + areas.reverse(); + let refused: Vec = areas.iter().copied().filter(|id| threaded.contains(id)).collect(); + let before = undo_len(&s); + s.execute("select.set", &json!({ "ids": areas })).unwrap(); + let why = format!("area type in a thread cannot use Dynamic Text: {}", listed(&refused)); + let (e, n) = scans(|| enabled(&s, "type.dynamicText")); + assert_eq!((e, n), (Err(why.clone()), 1)); + let (e, n) = scans(|| err(s.execute("type.dynamicText", &json!({"on": true, "ids": areas})))); + assert!(e.ends_with(&why) && n == 1, "{n}: {e}"); + let (e, n) = scans(|| err(s.execute("text.areaOptions", &json!({"fit": "block", "ids": areas})))); + assert!(e.ends_with(&why) && n == 1, "{n}: {e}"); + assert_eq!(undo_len(&s), before); + + // Type on a path in a thread comes first, before the area type. + let path = on_line(&mut s, "Threaded path"); + let path_frames = thread_with_frame(&mut s, path, 700.0, 500.0); + let mut mixed = areas.clone(); + mixed.push(path.0); + s.execute("select.set", &json!({ "ids": mixed })).unwrap(); + let why = format!("type on a path in a thread cannot use Dynamic Text: {}", path.0); + let (e, n) = scans(|| enabled(&s, "type.dynamicText")); + assert_eq!((e, n), (Err(why.clone()), 1)); + let (e, n) = scans(|| err(s.execute("type.dynamicText", &json!({"on": true, "ids": mixed})))); + assert!(e.ends_with(&why) && n == 1, "{n}: {e}"); + + // Presets: point type beside type on a path in a thread. + let mut points: Vec = (0..N) + .map(|i| s.execute("text.create", &json!({"x": 10.0 + 30.0 * i as f64, "y": 300, "text": "P"})).unwrap()["id"].as_u64().unwrap()) + .collect(); + points.insert(N / 2, path.0); + s.execute("select.set", &json!({ "ids": points })).unwrap(); + let (e, n) = scans(|| enabled(&s, "type.dynamicTextPreset")); + assert_eq!((e, n), (Err(why.clone()), 1)); + let (e, n) = scans(|| err(s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": points})))); + assert!(e.ends_with(&why) && n == 1, "{n}: {e}"); + + // Center on Top and Bottom: type on closed paths, some of it in threads. + let mut circles = vec![]; + let mut in_threads = vec![]; + for i in 0..N { + let (x, y) = (60.0 + 30.0 * i as f64, 200.0); + let d = format!("M{} {y} A12 12 0 1 1 {} {y} A12 12 0 1 1 {} {y} Z", x - 12.0, x + 12.0, x - 12.0); + let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "O", "size": 6, "at": [x - 12.0, y]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + if i % 3 == 0 { + thread_with_frame(&mut s, id, x, 650.0); + in_threads.push(id.0); + } + circles.push(id.0); + } + s.execute("select.set", &json!({ "ids": circles })).unwrap(); + let why = format!("type on a path in a thread cannot be centered on its top or bottom: {}", listed(&in_threads)); + for c in ["type.pathCenter.top", "type.pathCenter.bottom"] { + let (e, n) = scans(|| enabled(&s, c)); + assert_eq!((e, n), (Err(why.clone()), 1), "{c}"); + let (e, n) = scans(|| err(s.execute(c, &json!({ "ids": circles })))); + assert!(e.ends_with(&why) && n == 1, "{c} {n}: {e}"); + } + assert_eq!(path_frames.len(), 2); +} From e51e67116d7bb1c2b5c525a4e19b3415f845257a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:43:18 -0800 Subject: [PATCH 07/10] Path: anchor edits on a long selected path skip measuring it for the canvas check The check after each edit that the selected objects stay within the canvas measured the whole of every selected path. Moving one anchor of a 10,000-anchor path measured all 9,999 segments. A path or compound path whose anchors and handles are all finite and inside the canvas range now passes without being measured, because its curves stay inside their control points. A path with a point outside that range is still measured, so the check accepts and refuses the same edits as before. path.moveAnchor to the point the anchor is already at, and path.setHandle to the point the handle is already at, now leave the document as it is and record no undo step. vectorcraft_geom::path::measured_segments counts the segments that PathData::bounds measures, and the new engine tests use it. --- crates/engine/src/cmd/path.rs | 25 +++++ crates/engine/src/sanity.rs | 21 +++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/anchor_edit_work.rs | 111 ++++++++++++++++++++ crates/geom/src/path.rs | 11 ++ docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 7 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/tests/anchor_edit_work.rs diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 2dee458930..33bde936bf 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -314,6 +314,15 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { let out = str_param(p, "which") != Some("in"); let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); let independent = switch_or(p, "independent", false, "path.setHandle")?; + // A handle already there, on an anchor already of the kind it would get, changes nothing and + // records no undo step. + if let Some(a) = anchor_of(s, id, si, ai)? { + let mut b = a; + b.set_handle(out, pos, independent); + if b == a { + return ok(); + } + } s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; @@ -330,6 +339,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); + // An anchor already at (x, y) changes nothing and records no undo step. + if let Some(a) = anchor_of(s, id, si, ai)? + && a.transform(vectorcraft_geom::Affine::translate(pos - a.p)) == a + { + return ok(); + } s.edit("Move", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; @@ -345,6 +360,16 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { ok() } +/// Anchor `ai` of subpath `si` of path `id` in the active document, when `id` is a path that has it. +/// Busy while a script runs, as [`Session::edit`] is. +fn anchor_of(s: &Session, id: NodeId, si: usize, ai: usize) -> Result> { + if s.script_running() { + return Err(EngineError::Busy); + } + let d = &s.doc()?.doc; + Ok(d.node(id).and_then(|n| n.path_data()).and_then(|path| path.subpaths.get(si)).and_then(|sp| sp.anchors.get(ai)).copied()) +} + fn set_anchors(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index 22e0ce956f..f1b6fd55bb 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -54,7 +54,26 @@ fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { } /// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)? +/// +/// A path or compound path whose anchors and handles are all finite and inside the range by a +/// margin passes without being measured: its curves stay inside their control points. Any other +/// object, and a path with a point outside that margin, is measured. pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { let paths = d.index_paths(ids.iter().copied()); - ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range)) + ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).is_none_or(|n| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range))) +} + +/// Coordinates at most this far from 0 keep a curve's measured bounds within [`MAX_COORD`] +/// whatever the rounding of its extrema. +const POINTS_LIMIT: f64 = MAX_COORD * (1.0 - 1e-9); + +/// Whether every anchor and handle of path `n`, or of each path of compound path `n`, is finite and +/// within [`POINTS_LIMIT`]. `false` for any other object. +fn points_in_range(n: &vectorcraft_doc::Node) -> bool { + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= POINTS_LIMIT && p.y.abs() <= POINTS_LIMIT; + match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)), + NodeKind::Compound { .. } => n.editing_children().iter().all(|c| points_in_range(c)), + _ => false, + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c260b42d90..2f849901a4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7,6 +7,7 @@ use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; mod actionset; pub(super) mod adjust; mod ai_open_path; +mod anchor_edit_work; mod appearance; mod areafit; mod arrowheads; diff --git a/crates/engine/src/tests/anchor_edit_work.rs b/crates/engine/src/tests/anchor_edit_work.rs new file mode 100644 index 0000000000..7ad50a871d --- /dev/null +++ b/crates/engine/src/tests/anchor_edit_work.rs @@ -0,0 +1,111 @@ +//! An edit of one anchor of a long selected path does not measure the path's segments +//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, and +//! an edit that leaves the anchor as it was records no undo step. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::path::measured_segments; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; + +use crate::*; + +/// Most segments one anchor edit may measure, however many anchors the path has. +const MOST_MEASURED: u64 = 16; + +/// A session whose document holds one open zigzag path of `n` anchors, selected, with its anchor 5 +/// direct-selected. +fn long_path(n: usize) -> (Session, NodeId) { + let mut d = Document::new(2000.0, 1000.0); + let anchors = (0..n).map(|i| Anchor::corner(Point::new(i as f64 * 0.1, if i % 2 == 0 { 100.0 } else { 110.0 }))).collect(); + let id = d.alloc_id(); + let node = Node::path(id, PathData::single(SubPath::new(anchors, false)), Appearance::default_art()); + if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() { + ch.push(Arc::new(node)); + } + let mut s = Session::new(); + s.add_document(d, None); + let st = s.doc_mut().unwrap(); + st.selection.set([id]); + st.selection.anchors.insert(id, BTreeSet::from([(0, 5)])); + (s, id) +} + +fn anchor(s: &Session, id: NodeId, ai: usize) -> Anchor { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai] +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Runs `cmd` with `p` and returns how many segments it measured. +fn measured(s: &mut Session, cmd: &str, p: Value) -> u64 { + let before = measured_segments(); + s.execute(cmd, &p).unwrap(); + measured_segments() - before +} + +#[test] +fn one_anchor_edit_of_a_long_path_measures_few_segments() { + for n in [100, 10_000] { + let (mut s, id) = long_path(n); + let a = anchor(&s, id, 5); + let m = measured(&mut s, "path.moveAnchor", json!({"id": id.0, "anchor": 5, "x": 3, "y": 50})); + assert!(m <= MOST_MEASURED, "path.moveAnchor on {n} anchors measured {m} segments"); + assert_eq!(anchor(&s, id, 5).p, Point::new(3.0, 50.0)); + let m = measured(&mut s, "path.setHandle", json!({"id": id.0, "anchor": 5, "which": "out", "x": 4, "y": 40})); + assert!(m <= MOST_MEASURED, "path.setHandle on {n} anchors measured {m} segments"); + assert_eq!(anchor(&s, id, 5).h_out, Point::new(4.0, 40.0)); + let m = measured(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0})); + assert!(m <= MOST_MEASURED, "path.moveAnchors on {n} anchors measured {m} segments"); + let m = measured(&mut s, "object.nudge", json!({"dx": 1, "dy": 0})); + assert!(m <= MOST_MEASURED, "object.nudge on {n} anchors measured {m} segments"); + assert_eq!(anchor(&s, id, 5).p, Point::new(5.0, 50.0)); + // Only anchor 5 moved, and undoing the four edits gives it back. + assert_eq!(anchor(&s, id, 4), Anchor::corner(Point::new(4.0 * 0.1, 100.0))); + assert_eq!(anchor(&s, id, 6), Anchor::corner(Point::new(6.0 * 0.1, 100.0))); + for _ in 0..4 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(anchor(&s, id, 5), a); + } +} + +/// A handle past the canvas is measured: the edit stands while the curve stays within the canvas, +/// and is refused once the curve leaves it. +#[test] +fn a_handle_past_the_canvas_is_measured() { + let (mut s, id) = long_path(10_000); + // The curve from anchor 5 reaches 4/9 of the way to its outgoing handle. + let x = 2.0 * MAX_COORD; + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).unwrap(); + assert_eq!(anchor(&s, id, 5).h_out, Point::new(x, 110.0)); + let before = s.doc().unwrap().doc.clone(); + let undo = undo_steps(&s); + let x = 3.0 * MAX_COORD; + assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).is_err()); + assert!(s.doc().unwrap().doc == before, "the refused edit changed the document"); + assert_eq!(undo_steps(&s), undo); +} + +/// `path.moveAnchor` to where the anchor is and `path.setHandle` to where the handle is change +/// nothing and record no undo step. +#[test] +fn an_anchor_edit_that_changes_nothing_records_no_undo_step() { + let (mut s, id) = long_path(100); + let a = anchor(&s, id, 5); + s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x, "y": a.p.y})).unwrap(); + assert_eq!(undo_steps(&s), 0, "path.moveAnchor recorded an undo step"); + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": a.h_out.x, "y": a.h_out.y})).unwrap(); + assert_eq!(undo_steps(&s), 0, "path.setHandle recorded an undo step"); + assert_eq!(anchor(&s, id, 5), a); + // An anchor that is not there is still refused. + assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 100, "x": 0, "y": 0})).is_err()); + assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 100, "which": "out", "x": 0, "y": 0})).is_err()); + // A change still records one. + s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x + 1.0, "y": a.p.y})).unwrap(); + assert_eq!(undo_steps(&s), 1); +} diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index ac7dc63add..bef18334ae 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -248,6 +248,16 @@ fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result, d.deserialize_seq(V) } +thread_local! { + /// [`measured_segments`] of this thread. + static MEASURED_SEGMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many segments [`PathData::bounds`] has measured on this thread. +pub fn measured_segments() -> u64 { + MEASURED_SEGMENTS.get() +} + /// An open or closed run of anchors. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SubPath { @@ -568,6 +578,7 @@ impl PathData { } let mut bbox: Option = None; for sp in &self.subpaths { + MEASURED_SEGMENTS.set(MEASURED_SEGMENTS.get().saturating_add(sp.segment_count() as u64)); for i in 0..sp.segment_count() { let c = sp.segment(i); // `to_bezpath_into` writes a line where neither handle is out, else a cubic. diff --git a/docs/mcp.md b/docs/mcp.md index 707a414d0c..57e428ad72 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5052,6 +5052,8 @@ the canvas (coordinates out of range)". `path.setHandle`, `path.moveAnchor` and `path.insertAnchor` edit subpath 0 when `subpath` is absent or null, and refuse a `subpath` that is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded. +`path.moveAnchor` to the point the anchor is at, and `path.setHandle` to the point the handle is at on an anchor that +keeps its kind, leave the document as it is and record no undo step. `path.insertAnchor` keeps the direct-selected anchors and selected segments of the path on the same points: those after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7c1b28726..1c87d1cebe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1766,6 +1766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it 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, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view. When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). + The check after an edit that a selected path or compound path stays within the canvas passes without measuring the path when every anchor and handle of it is within the canvas, and measures it only when one is not. Moving one anchor of a selected open path of 10,000 anchors (`path.moveAnchor`, `path.setHandle`, `path.moveAnchors` or a nudge) measured all 9,999 segments of the path and now measures at most 16 (counted in the engine's tests). The edit still copies the path's anchors for undo, and the renderer still compares every anchor of the path to find the moved ones. Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). From 6e308c4e5438cd6c1e6ebbc654e4cb1729669712 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:05:05 -0800 Subject: [PATCH 08/10] Graphs: Global Edit, Randomize Opacity and Sort Objects keep what they give a series A path directly in a graph series lost what these commands gave it when the graph was drawn again, for example after graph.setData: - While Global Edit is on in an isolated graph, a match that is a series path, or a series group whose paths take the edit, now has what changed on its paths stored for their series: the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. The edited match is stored by its own command as before. - addon.arrange.randomizeOpacity and addon.arrange.sort with apply "opacity" now store the object opacity, with the blend mode, of the series paths whose opacity they change. When they change several paths of one series, the frontmost of them sets what the graph stores, as for the other commands. The graph stores them in the same undo step as the edit. The params text of addon.arrange.randomizeOpacity and addon.arrange.sort, docs/mcp.md and docs/target-app-parity.md describe the new behavior. --- crates/engine/src/cmd/addons/arrange.rs | 23 ++++- crates/engine/src/cmd/appearance.rs | 10 +- crates/engine/src/cmd/globaledit.rs | 17 ++++ crates/engine/src/cmd/graph/tests.rs | 118 ++++++++++++++++++++++++ docs/mcp.md | 10 +- docs/target-app-parity.md | 1 + 6 files changed, 171 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 735d721335..d7cae868ed 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; +use super::super::appearance::series_children_in_paint_order; use super::super::edit::selected_roots; +use super::super::graph::capture_series_object_opacity; use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; @@ -90,7 +92,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -99,7 +101,7 @@ pub fn specs() -> Vec { "Sort Objects", ["Object", "Plug-ins", "Arrange Tools"], None, - "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}", has_multi, sort ), @@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); s.edit("Randomize Opacity", |d, _| { + let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); - if let Some(n) = d.node_mut(*id) { + if let Some(n) = d.node_mut(*id) + && n.opacity != o + { n.opacity = o; + changed.push(*id); } } + keep_series_opacity(d, &changed); Ok(()) })?; Ok(json!({ "count": ids.len() })) } +/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are +/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn +/// again; the frontmost of them in a series sets them. +fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) { + let marks = series_children_in_paint_order(d, changed); + capture_series_object_opacity(d, &marks); +} + /// An object Sort Objects sorts: its layer or group, its place in it, its box as /// `object.setBounds` measures it and its opacity (%). struct Sorted { @@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result { n.opacity = (v / 100.0) as f32; } } + let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); + keep_series_opacity(d, &changed); Ok(()) })?; return Ok(count); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5a129f6d32..4c662b200e 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -798,12 +798,18 @@ pub(super) fn capture_series_writes( /// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, /// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + let marks = series_children_in_paint_order(d, ids); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + +/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier +/// one's, so the frontmost one sets what the graph stores. +pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); - // A later mark's capture replaces an earlier one's: take them in paint order. marks.sort_by_cached_key(|id| d.index_path(*id)); - !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) + marks } /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index 98f53b102e..b1d39e5b77 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -49,6 +49,10 @@ //! member with another order, and any other change to the list (items moved, or several added //! or removed at once), takes the model's whole list of fills and strokes. //! +//! A path directly in a graph series that takes an edit from another member (a graph's paths are +//! members only while the graph is isolated) also has what changed stored on its graph, so that +//! every path of the series takes it when the graph is drawn again (docs/mcp.md). +//! //! Members that are hidden or locked at the time are left as they are. An edit that would take any //! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that //! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag @@ -778,9 +782,22 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se } super::object::keep_compound_aligned(d, *id, Scaling::default()); } + keep_series_changes(before, d, &edited); edited } +/// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph +/// series, for the graph's next redraw. The edited member is left to the edit's own command. +fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) { + let mut taken = vec![]; + for id in edited { + if let Some(n) = d.node(*id) { + n.walk(&mut |c: &Node| taken.push(c.id)); + } + } + super::appearance::capture_series_changes(before, d, &taken); +} + /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas /// highlights them while Global Edit is on. While it is off, with one supported object selected, diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b1a491424e..5139cde43d 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -572,6 +572,124 @@ fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); } +/// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo +/// step, and every mark of the series has it when the graph is drawn again. +#[test] +fn a_random_opacity_on_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap(); + let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity; + assert!(front < 1.0); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored"); +} + +/// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of +/// the one another mark left there. +#[test] +fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3), "undo takes back the sort and what the graph stored"); +} + +/// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its +/// opacity leaves the stored value to the marks it changed. +#[test] +fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%. + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.6, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); +} + +/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark +/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them +/// when the graph is drawn again. +#[test] +fn what_global_edit_gives_series_marks_survives_regeneration() { + let (mut s, id, _, _) = column_marks(); + // Equal values: the first column of each series has the same shape. + s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap(); + let mark = s.doc().unwrap().doc.node(other).unwrap().clone(); + assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark"); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + assert_eq!(kept.fill, Some(mark.appearance.fill_paint())); + assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0))); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint())); + while s.doc().unwrap().history.undo.len() > steps { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints); +} + +/// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its +/// marks, and the graph stores it for their series too. +#[test] +fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Series without names draw no legend, so each series group holds only its columns, laid out like the other's. + let csv = ",,\nQ1,3,3\nQ2,5,5"; + let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap(); + let id = NodeId(made["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (first, other) = (series(&n, 0).clone(), series(&n, 1).id); + s.execute("object.isolate", &json!({"id": id.0})).unwrap(); + s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap(); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}"); + let marks: Vec = first.children().unwrap().iter().map(|m| m.id.0).collect(); + s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint(); + assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group"); + assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone())); + s.execute("object.exitIsolation", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given)); +} + /// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of /// the series again; Normal forgets it. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 66c4cc0cff..a6a8889e43 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -798,9 +798,13 @@ color changed by `paint.invert`, `paint.complement`, `edit.colors.invert`, `edit `addon.color.filter`, `addon.color.applyNearestSwatch` or the ink limit of `addon.color.printFixes`. The recolor commands store it too when they are given a series group, the graph or a group that holds the graph. A row a command leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of -them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that -placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width -profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +them sets what the graph stores. While Global Edit is on, a path directly in a series that takes an edit from another +match (a graph's paths are matches only while the graph is isolated) has what changed on it stored for its series: the +paint, weight, opacity and blend mode of the topmost visible fill and stroke, and the object's opacity and blend mode. +`addon.arrange.randomizeOpacity` and `addon.arrange.sort` with `apply: "opacity"` store the object opacity, with the +blend mode, of the frontmost path of a series whose opacity they change. A gradient is stored with its placement, and +every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's +dashes, caps, joins, arrowheads, width profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again with the paint they had. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9a9b0d06ed..0fd5e9ea1b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1090,6 +1090,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are. + What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 77e4dceb9d7e88f4e9bc4540d4dd4428e8101560 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:04:05 -0800 Subject: [PATCH 09/10] Type on a path: overflowing right-to-left type shows the longest start that fits Right-to-left type on a path that overflows searched for the start of its text that fits with a binary search, which assumed that a longer start never fits once a shorter one does not. A letter tracked to a negative advance breaks that: with steps of 6, 6, -3 and 6 points in a 10 point span, the first three letters fit while the first two do not, and the search showed only the first letter. - Without hanging punctuation, and with no spacing or on a path of one straight segment, every start is measured in O(n log n): each glyph keeps its place in the whole text's visual order, except the trailing whitespace and controls that UAX #9 rule L1 sets to the paragraph's level. - With hanging punctuation, or spacing on a curve, when a letter has a negative advance, a start is a candidate only when its glyphs, in its own visual order and each at the least step it can take, reach no farther than the path's length. This lower bound holds with negative advances. - At most 32 candidates are arranged from the longest. When none of them fits, a binary search over the rest shows a start that fits, which is not always the longest. - With hanging punctuation, or spacing on a curve, text with no negative advance keeps the binary search. Right-aligned and centered type on a curve with Spacing was placed using the width its glyphs have at the start bracket, while the steps were measured where the line was placed, so a line that fits could end past the end bracket and lose glyphs. It is now placed using the width of the steps it has where it is placed. --- crates/text/src/layout.rs | 2 + crates/text/src/layout/on_path.rs | 362 +++++++++++++++++++++++++++--- crates/text/src/tests.rs | 23 ++ crates/text/src/tests/bidi.rs | 167 ++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 527 insertions(+), 28 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6dbbb6108d..e42bd2044e 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -42,6 +42,8 @@ use breaking::{break_line, candidates}; pub use driver::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled}; #[cfg(test)] pub(crate) use driver::{fit_passes, layout_faces_per_call}; +#[cfg(test)] +pub(crate) use on_path::{layout_trying_each_start, path_arrangements}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; #[cfg(test)] diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index de0272cb44..d119f5d780 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -7,6 +7,7 @@ use super::{ SGlyph, Shape, TextObject, Vec2, style_metrics, }; use crate::fontdb::PathAlignHeights; +use unicode_bidi::{BidiClass, BidiInfo}; /// The distance along type on a path each glyph takes: its advance measured `spacing` points above /// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and @@ -132,6 +133,253 @@ fn bend_outline(outline: &BezPath, pre: Affine, ap: &ArcPath, bend: &GlyphBend) .then_some(out) } +/// Glyphs in a row, each taking its step: how far the row takes the line (`sum`), and how far past +/// where its first glyph starts the farthest of their ends is (`far`). +#[derive(Clone, Copy)] +struct Reach { + sum: f64, + far: f64, +} + +impl Reach { + /// No glyphs. + const NONE: Self = Self { sum: 0.0, far: f64::NEG_INFINITY }; + + /// One glyph. + fn step(step: f64) -> Self { + Self { sum: step, far: step } + } + + /// The glyphs of `self` followed by those of `next`. + fn then(self, next: Self) -> Self { + Self { sum: self.sum + next.sum, far: self.far.max(self.sum + next.far) } + } +} + +/// The reach of the glyphs at some of `n` positions in a row (a segment tree): a glyph is put at +/// its position in O(log n), and the reach of the glyphs in a range of positions is found in O(log n). +struct Reaches { + size: usize, + nodes: Vec, +} + +impl Reaches { + fn new(n: usize) -> Self { + let size = n.next_power_of_two(); + Self { size, nodes: vec![Reach::NONE; 2 * size] } + } + + fn node(&self, k: usize) -> Reach { + self.nodes.get(k).copied().unwrap_or(Reach::NONE) + } + + /// Puts a glyph that takes `step` at position `at`. + fn put(&mut self, at: usize, step: f64) { + let mut k = self.size + at; + let Some(leaf) = self.nodes.get_mut(k) else { return }; + *leaf = Reach::step(step); + while k > 1 { + k /= 2; + let joined = self.node(2 * k).then(self.node(2 * k + 1)); + if let Some(node) = self.nodes.get_mut(k) { + *node = joined; + } + } + } + + /// The reach of the glyphs put at positions `range`, in order. + fn of(&self, range: Range) -> Reach { + let (mut left, mut right) = (Reach::NONE, Reach::NONE); + let (mut a, mut b) = (range.start.min(self.size) + self.size, range.end.min(self.size) + self.size); + while a < b { + if a % 2 == 1 { + left = left.then(self.node(a)); + a += 1; + } + if b % 2 == 1 { + b -= 1; + right = self.node(b).then(right); + } + (a, b) = (a / 2, b / 2); + } + left.then(right) + } +} + +/// Whether a glyph whose character has bidi class `class` goes to its paragraph's level when the +/// line ends with it and the characters after it like it (UAX #9 L1): whitespace and isolate +/// controls, and the embedding controls and boundary neutrals, which `unicode_bidi` treats the same. +fn trails(class: BidiClass) -> bool { + use unicode_bidi::BidiClass::{BN, FSI, LRE, LRI, LRO, PDF, PDI, RLE, RLI, RLO, WS}; + matches!(class, WS | FSI | LRI | RLI | PDI | RLE | LRE | RLO | LRO | PDF | BN) +} + +/// Measures every start of the text in reading order, shortest first, in O(n log n) for n glyphs: +/// `visit(end, reach)` gets the byte each start ends at (a glyph's byte; the whole text is not +/// visited) and the reach of its glyphs in its own visual order when each glyph `g` takes `step(g)`. +/// +/// The visual order of a start of a paragraph keeps the order its glyphs have in the whole +/// paragraph (UAX #9 L2 reorders by the levels between two glyphs only), except for the glyphs +/// that L1 sets to the paragraph's level at the end of the start: those come after the rest in a +/// left-to-right paragraph and before it, last first, in a right-to-left one. The glyphs are put at +/// their positions in the whole text's visual order one start after another. +fn each_start(shaped: &[(Range, Option>, Vec)], step: impl Fn(&SGlyph) -> f64, mut visit: impl FnMut(usize, Reach)) { + let total = shaped.iter().map(|(_, _, glyphs)| glyphs.len()).sum(); + let mut reaches = Reaches::new(total); + let mut last_byte = None; + let mut para_at = 0; + for (pr, bidi, glyphs) in shaped { + // Where each glyph of the paragraph is in the whole text's visual order. + let mut at = vec![para_at; glyphs.len()]; + for (v, i) in visual_order(bidi.as_ref(), pr.start, pr.clone(), glyphs).into_iter().enumerate() { + if let Some(p) = at.get_mut(i) { + *p = para_at + v; + } + } + let para_rtl = bidi.as_ref().and_then(|b| b.paragraphs.first()).map(|p| p.level.is_rtl()); + // The glyphs at the end of the start so far that L1 sets to the paragraph's level, their + // reach in visual order, and the reach of the cluster being added to them. + let mut held: Vec<(usize, f64)> = Vec::new(); + let (mut trailing, mut cluster) = (Reach::NONE, Reach::NONE); + let mut prev: Option<&SGlyph> = None; + for (g, &p) in glyphs.iter().zip(&at) { + // A cluster is a glyph and those after it at its byte (a list marker's apart). + let joins = prev.is_some_and(|q| q.byte == g.byte && (q.len == 0) == (g.len == 0)); + if !joins { + trailing = match para_rtl { + Some(true) => cluster.then(trailing), + _ => trailing.then(cluster), + }; + cluster = Reach::NONE; + } + if last_byte != Some(g.byte) { + // The start that ends before this glyph. + let reach = match para_rtl { + _ if held.is_empty() => reaches.of(0..total), + Some(true) => reaches.of(0..para_at).then(trailing).then(reaches.of(para_at..total)), + _ => reaches.of(0..total).then(trailing), + }; + visit(g.byte, reach); + last_byte = Some(g.byte); + } + let s = step(g); + // Every character of the glyph's cluster is one that trails. + let from = g.byte.saturating_sub(pr.start); + let classes = bidi.as_ref().and_then(|b| b.original_classes.get(from..from + g.len)); + if para_rtl.is_some() && g.len > 0 && classes.is_some_and(|c| c.iter().copied().all(trails)) { + held.push((p, s)); + cluster = cluster.then(Reach::step(s)); + } else { + for (q, s) in held.drain(..) { + reaches.put(q, s); + } + (trailing, cluster) = (Reach::NONE, Reach::NONE); + reaches.put(p, s); + } + prev = Some(g); + } + // The whole paragraph is in its own visual order. + for (q, s) in held.drain(..) { + reaches.put(q, s); + } + para_at += glyphs.len(); + } +} + +/// The end (a byte) of the longest start of the text, in reading order, whose glyphs fit when each +/// takes its advance: `fits(w, far)` says whether glyphs in visual order that take `w` along the +/// path, the farthest of their ends `far` past where the first starts, fit. `first` is the first +/// glyph's byte, where nothing is laid out. A start can fit where a shorter one doesn't, after a +/// glyph that takes the line back, so every start is measured ([`each_start`]). +fn longest_fitting_start(shaped: &[(Range, Option>, Vec)], first: usize, fits: impl Fn(f64, f64) -> bool) -> usize { + let mut longest = first; + each_start( + shaped, + |g| g.adv, + |byte, reach| { + if fits(reach.sum, reach.far) { + longest = byte; + } + }, + ); + longest +} + +/// The ends (bytes) of the starts of the text, in reading order, shortest first, that can fit on a +/// path of length `len` with `spacing`: those whose glyphs, in their own visual order, reach at most +/// `len` past where the first starts when each takes the least step it can. A glyph's step is at +/// least a quarter of its advance with spacing, and its advance otherwise ([`path_steps`]); a line +/// starts no earlier than its span with the hang at its start, and every glyph that fits ends within +/// that span with both hangs, which is no longer than the path. The start before the first glyph, +/// where nothing is laid out, is always one of them. +fn starts_that_can_fit(shaped: &[(Range, Option>, Vec)], spacing: f64, len: f64) -> Vec { + let least = |g: &SGlyph| if spacing != 0.0 && g.adv > 1e-9 { g.adv * 0.25 } else { g.adv }; + let mut starts = Vec::new(); + each_start(shaped, least, |byte, reach| { + // A little room for sums added in another order than where the glyphs are placed. + if reach.far <= len + 1e-5 || reach.far.is_nan() { + starts.push(byte); + } + }); + starts +} + +/// The most starts of overflowing right-to-left type on a path that are arranged one by one, from +/// the longest, when a glyph takes the line back ([`longest_start_tried`]). +const PATH_START_TRIES: usize = 32; + +/// The longest of `starts` (ends of starts, shortest first, the first of which fits) for which +/// `fits` holds, arranging at most `tries` of them one by one, from the longest. When none of those +/// fits, a binary search among the rest finds one that fits with O(log n) more arrangements; as a +/// longer start can fit where a shorter one doesn't, that one is not always the longest that fits. +/// `None` when there are no starts. +fn longest_start_tried(starts: &[usize], tries: usize, mut fits: impl FnMut(usize) -> bool) -> Option { + let rest = starts.len().saturating_sub(tries); + if let Some(&byte) = starts.get(rest..).unwrap_or_default().iter().rev().find(|&&byte| fits(byte)) { + return Some(byte); + } + // `starts[fit]` fits; `starts[over]` was tried or doesn't. + let (mut fit, mut over) = (0, rest); + while over > fit + 1 { + let mid = fit + (over - fit) / 2; + if starts.get(mid).is_some_and(|&byte| fits(byte)) { + fit = mid; + } else { + over = mid; + } + } + starts.get(fit).copied() +} + +#[cfg(test)] +thread_local! { + /// How many times type on a path arranged glyphs on its path on this thread ([`path_arrangements`]). + static ARRANGEMENTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times type on a path arranged glyphs on its path on this thread. +#[cfg(test)] +pub(crate) fn path_arrangements() -> usize { + ARRANGEMENTS.get() +} + +#[cfg(test)] +thread_local! { + /// Set while [`layout_trying_each_start`] lays text out. + static EACH_START: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// [`layout`](super::layout) arranging every start of overflowing right-to-left type on a path in +/// turn, from the longest, until one fits: the reference the search for the longest start is +/// compared with. +#[cfg(test)] +pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObject) -> crate::TextLayout { + EACH_START.set(true); + let l = super::layout(db, t); + EACH_START.set(false); + l +} + /// Lay type on a path out along `path` (text space), between its brackets. pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; @@ -247,29 +495,33 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, }; (l, r, hung_r) }; + // Where a line that takes `w` along the path starts, aligned in the span with the hangs. + let align = |w: f64, (l, r, _): (f64, f64, usize)| { + let (lo, room) = (s_start - l, avail + l + r); + match para.justify { + Justify::Auto if rtl => lo + (room - w).max(0.0), + Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), + _ => lo, + } + }; // The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs. let arrange = |sg: &[SGlyph]| { + #[cfg(test)] + ARRANGEMENTS.set(ARRANGEMENTS.get() + 1); let mut steps = path_steps(&ap, sg, s_start, spacing); - let w: f64 = steps.iter().sum(); - let place = |(l, r, _): (f64, f64, usize)| { - let (lo, room) = (s_start - l, avail + l + r); - match para.justify { - Justify::Auto if rtl => lo + (room - w).max(0.0), - Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), - Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), - _ => lo, - } - }; + // Where the line starts when its glyphs take `steps`. + let place = |steps: &[f64], hung| align(steps.iter().sum(), hung); let mut hung = hangs(sg, &steps); - let mut s0 = place(hung); + let mut s0 = place(&steps, hung); if spacing != 0.0 && s0 != s_start { // Spaced from where the alignment puts them, the curve under them is another. That - // changes the hanging glyphs' steps, so the line is placed again from the new steps - // until it stays where it is (at most 8 times). + // changes the glyphs' steps, so what the line takes and what hangs, and the line is + // placed again from the new steps until it stays where it is (at most 8 times). steps = path_steps(&ap, sg, s0, spacing); for _ in 0..8 { hung = hangs(sg, &steps); - let moved = place(hung); + let moved = place(&steps, hung); if (moved - s0).abs() < 1e-9 { break; } @@ -303,23 +555,47 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, // Right-to-left text that overflows keeps the start of its text, not what comes first in visual // order: the longest start, in reading order, whose glyphs fit in visual order is laid out. if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() { - let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect(); - starts.dedup(); - // The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it - // doesn't. - let (mut fit, mut over) = (0, starts.len()); - while over - fit > 1 { - let mid = (fit + over) / 2; - let sg = visual(starts.get(mid).copied().unwrap_or(text_len)); + // Whether the text before byte `cut` fits, arranged as it would be laid out. + let fits_before = |cut: usize| { + let sg = visual(cut); let (steps, s0, hung) = arrange(&sg); - if fitting(&steps, s0, hung) == sg.len() { - fit = mid; - } else { - over = mid; + fitting(&steps, s0, hung) == sg.len() + }; + // The byte at which each start of the text ends, in reading order. + let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs).map(|g| g.byte).collect(); + starts.dedup(); + let first = starts.first().copied().unwrap_or(text_len); + // On one straight segment the path doesn't turn, so spacing changes no step. + let straight = matches!(path.to_bezpath().elements(), [PathEl::MoveTo(_), PathEl::LineTo(_)]); + #[cfg(test)] + let each_start = EACH_START.get(); + #[cfg(not(test))] + let each_start = false; + fits = if each_start { + starts.iter().rev().copied().find(|&byte| fits_before(byte)).unwrap_or(first) + } else if (spacing == 0.0 || straight) && !hanging { + // Each glyph takes its advance wherever the line is placed. + longest_fitting_start(&shaped, first, |w, far| align(w, (0.0, 0.0, 0)) - s_start + far <= avail + 1e-6) + } else if sg.iter().all(|g| g.adv >= 0.0) { + // No glyph takes the line back: a start fits when a longer one does. The text before + // `starts[fit]` fits; before `starts[over]` (the whole text at the end) it doesn't. + let (mut fit, mut over) = (0, starts.len()); + while over - fit > 1 { + let mid = (fit + over) / 2; + if fits_before(starts.get(mid).copied().unwrap_or(text_len)) { + fit = mid; + } else { + over = mid; + } } - } + starts.get(fit).copied().unwrap_or(text_len) + } else { + // A glyph that takes the line back can make a start fit where a shorter one doesn't: + // the starts that can fit the path however their glyphs are spaced are tried from the + // longest, at most `PATH_START_TRIES` of them before a binary search over the rest. + longest_start_tried(&starts_that_can_fit(&shaped, spacing, ap.len()), PATH_START_TRIES, &fits_before).unwrap_or(first) + }; cx.out.overflow = true; - fits = starts.get(fit).copied().unwrap_or(text_len); sg = visual(fits); (steps, s0, hung) = arrange(&sg); } @@ -432,3 +708,33 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, region: 0, }); } + +#[cfg(test)] +mod tests { + use super::longest_start_tried; + + /// The longest start that fits is found among the ones tried; past the tries, a binary search + /// finds one that fits, with as many more tries as halvings of the rest. + #[test] + fn starts_are_tried_from_the_longest_then_searched() { + let starts: Vec = (0..1000).collect(); + // Every start up to 600 fits, and so does 990. + let fits = |byte: usize| byte <= 600 || byte == 990; + let mut tried = 0; + let found = longest_start_tried(&starts, 32, |byte| { + tried += 1; + fits(byte) + }); + assert_eq!((found, tried), (Some(990), 10)); + tried = 0; + let found = longest_start_tried(&starts, 8, |byte| { + tried += 1; + fits(byte) + }); + assert_eq!(found, Some(600)); + assert!(tried <= 8 + 10, "{tried} tries"); + // With fewer starts than tries, each is tried. + assert_eq!(longest_start_tried(&starts[..5], 32, |byte| byte == 0), Some(0)); + assert_eq!(longest_start_tried(&[], 32, |_| true), None); + } +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8b1b2bc7e3..e7e4f33ff6 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -835,6 +835,29 @@ fn on_path_circle_and_overflow() { assert!(l.overflow && l.glyphs.len() < t.plain_text().len()); } +/// Type on a curve with Spacing, aligned right or centered, is placed by what its glyphs take where +/// it ends up, which differs from what they take at the start bracket: text that fits shows whole. +#[test] +fn on_path_aligned_spaced_text_that_fits_shows_whole() { + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 200.0).to_path(0.1)); + for text in ["Hi", "Around"] { + for justify in [Justify::Right, Justify::Center] { + for spacing in [20.0, -20.0] { + for (start, end) in [(0.0, Some(0.05)), (0.3, Some(0.4)), (0.0, None)] { + let mut t = point(text, style(12.0)); + t.para.justify = justify; + t.path_spacing = spacing; + t.kind = TextKind::OnPath { path: circle.clone(), start, end }; + let l = layout(db(), &t); + let case = format!("{text} {justify:?} {spacing} {start} {end:?}"); + assert!(!l.overflow, "{case}"); + assert_eq!(l.glyphs.len(), text.len(), "{case}"); + } + } + } + } +} + #[test] fn fallback_font_per_character() { let primary = db().face("Source Sans 3", "Regular").unwrap(); diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index 431ec9e568..f02db481ab 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -698,3 +698,170 @@ fn mixed_type_on_a_path_hangs_its_final_period() { assert!(l.glyphs.iter().any(|g| g.byte == s.len() - 1), "the period shows"); assert!(!l.overflow, "all of it fits"); } + +/// `n` Hebrew letters on `path` from `start` to `end` (fractions of its length), one run each, the +/// letter at `i` tracked so that it takes `steps[i % steps.len()]` points: what each takes with no +/// tracking is measured first, and what each takes when tracked is checked. +fn stepped_hebrew(db: &FontDb, n: usize, steps: &[f64], path: &BezPath, (start, end): (f64, Option)) -> TextObject { + let letters = ['א', 'ב', 'ג', 'ד']; + let style = CharStyle::default(); + let mut t = TextObject::point(Point::ZERO, "", style.clone()); + t.runs = (0..n).map(|i| TextRun { text: letters[i % letters.len()].to_string(), style: style.clone(), inline: None }).collect(); + let long = kurbo::Line::new((0.0, 0.0), (100_000.0, 0.0)).to_path(0.1); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&long), start: 0.0, end: None }; + let advances = |t: &TextObject| { + let l = layout(db, t); + assert!(!l.overflow); + let mut a: Vec<(usize, f64)> = l.glyphs.iter().map(|g| (g.byte, g.advance)).collect(); + a.sort_by_key(|&(byte, _)| byte); + assert_eq!(a.len(), n, "one glyph per letter"); + a.into_iter().map(|(_, adv)| adv).collect::>() + }; + let plain = advances(&t); + for (i, (run, adv)) in t.runs.iter_mut().zip(&plain).enumerate() { + run.style.tracking = (steps[i % steps.len()] - adv) / style.size * 1000.0; + assert!((-1000.0..=10000.0).contains(&run.style.tracking), "tracking {} is in the range commands allow", run.style.tracking); + } + for (i, adv) in advances(&t).into_iter().enumerate() { + assert!((adv - steps[i % steps.len()]).abs() < 1e-9, "letter {i} takes {adv}"); + } + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(path), start, end }; + t +} + +/// Right-to-left type on a path that overflows shows the longest start of its text whose glyphs +/// fit, even where a shorter start doesn't fit: a letter tracked to take the line back (a negative +/// step) lets a longer start fit than the one before it. +#[test] +fn overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + // The span runs from x = 0 to x = 10, with path on both sides of it. + let line = kurbo::Line::new((-100.0, 0.0), (100.0, 0.0)).to_path(0.1); + let t = stepped_hebrew(&db, 4, &[6.0, 6.0, -3.0, 6.0], &line, (0.5, Some(0.55))); + // Letters 0 and 1 take 12 points, more than the span holds; letters 0 to 2 take 9 and fit: + // in visual order letter 2 (-3), then 1 and 0 (6 each). All four take 15. + for (justify, s0) in [(Justify::Right, 1.0), (Justify::Auto, 1.0), (Justify::Center, 0.5), (Justify::Left, 0.0)] { + let mut t = t.clone(); + t.para.justify = justify; + let l = layout(&db, &t); + assert!(l.overflow, "{justify:?}"); + assert_eq!(l.lines.first().map(|line| line.end), Some(6), "{justify:?}: the first three letters show"); + assert_eq!(l.glyphs.len(), 3, "{justify:?}"); + let x = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x); + for (byte, at) in [(4, s0), (2, s0 - 3.0), (0, s0 + 3.0)] { + assert!(x(byte).is_some_and(|x| (x - at).abs() < 1e-6), "{justify:?}: letter at byte {byte} at {:?}, not {at}", x(byte)); + } + } +} + +/// The longest start that fits is the one found by arranging every start in turn, in mixed text +/// whose letters take the line forward and back: with spaces and controls at the end of a start +/// (UAX #9 L1), isolates, embeddings, several paragraphs and either base direction. +#[test] +fn the_longest_rtl_start_on_a_path_is_the_one_each_start_gives() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + let texts = [ + "abc אבג de שלום ", + "שלום עולם, hello world 123 שלום ", + "a \u{2067}אב \u{2069} cd אב \u{2066}x ", + "אב גד\nef גה \nשלום ab ", + "\u{202B}abc \u{202C} אב \u{200B} cd\u{0301} ", + ]; + let tracking = [-900.0, 300.0, -400.0, 0.0, 800.0, -1000.0, 150.0]; + let line = kurbo::Line::new((0.0, 0.0), (200.0, 0.0)).to_path(0.1); + let mut overflowed = 0; + for s in texts { + let style = CharStyle::default(); + let mut t = TextObject::point(Point::ZERO, "", style.clone()); + t.runs = s + .chars() + .enumerate() + .map(|(i, c)| TextRun { text: c.to_string(), style: CharStyle { tracking: tracking[i % tracking.len()], ..style.clone() }, inline: None }) + .collect(); + for direction in [None, Some(ParaDirection::RightToLeft), Some(ParaDirection::LeftToRight)] { + for justify in [Justify::Right, Justify::Center, Justify::Left, Justify::Auto] { + for end in (1..=25).map(|i| f64::from(i) * 0.02) { + t.para.direction = direction; + t.para.justify = justify; + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: Some(end) }; + let (found, each) = (layout(&db, &t), crate::layout::layout_trying_each_start(&db, &t)); + let case = format!("{s:?} {direction:?} {justify:?} {end}"); + overflowed += usize::from(found.overflow); + assert_eq!(found.overflow, each.overflow, "{case}"); + assert_eq!(found.lines.first().map(|l| l.end), each.lines.first().map(|l| l.end), "{case}"); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.byte, g.origin.x)).collect::>(); + assert_eq!(placed(&found), placed(&each), "{case}"); + } + } + } + } + assert!(overflowed > 500, "{overflowed} of the layouts overflow"); +} + +/// Finding the longest start that fits in a long right-to-left text whose letters take the line +/// back does not arrange the glyphs again for every start, nor for a search over them. +#[test] +fn the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + // Every 4 letters take 15 points, but 2 letters into a group the line is 3 points longer than + // after 3: with 940 points, 251 letters (939 points) fit and 250 (942) don't. + let line = kurbo::Line::new((0.0, 0.0), (940.0, 0.0)).to_path(0.1); + let t = stepped_hebrew(&db, 2000, &[6.0, 6.0, -3.0, 6.0], &line, (0.0, None)); + let mut cases = Vec::new(); + for justify in [Justify::Right, Justify::Center, Justify::Left] { + let mut t = t.clone(); + t.para.justify = justify; + cases.push((format!("{justify:?}"), t.clone(), 2)); + // Spacing on a straight path changes no step. + t.path_spacing = 5.0; + cases.push((format!("{justify:?} spaced"), t.clone(), 2)); + // Hanging punctuation: starts are arranged one by one, from the longest that could fit. + t.path_spacing = 0.0; + t.para.roman_hanging = true; + cases.push((format!("{justify:?} hanging"), t, 3)); + } + for (case, t, most) in cases { + let before = crate::layout::path_arrangements(); + let l = layout(&db, &t); + let arrangements = crate::layout::path_arrangements() - before; + assert!(l.overflow, "{case}"); + assert_eq!(l.lines.first().map(|line| line.end), Some(251 * 'א'.len_utf8()), "{case}: 251 letters show"); + assert_eq!(l.glyphs.len(), 251, "{case}"); + assert!(arrangements <= most, "{case}: {arrangements} arrangements"); + } +} + +/// Long right-to-left type on a curved, spaced path with hanging punctuation, whose letters take the +/// line forward and back in turn, is arranged a bounded number of times however long the text is, +/// and the start it shows fits. +#[test] +fn rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times() { + use vectorcraft_doc::Justify; + let db = FontDb::with_font_dirs(vec![]); + let n = 600; + let circle = kurbo::Circle::new((0.0, 0.0), 200.0).to_path(0.1); + let t = stepped_hebrew(&db, n, &[6.0, -4.0], &circle, (0.0, Some(0.05))); + for justify in [Justify::Right, Justify::Left] { + for spacing in [20.0, -20.0] { + let mut t = t.clone(); + t.para.justify = justify; + t.para.roman_hanging = true; + t.path_spacing = spacing; + let case = format!("{justify:?} spacing {spacing}"); + let before = crate::layout::path_arrangements(); + let l = layout(&db, &t); + let arrangements = crate::layout::path_arrangements() - before; + let end = l.lines.first().map_or(0, |line| line.end); + // The whole text, at most 32 starts one by one, a binary search over 600, and the start shown. + assert!(arrangements <= 44, "{case}: {arrangements} arrangements, overflow {}, end {end}", l.overflow); + assert!(l.overflow, "{case}"); + assert!(end > 0, "{case}: some letters show"); + assert_eq!(l.glyphs.len(), end / 'א'.len_utf8(), "{case}: every letter of the start shows"); + let each = crate::layout::layout_trying_each_start(&db, &t); + assert!(end <= each.lines.first().map_or(0, |line| line.end), "{case}: no start longer than the longest that fits"); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7c1b28726..d44eac5e1c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1313,6 +1313,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs. + Type on a path with right-to-left text that does not fit shows the longest start of its text whose glyphs fit, also where tracking gives a letter a negative advance and a shorter start does not fit; the rest overflows or flows to the next threaded object (VectorCraft's own rule). Without hanging punctuation, and on a straight path or with no spacing, every start is measured without arranging it on the path again. With hanging punctuation, or spacing on a curve, when a letter has a negative advance, at most 32 of the starts that could fit are arranged one by one from the longest; when none of them fits, a binary search over the rest shows a start that fits, which is not always the longest (`overflowing_rtl_type_on_a_path_shows_the_longest_start_that_fits`, `the_longest_rtl_start_on_a_path_is_found_without_arranging_each_start`, `the_longest_rtl_start_on_a_path_is_the_one_each_start_gives` and `rtl_type_on_a_curve_whose_letters_take_the_line_back_is_arranged_a_bounded_number_of_times` in `crates/text/src/tests/bidi.rs`). Right-aligned and centered type on a curve with Spacing is placed by what its glyphs take where the line ends up, so text that fits is not cut off at the end bracket (`on_path_aligned_spaced_text_that_fits_shows_whole` in `crates/text/src/tests.rs`). The Paragraph panel menu's Kinsoku Shori Type (`text.setFormat {kinsokuType}`, with the East Asian options) picks Push In First, Push Out First or Push Out Only for each paragraph: a character the Kinsoku Set keeps off a line's start or end is set on the line, with the line's gaps next to CJK characters closed by at most a tenth of an em each to fit, or moved to the next line with the character before it. Push Out First pushes in only when pushing out would leave the line more than an em short. Push Out Only is the default, the setting is saved only when it is not, and paragraph styles carry it. The Paragraph panel menu's Bunri-Kinshi (`text.setFormat {bunriKinshi}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) keeps inseparable pairs on one line in each paragraph that has it: two em dashes, two ellipses, two two-dot leaders and the two halves of a vertical kana repeat mark (the pairs of W3C JLREQ's class cl-08, VectorCraft's set). Both composers and vertical type follow it, and justification adds no space inside a pair. It works with a Kinsoku Set only, is off by default and is saved only when on; editing the set of pairs is not available. The Paragraph panel menu's Korean Line Break Anywhere (`text.setFormat {koreanLineBreakAnywhere}`, with the East Asian options, and the East Asian Composition Settings section of Paragraph Style Options) sets for each paragraph whether a line may break between any two Hangul syllables. When it is off, a word that contains Hangul breaks only at its spaces and after punctuation, or where the word is wider than the line, and takes no hyphen. New type has it off; paragraphs saved before the option read as on, so their lines do not move. From 9fd0ede79f0e8a8a2489bc8c723b98104ca19164 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 14:15:25 -0800 Subject: [PATCH 10/10] Canvas range: removed guides are looked up through sets, and nested layers are walked once When a read document is brought within the canvas, each saved guide was checked against the removed artboard ids and the removed layer ids with linear scans, and each guide node against the removed artboard ids, so the work grew with the number of guides times the number of removed ids. The ids are now collected into ordered sets, and each guide is checked in logarithmic time. Removing what lies beyond the canvas scanned each container's whole subtree to see whether anything reached beyond, then scanned each child again while removing, one level down at a time, so a deeply nested object was measured once for every group around it. Removing guide nodes probed each layer's subtree the same way before descending into its sublayers. Both now make one read-only walk that records, by index, which objects reach beyond (or which guides are removed), and the removal follows that record. The objects clipped by a clipping path beyond the canvas are still not measured. Containers with nothing to remove are still not copied. The objects and guides that remain, their order, the counts, the retired artboard ids and the saved selections are unchanged. --- crates/doc/src/canvas_range.rs | 281 +++++++++++++++++++++++++++------ 1 file changed, 237 insertions(+), 44 deletions(-) diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 491b79f158..d920c51332 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -3,6 +3,7 @@ //! the ruler origin and the objects an edit touches), so a file holding something beyond it could be //! opened but not edited: one object, artboard or guide out there made every move fail. +use std::collections::BTreeSet; use std::sync::Arc; use vectorcraft_geom::{Point, Rect}; @@ -72,27 +73,41 @@ fn clipping(n: &Node) -> Option { } } -/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by -/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its -/// clipping path. -fn reaches_beyond(n: &Node) -> bool { - match clipping(n) { - None => beyond(n), - Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))), +/// The objects of a layer or group that a walk picked, by index in order, each with what was +/// picked inside it (nothing for an object that is not a container). +#[derive(Debug, Default)] +struct Picked(Vec<(usize, Picked)>); + +/// What in `n` reaches beyond the canvas, by `far`, or `None` when nothing does. A container is +/// looked into object by object: its own bounds leave out a layer's hidden objects and, when it +/// clips, all but its clipping path. Each object is measured once, and the objects a clipping +/// path beyond the canvas clips are not measured: they go with it (see [`clips_beyond`]). +fn reach(n: &Node, far: &impl Fn(&Node) -> bool) -> Option { + let Some(clip) = clipping(n) else { return far(n).then(Picked::default) }; + let mut inner = Vec::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + if let Some(p) = reach(c, far) { + inner.push((i, p)); + if clip && i == 0 { + break; + } + } } + (!inner.is_empty()).then_some(Picked(inner)) } -/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips -/// go together, so no other object becomes the clipping path. -fn clips_beyond(n: &Node) -> bool { - clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c)) +/// Does `n`, whose objects beyond the canvas are `found`, clip by a path that reaches beyond it? +/// Then the path and the objects it clips go together, so no other object becomes the clipping +/// path. +fn clips_beyond(n: &Node, found: &Picked) -> bool { + clipping(n) == Some(true) && found.0.first().is_some_and(|(i, _)| *i == 0) } /// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go /// with them. -fn layers_in(n: &Node, layers: &mut Vec) { +fn layers_in(n: &Node, layers: &mut BTreeSet) { if matches!(n.kind, NodeKind::Layer { .. }) { - layers.push(n.id); + layers.insert(n.id); } if clipping(n).is_some() { for c in n.children().into_iter().flatten() { @@ -109,22 +124,22 @@ fn guides_in(n: &Node) -> usize { } } -/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its -/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything -/// else goes whole. Only containers holding such an object are copied. Recursion follows the -/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and -/// the ruler guides they held to `guides`. -fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut Vec, guides: &mut usize) { - if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) { - return; - } +/// Remove the objects in `node` that reach beyond the canvas, which [`reach`] `found`: a layer or +/// group keeps those of its objects within it, unless its clipping path reaches beyond (see +/// [`clips_beyond`]); anything else goes whole. Only containers holding such an object are +/// copied, and only those are visited again. Recursion follows the nesting, which +/// [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and the ruler +/// guides they held to `guides`. +fn drop_children_beyond(node: &mut Arc, found: Picked, dropped: &mut usize, layers: &mut BTreeSet, guides: &mut usize) { let Some(children) = Arc::make_mut(node).children_mut() else { return }; + let mut found = found.0.into_iter().peekable(); + let mut next = 0; children.retain_mut(|c| { - if !reaches_beyond(c) { - return true; - } - if clipping(c).is_some() && !clips_beyond(c) { - drop_children_beyond(c, dropped, layers, guides); + let at = next; + next += 1; + let Some((_, inner)) = found.next_if(|(i, _)| *i == at) else { return true }; + if clipping(c).is_some() && !clips_beyond(c, &inner) { + drop_children_beyond(c, inner, dropped, layers, guides); return true; } layers_in(c, layers); @@ -134,21 +149,49 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Is `id` unset, or not one of `ids`? The check takes time logarithmic in the number of ids. +fn not_in(id: Option, ids: &BTreeSet) -> bool { + id.is_none_or(|i| !ids.contains(&i)) +} + /// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many -/// to `dropped`. Only layers holding one are copied. +/// to `dropped`. `out` looks at each object in the layers once. Only layers holding one are +/// copied. fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { - fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { - n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + /// The objects of `n`, if it is a layer, that `out` picks (with nothing inside), and its + /// sublayers holding one (with what they hold), or `None` when it holds none. + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> Option { + if !n.is_layer() { + return None; + } + let inner: Vec<(usize, Picked)> = n + .children() + .into_iter() + .flatten() + .enumerate() + .filter_map(|(i, c)| if out(c) { Some((i, Picked::default())) } else { holds(c, out).map(|p| (i, p)) }) + .collect(); + (!inner.is_empty()).then_some(Picked(inner)) } - if !holds(layer, out) { - return; + fn drop(layer: &mut Arc, held: Picked, dropped: &mut usize) { + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let mut held = held.0.into_iter().peekable(); + let mut next = 0; + children.retain_mut(|c| { + let at = next; + next += 1; + let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true }; + // A sublayer holds something; an object picked itself has nothing inside. + if inner.0.is_empty() { + *dropped += 1; + return false; + } + drop(c, inner, dropped); + true + }); } - let Some(children) = Arc::make_mut(layer).children_mut() else { return }; - let before = children.len(); - children.retain(|c| !out(c)); - *dropped += before - children.len(); - for c in children.iter_mut().filter(|c| c.is_layer()) { - drop_guides(c, out, dropped); + if let Some(held) = holds(layer, out) { + drop(layer, held, dropped); } } @@ -162,9 +205,10 @@ impl Document { /// whose artboards were all removed has none left. pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas { let mut gone = BeyondCanvas::default(); - let mut layers = vec![]; + let mut layers = BTreeSet::new(); for layer in &mut self.layers { - if clips_beyond(layer) { + let Some(found) = reach(layer, &beyond) else { continue }; + if clips_beyond(layer, &found) { let layer = Arc::make_mut(layer); if let NodeKind::Layer { children, clip, .. } = &mut layer.kind { for c in children.iter() { @@ -177,7 +221,7 @@ impl Document { *clip = false; } } else { - drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides); + drop_children_beyond(layer, found, &mut gone.objects, &mut layers, &mut gone.guides); } } if gone.objects > 0 { @@ -192,13 +236,13 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); + let removed: BTreeSet = removed.into_iter().collect(); // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). let guides = self.saved_guides.len(); - self.saved_guides - .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + self.saved_guides.retain(|g| in_range(g.pos) && not_in(g.artboard, &removed) && not_in(g.layer, &layers)); gone.guides += guides - self.saved_guides.len(); let out = |n: &Node| match n.kind { - NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || !not_in(artboard, &removed), _ => false, }; for layer in &mut self.layers { @@ -392,4 +436,153 @@ mod tests { assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); } + + #[test] + fn guides_of_many_removed_artboards_and_layers_go_and_the_rest_keep_their_order() { + let probe = Document::new(1.0, 1.0); + // Sublayers 40 to 43 clip by a path beyond the canvas, so they go whole; 50 stays. + let far: Vec> = (40..44).map(|id| sublayer(&probe, id, vec![rect(id * 10, 1e17), rect(id * 10 + 1, 0.0)], true)).collect(); + let near = sublayer(&probe, 50, vec![rect(500, 0.0)], false); + let mut d = doc(far.into_iter().chain([near, rect(10, 0.0)]).collect()); + let kept = d.artboards[0].id; + for id in 9..13 { + d.artboards.push(Artboard { id, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }); + } + let top = d.layers[0].id; + let guide = |pos: f64, artboard: Option, layer: Option| Guide { artboard, layer, ..Guide::new(false, pos) }; + d.saved_guides = vec![ + guide(1.0, Some(9), None), + guide(2.0, None, None), + guide(3.0, Some(kept), Some(NodeId(41))), + guide(4.0, Some(kept), None), + guide(5.0, Some(12), None), + guide(6.0, None, Some(NodeId(50))), + guide(7.0, None, Some(NodeId(43))), + guide(8.0, Some(10), Some(top)), + guide(9.0, None, Some(top)), + ]; + d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(42), NodeId(10), NodeId(500)] }].into(); + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 9); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone, BeyondCanvas { objects: 4, artboards: 4, guides: 5, ruler_origin: false }); + assert_eq!(d.artboards.iter().map(|a| a.id).collect::>(), [kept]); + let left: Vec = d.saved_guides.iter().map(|g| g.pos).collect(); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0], "the guides that stay keep their order"); + assert_eq!(d.saved_selections[0].objects, [NodeId(10), NodeId(500)]); + assert_eq!(nodes.drop_beyond_canvas(), gone, "guide nodes go as saved guides do"); + let mut left: Vec = nodes.ruler_guides().iter().map(|g| g.pos).collect(); + left.sort_by(f64::total_cmp); + assert_eq!(left, [2.0, 4.0, 6.0, 9.0]); + assert_eq!(nodes.saved_selections, d.saved_selections); + } + + thread_local! { + static COMPARISONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// An id that counts how often it is compared. + #[derive(Debug)] + struct Counted(u32); + + impl Ord for Counted { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + COMPARISONS.with(|c| c.set(c.get() + 1)); + self.0.cmp(&other.0) + } + } + + impl PartialOrd for Counted { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + impl PartialEq for Counted { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } + } + + impl Eq for Counted {} + + #[test] + fn a_guide_is_checked_without_scanning_every_removed_id() { + let per_guide = |removed: u32| { + let ids: BTreeSet = (0..removed).map(|i| Counted(i * 2)).collect(); + COMPARISONS.with(|c| c.set(0)); + // Guides on removed ids, on ids between them, on ids past them and on none. + let guides: Vec> = (0..64).map(|i| (i % 4 != 3).then(|| Counted(i * 31 % (removed * 2 + 8)))).collect(); + let kept = guides.into_iter().filter(|g| not_in(g.as_ref().map(|c| Counted(c.0)), &ids)).count(); + assert!(kept > 0 && kept < 64); + COMPARISONS.with(|c| c.get()) as f64 / 64.0 + }; + let small = per_guide(16); + let large = per_guide(16_384); + // A sorted lookup grows with the depth of the tree; a scan of 16,384 ids compares thousands per guide. + assert!(large < small * 32.0 && large < 256.0, "{small} comparisons per guide among 16 ids, {large} among 16,384"); + } + + #[test] + fn each_object_is_measured_once_however_deep_the_nesting() { + // 32 groups, each inside the one before and holding an object within the canvas; the + // innermost also holds one beyond it. Beside them, a group with nothing beyond and a clip + // group whose clipping path reaches beyond. + let mut chain = Arc::new(Node::group(NodeId(100), vec![rect(200, 0.0), rect(201, 1e17)])); + for depth in 1..32 { + chain = Arc::new(Node::group(NodeId(100 + depth), vec![rect(300 + depth, 0.0), chain])); + } + let still = Arc::new(Node::group(NodeId(400), vec![rect(401, 0.0), rect(402, 5.0)])); + let clip = Arc::new(Node::new(NodeId(600), NodeKind::Group { children: vec![rect(601, 1e17), rect(602, 0.0), rect(603, 0.0)], clip: true })); + let mut d = doc(vec![chain, Arc::clone(&still), clip]); + let mut whole = d.clone(); + let measured = std::cell::Cell::new(0_usize); + let far = |n: &Node| { + measured.set(measured.get() + 1); + beyond(n) + }; + let layer = &mut d.layers[0]; + let found = reach(layer, &far).unwrap(); + assert_eq!( + measured.get(), + 36, + "the 33 objects in the groups, the 2 beside them and the clipping path, each measured once; not what the path clips" + ); + assert!(!clips_beyond(layer, &found)); + let (mut dropped, mut layers, mut guides) = (0, BTreeSet::new(), 0); + drop_children_beyond(layer, found, &mut dropped, &mut layers, &mut guides); + assert_eq!(measured.get(), 36, "removing measures nothing again"); + assert_eq!((dropped, layers.len(), guides), (2, 0, 0)); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a group with nothing beyond is not copied"); + assert_eq!(whole.drop_beyond_canvas(), BeyondCanvas { objects: 2, ..Default::default() }); + assert_eq!(ids(&whole), ids(&d)); + assert!(!ids(&d).contains(&201) && !ids(&d).contains(&600) && ids(&d).contains(&200) && ids(&d).contains(&331)); + } + + #[test] + fn each_object_is_looked_at_once_when_guides_go_from_nested_layers() { + let guide = |id: u64, pos: f64| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos, artboard: None })); + let probe = Document::new(1.0, 1.0); + // 32 sublayers, each inside the one before, each holding a guide within the canvas and one + // beyond it. Beside them, a sublayer with nothing to remove. + let mut chain = sublayer(&probe, 100, vec![guide(200, 5.0), guide(201, 1e17)], false); + for depth in 1..32 { + chain = sublayer(&probe, 100 + depth, vec![guide(300 + depth * 2, 5.0), guide(301 + depth * 2, 1e17), chain], false); + } + let still = sublayer(&probe, 500, vec![guide(501, 5.0), rect(502, 0.0)], false); + let mut d = doc(vec![chain, Arc::clone(&still)]); + let looked = std::cell::Cell::new(0_usize); + let out = |n: &Node| { + looked.set(looked.get() + 1); + matches!(n.kind, NodeKind::RulerGuide { pos, .. } if !in_range(pos)) + }; + let mut dropped = 0; + drop_guides(&mut d.layers[0], &out, &mut dropped); + assert_eq!(dropped, 32); + assert_eq!(looked.get(), 2 + 2 + 31 * 3 + 2, "each object in the layers is looked at once"); + assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied"); + let left = d.ruler_guides(); + assert_eq!(left.len(), 33); + assert!(left.iter().all(|g| g.pos == 5.0)); + } }