Layout: a paragraph continued into the next column wraps around a shape there (#1374) (#1505)

* #1374: a paragraph continued into the next column or page wraps around the shapes there

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1374: wrap relays keep equation numbers, need the same line top, skip far-off shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 14:06:29 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov
parent 0c7b4a72f2
commit 3d2002b699
4 changed files with 234 additions and 3 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: `.doc` table shading and the extra column, #1380; previously trivial: progress log: `.doc` table shading, columns and merged rows, #1380; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: HTML table cells and region paper for opened HTML, #1381; previously trivial: progress log: continued paragraphs wrap around the next column's shapes, #1374; previously trivial: progress log: PAGE fields on the pages a paragraph runs onto, #1352; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -166,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
@@ -334,6 +335,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
+56
View File
@@ -2812,6 +2812,33 @@ fn again<T>(ctx: &mut Ctx, eq_start: u32, f: impl FnOnce(&mut Ctx) -> T) -> T {
out
}
/// How many times one body paragraph's lines are broken again for the wrap areas of the later
/// columns and pages it runs onto (#1374).
const MAX_WRAP_RELAYS: usize = 64;
#[cfg(test)]
thread_local! {
/// How many wrap relays (#1374) the layouts on this thread made: tests check none happen
/// without something new to wrap around.
static WRAP_RELAYS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The wrap areas for a paragraph laid out against `rel` (relative to its top) whose lines from the
/// one at `t` (relative to its top) on go into the column starting at `x0`, that line at `y` on
/// the page, which has the wrap areas `excl` (#1374). The areas the lines before were broken
/// around end at `t`; the column's start there (one reaching above `t` with an outline keeps its
/// whole outline, so it may change a line before, and the paragraph is then not broken again).
/// Areas well clear of the column (outside `-width..2 * width`, room for negative indents) can't
/// touch its lines and are left out.
fn continued_exclusions(rel: &[para::Exclusion], excl: &[WrapArea], x0: f32, y: f32, t: f32, width: f32) -> Vec<para::Exclusion> {
let before = rel.iter().filter(|e| e.top < t).map(|e| para::Exclusion { bottom: e.bottom.min(t), ..e.clone() });
let here = rel_exclusions(excl, x0, y - t)
.into_iter()
.filter(|e| e.bottom > t && e.right > -width && e.left < 2.0 * width)
.map(|e| if e.top < t && e.contour.is_none() { para::Exclusion { top: t, ..e } } else { e });
before.chain(here).collect()
}
/// Run `f` with `map` as the field context's page map (`FieldCtx::page_from`), then take it back.
fn with_page_map<T>(ctx: &mut Ctx, map: &mut Vec<(usize, u32)>, f: impl FnOnce(&mut Ctx) -> T) -> T {
ctx.fields.page_from = std::mem::take(map);
@@ -2840,6 +2867,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// page (bounded: a paragraph over hundreds of pages keeps the last number it got).
let (mut laid_page, mut laid_width) = (pb.number, pb.col_w());
let mut page_relays = 0usize;
// How many times its lines were broken again for the wrap areas of a later column (#1374).
let mut wrap_relays = 0usize;
// The anchor paragraph's top (above its space before) the floats were placed from.
let mut anchor_y = pb.y;
let spot = |pb: &PageBuilder| (pb.pages.len(), pb.col);
@@ -2924,6 +2953,33 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let mut after_on_page: Option<f32> = None;
let mut l0 = 0;
while l0 < n {
// The lines going into a new column (or page) were broken around the wrap areas where the
// paragraph started: break them again around this column's (the lines before stay as they
// were, and so do the paragraph's own floating objects), when they differ (#1374).
if l0 > 0
&& !pb.web
&& wrap_relays < MAX_WRAP_RELAYS
&& let Some((resume, t)) = pl.lines.get(l0).map(|l| (l.start, l.top))
{
let here = continued_exclusions(&rel, &pb.excl, pb.col_x(), pb.y, t, laid_width);
if here != rel {
wrap_relays += 1;
ctx.fields.page = laid_page;
#[cfg(test)]
WRAP_RELAYS.with(|c| c.set(c.get() + 1));
let relaid =
with_page_map(ctx, &mut page_map, |ctx| again(ctx, eq_start, |ctx| ctx.para_labelled(p, laid_width, None, &here, label.clone())));
// Only if the rest still starts at the same line, at the same height (an outline
// reaching above it could move the lines before down, and the column's areas with them).
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume)
&& relaid.lines.get(k).is_some_and(|l| (l.top - t).abs() < 0.01)
{
(pl, rel) = (relaid, here);
n = pl.lines.len();
l0 = k;
}
}
}
// Page fields in the lines going onto a new page show that page's number: lay the
// paragraph out again with the fields from there on on this page (the lines before stay as
// they were, so the line the rest starts with is still there), breaking the rest again for
+172
View File
@@ -5806,3 +5806,175 @@ fn space_after_stays_clear_before_a_numbered_paragraph_without_a_label() {
wordcraft_doc::resolve::AUTO_SPACING,
);
}
/// #1374 helpers: a page-anchored, square-wrapped rectangle `w`×`h` at (`x`, `y`) on the page.
fn page_shape(x: f32, y: f32, w: f32, h: f32) -> InlineObject {
use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap};
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x, y, dist: 9.0, ..Default::default() };
InlineObject::Shape {
kind: ShapeKind::Rectangle,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
/// #1374: a two-column (`cols`) document: paragraph 0 holds `shape` (if any), paragraph 1 is
/// `words` numbered words, long enough to run on from where it starts.
fn column_wrap_doc(cols: u32, shape: Option<InlineObject>, words: usize) -> Document {
let text: String = (0..words).map(|i| format!("word{i} ")).collect();
let mut d = Document::from_text(&format!("Anchor\n{}", text.trim_end()));
d.last_section.columns.count = cols;
if let Some(s) = shape {
d.insert_object(&Pos::body(0, 0), s, &Default::default()).unwrap();
}
d
}
/// #1374: every line of body paragraph `block` drawn on page `page` whose x is `col_x` (one column):
/// its page box (left, top, right, bottom).
fn line_boxes(l: &DocLayout, page: usize, block: u32, col_x: f32) -> Vec<(f32, f32, f32, f32)> {
let mut v = Vec::new();
for it in &l.pages[page].items {
let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, x, y, .. } = it else { continue };
if path.0 != [block] || (x - col_x).abs() > 1.0 {
continue;
}
let first = &para.lines[*l0];
for line in &para.lines[*l0..*l1] {
let top = y + (line.top - first.top);
v.push((x + line.left, top, x + line.right, top + line.height));
}
}
v
}
/// #1374: no line in `lines` runs through `r` (grown by the shape's 9 pt wrap distance at the sides).
fn assert_clear_of(lines: &[(f32, f32, f32, f32)], r: Rect, what: &str) {
let mut beside = 0;
for &(l, t, rt, b) in lines {
if b <= r.y + 0.01 || t >= r.bottom() - 0.01 {
continue;
}
beside += 1;
assert!(l >= r.right() + 8.5 || rt <= r.x - 8.5, "{what}: line {l}..{rt} at {t}..{b} runs through the shape {r:?}");
}
assert!(beside > 0, "{what}: some lines are beside the shape: {lines:?}");
}
/// #1374: a paragraph that runs on from column 1 into column 2 wraps around a shape in column 2.
#[test]
fn paragraph_continued_into_next_column_wraps_around_a_shape_there() {
let r = Rect::new(340.0, 300.0, 100.0, 100.0);
let d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450);
let l = lay(&d);
let (_, ranges) = shown_and_ranges(&l, 1);
assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0));
let col2 = l.pages[0].body.x + d.last_section.column_boxes()[1].0;
assert!(ranges.len() >= 2, "the paragraph runs into column 2: {ranges:?}");
assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2");
}
/// #1374: a shape far to the left of the next column (in the page margin, beyond a column's width
/// from it) doesn't make the paragraph going into that column be laid out again.
#[test]
fn paragraph_continued_into_next_column_ignores_shapes_well_clear_of_it() {
let d = column_wrap_doc(2, Some(page_shape(0.0, 300.0, 20.0, 100.0)), 450);
WRAP_RELAYS.with(|c| c.set(0));
let l = lay(&d);
assert_eq!(WRAP_RELAYS.with(|c| c.get()), 0, "no wrap relay");
let (_, ranges) = shown_and_ranges(&l, 1);
assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0));
}
/// #1374: lines continued onto a new page are broken for that page: a shape on the page before
/// (reaching below its body) doesn't push them aside there.
#[test]
fn paragraph_continued_onto_next_page_is_broken_for_that_page() {
// On page 1 from 640 down past the page bottom, at the left of the text.
let d = column_wrap_doc(1, Some(page_shape(72.0, 640.0, 150.0, 150.0)), 900);
let l = lay(&d);
assert!(l.pages.len() >= 2, "{} pages", l.pages.len());
let (_, ranges) = shown_and_ranges(&l, 1);
assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0));
// Page 1's lines beside the shape keep clear of it; page 2 has nothing to wrap around.
let page1 = line_boxes(&l, 0, 1, 72.0);
assert_clear_of(&page1, Rect::new(72.0, 640.0, 150.0, 150.0), "page 1");
let page2 = line_boxes(&l, 1, 1, 72.0);
assert!(!page2.is_empty());
for (left, top, ..) in page2 {
assert!(left < 72.5, "page 2: the line at {top} starts at {left}, beside a shape that isn't on this page");
}
}
/// #1374: with nothing to wrap around, the lines going into the next column are those broken in
/// the first: the paragraph isn't laid out again (no wrap relay), and both pieces share one layout.
#[test]
fn paragraph_continued_into_next_column_without_shapes_is_not_laid_out_again() {
let d = column_wrap_doc(2, None, 450);
WRAP_RELAYS.with(|c| c.set(0));
let l = lay(&d);
assert_eq!(WRAP_RELAYS.with(|c| c.get()), 0, "no wrap relay");
let pieces: Vec<&Arc<ParaLayout>> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, .. } if path.0 == [1] => Some(para),
_ => None,
})
.collect();
assert_eq!(pieces.len(), 2, "one piece per column");
assert!(Arc::ptr_eq(pieces[0], pieces[1]), "the second column reuses the first column's layout");
}
/// #1374: the paragraph's own floating object stays where it was placed (at its start, in column 1)
/// when its lines in column 2 are broken again around a shape there.
#[test]
fn paragraph_own_float_is_not_replaced_when_its_rest_wraps_in_the_next_column() {
use wordcraft_doc::para::{Anchor, Float, ShapeKind, Wrap};
let r = Rect::new(340.0, 300.0, 100.0, 100.0);
let mut d = column_wrap_doc(2, Some(page_shape(r.x, r.y, r.w, r.h)), 450);
let own = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, dist: 9.0, ..Default::default() };
let own = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 80.0,
h: 60.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float: own,
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
d.insert_object(&Pos::body(1, 0), own, &Default::default()).unwrap();
let l = lay(&d);
let owns: Vec<(usize, Rect)> = l
.pages
.iter()
.enumerate()
.flat_map(|(pi, p)| p.items.iter().map(move |it| (pi, it)))
.filter_map(|(pi, it)| match it {
Placed::Shape { rect, .. } if (rect.w - 80.0).abs() < 0.5 => Some((pi, *rect)),
_ => None,
})
.collect();
let col2 = l.pages[0].body.x + d.last_section.column_boxes()[1].0;
let boxes = para_boxes(&l);
assert_eq!(owns.len(), 1, "the own float is drawn once: {owns:?}");
let (pi, own_rect) = owns[0];
assert_eq!(pi, 0);
assert!((own_rect.x - 72.0).abs() < 0.5, "in column 1: {own_rect:?}");
assert!((own_rect.y - boxes[1].2).abs() < 20.0, "at the paragraph's top {}: {own_rect:?}", boxes[1].2);
let (_, ranges) = shown_and_ranges(&l, 1);
assert_contiguous(&ranges, d.para(StoryRef::Body, &Path::top(1)).map(|p| p.len()).unwrap_or(0));
assert_clear_of(&line_boxes(&l, 0, 1, col2), r, "column 2");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: colour emoji drawn on screen, partial: CBDT verified, COLR and sbix not yet seen on real fonts, #1269; previously trivial: colour emoji drawn on screen, #1269; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (PAGEREF in headers and footers shows its bookmark's page, #1477; previously trivial: a paragraph continued into the next column or page wraps around that column's shapes, #1374; previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
@@ -46,7 +46,7 @@ font can't match Word's metrics.
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 |
| Nested tables | 🟡 | render; polish missing | 2–4 |
| Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — |
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | #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; 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: 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; 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) | — |
| 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 |
@@ -86,6 +86,7 @@ font can't match Word's metrics.
| 2026-10-12 | trivial | a list label moves with its first line when the paragraph is centred or end-aligned (#1159) |
| 2026-10-13 | trivial | A paragraph ending with a page break keeps its space after on the old page with its mark: the next page starts with the next paragraph at the top (it started that much too low, and a page break before after it opened a blank page) |
| 2026-10-13 | trivial | Table rows: every cell gets the row's largest top and bottom margin; exact rows add the bottom margin on top and never split; a split row keeps its cells' bottom margin, border and space after on the first page, applies widow control in its cells and continues each cell at the top of the next page |
| 2026-10-14 | trivial | A paragraph continued into the next column or page is broken again around the shapes there, not the ones where it started (#1374) |
| 2026-10-13 | trivial | PAGE fields after a page break inside their paragraph, or on a page the paragraph runs onto, show that page's number (#1352) |
| 2026-10-14 | trivial | Colour emoji row set to 🟡: CBDT verified on Noto Color Emoji; COLR v0/v1 and sbix not yet seen on a real font (#1269) |
| 2026-10-14 | trivial | Colour emoji and colour-font glyphs (COLR, sbix, CBDT) drawn on screen and in page renders (#1269) |