From a1949095c61e21d2ea73eaca73a5b598eced6d98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 21:07:55 -0800 Subject: [PATCH 1/2] Type: an option fits a text object's bounds to its glyph outlines Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default) is in Preferences > Type > Glyph Bounds and the Transform panel menu. With it on, the bounding box, the Transform, Control bar and Properties X, Y, W and H, `object.setBounds`, the transform tools' reference point and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size. - The engine caches each type object's ink in text space with its layout bounds; the cache is not saved. - Use Preview Bounds adds the characters' strokes, and type with geometry effects is measured as drawn. Skewed type is measured by its outlines. - Type that draws nothing, area type, type on a path and type inside a selected group are measured as before. Align and Distribute keep following Align to Glyph Bounds. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md, object.setBounds params text. --- Cargo.lock | 1 + crates/doc/src/blend.rs | 1 + crates/doc/src/document_guides.rs | 1 + crates/doc/src/inline.rs | 1 + crates/doc/src/live/envelope.rs | 1 + crates/doc/src/text.rs | 5 + crates/doc/src/text/object.rs | 8 + crates/effects/src/preview.rs | 107 +++++++ crates/engine/Cargo.toml | 2 + .../engine/src/cmd/addons/cleanup/checks.rs | 1 + crates/engine/src/cmd/object/align.rs | 17 +- crates/engine/src/cmd/object/bounds.rs | 35 ++- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/transform.rs | 2 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/typecmd.rs | 2 + crates/engine/src/prefs.rs | 6 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/typeglyphbounds.rs | 297 ++++++++++++++++++ crates/engine/src/tooling.rs | 1 + crates/tools/src/context.rs | 8 + crates/tools/src/direct.rs | 4 +- crates/tools/src/lib.rs | 1 + crates/tools/src/select.rs | 15 +- crates/tools/src/select/tests.rs | 24 ++ crates/tools/src/xform/transform.rs | 2 +- crates/ui-egui/src/app/view.rs | 5 +- crates/ui-egui/src/caches.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/transform.rs | 5 + crates/ui-egui/src/panels/transform/tests.rs | 20 ++ docs/control-protocol.md | 4 + docs/mcp.md | 23 ++ docs/target-app-parity.md | 1 + 61 files changed, 632 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/tests/typeglyphbounds.rs diff --git a/Cargo.lock b/Cargo.lock index d82f8255e..88a9abca0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,6 +6499,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "skrifa 0.47.0", "thiserror 2.0.21", "vectorcraft-affinity", "vectorcraft-apischema", diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 5ea0ff9bd..d6b7eda2c 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -750,6 +750,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64 if resized { o.cached_bounds = None; o.cached_baselines.clear(); + o.cached_ink = None; } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index f4a037f36..510de3a4d 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -347,6 +347,7 @@ impl Document { t.first_para_continues = false; t.cached_bounds = None; t.cached_baselines.clear(); + t.cached_ink = None; } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); diff --git a/crates/doc/src/inline.rs b/crates/doc/src/inline.rs index 3ab3eb655..c5bf1972d 100644 --- a/crates/doc/src/inline.rs +++ b/crates/doc/src/inline.rs @@ -71,6 +71,7 @@ impl Document { } if changed { t.cached_bounds = None; + t.cached_ink = None; } changed } diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 3fdddd680..8f9b19704 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -236,6 +236,7 @@ impl TypeInks { text.xf = Affine::new([a, b, c, d, 0.0, 0.0]); text.cached_bounds = None; text.cached_baselines.clear(); + text.cached_ink = None; let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs)); match content.and_then(|key| self.content.get(&key)) { Some(ink) => { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 249b46d55..2d1a60e59 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,6 +121,11 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, + /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's + /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. + #[serde(skip)] + pub cached_ink: Option<(Rect, Option)>, } /// Conversion from stored character attributes to document-space type controls. diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index dc83c33cc..12bc7ec89 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -71,6 +71,7 @@ impl TextObject { self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); self.cached_bounds = None; self.cached_baselines.clear(); + self.cached_ink = None; true } @@ -97,6 +98,7 @@ impl TextObject { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, } } /// The list counts the story's earlier frames carry into this frame at each level @@ -288,6 +290,12 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// The cached ink of the glyphs in text space ([`Self::cached_ink`]): `Some(None)` when nothing + /// draws, and `None` when it was not measured with the current `cached_bounds`. + pub fn ink(&self) -> Option> { + let (bounds, ink) = self.cached_ink?; + (self.cached_bounds == Some(bounds)).then_some(ink) + } /// How far the characters' strokes paint past their glyphs, in document space: they scale /// with the type. pub fn char_stroke_outset(&self) -> f64 { diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index 5a619f8ac..ee8d4e1c1 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -17,6 +17,8 @@ //! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows, //! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured //! with its geometry effects, and the other instances in it are measured without them. +//! [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure as these do and, +//! with [`Measure::glyphs`], take point type by the ink of its glyphs (Measure Type by Glyph Bounds). use std::cell::{Cell, RefCell}; use std::collections::HashMap; @@ -74,6 +76,111 @@ pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Op union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc)) } +/// How [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure objects: +/// `preview` as in [`bounds_of`], and with `glyphs` (Preferences › Type › Measure Type by Glyph +/// Bounds), point type, horizontal or vertical, by the ink of its glyphs instead of its em box +/// ([`text_ink_in`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Measure { + pub preview: bool, + pub glyphs: bool, +} + +impl Measure { + /// See [`by_glyphs`]. + fn by_glyphs(self, drawn: &Drawn, n: &Node) -> bool { + self.glyphs + && matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Point)) + && n.projection().is_none() + && !(self.preview && drawn.draws_geometry(n)) + } +} + +/// Whether `m` measures `n` by the ink of its glyphs: point type, not in perspective, and with +/// `preview` only when no geometry effect changes what it draws. +pub fn by_glyphs(symbols: &SymbolBoxes, n: &Node, m: Measure) -> bool { + m.by_glyphs(&Drawn::new(symbols), n) +} + +/// [`bounds_of`] measured by `m`. +pub fn bounds_of_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option { + if !m.glyphs { + return bounds_of(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return bounds_of(doc, ids, m.preview); + } + ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, Affine::IDENTITY, m))) +} + +/// [`oriented_bounds`] measured by `m`. +pub fn oriented_bounds_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option { + if !m.glyphs { + return oriented_bounds(doc, ids, m.preview); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) { + return oriented_bounds(doc, ids, m.preview); + } + let angle = doc.bbox_angle(ids); + let to_local = if angle == 0.0 { Affine::IDENTITY } else { OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse() }; + let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, to_local, m)))?; + Some(OrientedBox { angle, rect }) +} + +/// [`anchor_bounds`] with the objects selected whole measured by `m`. +pub fn anchor_bounds_with(selection: &Selection, doc: &Document, m: Measure) -> Option { + if selection.anchors.is_empty() { + return None; + } + union_opt(bounds_of_with(doc, &selection.whole(), m), selection.anchors_box(doc)) +} + +/// The box of `n` as `a` would place it, without changing it, measured by `m`: [`bounds_in`] with +/// `preview`, else [`Node::bounds_in`] of its geometric bounds, and point type by its glyphs as +/// [`Measure`] says. +pub fn bounds_in_with(symbols: &SymbolBoxes, n: &Node, a: Affine, m: Measure) -> Option { + measured_in(&Drawn::new(symbols), n, a, m) +} + +/// See [`bounds_in_with`]. +fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option { + if m.by_glyphs(drawn, n) + && let NodeKind::Text(t) = &n.kind + && let Some(r) = text_ink_in(t, a) + { + // The fills' and characters' strokes paint around the glyphs, as in `Node::visual_bounds`. + let o = if m.preview { n.appearance.outset().max(t.char_stroke_outset()) } else { 0.0 }; + return Some(r.inflate(o, o)); + } + match (m.preview, a == Affine::IDENTITY) { + (true, _) => drawn.next().bounds_in(n, a), + (false, true) => n.geometric_bounds_with(drawn.symbols), + (false, false) => n.bounds_in(drawn.symbols, a, false), + } +} + +/// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached +/// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the +/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` +/// when nothing draws (no text, only spaces, or glyphs without outlines). +pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { + let m = a * t.xf; + let [p, q, r, s, _, _] = m.as_coeffs(); + let square = (q.abs() <= 1e-12 && r.abs() <= 1e-12) || (p.abs() <= 1e-12 && s.abs() <= 1e-12); + if square { + let ink = match t.ink() { + Some(ink) => ink, + None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + }; + return ink.map(|i| m.transform_rect_bbox(i)); + } + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) +} + /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else /// [`Node::visual_bounds_with`]. pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 10bb22599..ef403dc80 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -88,3 +88,5 @@ zune-jpegxl = { workspace = true } zune-core = { workspace = true } # The JavaScript parser's lexer, compared with the script checker's on the same scripts. vectorcraft-js = { workspace = true } +# A bundled font's own glyph boxes, for the glyph bounds tests. +skrifa = { workspace = true } diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index 4b03f0e43..dfb450fae 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -275,6 +275,7 @@ fn same_art(a: &NodeKind, b: &NodeKind) -> bool { moved.xf = a.xf; moved.cached_bounds = a.cached_bounds; moved.cached_baselines.clone_from(&a.cached_baselines); + moved.cached_ink = a.cached_ink; **a == moved } } diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 82a61b02a..b32275d1b 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -6,16 +6,23 @@ use super::{ use vectorcraft_doc::{NodeKind, TextKind}; impl Session { - /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: - /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, - /// see [`effects::preview`]), else geometric bounds. + /// The bounds of `ids` the Transform panel and the bounding box measure: with Use Preview + /// Bounds on, visual bounds as drawn (strokes and geometry effects included, see + /// [`effects::preview`]), else geometric bounds; with Measure Type by Glyph Bounds on, point + /// type by the ink of its glyphs ([`Session::bounds_measure`]). pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { - effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::bounds_of_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) } /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding /// box, see [`Document::oriented_bounds`]). pub fn transform_box(&self, ids: &[NodeId]) -> Option { - effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) + effects::preview::oriented_bounds_with(&self.doc().ok()?.doc, ids, self.bounds_measure()) + } + /// How the bounding box, the Transform panel and `object.setBounds` measure objects: Use + /// Preview Bounds, and point type by its glyphs with Measure Type by Glyph Bounds + /// (`typeGlyphBounds`). + pub fn bounds_measure(&self) -> effects::preview::Measure { + effects::preview::Measure { preview: self.prefs.use_preview_bounds, glyphs: self.prefs.type_glyph_bounds } } } diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index df2c1ffd1..3dc1c37da 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -59,24 +59,31 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor let st = s.doc()?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?; + let how = s.bounds_measure(); if anchors && transforms_objects(p) && !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, how) { return Ok(Bounds::Anchors(ids, b)); } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: - // the math below runs in its frame. - let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // the math below runs in its frame. With Measure Type by Glyph Bounds, point type is measured + // by its glyphs' ink, which a transform of the type moves and scales with it. + let geometric = effects::preview::Measure { preview: false, ..how }; + let frame = effects::preview::oriented_bounds_with(d, &ids, geometric).ok_or_else(|| EngineError::Other("no bounds".into()))?; let g = frame.rect; + let symbols = d.symbol_boxes(); + let by_glyphs = how.glyphs && ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::by_glyphs(&symbols, n, how)); // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their - // weight when an instance scales, so the rest of the margins stay. + // weight when an instance scales, so the rest of the margins stay. Type measured by its glyphs + // is measured as drawn after each scale tried instead (below). let (v, vs) = if s.prefs.use_preview_bounds { - (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) + let v = effects::preview::oriented_bounds_with(d, &ids, how).map_or(g, |b| b.rect); + (v, if by_glyphs { v } else { scaling_box(d, &ids, &frame).unwrap_or(g) }) } else { (g, g) }; @@ -125,16 +132,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, // objects with a butt-capped stroke along a segment at a slant, whose margins change with the - // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). - let symbols = d.symbol_boxes(); + // segment's direction, and type measured by its glyphs, are measured as drawn after each scale + // tried instead ([`refit_drawn`]). let to_local = frame.to_doc().inverse(); let drawn = || { - ids.iter() - .filter_map(|id| d.node(*id)) - .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + by_glyphs + || ids + .iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) }; + // Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink + // included), so the scales above are exact and nothing is refitted. let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { @@ -145,7 +156,7 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { let mut n = n.clone(); n.transform(xf, sc); - vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) + vectorcraft_geom::union_opt(acc, effects::preview::bounds_in_with(&symbols, &n, to_local, how)) }) }; // The scales above keep the effects' reach as a margin that stays, and give 0 for a size diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index a923ab2eb..086bf3843 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index ae45f4d97..618486097 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -12,7 +12,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. let st = s.doc()?; if !bool_or(p, "copy", false) - && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()) { return Ok(b.center()); } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index f3cf6416f..dde754843 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -434,6 +434,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink. p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool), p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool), + // The bounding box and the Transform panel measure point type by its glyphs' ink (also in the Transform panel menu). + p!("typeGlyphBounds", "Type", "Glyph Bounds", "Measure Type by Glyph Bounds", bool), // Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active. p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool), p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool), diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index a1bb2c64e..b6d20999a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1611,6 +1611,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; super::create::new_type_alignment(s, p, C, &mut t)?; if !on_path { @@ -1901,6 +1902,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { list_level_counts: Vec::new(), cached_bounds: None, cached_baselines: Vec::new(), + cached_ink: None, }; for r in &mut h.runs { r.style.tracking = tr; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index c0e5b37fe..1bf212717 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,6 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -315,6 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); + t.cached_ink = Some((lay.bounds, lay.ink_bounds())); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index d3b034526..e6fa7cb8d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -41,6 +41,11 @@ pub struct Prefs { /// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of /// its frame. pub align_glyph_bounds_area: bool, + /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform + /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, + /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its + /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. pub fade_region_outside_artboard: bool, @@ -370,6 +375,7 @@ impl Default for Prefs { use_preview_bounds: false, align_glyph_bounds_point: false, align_glyph_bounds_area: false, + type_glyph_bounds: false, fade_region_outside_artboard: true, artboard_update_while_dragging: true, display_print_size: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 000741d5d..6063feab4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -186,6 +186,7 @@ mod transformfx; mod transparencygrid; mod trap; mod typearea; +mod typeglyphbounds; mod typescale; mod typeundo; mod units; diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs new file mode 100644 index 000000000..0f42fcc7f --- /dev/null +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -0,0 +1,297 @@ +//! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the +//! Transform panel's values and `object.setBounds` measure point type selected on its own by the +//! ink of its glyphs instead of its em box. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; +use vectorcraft_geom::Rect; + +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A new document holding point type made by `text.create` with `params`, selected. +fn with_type(params: Value) -> (Session, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let t = id(run(&mut s, "text.create", params)); + run(&mut s, "select.set", json!({"ids": [t.0]})); + (s, t) +} + +fn glyphs(s: &mut Session, on: bool) { + run(s, "prefs.set", json!({"values": {"typeGlyphBounds": on}})); +} + +fn text(s: &Session, id: NodeId) -> TextObject { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("{id:?} is not type") }; + (**t).clone() +} + +/// The ink of the type's glyphs on the page, laid out afresh (not from the cache). +fn ink(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()) +} + +/// The em box (the line box) of point type: what the bounding box measures by default. +fn em(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() <= tol && (a.y0 - b.y0).abs() <= tol && (a.x1 - b.x1).abs() <= tol && (a.y1 - b.y1).abs() <= tol +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The glyph box of `c` in Inter Regular, in font units (x min, y min, x max, y max), and the +/// font's units per em, read from the font file's own `head`, `cmap`, `loca` and `glyf` tables. +fn inter_glyph_box(c: char) -> ([f64; 4], f64) { + use skrifa::MetadataProvider; + use skrifa::raw::TableProvider; + let font = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let upem = f64::from(font.head().unwrap().units_per_em()); + let gid = font.charmap().map(c).unwrap(); + let glyph = font.loca(None).unwrap().get_glyf(gid, &font.glyf().unwrap()).unwrap().unwrap(); + ([glyph.x_min(), glyph.y_min(), glyph.x_max(), glyph.y_max()].map(f64::from), upem) +} + +/// "H" in Inter at 100 pt is drawn with straight lines only, so the box of its outline is the glyph +/// box the font file stores: the bounding box takes its size and place from the font itself. +#[test] +fn point_type_measures_the_glyph_box_the_font_file_stores() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "font": "Inter", "text": "H"})); + glyphs(&mut s, true); + let ([x0, y0, x1, y1], upem) = inter_glyph_box('H'); + let k = 100.0 / upem; + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, 0.0); + // Text space runs down the page from the baseline at y = 300. + let want = Rect::new(100.0 + x0 * k, 300.0 - y1 * k, 100.0 + x1 * k, 300.0 - y0 * k); + assert!(near(b.rect, want, 1e-6), "{:?} != {want:?}", b.rect); + assert!(near(s.transform_bounds(&[t]).unwrap(), want, 1e-6)); + // The em box is taller than the cap height. + assert!(em(&s, t).height() > b.rect.height() + 10.0, "em box {:?}", em(&s, t)); +} + +#[test] +fn off_by_default_the_box_is_the_em_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + assert!(!s.prefs.type_glyph_bounds); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); + glyphs(&mut s, true); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); + glyphs(&mut s, false); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t)); +} + +/// A typed height scales the type so its ink takes that height, in one undo step, and the type +/// stays type. +#[test] +fn set_bounds_gives_the_ink_the_typed_size() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let xf = text(&s, t).xf; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert_eq!(undo_len(&s), n + 1); + assert!((ink(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-6)); + assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s, t).xf, xf); +} + +/// A typed X moves the ink box's reference point there and keeps its size; a typed width gives +/// the ink that width. +#[test] +fn set_bounds_moves_and_sizes_the_ink_box() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"x": 400, "reference": 0})); + let moved = ink(&s, t); + assert!((moved.x0 - 400.0).abs() < 1e-6 && (moved.y0 - before.y0).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - before.width()).abs() < 1e-6 && (moved.height() - before.height()).abs() < 1e-6, "{moved:?}"); + run(&mut s, "object.setBounds", json!({"width": 123})); + assert!((ink(&s, t).width() - 123.0).abs() < 1e-6, "{:?}", ink(&s, t)); + assert!((ink(&s, t).height() - before.height()).abs() < 1e-6, "{:?}", ink(&s, t)); +} + +#[test] +fn set_bounds_with_the_current_values_records_no_undo_step() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + let n = undo_len(&s); + run(&mut s, "object.setBounds", json!({"x": b.center().x, "y": b.center().y, "width": b.width(), "height": b.height()})); + assert_eq!(undo_len(&s), n); + assert_eq!(s.transform_box(&[t]).unwrap().rect, b); +} + +/// A proportional width scales both sides of the ink by the same factor, about the reference +/// point of the ink box (here its top-left corner). +#[test] +fn proportional_width_keeps_the_ink_in_proportion_about_the_reference_point() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let before = ink(&s, t); + run(&mut s, "object.setBounds", json!({"width": before.width() * 2.0, "proportional": true, "reference": 0})); + let after = ink(&s, t); + assert!((after.width() - before.width() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.height() - before.height() * 2.0).abs() < 1e-6, "{after:?}"); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +/// Rotated type keeps its own angle, and the box is the size of the ink before the rotation. A +/// skewed object's box is the box of its glyph outlines, not of its mapped ink rectangle. +#[test] +fn rotated_and_skewed_type_measure_their_outlines() { + let (mut s, flat) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + glyphs(&mut s, true); + let size = s.transform_box(&[flat]).unwrap().rect.size(); + let t = id(run(&mut s, "text.create", json!({"x": 300, "y": 300, "size": 100, "text": "Hg", "rotation": 30}))); + let b = s.transform_box(&[t]).unwrap(); + assert_eq!(b.angle, s.doc().unwrap().doc.oriented_bounds(&[t], false).unwrap().angle); + assert!(b.angle != 0.0); + assert!((b.rect.width() - size.width).abs() < 1e-6 && (b.rect.height() - size.height).abs() < 1e-6, "{b:?} vs {size:?}"); + + let o = id(run(&mut s, "text.create", json!({"x": 100, "y": 600, "size": 100, "text": "o"}))); + run(&mut s, "select.set", json!({"ids": [o.0]})); + run(&mut s, "object.transform", json!({"matrix": [1, 0, 0.6, 1, -360, 0]})); + let b = s.transform_box(&[o]).unwrap(); + let tx = text(&s, o); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + let to_local = OrientedBox { angle: b.angle, rect: Rect::ZERO }.to_doc().inverse(); + let want = lay.ink_bounds_in(to_local * tx.xf).unwrap(); + assert!(near(b.rect, want, 1e-9), "{:?} != {want:?}", b.rect); + let mapped = (to_local * tx.xf).transform_rect_bbox(lay.ink_bounds().unwrap()); + assert!(mapped.width() > b.rect.width() + 1.0, "a round letter's outline is narrower than its slanted box"); +} + +/// Type that draws nothing is measured by its em box. +#[test] +fn empty_and_space_only_type_keep_the_em_box() { + for text in ["", " "] { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": text})); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap().rect, em(&s, t), "{text:?}"); + } +} + +/// Area type keeps its frame, and type on a path its path. +#[test] +fn area_type_and_type_on_a_path_are_measured_as_before() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 800, "height": 800})); + let area = id(run(&mut s, "text.create", json!({"x": 100, "y": 100, "size": 24, "text": "ace", "area": {"width": 200, "height": 100}}))); + let line = id(run(&mut s, "shape.line", json!({"x1": 100, "y1": 400, "x2": 500, "y2": 400}))); + let path = id(run(&mut s, "text.createInPath", json!({"path": line.0, "mode": "onPath", "text": "ace", "size": 24}))); + for t in [area, path] { + let off = s.transform_box(&[t]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[t]).unwrap(), off, "{t:?}"); + glyphs(&mut s, false); + } +} + +#[test] +fn vertical_point_type_measures_its_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 100, "size": 60, "text": "ace", "vertical": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t), 1e-9), "{b:?} != {:?}", ink(&s, t)); + assert!(em(&s, t).width() > b.width() + 1.0, "em box {:?}", em(&s, t)); +} + +/// With Use Preview Bounds, the characters' strokes paint around the glyphs. +#[test] +fn preview_bounds_add_the_character_strokes_to_the_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + run(&mut s, "text.setStyle", json!({"ids": [t.0], "stroke": "#000000", "strokeWidth": 6})); + run(&mut s, "prefs.set", json!({"values": {"typeGlyphBounds": true, "usePreviewBounds": true}})); + let o = text(&s, t).char_stroke_outset(); + assert!(o > 0.0); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, ink(&s, t).inflate(o, o), 1e-9), "{b:?}"); + // A typed height is the height with the strokes. + run(&mut s, "object.setBounds", json!({"height": 80})); + assert!((s.transform_box(&[t]).unwrap().rect.height() - 80.0).abs() < 1e-6, "{:?}", s.transform_box(&[t])); +} + +/// Type inside a selected group is measured as before: the group's box does not change. +#[test] +fn a_group_holding_type_keeps_its_box() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let g = id(run(&mut s, "object.group", json!({}))); + let off = s.transform_box(&[g]).unwrap(); + glyphs(&mut s, true); + assert_eq!(s.transform_box(&[g]).unwrap(), off); +} + +/// Runs in two fonts: the box covers the ink of both, and follows an edit of the type. +#[test] +fn mixed_fonts_measure_the_ink_of_every_run() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hgxo"})); + glyphs(&mut s, true); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "font": "Source Serif 4", "start": 2, "end": 4})); + let tx = text(&s, t); + assert!(tx.runs.iter().any(|r| r.style.font_family == "Source Serif 4") && tx.runs.len() > 1, "{:?}", tx.runs); + assert!(tx.ink().is_some(), "the edit refreshed the cached ink"); + assert!(near(s.transform_box(&[t]).unwrap().rect, ink(&s, t), 1e-9)); +} + +/// A selection of type and a shape is measured by the type's ink and the shape's bounds. +#[test] +fn a_mixed_selection_takes_the_ink_and_the_shapes_bounds() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let r = id(run(&mut s, "shape.rectangle", json!({"x": 500, "y": 500, "width": 50, "height": 50}))); + glyphs(&mut s, true); + assert!(near(s.transform_bounds(&[t, r]).unwrap(), ink(&s, t).union(Rect::new(500.0, 500.0, 550.0, 550.0)), 1e-9)); +} + +#[test] +fn the_preference_is_checked_and_saved() { + let mut s = Session::new(); + assert!(s.execute("prefs.set", &json!({"values": {"typeGlyphBounds": "yes"}})).is_err()); + assert!(!s.prefs.type_glyph_bounds); + assert!(!serde_json::from_value::(json!({})).unwrap().type_glyph_bounds); + run(&mut s, "prefs.set", json!({"key": "typeGlyphBounds", "value": true})); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["typeGlyphBounds"], json!(true)); + assert!(serde_json::from_value::(saved).unwrap().type_glyph_bounds); + assert_eq!(run(&mut s, "prefs.get", json!({"key": "typeGlyphBounds"})), json!(true)); +} + +/// The preference is not part of the document: a saved document is the same with it on or off. +#[test] +fn saved_documents_do_not_depend_on_the_preference() { + let (mut s, _) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let off = serde_json::to_string(&s.doc().unwrap().doc).unwrap(); + assert!(!off.contains("cached_ink") && !off.contains("cachedInk"), "the cached ink is not saved"); + glyphs(&mut s, true); + assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); +} + +/// A transform of type keeps its cached ink, which is in text space. +#[test] +fn the_cached_ink_is_the_laid_out_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "Hg"})); + let tx = text(&s, t); + assert_eq!(tx.ink(), Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds())); + run(&mut s, "object.transform", json!({"matrix": [2, 0, 0, 3, 0, 0]})); + assert_eq!(text(&s, t).ink(), tx.ink()); + // A cache measured with other bounds is not read. + let mut stale = text(&s, t); + stale.cached_bounds = Some(Rect::new(0.0, 0.0, 1.0, 1.0)); + assert_eq!(stale.ink(), None); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d8b32eab7..e818c3686 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -365,6 +365,7 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + glyph_bounds: self.prefs.type_glyph_bounds, preview_memo: Some(&self.preview_memo), unit, stroke_unit, diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 054db258d..971323bf1 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,6 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its + /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. pub preview_memo: Option<&'a select::PreviewMemo>, @@ -284,6 +287,11 @@ pub struct ToolContext<'a> { } impl ToolContext<'_> { + /// How the bounding box measures the selection: [`Self::preview_bounds`] and + /// [`Self::glyph_bounds`]. + pub fn bounds_measure(&self) -> vectorcraft_effects::preview::Measure { + vectorcraft_effects::preview::Measure { preview: self.preview_bounds, glyphs: self.glyph_bounds } + } /// Tolerance in document units for `px` screen pixels. pub fn tol(&self, px: f64) -> f64 { px / self.zoom.max(1e-9) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a2ffbbf87..833f48225 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1437,7 +1437,7 @@ impl DirectSelectionTool { // them, an edge on a gridline on each axis), not that point (#3835). let grid = segment && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); } let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; @@ -1474,7 +1474,7 @@ impl DirectSelectionTool { self.partial_snap = (!cx.selection.anchors.is_empty()).then(|| PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); let grid = self.partial_snap.is_some() && grab.is_none() && (cx.snap_to_grid || cx.snap_to_pixel); self.partial_grid = - grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds))); + grid.then(|| MoveSnap::grid_only(vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()))); let copy = ev.mods.alt && self.partial_snap.is_none(); out.push(Action::Begin(if copy { "Copy".into() } else { "Move".into() })); self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 557fe52b1..b4a7d6997 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -536,6 +536,7 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + glyph_bounds: false, preview_memo: None, unit: Unit::Points, stroke_unit: Unit::Points, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index e39837c7c..fe666bd67 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -419,7 +419,7 @@ struct MemoState { struct Measured { revision: (u64, u64), objects: Vec, - preview: bool, + measure: vectorcraft_effects::preview::Measure, bounds: Option>, oriented: Option>, by_object: HashMap>>, @@ -427,7 +427,7 @@ struct Measured { impl Measured { fn is_for(&self, cx: &ToolContext) -> bool { - self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects + self.revision == cx.revision && self.measure == cx.bounds_measure() && self.objects == cx.selection.objects } } @@ -450,7 +450,7 @@ impl PreviewMemo { let fresh = || Measured { revision: cx.revision, objects: cx.selection.objects.clone(), - preview: cx.preview_bounds, + measure: cx.bounds_measure(), bounds: None, oriented: None, by_object: HashMap::new(), @@ -487,15 +487,16 @@ fn remembered(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut O } /// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds -/// ([`vectorcraft_effects::preview`]), else geometric bounds. +/// ([`vectorcraft_effects::preview`]), else geometric bounds, with point type measured by its +/// glyphs with Measure Type by Glyph Bounds ([`ToolContext::bounds_measure`]). pub fn selection_bounds(cx: &ToolContext) -> Option { - remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool /// shows and drags (rotated after a rotation). pub fn selection_box(cx: &ToolContext) -> Option { - remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds)) + remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds_with(cx.doc, &cx.selection.objects, cx.bounds_measure())) } /// [`selection_box`] while the canvas shows it (View › Show Bounding Box, and no anchors selected @@ -527,7 +528,7 @@ fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { /// which compares the whole selection: a walk of every selected object checking it each time takes /// time in the square of their number. fn with_object_bounds(cx: &ToolContext, walk: impl FnOnce(&mut dyn FnMut(NodeId) -> Option) -> R) -> R { - let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds); + let mut measure = |id: NodeId| vectorcraft_effects::preview::bounds_of_with(cx.doc, &[id], cx.bounds_measure()); let Some(mut st) = cx.preview_memo.and_then(|memo| memo.0.try_borrow_mut().ok()) else { return walk(&mut measure); }; diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 9921d8f5b..2b1151742 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -2255,6 +2255,30 @@ fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); } +/// With Measure Type by Glyph Bounds, the bounding box of selected point type is the ink of its +/// glyphs; the memo measures again when only that setting changes. +#[test] +fn the_bounding_box_of_point_type_follows_measure_type_by_glyph_bounds() { + use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; + let mut d = Document::new(500.0, 500.0); + let (l, id) = (d.layers[0].id, d.alloc_id()); + let text = TextObject::point(Point::new(100.0, 300.0), "Hg", CharStyle { size: 60.0, ..Default::default() }); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).ink_bounds().unwrap(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |glyph_bounds| { + let c = ToolContext { revision: (1, 1), glyph_bounds, preview_memo: Some(&memo), ..cx(&d, &s, &p) }; + (selection_box(&c).map(|b| b.rect), object_bounds(&c, id)) + }; + let em = d.node(id).and_then(|n| n.geometric_bounds()); + assert_eq!(at(false), (em, em)); + let glyphs = Some(text.xf.transform_rect_bbox(ink)); + assert_eq!(at(true), (glyphs, glyphs)); + assert_ne!(glyphs, em); + assert_eq!(memo.measurements(), 4, "the same revision measured again for the setting"); +} + /// A double-click on a symbol instance edits its symbol in symbol editing mode (`symbol.edit`), and /// one outside the symbol's art in that mode leaves it, as it leaves isolation mode. An instance /// inside the art of the symbol being edited is not entered. diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 04ecc971b..5cabbed6b 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -118,7 +118,7 @@ impl TransformTool { return Some(*p); } // Anchors selected with Direct Selection transform alone, about their own centre. - vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds) + vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()) .map(|b| b.center()) .or_else(|| selection_box(cx).map(|b| b.center())) } diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 989514991..6aa3eab76 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -137,13 +137,14 @@ impl VectorcraftApp { /// frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { let st = self.session.active()?; - let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + let measure = self.session.bounds_measure(); + let key = (st.uid, st.revision, measure); if let Some((k, b)) = self.canvas.selection_box && k == key { return b; } - let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { + let b = match vectorcraft_effects::preview::anchor_bounds_with(&st.selection, &st.doc, measure) { Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), None => self.session.transform_box(&st.selection.objects), }; diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d8ec2b10e..1e69fc57d 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,8 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, - /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). - pub selection_box: Option<((u64, u64, bool), Option)>, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and + /// Measure Type by Glyph Bounds). + pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). pub selection_bounds: Option<((u64, u64), Option)>, /// [`VectorcraftApp::content_bounds`] for (document uid, revision). diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97f8b7006..1287aae70 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5896,6 +5896,8 @@ axis Y Axis Y ос # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точков текст Area Text Текст в зона +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерване на текста по границите на глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показване на опциите за прилепване към глиф Hide Snap to Glyph Options Скриване на опциите за прилепване към глиф diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 00859a655..8c11a1cb0 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5952,6 +5952,8 @@ axis Y Axis Y osa Live preview Pregled uživo Point Text Tačkasti tekst Area Text Tekst u oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mjeri tekst po granicama glifa Show Snap to Glyph Options Prikaži opcije prianjanja uz glif Hide Snap to Glyph Options Sakrij opcije prianjanja uz glif Snap to Glyph Options Opcije prianjanja uz glif diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index bc984eec5..489ece200 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6067,6 +6067,8 @@ theme Appearance Aparença # Align panel menu › Align to Glyph Bounds (#4231). Point Text Text de punt Area Text Text d’àrea +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesura el text pels límits del glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra les opcions d’ajust al glif Hide Snap to Glyph Options Amaga les opcions d’ajust al glif diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fc357028f..707dfb8d8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6104,6 +6104,8 @@ axis Y Axis osa Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Bodový text Area Text Text v oblasti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Měřit text podle hranic glyfů # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Zobrazit možnosti přichytávání ke glyfům Hide Snap to Glyph Options Skrýt možnosti přichytávání ke glyfům diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index df445718b..c860cf011 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Y-akse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttekst Area Text Områdetekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mål tekst efter glyfafgrænsning # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Vis indstillinger for fastgørelse til tegn Hide Snap to Glyph Options Skjul indstillinger for fastgørelse til tegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b66afbe45..9cc4bd50b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5687,6 +5687,8 @@ axis Y Axis Y-Achse # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Flächentext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Text an Glyphenbegrenzung messen # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Optionen für „An Glyphe ausrichten“ einblenden Hide Snap to Glyph Options Optionen für „An Glyphe ausrichten“ ausblenden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 632a6feec..b2e307d21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6055,6 +6055,8 @@ axis Y Axis Άξονας Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Κείμενο σημείου Area Text Κείμενο περιοχής +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Μέτρηση κειμένου με βάση τα όρια γλυφής # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Εμφάνιση επιλογών προσκόλλησης σε γλυφή Hide Snap to Glyph Options Απόκρυψη επιλογών προσκόλλησης σε γλυφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3369528a1..99b717e2f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6046,6 +6046,8 @@ axis Y Axis Eje Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de punto Area Text Texto de área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir el texto por los límites del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar opciones de ajuste a glifo Hide Snap to Glyph Options Ocultar opciones de ajuste a glifo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1ba0cff27..f23a250cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6134,6 +6134,8 @@ axis Y Axis Y-akseli # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pisteteksti Area Text Alueteksti +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mittaa teksti merkin rajoista # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Näytä merkkiin kohdistuksen asetukset Hide Snap to Glyph Options Piilota merkkiin kohdistuksen asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a7817aea..063551fac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6099,6 +6099,8 @@ axis Y Axis Axe Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texte de point Area Text Texte captif +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mesurer le texte d’après les limites du glyphe # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Afficher les options de magnétisme du glyphe Hide Snap to Glyph Options Masquer les options de magnétisme du glyphe diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1d5571d2d..f72d85e92 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -394,6 +394,7 @@ antiAlias Sharp शार्प Area / Path Type एरिया / पाथ टाइप Area Graph Tool एरिया ग्राफ़ टूल Area Text एरिया टेक्स्ट + Measure Type by Glyph Bounds टाइप को ग्लिफ़ की सीमाओं से मापें Area Text Objects एरिया टेक्स्ट ऑब्जेक्ट Area Type एरिया टाइप Area Type Options एरिया टाइप ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 92d7822e4..f065068ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6087,6 +6087,8 @@ axis Y Axis Y tengely # Align panel menu › Align to Glyph Bounds (#4231). Point Text Pontszöveg Area Text Területszöveg +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Szöveg mérése a glifahatárok szerint # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glifához illesztés beállításainak megjelenítése Hide Snap to Glyph Options Glifához illesztés beállításainak elrejtése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 476a6988f..83d51d692 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5911,6 +5911,8 @@ axis Y Axis Sumbu Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Teks titik Area Text Teks area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Ukur teks menurut batas glif # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Tampilkan opsi snap ke glif Hide Snap to Glyph Options Sembunyikan opsi snap ke glif diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f710a566..f9c87895e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5721,6 +5721,8 @@ axis Y Axis Asse Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Testo per punti Area Text Testo in area +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Misura il testo in base ai limiti del glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostra opzioni di aggancio a glifo Hide Snap to Glyph Options Nascondi opzioni di aggancio a glifo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6403b1e8a..2c219e926 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5786,6 +5786,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text ポイント文字 Area Text エリア内文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 文字を字形の境界で測定 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options グリフにスナップのオプションを表示 Hide Snap to Glyph Options グリフにスナップのオプションを隠す diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 0b6755253..f333baeeb 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6100,6 +6100,8 @@ swatch Bright Violet კაშკაშა იისფერი # Align panel menu › Align to Glyph Bounds (#4231). Point Text წერტილოვანი ტექსტი Area Text არის ტექსტი +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds ტექსტის გაზომვა გლიფის საზღვრებით # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options გლიფზე მიბმის პარამეტრების ჩვენება Hide Snap to Glyph Options გლიფზე მიბმის პარამეტრების დამალვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 64844bf7c..406af4f70 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5682,6 +5682,8 @@ axis Y Axis Y축 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 포인트 문자 Area Text 영역 문자 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 글리프 경계로 문자 측정 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 글리프에 스냅 옵션 표시 Hide Snap to Glyph Options 글리프에 스냅 옵션 숨기기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 073203ca3..19028d534 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5962,6 +5962,8 @@ axis Y Axis Y-as # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punttekst Area Text Vlaktekst +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Tekst meten op glyphbegrenzing # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Opties voor uitlijnen op glyph tonen Hide Snap to Glyph Options Opties voor uitlijnen op glyph verbergen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd33ebd05..20b179393 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5958,6 +5958,8 @@ axis Y Axis Oś Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Tekst punktowy Area Text Tekst akapitowy +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mierz tekst według granic glifów # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Pokaż opcje przyciągania do glifów Hide Snap to Glyph Options Ukryj opcje przyciągania do glifów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a80de1e0c..956356193 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6019,6 +6019,8 @@ axis Y Axis Eixo Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Texto de Ponto Area Text Texto de Área +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Medir Texto pelos Limites do Glifo # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Mostrar Opções de Ajuste ao Glifo Hide Snap to Glyph Options Ocultar Opções de Ajuste ao Glifo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2ddc692a3..294ac503e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6081,6 +6081,8 @@ theme Appearance Оформление # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точечный текст Area Text Текст в области +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Измерять текст по границам глифа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показать параметры привязки к глифу Hide Snap to Glyph Options Скрыть параметры привязки к глифу diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a0e3f39b..73ec524fa 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6127,6 +6127,8 @@ axis Y Axis Y-axel # Align panel menu › Align to Glyph Bounds (#4231). Point Text Punkttext Area Text Områdestext +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Mät text efter glyfgränser # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Visa alternativ för fäst mot glyf Hide Snap to Glyph Options Dölj alternativ för fäst mot glyf diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 67b958ebd..9768ae1dd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6060,6 +6060,8 @@ axis Y Axis Y ekseni # Align panel menu › Align to Glyph Bounds (#4231). Point Text Nokta Metni Area Text Alan Metni +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Yazıyı Glif Sınırlarına Göre Ölç # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Glif'e Yapışma Seçeneklerini Göster Hide Snap to Glyph Options Glif'e Yapışma Seçeneklerini Gizle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67050653e..d41f86f00 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5701,6 +5701,8 @@ axis Y Axis Вісь Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Точковий текст Area Text Текст в області +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Вимірювати текст за межами гліфа # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Показати параметри прив’язки до гліфа Hide Snap to Glyph Options Приховати параметри прив’язки до гліфа diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7c0279792..445a72000 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5702,6 +5702,8 @@ axis Y Axis Trục Y # Align panel menu › Align to Glyph Bounds (#4231). Point Text Chữ điểm Area Text Chữ vùng +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds Đo chữ theo giới hạn glyph # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options Hiện tùy chọn hút vào glyph Hide Snap to Glyph Options Ẩn tùy chọn hút vào glyph diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7b8f6561..b8720d333 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5942,6 +5942,8 @@ axis Y Axis Y 轴 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 点文字 Area Text 区域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 按字形边界度量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 显示靠齐字符选项 Hide Snap to Glyph Options 隐藏靠齐字符选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 328cbb1f8..1aff8f69d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5739,6 +5739,8 @@ axis Y Axis Y 軸 # Align panel menu › Align to Glyph Bounds (#4231). Point Text 點狀文字 Area Text 區域文字 +# Transform panel menu › Measure Type by Glyph Bounds. + Measure Type by Glyph Bounds 依字符邊界測量文字 # Character panel menu › Show Snap to Glyph Options (#4232). Show Snap to Glyph Options 顯示靠齊字符選項 Hide Snap to Glyph Options 隱藏靠齊字符選項 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index b1f8322fd..2596b218b 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -576,6 +576,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Scale Strokes & Effects"), true, ss) { set_pref(app, "scaleStrokes", !ss); } + // The panel's values and the bounding box measure point type by its glyphs' ink. + let glyphs = app.session.prefs.type_glyph_bounds; + if menu_item(ui, tl!("Measure Type by Glyph Bounds"), true, glyphs) { + set_pref(app, "typeGlyphBounds", !glyphs); + } ui.separator(); // What the panel transforms (Transform Object Only / Pattern Only / Both), one checked. let current = pattern_mode(app); diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index b5598689a..67cebc7cd 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -32,6 +32,26 @@ fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() { assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); } +/// The panel menu's Measure Type by Glyph Bounds is checked while `typeGlyphBounds` is on, and the +/// bounding box of selected point type then measures its glyphs without a change to the document. +#[test] +fn measure_type_by_glyph_bounds_is_checked_and_resizes_the_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 200, "size": 100, "text": "Hg"})).unwrap(); + assert!(texts(&mut app, menu).iter().any(|t| t == " Measure Type by Glyph Bounds")); + let em = app.selection_box().unwrap().rect; + let revision = app.session.active().unwrap().revision; + set_pref(&mut app, "typeGlyphBounds", true); + assert!(app.session.prefs.type_glyph_bounds); + assert!(texts(&mut app, menu).iter().any(|t| t == "✓ Measure Type by Glyph Bounds")); + assert_eq!(app.session.active().unwrap().revision, revision); + let glyphs = app.selection_box().unwrap().rect; + assert!(glyphs.height() < em.height() - 10.0, "{glyphs:?} inside {em:?}"); + set_pref(&mut app, "typeGlyphBounds", false); + assert_eq!(app.selection_box().unwrap().rect, em); +} + #[test] fn use_preview_bounds_shows_the_size_after_geometry_effects() { // A 100 × 50 rectangle without a stroke, with Offset Path 10: 120 × 70 as drawn. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2cb542c6b..b480106b3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -529,6 +529,10 @@ fields measure visual bounds. Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu shows and toggles the `alignGlyphBoundsPoint` (Point Text) and `alignGlyphBoundsArea` (Area Text) preferences (`prefs.set`), checked while on. +Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the +`typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform +panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. + Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align panel, the Control bar and the Properties panel run `object.align` with `to: "artboard"` when one object is selected, diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..c33243f42 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3963,6 +3963,29 @@ an error that lists the choices of a `horizontal`, `vertical`, `to`, `axis` or ` (`object.reflect` also takes an angle and names the value given), and one for a `spacing` that is not a number; nothing moves. +## Glyph bounds for type + +The bounding box normally measures point type by its em box, which leaves room above and below the letters for +ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", +value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the +bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type +selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +default. + +A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its +font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt +font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is +measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that +draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as +before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is +not saved in documents. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} +{"name":"run_command","arguments":{"command":"object.setBounds","params":{"height":72,"proportional":true}}} +``` + ## Selection preferences The Selection & Anchor Display and General preferences apply to `pointer_gesture` as they do to the mouse: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..2140f5410 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,6 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)). From 156613941af6144a9dd115444e0d4b705f83f268 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 21:53:22 -0800 Subject: [PATCH 2/2] Type: glyph bounds take in underline and strikethrough bars With Measure Type by Glyph Bounds on, point type is measured by its glyph outlines and its underline and strikethrough bars, as the canvas draws them. A typed W or H gives what is drawn that size, and underlined or struck-through spaces are measured by their bars instead of the em box. The texts now say that each point type object selected as an object (not inside a selected group) is measured this way, that moved type snaps by its ink box, and which Arrange Tools commands follow the option: Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects. Arrange in Grid and Group Overlapping Objects keep measuring visual bounds. --- crates/doc/src/text.rs | 3 +- crates/effects/src/preview.rs | 18 +++++++++--- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 4 +-- crates/engine/src/prefs.rs | 5 ++-- crates/engine/src/tests/typeglyphbounds.rs | 32 ++++++++++++++++++++-- crates/tools/src/context.rs | 5 ++-- docs/control-protocol.md | 3 +- docs/mcp.md | 16 +++++++---- docs/target-app-parity.md | 2 +- 10 files changed, 69 insertions(+), 21 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 2d1a60e59..74aa5f444 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -121,7 +121,8 @@ pub struct TextObject { /// vertical column's centre line), filled in with `cached_bounds` (not serialized). #[serde(skip)] pub cached_baselines: Vec<(Point, Point)>, - /// Cached ink of the glyphs in text space (`TextLayout::ink_bounds`, `None` when nothing draws), + /// Cached ink of the glyphs in text space, underline and strikethrough bars included + /// (`vectorcraft_effects::preview::laid_out_ink`, `None` when nothing draws), /// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's /// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized. #[serde(skip)] diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs index ee8d4e1c1..ad36ab56d 100644 --- a/crates/effects/src/preview.rs +++ b/crates/effects/src/preview.rs @@ -165,8 +165,9 @@ fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option { /// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached /// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the -/// glyph outlines mapped point by point ([`vectorcraft_text::TextLayout::ink_bounds_in`]). `None` -/// when nothing draws (no text, only spaces, or glyphs without outlines). +/// glyph outlines and the underline and strikethrough bars mapped point by point +/// ([`vectorcraft_text::envelope_ink`]). `None` when nothing draws (no text, only spaces without +/// underline or strikethrough, or glyphs without outlines). pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { let m = a * t.xf; let [p, q, r, s, _, _] = m.as_coeffs(); @@ -174,11 +175,20 @@ pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option { if square { let ink = match t.ink() { Some(ink) => ink, - None => vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds(), + None => vectorcraft_text::envelope_ink(t, Affine::IDENTITY, None), }; return ink.map(|i| m.transform_rect_bbox(i)); } - vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).ink_bounds_in(m) + vectorcraft_text::envelope_ink(t, m, None) +} + +/// The ink of type `t` laid out as `layout`, in text space: its glyphs +/// ([`vectorcraft_text::TextLayout::ink_bounds`]) and its underline and strikethrough bars +/// ([`vectorcraft_text::decorations`]), what [`text_ink_in`] measures. `None` when nothing draws. +pub fn laid_out_ink(layout: &vectorcraft_text::TextLayout, t: &vectorcraft_doc::TextObject) -> Option { + vectorcraft_text::decorations(layout, vectorcraft_text::FontDb::global(), t) + .into_iter() + .fold(layout.ink_bounds(), |acc, (_, bar)| union_opt(acc, Some(bar.bounding_box()))) } /// The visual bounds of `n` as drawn: those of its [`drawn_art`], else diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 086bf3843..2f0aa742d 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -287,7 +287,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, point type selected on its own is measured by its glyphs' ink, which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; with the typeGlyphBounds preference, each point type object selected as an object (not inside a selected group) is measured by its glyphs' ink (underline and strikethrough included), which the scale gives the size asked for; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 1bf212717..a4fe33762 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -304,7 +304,7 @@ pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); } /// [`refresh_bounds`] for a document that is opening: the first frame takes the layout @@ -316,7 +316,7 @@ pub(crate) fn refresh_bounds_for_first_frame(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); t.cached_bounds = Some(lay.bounds); t.cached_baselines = lay.baselines(); - t.cached_ink = Some((lay.bounds, lay.ink_bounds())); + t.cached_ink = Some((lay.bounds, vectorcraft_render::effects::preview::laid_out_ink(&lay, t))); vectorcraft_text::keep_layout(t, stamp, lay); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index e6fa7cb8d..d6fd21d28 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -43,8 +43,9 @@ pub struct Prefs { pub align_glyph_bounds_area: bool, /// Preferences › Type › Glyph Bounds › Measure Type by Glyph Bounds (also in the Transform /// panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, - /// Y, W and H, and `object.setBounds` measure point type selected on its own by the ink of its - /// glyphs instead of its em box ([`crate::Session::bounds_measure`]). + /// Y, W and H, and `object.setBounds` measure each point type object selected as an object (not + /// inside a selected group) by the ink of its glyphs instead of its em box + /// ([`crate::Session::bounds_measure`]). pub type_glyph_bounds: bool, /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. diff --git a/crates/engine/src/tests/typeglyphbounds.rs b/crates/engine/src/tests/typeglyphbounds.rs index 0f42fcc7f..9fd8e400b 100644 --- a/crates/engine/src/tests/typeglyphbounds.rs +++ b/crates/engine/src/tests/typeglyphbounds.rs @@ -1,6 +1,6 @@ //! Preferences › Type › Measure Type by Glyph Bounds (`typeGlyphBounds`): the bounding box, the -//! Transform panel's values and `object.setBounds` measure point type selected on its own by the -//! ink of its glyphs instead of its em box. +//! Transform panel's values and `object.setBounds` measure each point type object selected as an +//! object (not inside a selected group) by the ink of its glyphs instead of its em box. use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind, OrientedBox, TextObject}; @@ -282,6 +282,34 @@ fn saved_documents_do_not_depend_on_the_preference() { assert_eq!(serde_json::to_string(&s.doc().unwrap().doc).unwrap(), off); } +/// What the type draws on the page: its glyphs and its underline and strikethrough bars. +fn drawn(s: &Session, id: NodeId) -> Rect { + let t = text(s, id); + vectorcraft_text::envelope_ink(&t, t.xf, None).unwrap() +} + +/// Underline and strikethrough bars are drawn with the type, so the box takes them in and a typed +/// height gives what is drawn that height. Underlined spaces are measured by their bar. +#[test] +fn underline_and_strikethrough_bars_count_as_ink() { + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 100, "text": "H"})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 1, "underline": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(b.y1 > ink(&s, t).y1 + 1.0, "the underline hangs below the glyph"); + run(&mut s, "object.setBounds", json!({"height": 50})); + assert!((drawn(&s, t).height() - 50.0).abs() < 1e-6, "{:?}", drawn(&s, t)); + assert!(near(s.transform_box(&[t]).unwrap().rect, drawn(&s, t), 1e-6)); + + let (mut s, t) = with_type(json!({"x": 100, "y": 300, "size": 40, "text": " "})); + run(&mut s, "text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 3, "strikethrough": true})); + glyphs(&mut s, true); + let b = s.transform_box(&[t]).unwrap().rect; + assert!(near(b, drawn(&s, t), 1e-9), "{b:?} != {:?}", drawn(&s, t)); + assert!(em(&s, t).height() > b.height() + 10.0, "em box {:?}", em(&s, t)); +} + /// A transform of type keeps its cached ink, which is in text space. #[test] fn the_cached_ink_is_the_laid_out_ink() { diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 971323bf1..741e96229 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -178,8 +178,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, - /// Type › Measure Type by Glyph Bounds: the bounding box measures point type selected on its - /// own by the ink of its glyphs ([`vectorcraft_effects::preview::Measure`]). + /// Type › Measure Type by Glyph Bounds: the bounding box measures each point type object + /// selected as an object (not inside a selected group) by the ink of its glyphs + /// ([`vectorcraft_effects::preview::Measure`]). pub glyph_bounds: bool, /// The session's memo of the selection tools' measurements for the document as it is /// ([`select::PreviewMemo`]); without one they measure on every call. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b480106b3..bcf7644d7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -531,7 +531,8 @@ Align to Glyph Bounds: the Align panel flyout's Align to Glyph Bounds submenu sh Measure Type by Glyph Bounds: the Transform panel menu's Measure Type by Glyph Bounds item shows and toggles the `typeGlyphBounds` preference (`prefs.set`), checked while on; while it is on, the bounding box and the Transform -panel, Properties and Control bar fields measure point type selected on its own by its glyphs' ink. +panel, Properties and Control bar fields measure each point type object selected as an object (not inside a selected +group) by its glyphs' ink. Align To: the Align panel's Align To choice is saved with the preferences, and `ui.inspect` reports it as `ui.align_to` (`"selection"` or `"artboard"`; Align to Key Object stores no choice). The align buttons of the Align diff --git a/docs/mcp.md b/docs/mcp.md index c33243f42..d22e27342 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3969,17 +3969,23 @@ The bounding box normally measures point type by its em box, which leaves room a ascenders, descenders and capitals. With the preference `typeGlyphBounds` on (`prefs.set {key: "typeGlyphBounds", value: true}`; also Preferences › Type › Glyph Bounds and the Transform panel menu's Measure Type by Glyph Bounds), the bounding box and its handles, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, -`object.setBounds`, the Scale, Rotate and Reflect tools' reference point and the Arrange commands measure point type -selected on its own, horizontal or vertical, by the ink of its glyphs. The option is VectorCraft's own and is off by +`object.setBounds`, the Scale, Rotate and Reflect tools' reference point, and Swap Objects, Replace with Top Object, +Set Size of Each and Sort Objects measure point type, horizontal or vertical, by the ink of its glyphs: its glyph +outlines and its underline and strikethrough bars. This applies to each point type object that is selected as an object +(not inside a selected group), in a selection with other objects too. The option is VectorCraft's own and is off by default. A width or height typed with the option on scales the type until its ink has that size. The type stays live type: its font size changes by the scale, so `object.setBounds {height: 72}` on "H" gives the cap height 72 pt, not a 72 pt font. With Use Preview Bounds on, the ink takes in the characters' strokes, and type with geometry effects is measured as drawn. Rotated type keeps its own angle, and skewed type is measured by its glyph outlines. Type that -draws nothing (no text, only spaces), area type, type on a path and type inside a selected group are measured as -before. Align and Distribute follow Align to Glyph Bounds, and snapping and clicks do not change. The preference is -not saved in documents. +draws nothing (no text, or only spaces without underline or strikethrough), area type, type on a path and type inside +a selected group are measured as before. Arrange in Grid and Group Overlapping Objects measure visual bounds as +before, and Align and Distribute follow Align to Glyph Bounds. + +When moved type snaps (Snap to Grid, Snap to Pixel, Smart Guides and ruler guides), the edges and center of its ink box +snap, and the Distance Guides measure from that box. The other objects that it snaps to keep their usual boxes, as +they do with Use Preview Bounds. The preference is not saved in documents. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeGlyphBounds":true}}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2140f5410..c769dafd2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1052,7 +1052,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds` and the Arrange commands measure point type selected on its own by the ink of its glyphs instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). + - VectorCraft's own Measure Type by Glyph Bounds (`typeGlyphBounds`, off by default; Preferences › Type and the Transform panel menu): the bounding box, the Transform panel's, Control bar's and Properties panel's X, Y, W and H, `object.setBounds`, Swap Objects, Replace with Top Object, Set Size of Each and Sort Objects measure each point type object selected as an object by the ink of its glyphs, underline and strikethrough included, instead of its em box. A typed W or H scales the live type until its ink has that size, and the font size changes by the scale. Moved type snaps by its ink box. Type that draws nothing, area type, type on a path and type in a selected group are measured as before, and Align and Distribute follow Align to Glyph Bounds (`tests::typeglyphbounds`, `panels::transform::tests::measure_type_by_glyph_bounds_is_checked_and_resizes_the_box`). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. The Lights list, Add Light and Delete Light manage one to ten independent colored lights; ambient is shared and visibility remains approximate ([revolve.md](revolve.md)).