diff --git a/ROADMAP.md b/ROADMAP.md index 85af546..d6bd358 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,8 +132,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | -| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | |---|---| +| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged | | 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon | | 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` | | 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared | @@ -153,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) | | 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" | | 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together | +| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it | | 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged | | 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) | |---|---| diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4d47286..b2d2851 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -600,6 +600,9 @@ struct Ctx<'a> { uses_totals: bool, /// Whether to note `uses_totals` now (off while laying out headers and footers). track_totals: bool, + /// Body blocks that are empty paragraphs carrying only a section break before a continuous + /// section, in order: they take no room (see [`place_para`]). + quiet_marks: Vec, } impl Ctx<'_> { @@ -1755,11 +1758,18 @@ fn layout_pass( grid: para::Grid::default(), uses_totals: false, track_totals: true, + quiet_marks: Vec::new(), }; let sections = doc.sections(); let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline); let read = opts.view == ViewMode::Read; let reflow = web || read; + ctx.quiet_marks = sections + .windows(2) + .filter(|w| w[1].1.start == SectionStart::Continuous) + .map(|w| w[0].0) + .filter(|end| doc.body.get(*end).and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty())) + .collect(); let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default())); let default_sect = SectionProps::default(); let mut web_sect; @@ -2584,7 +2594,25 @@ fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32 notes } +/// Place a body paragraph. An empty paragraph that only carries a section break before a +/// continuous section takes no room (Word for Windows: the text after it follows right on, and +/// columns balanced above it don't count it). fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) { + if !pb.web && ctx.quiet_marks.binary_search(&block).is_ok() { + // Its (empty) line stays where the text ended, even at the bottom of a full column. + let (y, prev, bottom, content_bottom, at) = (pb.y, pb.prev.clone(), pb.bottom, pb.content_bottom, (pb.pages.len(), pb.col)); + pb.bottom = f32::MAX / 4.0; + place_para_in_flow(ctx, pb, p, block, body_top); + pb.bottom = bottom; + if (pb.pages.len(), pb.col) == at { + (pb.y, pb.prev, pb.content_bottom) = (y, prev, content_bottom); + } + return; + } + place_para_in_flow(ctx, pb, p, block, body_top); +} + +fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, body_top: f32) { let width = pb.col_w(); // Floating objects anchored here join the page's wrap areas before the text is laid out; if // the paragraph then starts somewhere else (another column or page, or contextual spacing diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 975045a..17996ed 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4387,3 +4387,42 @@ fn keep_with_next_moves_a_paragraph_only_when_it_would_fit() { assert_eq!(pages(fit + 1, kl.clone(), none.clone()), [1, 1, 1, 1, 1, 1], "with keep lines together it moves whole"); assert_eq!(pages(fit - 1, kn.clone(), kn.clone()), [1, 1, 1, 1, 1, 1], "a kept chain moves together"); } + +/// An empty paragraph that only carries a section break before a continuous section takes no +/// room, measured in Word for Windows (`plan/word` R21, 10 cases): the text after it follows right +/// on, and columns balanced above it don't count it (two one-line paragraphs in two columns sit +/// side by side). A section break on a paragraph with text keeps its line, and so does any break +/// before a section on a new page. The SARP form's fields drifted a line lower at each of its +/// many continuous breaks. +#[test] +fn an_empty_continuous_section_mark_takes_no_room() { + use wordcraft_doc::section::{SectionProps, SectionStart}; + // "Pa", the section's last paragraph (`mark`), "Pb", "Pc" in the next section of `cols` + // columns, then "Pd" after a second break. + let tops = |mark: &str, cols: u32, start: SectionStart| { + let mut d = Document::from_text(&format!("Pa\n{mark}\nPb\nPc\n\nPd")); + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = + Some(Box::new(SectionProps { start: SectionStart::Continuous, ..Default::default() })); + let mut s = SectionProps { start, ..Default::default() }; + s.columns.count = cols; + d.para_mut(StoryRef::Body, &Path::top(4)).unwrap().section = Some(Box::new(s)); + d.last_section.start = SectionStart::Continuous; + let l = lay(&d); + [0, 2, 3, 5].map(|i| { + let c = l.caret(&Pos::body(i, 0)).unwrap(); + (c.page, (c.top * 10.0).round() / 10.0, c.x) + }) + }; + let one = tops("", 1, SectionStart::Continuous); + let line = one[2].1 - one[1].1; + assert!((one[1].1 - one[0].1 - line).abs() < 0.15, "Pb right under Pa: {one:?}"); + assert!((one[3].1 - one[2].1 - line).abs() < 0.15, "Pd right under Pc: {one:?}"); + let text = tops("Mark", 1, SectionStart::Continuous); + assert!((text[1].1 - text[0].1 - 2.0 * line).abs() < 0.15, "a mark with text keeps its line: {text:?}"); + let two = tops("", 2, SectionStart::Continuous); + assert_eq!(two[1].1, two[2].1, "balanced: Pb and Pc side by side: {two:?}"); + assert!(two[2].2 > two[1].2 + 100.0, "Pc in the second column: {two:?}"); + assert!((two[3].1 - two[1].1 - line).abs() < 0.15, "Pd a line below them: {two:?}"); + let new_page = tops("", 1, SectionStart::NextPage); + assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 7b3e133..2d2cdcb 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -81,6 +81,7 @@ font can't match Word's metrics. | 2026-10-12 | trivial | Text frames in headers, footers, cells and text boxes are placed like floating objects with the text wrapping around them; body frames still lay out in line | | 2026-10-12 | trivial | List indents and style indents combine like Word's: a numbered style's own indents (and those of styles based on it) beat the list level's, its base styles' don't, the paragraph's own list beats every style; a paragraph that switches its list off (`numId` 0) drops the styles' indents | | 2026-10-12 | trivial | Keep with next: a paragraph that doesn't fit on the page anyway breaks as usual (its last lines go over with the next paragraph) instead of moving whole; a chain of kept paragraphs moves together | +| 2026-10-12 | trivial | Continuous section breaks: an empty paragraph carrying only the break takes no room (text after it follows on; balanced columns ignore it) | | 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) |