From 1ae38ec2be76b47e6adf7d6486a940a9448587de Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:23 +0100 Subject: [PATCH] =?UTF-8?q?#3692:=20review=20=E2=80=94=20stacked=20linked?= =?UTF-8?q?=20boxes=20share=20one=20layout;=20past=20the=20cap=20a=20box?= =?UTF-8?q?=20takes=20one=20in=20its=20own=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked text is laid out at STACK_WIDTH whatever the frame, so its flow key now is too. The nearest-layout fallback only picks layouts of the box's TextVert; the first box in a direction is laid out (at most a few more layouts). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/tests_budget.rs | 53 +++++++++++++++++++++++++++++++ crates/layout/src/textbox.rs | 16 +++++----- docs/layout-parity.md | 2 +- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 9e95ca076..72e1d343b 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -59,6 +59,59 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); } +/// The paragraph layouts (by address) showing story `head` across the document. +fn story_layouts(l: &DocLayout, head: u32) -> Vec { + l.pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == head => Some(Arc::as_ptr(para) as usize), + _ => None, + }) + .collect() +} + +#[test] +fn stacked_boxes_of_any_width_share_a_layout() { + // Stacked text is laid out one letter to a line whatever the box's width. + let n = 20; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Stacked letters run on through every box. ".repeat(20); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + for id in &ids { + d.parts.get_mut(id).unwrap().body.vert = wordcraft_doc::props::TextVert::Stacked; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), 1, "one layout for every stacked box"); +} + +#[test] +fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() { + // The cap is full of horizontal widths when a vertical box comes: it lays the story out + // its own way rather than showing a horizontal layout. + let n = textbox::MAX_FLOW_WIDTHS + 1; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + if let Some(last) = ids.last() { + d.parts.get_mut(last).unwrap().body.vert = wordcraft_doc::props::TextVert::Vert; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), n, "the vertical box has a layout of its own"); +} + #[test] fn boxes_a_hair_apart_in_width_share_a_layout() { // Resized boxes differ by fractions of a point; those within a twentieth of a point of each diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 975b39390..4ebe96c9c 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -215,8 +215,9 @@ struct Cut { pub(crate) const MAX_FLOW_WIDTHS: usize = 16; /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart -/// share one layout. +/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size. fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + let len = if vert == TextVert::Stacked { STACK_WIDTH } else { len }; ((len * 20.0).round() as i32, vert) } @@ -246,14 +247,13 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); let key = flow_key(len, body.vert); - let items = match layouts.iter().find(|(k, _)| *k == key) { + // Past the cap the box takes the nearest layout in its direction: the widest line length + // that fits it, else the narrowest. The first box in a direction is laid out (one more + // layout for each of the few directions at most). + let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0))); + let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None }); + let items = match kept { Some((_, items)) => items.clone(), - // Past the cap the box takes the nearest layout: the same direction first, then the - // widest line length that fits it, else the narrowest. - None if layouts.len() >= MAX_FLOW_WIDTHS => { - let near = layouts.iter().min_by_key(|((k, v), _)| (*v != key.1, *k > key.0, k.abs_diff(key.0))); - near.map(|(_, items)| items.clone()).unwrap_or_default() - } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2d06368c9..23252ea7f 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -68,7 +68,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one (#3692) | — | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — |