diff --git a/ROADMAP.md b/ROADMAP.md index 9d1662a6..37dd2a42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) | | 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) | | 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) | diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 38f0a7b4..089023d9 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1911,9 +1911,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts // PowerPoint draws justified and distributed anchoring at the bottom. Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, }; - // anchorCtr: centre the block horizontally. - let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 }; - let ox = inner.x0 + dx; + // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put + // them, so the origin is set so the block's centre lands on the box's centre — a left-edge + // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink + // and stay out of the block. Overflowing content centres symmetrically past both edges. + // Not applied to vertical text (there the offset would run along the wrong axis) or + // multi-column bodies (centring unimplemented for them). + let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal); + let ox = if centre_block && !all_lines.is_empty() { + let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start); + let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1))); + if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } + } else { + inner.x0 + }; let oy = inner.y0 + dy; // Emit runs, in visual order. for (line, place, _, bullet) in all_lines.iter_mut() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 9d40d79b..57fda755 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1342,3 +1342,68 @@ fn stacked_combining_marks_take_a_face_that_draws_them() { } let _ = f; } + +/// #736: `anchorCtr` centres the text block horizontally, whatever the paragraphs' own +/// alignment, and overflowing content centres symmetrically past both edges. +#[test] +fn anchorctr_centres_the_text_block() { + let text = "The shape is top - center - overriding paragraph right alignment."; + // The block's centre: (min left + max right) over the laid-out lines, in box coordinates. + let block_centre = |l: &TextLayout| -> f64 { + // Empty lines carry no ink and sit where their alignment left them: not part of the block. + let ink = l.lines.iter().filter(|li| li.end > li.start); + let (min, max) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1))); + (min + max) / 2.0 + }; + let para = |align: Option, text: &str| Paragraph { + props: ParaProps { align, ..Default::default() }, + runs: vec![Run::new(text)], + ..Default::default() + }; + let anchored = + |paragraphs: Vec| TextBody { body: BodyProps { anchor_ctr: Some(true), ..Default::default() }, paragraphs, ..Default::default() }; + // One right-aligned line in a wide box: the block is centred, not pushed past the right edge. + let body_right = anchored(vec![para(Some(Align::Right), text)]); + let (p, mut sh) = setup("", 828.0, 104.0); + sh.text = Some(body_right); + let l = lay(&p, &sh, 828.0, 104.0); + assert!((block_centre(&l) - 414.0).abs() <= 1.0, "right-aligned: centre {}, want 414", block_centre(&l)); + // Left-aligned text centres the same way. + let body_left = anchored(vec![para(None, text)]); + let (p, mut sh) = setup("", 828.0, 104.0); + sh.text = Some(body_left); + let l = lay(&p, &sh, 828.0, 104.0); + assert!((block_centre(&l) - 414.0).abs() <= 1.0, "left-aligned: centre {}, want 414", block_centre(&l)); + // A trailing empty paragraph carries no ink and must not skew the block. + let body_empty = anchored(vec![para(Some(Align::Right), text), para(None, "")]); + let (p, mut sh) = setup("", 828.0, 104.0); + sh.text = Some(body_empty); + let l = lay(&p, &sh, 828.0, 104.0); + assert!((block_centre(&l) - 414.0).abs() <= 1.0, "trailing empty paragraph: centre {}, want 414", block_centre(&l)); + // Mixed alignments: the drawn block (not either alignment edge) is what centres. + let body_mixed = anchored(vec![para(Some(Align::Right), text), para(None, "short")]); + let (p, mut sh) = setup("", 828.0, 104.0); + sh.text = Some(body_mixed); + let l = lay(&p, &sh, 828.0, 104.0); + assert!((block_centre(&l) - 414.0).abs() <= 1.0, "mixed: centre {}, want 414", block_centre(&l)); + // Content wider than the box overflows symmetrically past both edges (no wrap). + let body_wide = TextBody { + body: BodyProps { anchor_ctr: Some(true), wrap: Some(false), ..Default::default() }, + paragraphs: vec![para(Some(Align::Right), text)], + ..Default::default() + }; + let (p, mut sh) = setup("", 300.0, 104.0); + sh.text = Some(body_wide); + let l = lay(&p, &sh, 300.0, 104.0); + assert!((block_centre(&l) - 150.0).abs() <= 2.0, "overflowing: centre {}, want 150", block_centre(&l)); + // Control: without anchorCtr a right-aligned line still ends at the box's right edge. + let body_control = TextBody { body: BodyProps::default(), paragraphs: vec![para(Some(Align::Right), text)], ..Default::default() }; + let (p, mut sh) = setup(text, 828.0, 104.0); + sh.text = Some(body_control); + let l = lay(&p, &sh, 828.0, 104.0); + assert!( + l.lines.iter().all(|li| li.extent.1 > 810.0), + "control: right-aligned at the box edge, got {:?}", + l.lines.iter().map(|li| li.extent).collect::>() + ); +}