From 7f4db9db640395987d808c4730f56ff9e6421178 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 04:59:38 -0400 Subject: [PATCH] Text: centre, right and decimal tab stops align the text after them; a tab past the end wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab stops were all left stops: a:tab/@algn was read and saved but ignored. As PowerPoint draws them, the text after a tab (up to the next tab or line break) now ends at a right stop, is centred on a centre stop, and has its first "." on a decimal stop (text without one ends there); when that text is wider than the room before the stop it starts where the tab is, never further left. Measured on PowerPoint's export: "right" ends at 180 pt, "12.5" and "1234.567" put the point on the stop, "12,5" and "nodot" end at it. A tab whose stop lies past the end of the line now starts the next line, so the text after it sits on that line's first stop, as PowerPoint wraps "abcdefghijklm…". Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/lib.rs | 95 ++++++++++++++++++++++++++++++++++++---- crates/text/src/tests.rs | 49 +++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e4e189c2..f587b73e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -990,7 +990,7 @@ fn break_lines( first_w: f64, rest_w: f64, wrap: bool, - tab_stops: &[f64], + tab_stops: &[Tab], default_tab: f64, x0_first: f64, x0_rest: f64, @@ -1019,7 +1019,11 @@ fn break_lines( next = i + 1; break; } - let adv = if c == '\t' { tab_advance(x0 + x, tab_stops, default_tab) } else { p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) }; + let adv = if c == '\t' { + tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i)) + } else { + p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) + }; let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j)); if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { last_break = Some(i); @@ -1031,6 +1035,15 @@ fn break_lines( next = i + 1; break; } + // A tab that would run past the end moves to the next line (and to its first stop there). + if wrap && c == '\t' && x + adv > avail + 0.01 && i > start { + end = i; + next = i; + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { // Break at the last opportunity, or force mid-word (between clusters). let b = last_break @@ -1072,10 +1085,72 @@ fn break_lines( lines } -fn tab_advance(x: f64, stops: &[f64], default_tab: f64) -> f64 { +/// How a tab stop aligns the text after it (`a:tab/@algn`). +#[derive(Clone, Copy, Debug, PartialEq)] +enum TabAlign { + Left, + Center, + Right, + /// The first "." of the text sits at the stop (text without one ends there). + Decimal, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Tab { + pos: f64, + align: TabAlign, +} + +fn tabs_of(props: &ParaProps) -> Vec { + props + .tabs + .as_ref() + .map(|t| { + t.iter() + .map(|s| Tab { + pos: s.pos, + align: match s.align.as_str() { + "ctr" => TabAlign::Center, + "r" => TabAlign::Right, + "dec" => TabAlign::Decimal, + _ => TabAlign::Left, + }, + }) + .collect() + }) + .unwrap_or_default() +} + +/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of +/// that text before its first ".". +fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { + let mut w = 0.0; + let mut dec = None; + for c in cells.iter().skip(i + 1) { + if matches!(c.ch, '\t' | '\u{b}' | '\n') { + break; + } + if c.ch == '.' && dec.is_none() { + dec = Some(w); + } + w += c.adv; + } + (w, dec.unwrap_or(w)) +} + +/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it +/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never +/// moving back, as PowerPoint does; past the last stop, to the next default stop. +fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { for s in stops { - if *s > x + 0.5 { - return s - x; + if s.pos > x + 0.5 { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; } } let d = if default_tab > 1.0 { default_tab } else { 72.0 }; @@ -1197,7 +1272,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts (margin + indent).max(0.0) }; let rest_x = margin; - let tabs: Vec = props.tabs.as_ref().map(|t| t.iter().map(|s| s.pos).collect()).unwrap_or_default(); + let tabs = tabs_of(props); let first_w = (col_w - first_x - mr).max(1.0); let rest_w = (col_w - rest_x - mr).max(1.0); let raw = break_lines(p, first_w, rest_w, wrap, &tabs, props.default_tab.unwrap_or(72.0), first_x, rest_x); @@ -1239,8 +1314,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // Logical advances; tabs measure from the start edge. let mut adv = Vec::with_capacity(n); let mut x = 0.0; - for c in line_cells { - let a = if c.ch == '\t' { tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0)) } else { c.adv }; + for (k, c) in line_cells.iter().enumerate() { + let a = if c.ch == '\t' { + tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; adv.push(a); x += a; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 58c6c7d5..d5c25fba 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -885,3 +885,52 @@ fn transparent_text_is_faded_once() { let opacity = r.color.a as f64 / 255.0 * r.alpha; assert!((opacity - 0.4).abs() < 0.01, "{opacity}"); } + +/// One paragraph `text` with tab stops `tabs` (position, `algn`), no bullet or indent. +fn tabbed(text: &str, tabs: &[(f64, &str)], w: f64) -> TextLayout { + let (p, mut sh) = setup(text, w, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + for r in &mut para.runs { + r.props.size = Some(18.0); + } + let pp = &mut para.props; + pp.bullet = Some(Bullet::None); + pp.margin_left = Some(0.0); + pp.indent = Some(0.0); + pp.default_tab = Some(72.0); + pp.tabs = Some(tabs.iter().map(|(pos, a)| deckcraft_model::text::TabStop { pos: *pos, align: a.to_string() }).collect()); + lay(&p, &sh, w, 300.0) +} + +#[test] +fn tab_stops_align_the_text_after_them() { + let x0 = |l: &TextLayout| l.inner.x0; + // Right: the text ends at the stop. Centre: centred on it. Decimal: the "." at the stop. + let l = tabbed("a\tright", &[(200.0, "r")], 600.0); + assert!((l.lines[0].extent.1 - x0(&l) - 200.0).abs() < 0.01, "{:?}", l.lines[0].extent); + let l = tabbed("a\tmid", &[(200.0, "ctr")], 600.0); + let (s, e) = (l.lines[0].edges[2].0, l.lines[0].edges[4].1); + assert!(((s + e) / 2.0 - x0(&l) - 200.0).abs() < 0.01); + let l = tabbed("x\t1234.567", &[(150.0, "dec")], 600.0); + assert!((l.lines[0].edges[6].0 - x0(&l) - 150.0).abs() < 0.01, "the point sits at the stop"); + // Text without a point ends at a decimal stop. + let l = tabbed("x\t12,5", &[(150.0, "dec")], 600.0); + assert!((l.lines[0].extent.1 - x0(&l) - 150.0).abs() < 0.01); + // Text wider than the room before a right stop starts where the tab is; it never moves back. + let l = tabbed("x\twider than the gap", &[(20.0, "r")], 600.0); + assert!((l.lines[0].edges[2].0 - l.lines[0].edges[0].1).abs() < 0.01); + // A left stop and then default stops every 72 pt. + let l = tabbed("a\tb\tc", &[(30.0, "l")], 600.0); + assert!((l.lines[0].edges[2].0 - x0(&l) - 30.0).abs() < 0.01); + assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01); +} + +#[test] +fn a_tab_past_the_end_moves_to_the_next_line() { + // PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next + // line, so the text after it lands on that line's first stop. + let l = tabbed("abcdefghij\tk", &[], 130.0); + assert_eq!(l.lines.len(), 2); + assert_eq!(l.lines[1].start, 10, "the tab starts the second line: {:?}", l.lines.iter().map(|x| (x.start, x.end, x.extent)).collect::>()); + assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01); +}