mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-19 02:10:15 -05:00
Honor table row keep-next during pagination (#2494)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
86ad3ddb63
commit
c92d120098
3 files changed
+246
-3
No files matched your search
@@ -3680,6 +3680,43 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32, depth: usize) -> f32
|
||||
}
|
||||
}
|
||||
|
||||
fn row_keeps_next(row: &table::RowLayout) -> bool {
|
||||
let Some(items) = row.cells.first().and_then(|cell| row.items.get(cell.items.clone())) else { return false };
|
||||
let Some((depth, story)) = items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Placed::Cell { table, story, .. } => Some((table.0.len() + 3, *story)),
|
||||
_ => None,
|
||||
})
|
||||
.min_by_key(|(depth, _)| *depth)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// Word takes the row's keep flag from the first paragraph of its first cell.
|
||||
items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
Placed::Lines { path, story: own, para, .. } if path.0.len() == depth && path.0.last() == Some(&0) && *own == story => {
|
||||
Some(para.rp.keep_next)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, page_height: f32) -> bool {
|
||||
if laid.height <= room + 0.01 {
|
||||
return true;
|
||||
}
|
||||
if row.props.header || row.props.cant_split || (row.props.height_rule == HeightRule::Exact && row.props.height.is_some_and(|h| h > 0.0)) {
|
||||
return false;
|
||||
}
|
||||
if row.props.height_rule == HeightRule::AtLeast && row.props.height.is_some_and(|h| h > room + 0.01 && h <= page_height + 0.01) {
|
||||
return false;
|
||||
}
|
||||
table::split_row(laid, room).is_some()
|
||||
}
|
||||
|
||||
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
pb.prev = None;
|
||||
let width = pb.col_w();
|
||||
@@ -3707,6 +3744,19 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
// table (e.g. 3 header rows over 30 000 pages).
|
||||
const MAX_HEADER_REPEATS: usize = 200_000;
|
||||
let mut header_repeats = 0usize;
|
||||
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
|
||||
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
|
||||
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
|
||||
let Some(row) = tl.rows.get(i) else { continue };
|
||||
if i < header_rows.len() || !row_keeps_next(row) {
|
||||
continue;
|
||||
}
|
||||
let Some((end, height)) = kept.get(i + 1).copied() else { continue };
|
||||
if let Some(group) = kept.get_mut(i) {
|
||||
*group = (end, row.height + height);
|
||||
}
|
||||
}
|
||||
let repeated_height: f32 = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.height).sum();
|
||||
let place = |pb: &mut PageBuilder, row: &table::RowLayout| {
|
||||
let (x, y) = (pb.col_x() + tl.x, pb.y);
|
||||
if let Some(pg) = pb.page() {
|
||||
@@ -3745,7 +3795,23 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
// Each pass places the part of the row that fits, then breaks the page.
|
||||
for _ in 0..1000 {
|
||||
let cur = rest.as_ref().unwrap_or(row);
|
||||
if pb.y + cur.height <= pb.bottom + 0.01 {
|
||||
// A fitting kept row follows the next row when that row cannot start below it.
|
||||
// Move only the prefix's start; an oversized follower cannot separate its kept rows.
|
||||
let defer_kept = pb.y + cur.height <= pb.bottom + 0.01
|
||||
&& !pb.web
|
||||
&& !pb.at_top()
|
||||
&& !fresh
|
||||
&& rest.is_none()
|
||||
&& !ri.checked_sub(1).and_then(|prev| kept.get(prev)).is_some_and(|&(end, _)| end >= ri)
|
||||
&& kept.get(ri).is_some_and(|&(end, height)| {
|
||||
if end == ri {
|
||||
return false;
|
||||
}
|
||||
let (Some(next), Some(laid)) = (t.rows.get(end), tl.rows.get(end)) else { return false };
|
||||
let page_height = pb.bottom - body_top - repeated_height;
|
||||
height <= page_height + 0.01 && !row_can_start(next, laid, pb.bottom - pb.y - height, page_height)
|
||||
});
|
||||
if pb.y + cur.height <= pb.bottom + 0.01 && !defer_kept {
|
||||
break;
|
||||
}
|
||||
// Measured in Word: a row with an at-least height doesn't split where less than that
|
||||
@@ -3756,7 +3822,7 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
r.props.height_rule == wordcraft_doc::props::HeightRule::AtLeast
|
||||
&& r.props.height.is_some_and(|h| h > room + 0.01 && h <= pb.bottom - body_top + 0.01)
|
||||
});
|
||||
let split = if splittable && !short_of_min { table::split_row(cur, room) } else { None };
|
||||
let split = if splittable && !short_of_min && !defer_kept { table::split_row(cur, room) } else { None };
|
||||
match split {
|
||||
Some((a, b)) => {
|
||||
place(pb, &a);
|
||||
|
||||
@@ -7627,3 +7627,179 @@ fn split_cell_moves_image_frame() {
|
||||
assert!((rest.height - (50.0 + top)).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
fn row_keep_doc() -> Document {
|
||||
use wordcraft_doc::props::{HeightRule, LineSpacing};
|
||||
let plain = ParaProps {
|
||||
space_before: Some(0.0),
|
||||
space_after: Some(0.0),
|
||||
line_spacing: Some(LineSpacing::Exactly(12.0)),
|
||||
widow_control: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let para = |text: &str, props: ParaProps| {
|
||||
let mut p = Paragraph::with_text(text, Default::default());
|
||||
p.props = props;
|
||||
wordcraft_doc::para_block(p)
|
||||
};
|
||||
let mut d = Document::new();
|
||||
d.last_section.page_w = 400.0;
|
||||
d.last_section.page_h = 360.0;
|
||||
d.last_section.margin_top = 36.0;
|
||||
d.last_section.margin_bottom = 36.0;
|
||||
d.last_section.margin_left = 36.0;
|
||||
d.last_section.margin_right = 36.0;
|
||||
d.styles.upsert(wordcraft_doc::Style {
|
||||
id: "KeptRow".into(),
|
||||
name: "Kept row".into(),
|
||||
para: ParaProps { keep_next: Some(true), keep_lines: Some(true), ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let mut t = Table::new(2, 1, 328.0);
|
||||
t.props.style = None;
|
||||
t.props.cell_margins = Some([0.0; 4]);
|
||||
t.rows[0].props.height = Some(24.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = vec![para("Heading", ParaProps { style: Some("KeptRow".into()), ..plain.clone() })];
|
||||
t.rows[1].props.height = Some(180.0);
|
||||
t.rows[1].props.height_rule = HeightRule::AtLeast;
|
||||
t.rows[1].cells[0].blocks = vec![para("", plain.clone()), para("Following row", plain.clone())];
|
||||
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(190.0)), ..plain }), Arc::new(Block::Table(t))];
|
||||
d
|
||||
}
|
||||
|
||||
fn row_keep_pages(d: &Document) -> Vec<usize> {
|
||||
let l = lay(d);
|
||||
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
|
||||
.map(|row| {
|
||||
l.pages
|
||||
.iter()
|
||||
.position(|page| {
|
||||
page.items.iter().any(|item| matches!(item, Placed::Lines { path, .. } if path.0.len() == 4 && path.0[..3] == [1, row as u32, 0]))
|
||||
})
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_moves_with_next() {
|
||||
for rule in [wordcraft_doc::props::HeightRule::AtLeast, wordcraft_doc::props::HeightRule::Exact] {
|
||||
let mut d = row_keep_doc();
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height_rule = rule;
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1], "unkept control, {rule:?}");
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = None;
|
||||
assert_eq!(row_keep_pages(&d), [1, 1], "inherited keep, {rule:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_allows_next_split() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[1].props.height = None;
|
||||
let p = d.para_mut(StoryRef::Body, &Path(vec![1, 1, 0, 0])).unwrap();
|
||||
let props = p.props.clone();
|
||||
*p = Paragraph::with_text(&"Next\n".repeat(30), Default::default());
|
||||
p.props = props;
|
||||
assert_eq!(row_keep_pages(&d), [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_rows_move_as_chain() {
|
||||
let mut d = row_keep_doc();
|
||||
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(170.0));
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
let mut middle = t.rows[0].clone();
|
||||
middle.props.height = Some(80.0);
|
||||
middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
|
||||
let mut p = Paragraph::with_text("Middle", Default::default());
|
||||
p.props = middle.cells[0].blocks[0].as_para().unwrap().props.clone();
|
||||
middle.cells[0].blocks = vec![wordcraft_doc::para_block(p)];
|
||||
t.rows.insert(1, middle);
|
||||
assert_eq!(row_keep_pages(&d), [1, 1, 1]);
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_kept_rows_finish() {
|
||||
let mut d = row_keep_doc();
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[1].props.height = Some(400.0);
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() <= 4, "{} pages", l.pages.len());
|
||||
assert_eq!(row_keep_pages(&d), [1, 2], "Word starts the oversized kept group on a fresh page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_row_can_split_itself() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[0].props.height = None;
|
||||
t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Auto;
|
||||
t.rows[1].props.height = Some(20.0);
|
||||
let p = d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap();
|
||||
let mut props = p.props.clone();
|
||||
props.keep_lines = Some(false);
|
||||
*p = Paragraph::with_text(&"Heading\n".repeat(10), Default::default());
|
||||
p.props = props;
|
||||
assert_eq!(row_keep_pages(&d), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_cell_controls_keep() {
|
||||
for (first, last, pages) in [(true, false, [1, 1]), (false, true, [0, 1]), (true, true, [1, 1]), (false, false, [0, 1])] {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.grid = vec![164.0, 164.0];
|
||||
for (ri, row) in t.rows.iter_mut().enumerate() {
|
||||
row.cells.push(row.cells[0].clone());
|
||||
for (ci, cell) in row.cells.iter_mut().enumerate() {
|
||||
let mut p = Paragraph::with_text("Cell", Default::default());
|
||||
p.props = cell.blocks[0].as_para().unwrap().props.clone();
|
||||
p.props.keep_next = Some(ri == 0 && if ci == 0 { first } else { last });
|
||||
cell.blocks = vec![wordcraft_doc::para_block(p)];
|
||||
}
|
||||
}
|
||||
assert_eq!(row_keep_pages(&d), pages, "first={first}, last={last}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_para_controls_row_keep() {
|
||||
let mut d = row_keep_doc();
|
||||
let mut p = Paragraph::with_text("Last paragraph", Default::default());
|
||||
p.props = d.para(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.clone();
|
||||
p.props.keep_next = Some(false);
|
||||
d.table_mut(StoryRef::Body, &Path::top(1)).unwrap().rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p));
|
||||
assert_eq!(row_keep_pages(&d), [1, 1], "Word honors the first paragraph even when the last opts out");
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 0])).unwrap().props.keep_next = Some(false);
|
||||
d.para_mut(StoryRef::Body, &Path(vec![1, 0, 0, 1])).unwrap().props.keep_next = Some(true);
|
||||
assert_eq!(row_keep_pages(&d), [0, 1], "a later paragraph cannot keep its row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_keep_leaves_outer_row() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[0].props.height = Some(60.0);
|
||||
let mut nested = Table::new(1, 1, 328.0);
|
||||
nested.rows[0].cells[0].blocks = vec![t.rows[0].cells[0].blocks[0].clone()];
|
||||
let mut p = Paragraph::with_text("Outer tail", Default::default());
|
||||
p.props.keep_next = Some(false);
|
||||
t.rows[0].cells[0].blocks = vec![Arc::new(Block::Table(nested)), wordcraft_doc::para_block(p)];
|
||||
assert_eq!(row_keep_pages(&d), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_prefix_stays_together() {
|
||||
let mut d = row_keep_doc();
|
||||
let t = d.table_mut(StoryRef::Body, &Path::top(1)).unwrap();
|
||||
t.rows[1].props.height = Some(400.0);
|
||||
let mut middle = t.rows[0].clone();
|
||||
middle.props.height = Some(80.0);
|
||||
middle.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
|
||||
t.rows.insert(1, middle);
|
||||
assert_eq!(row_keep_pages(&d), [1, 1, 2], "an oversized follower cannot separate an already placed kept prefix");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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-17 · **Change:** trivial (AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; 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
|
||||
|
||||
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,
|
||||
@@ -186,6 +186,7 @@ When content exceeds a preferred column width, AutoFit donors lose equal widths
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. |
|
||||
| 2026-10-16 | trivial | Include table-cell headings in regenerated TOCs with full target paths |
|
||||
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
|
||||
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
|
||||
|
||||
Reference in new issue
Block a user