From 40e05abd45584c13731c6b1dffa8489f6fd35627 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Thu, 15 Oct 2026 04:41:37 +0600 Subject: [PATCH] Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736) anchorCtr's centreing offset was computed for a block that starts at the box's left edge and then added to lines that alignment had already placed within the box. For a right-aligned line the two shifts stacked: the line ended up starting at the box's centre and ran past the right edge - on the tdf165341 fixture the sentence was cut off at "...paragraph rig". The clamp at zero made it worse for content wider than the box: all the overflow went to the right only. The origin is now set so the drawn text block's own centre (min/max of the laid-out line extents) lands on the box's centre: every alignment stays inside the box when it fits, and overflow centres symmetrically past both edges, as PowerPoint draws it. Empty lines carry no ink and stay out of the block; vertical text and multi-column bodies are unchanged (centring is unimplemented for them, as before). Tests: right/left-aligned, trailing empty paragraph, mixed alignment, symmetric overflow, and a no-anchorCtr control - the block's centre equals the box's centre in every case. Reported with a public LibreOffice test fixture (tdf165341.pptx); verified by rendering the slide before (text cut at the right edge) and after (whole sentence centred). --- ROADMAP.md | 1 + crates/text/src/lib.rs | 17 +++++++++-- crates/text/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) 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 840b7834..d028a845 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1908,9 +1908,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 d647a3e7..f9dbd0a1 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1330,3 +1330,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::>() + ); +}