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:
parent
ef313b91b0
commit
ed562d7305
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue