diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e4e189c..f587b73 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 58c6c7d..d5c25fb 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); +}