Merge PR #760 from albayazid: Text: anchorCtr centres the drawn text block - right-aligned text stayed glued to the box edge (#736)

This commit is contained in:
Brandon Thomas
2026-10-14 21:59:39 -04:00
3 changed files with 80 additions and 3 deletions
+1
View File
@@ -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) |
+14 -3
View File
@@ -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() {
+65
View File
@@ -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<Align>, text: &str| Paragraph {
props: ParaProps { align, ..Default::default() },
runs: vec![Run::new(text)],
..Default::default()
};
let anchored =
|paragraphs: Vec<Paragraph>| 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::<Vec<_>>()
);
}