diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 4cf1b3d..31dc291 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -917,7 +917,11 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); let first_x = if p.bullet.is_some() { let bx = (margin + indent).max(0.0); - if indent < 0.0 && bx + bullet_w <= margin { margin } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } } else { (margin + indent).max(0.0) }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 959d614..25f86f9 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -447,3 +447,45 @@ fn superscript_glyphs_sit_inside_their_cells() { assert!(g.1 >= x0 - 0.01 && g.1 < x1, "glyph at {} outside its cell {x0}..{x1}", g.1); } } + +/// A wrapping numbered paragraph with `marL` and `indent`; returns (bullet x, first line's text +/// x, second line's text x). +fn hanging(margin: f64, indent: f64, bullet: Bullet) -> (Option, f64, f64) { + let text = "Wrapping words go on and on so the paragraph needs at least two or three lines here"; + let (p, mut sh) = setup(text, 300.0, 300.0); + let para = &mut sh.text.as_mut().unwrap().paragraphs[0]; + para.props.bullet = Some(bullet); + para.props.margin_left = Some(margin); + para.props.indent = Some(indent); + let l = lay(&p, &sh, 300.0, 300.0); + assert!(l.lines.len() >= 2, "{} lines", l.lines.len()); + let bx = l.runs.iter().find(|r| r.cells.is_empty() && !r.glyphs.is_empty()).map(|r| r.glyphs[0].1); + (bx, l.lines[0].caret_x[0], l.lines[1].caret_x[0]) +} + +#[test] +fn a_hang_past_the_box_edge_keeps_its_width_on_the_first_line() { + // PowerPoint, marL 0 / indent -36pt: the number sits at the box's edge, the first line's text + // 36pt in, and the wrapped lines at the margin (observed with PowerPoint's slide export). + let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }; + let (bx, first, rest) = hanging(0.0, -36.0, num()); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + assert!((bx.unwrap() - rest).abs() < 0.5, "bullet {bx:?} rest {rest}"); + // marL 18 / indent -54: number at the edge, first line at 54, the rest at 18. + let (bx, first, rest) = hanging(18.0, -54.0, num()); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + assert!((rest - bx.unwrap() - 18.0).abs() < 0.5, "bullet {bx:?} rest {rest}"); + // A character bullet behaves the same. + let (_, first, rest) = hanging(0.0, -36.0, Bullet::Char { char: "•".into() }); + assert!((first - rest - 36.0).abs() < 0.5, "first {first} rest {rest}"); + // Without a bullet the first line just starts at the edge. + let (_, first, rest) = hanging(0.0, -36.0, Bullet::None); + assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); +} + +#[test] +fn an_ordinary_hang_still_puts_the_text_at_the_margin() { + let (bx, first, rest) = hanging(36.0, -36.0, Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 }); + assert!((first - rest).abs() < 0.5, "first {first} rest {rest}"); + assert!((rest - bx.unwrap() - 36.0).abs() < 0.5); +}