From eff2ccdc342bff77e811c2215f0ccc1ece529a61 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:50:20 +0200 Subject: [PATCH] Text: draw tab leaders in the tab's gap (fixes #1877) --- crates/engine/src/cmd/tabs.rs | 34 +++++++++++++++++++ crates/text/src/layout.rs | 63 ++++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 27 +++++++++++++++ docs/type-parity.md | 5 +-- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index c62e1821..766335ca 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -113,4 +113,38 @@ mod tests { s.execute("text.tabs.clear", &json!({})).unwrap(); assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([])); } + + /// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots. + #[test] + fn tab_leader_fills_the_gap_with_its_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap(); + let st = s.doc().unwrap(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x; + let (from, to) = (x_of(3), x_of(4)); + assert!(to - from > 150.0, "{from}..{to}"); + // Contours drawn inside the gap: only the leader's dots can be there. + let mut dots = 0; + for g in &lay.glyphs { + let mut contours: Vec = vec![]; + for el in g.outline.elements() { + match (el, contours.last_mut()) { + (kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])), + (_, Some(c)) => c.push(*el), + } + } + for c in contours { + let r = kurbo::Shape::bounding_box(&c); + if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 { + assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}"); + dots += 1; + } + } + } + assert!(dots > 30, "{dots} dots in a {} pt gap", to - from); + } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index be0edba3..2cb943d4 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1113,9 +1113,9 @@ impl Pen<'_> { } /// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then -/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the -/// next tab) on the stop. -fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 { +/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal +/// stops align the text that follows (`rest`, up to the next tab) on the stop. +fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); @@ -1128,14 +1128,52 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S }; let explicit = tabs.iter().find_map(|t| { let at = target(t.position, t.align, t.align_on); - (at > rel + 1e-6).then_some(at) + (at > rel + 1e-6).then_some((at, t.leader.as_str())) }); - let at = explicit.unwrap_or_else(|| { + let (at, leader) = explicit.unwrap_or_else(|| { let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); let base = rel.max(last); - ((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL + (((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "") }); - (at - rel).max(0.0) + ((at - rel).max(0.0), leader) +} + +/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make +/// millions of outlines. +const MAX_LEADER_COPIES: f64 = 4096.0; + +/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated +/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width +/// from `origin` (where tab stops are measured from), so the leaders of lines above one another +/// line up; only whole copies are drawn. Characters the font lacks draw nothing. +fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath { + let mut out = BezPath::new(); + let glyphs: Vec<(u32, f64)> = leader + .chars() + .filter(|c| !c.is_control()) + .map(|c| { + let gid = g.face.glyph_for(c); + (gid, g.face.advance(gid) * g.sx) + }) + .collect(); + let width: f64 = glyphs.iter().map(|(_, a)| a).sum(); + if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) { + return out; + } + let first = ((from - origin) / width - EPS).ceil(); + let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize; + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + for k in 0..copies { + let mut pen = origin + (first + k as f64) * width; + for &(gid, adv) in &glyphs { + if gid != 0 { + let m = Affine::translate((pen, y)) * local; + out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el)); + } + pen += adv; + } + } + out } /// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces @@ -1734,8 +1772,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio if j + 1 == trimmed { adv -= end_trim; } + let mut leader = ""; if g.ch == '\t' { - adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + (adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); } else if j < body { if g.is_space() { adv += per_space; @@ -1750,6 +1789,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li); + if !leader.is_empty() { + // The leader belongs to the tab's glyph: the gap stays one character for the + // caret, selection and hit testing. + let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); + } + } x += adv; if j < trimmed { x_end = x; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ec0223b2..e54aac75 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -466,6 +466,33 @@ fn tab_stops_position_text() { assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } +#[test] +fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { + use vectorcraft_doc::{TabAlign, TabStop}; + let mut t = point("A\t1\nAAAAA\t2", style(12.0)); + t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }]; + let lay = layout(db(), &t); + // The left edges of the dots of each line's tab. + let dots = |byte: usize| -> Vec { + let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let mut xs: Vec = vec![]; + for el in tab.outline.elements() { + if let kurbo::PathEl::MoveTo(p) = el { + xs.push(p.x); + } + } + xs.sort_by(f64::total_cmp); + xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + xs + }; + let (short, long) = (dots(1), dots(9)); + assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}"); + // The longer line's dots are the shorter line's last ones, at the same x. + let tail = &short[short.len() - long.len()..]; + assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}"); + assert!(short.last().is_some_and(|&x| x < 120.0)); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { diff --git a/docs/type-parity.md b/docs/type-parity.md index b1c57d91..7ece6d8e 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Every-line and Single-line composers | done | tuned by eye, never compared side by side | | Hyphenation… and Justification… options | missing | | | Optical Margin Alignment | missing | menu stub | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leader drawing not verified against the docs | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | @@ -58,6 +58,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Tab leaders are drawn: the leader's characters fill the tab's gap in the tab's font and size, whole copies on a grid from the frame's edge so leaders line up across lines (fixes #1877); scores and hours unchanged | | 2026-10-14 | minor | Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Score unchanged (estimated) | | 2026-10-14 | minor | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); Select › Same matches type on its characters' fill and stroke (#1746, #1737); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); scores and hours unchanged | | 2026-10-13 | minor | Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); scores and hours unchanged |