Text: centre, right and decimal tab stops align the text after them; a tab past the end wraps

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 "abcdefghij<tab>k<tab>l<tab>m…".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 04:59:38 -04:00
parent ef313b91b0
commit ed562d7305
2 changed files with 136 additions and 8 deletions

View File

@ -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<Tab> {
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<f64> = 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;
}

View File

@ -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::<Vec<_>>());
assert!((l.lines[1].edges[1].0 - l.inner.x0 - 72.0).abs() < 0.01);
}