Keep the line in an empty section starting a page (#2609)

* 371: retain the line in an empty page section

* 371: resolve hidden section marks

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fin Warman
2026-10-18 11:44:45 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent beedd1b30d
commit 473df388f3
5 changed files with 92 additions and 4 deletions
+2
View File
@@ -179,6 +179,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | A picture watermark in a header that starts with a table is drawn centred on the page behind the body text, on every page, instead of in the header's cell at the top of the page (#1775); `layoutInCell="0"` and VML `o:allowincell="f"` survive save and reopen |
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
| 2026-10-17 | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…); the dialog sets position, font, lines to drop and distance; letters in the margin; DOCX keeps them (#2601). |
| 2026-10-16 | An otherwise empty section starting a new page retains its paragraph line before a continuous section, matching an original native Word control. |
|---|---|
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
@@ -543,6 +544,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-15 | trivial | Progress log: Highlight No Color sticks to the button (#2105) |
| 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged |
| 2026-10-17 | minor | Progress log: Drop Cap Options (#2601) |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+17 -3
View File
@@ -3185,11 +3185,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).
/// A closing mark before a continuous section takes no room after content. An otherwise empty
/// section starting a new page retains its paragraph line.
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() {
let follows_section = block.checked_sub(1).and_then(|i| ctx.doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
let section = pb.sect_idx;
let own_page = pb.page().is_some_and(|page| page.section == section);
// Word keeps the line when a new page's section contains only this mark.
if p.section.is_some()
&& p.objects.is_empty()
&& (ctx.opts.show_hidden || !ctx.doc.styles.resolve_char(p.props.style.as_deref(), &p.mark).hidden)
&& !ctx.has_label(p)
&& (block == 0 || follows_section)
&& pb.at_top()
&& own_page
{
place_para_in_flow(ctx, pb, p, block, body_top);
return;
}
// 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;
+68
View File
@@ -6637,6 +6637,74 @@ fn keep_with_next_carries_the_heading_when_keep_lines_moves_the_next_paragraph()
assert_eq!(pages(fit, false), [0, 0, 1], "without it the paragraph splits: {fit}");
}
#[test]
fn section_mark_hidden_styles() {
use wordcraft_doc::props::LineSpacing;
use wordcraft_doc::section::{SectionProps, SectionStart};
use wordcraft_doc::{CharProps, Style, StyleKind};
let position = |kind: Option<StyleKind>, show_hidden| {
let mut doc = Document::from_text("\nAfter marker");
doc.styles.upsert(Style {
id: "HiddenMark".into(),
name: "Hidden mark".into(),
kind: kind.unwrap_or(StyleKind::Character),
chr: CharProps { hidden: Some(true), ..Default::default() },
..Default::default()
});
for index in 0..2 {
let p = doc.para_mut(StoryRef::Body, &Path::top(index)).unwrap();
p.props.line_spacing = Some(LineSpacing::Exactly(12.0));
p.props.space_before = Some(0.0);
p.props.space_after = Some(0.0);
}
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.section = Some(Box::new(SectionProps::default()));
match kind {
None => p.mark.hidden = Some(true),
Some(StyleKind::Character) => p.mark.style = Some("HiddenMark".into()),
Some(StyleKind::Paragraph) => p.props.style = Some("HiddenMark".into()),
_ => panic!("expected a character or paragraph style"),
}
let p = doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert!(doc.styles.resolve_char(p.props.style.as_deref(), &p.mark).hidden);
doc.last_section.start = SectionStart::Continuous;
let laid = layout(&doc, &mut LayoutCache::new(), &LayoutOptions { show_hidden, ..Default::default() });
laid.caret(&Pos::body(1, 0)).unwrap().top
};
for show_hidden in [false, true] {
let direct = position(None, show_hidden);
let character = position(Some(StyleKind::Character), show_hidden);
let paragraph = position(Some(StyleKind::Paragraph), show_hidden);
assert!((character - direct).abs() < 0.01, "show_hidden={show_hidden}: direct={direct}, character={character}");
assert!((paragraph - direct).abs() < 0.01, "show_hidden={show_hidden}: direct={direct}, paragraph={paragraph}");
}
assert!((position(None, true) - position(None, false) - 12.0).abs() < 0.01);
}
#[test]
fn empty_page_section_keeps_line() {
use wordcraft_doc::section::{SectionProps, SectionStart};
let mut doc = Document::from_text("\nAfter empty section\n\nReference prefix\n\nAfter middle mark");
for index in 0..6 {
let para = doc.para_mut(StoryRef::Body, &Path::top(index)).unwrap();
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(12.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(0.0);
}
for (index, start) in [(0, SectionStart::NextPage), (2, SectionStart::Continuous), (4, SectionStart::NextPage)] {
doc.para_mut(StoryRef::Body, &Path::top(index)).unwrap().section = Some(Box::new(SectionProps { start, ..Default::default() }));
}
doc.last_section.start = SectionStart::Continuous;
let laid = lay(&doc);
let after_empty = laid.caret(&Pos::body(1, 0)).unwrap();
let prefix = laid.caret(&Pos::body(3, 0)).unwrap();
let after_middle = laid.caret(&Pos::body(5, 0)).unwrap();
assert_eq!([after_empty.page, prefix.page, after_middle.page], [0, 1, 1]);
assert!((after_empty.top - prefix.top - 12.0).abs() < 0.01, "empty page section: {after_empty:?}, reference: {prefix:?}");
assert!((after_middle.top - prefix.top - 12.0).abs() < 0.01, "middle mark: {after_middle:?}, reference: {prefix:?}");
}
/// 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
+3
View File
@@ -12,6 +12,8 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees).
Row table border exceptions (`w:tblPrEx/w:tblBorders`) are read, drawn and saved. Other row table exceptions, including widths and cell margins, remain unsupported.
An otherwise empty section at page start retains its line before continuous content. Shared-section page totals and wrapping earlier text around later floating tables remain open.
Consistent cell widths override stale table grid proportions when their totals agree. Conflicting widths, spans and percentages still need the shared-grid algorithm.
Local desktop recovery now covers unsaved drafts and edits to existing files; see
@@ -126,6 +128,7 @@ The Language dialog reads effective character formatting, including document def
| 2026-10-15 | trivial | #2 narrowed: native ink in groups and drawing canvases (`w14:contentPart` members, with their picture fallback) survives DOCX edits and repeated saves; native ink rendering remains |
| 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged |
| 2026-10-18 | minor | #7: Drop Cap Options dialog (#2601), 59 modal dialogs |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+2 -1
View File
@@ -50,7 +50,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| Drop caps (dropped and in margin, lines, distance, font; `w:framePr w:dropCap`) | ✅ | 2026-10-17 | — |
| All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — |
| Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — |
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852) | 1–2 |
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet. | 1–2 |
| Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — |
| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — |
| Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — |
@@ -232,6 +232,7 @@ Footer flow-table strokes reserve their drawn top edge, including the half-width
| 2026-10-18 | trivial | Any VML float in a header or footer cell is placed on the page, as Word for Mac does (user-checked, #1775) |
| 2026-10-16 | trivial | A header or footer cell's float placed relative to the page and not laid out in its cell (a picture watermark in a header that starts with a table) is placed on the page, behind the text (#1775) |
| 2026-10-17 | minor | Drop caps: In margin (letter in the left margin, text not indented), distance from text and the letter's font, round-tripped as `w:framePr` (#2601) |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |