mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Text: draw tab leaders in the tab's gap (fixes #1877)
This commit is contained in:
1 parent
c73871eacc
commit
eff2ccdc34
4 files changed
+119
-10
No files matched your search
@@ -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<kurbo::BezPath> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<usize>], 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<usize>], 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;
|
||||
|
||||
@@ -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<f64> {
|
||||
let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
let mut xs: Vec<f64> = 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 {
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user