Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) (#5347)

* Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288)

A paragraph with a hidden mark and no plain text was a quiet mark: laid out
with no room, its drawing kept. An in-line picture adds no plain text, so a
hidden-mark paragraph holding only a picture qualified, and the text after it
(the next continuous section in the report) was drawn over the picture. Such
a paragraph, or one holding a note reference mark, now keeps its line; one
with only floating objects stays quiet.

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

* Layout: a legacy check box in a hidden closing mark keeps its room too (#5288)

A FORMCHECKBOX field has an empty result, so a hidden-mark paragraph holding
only a check box still counted as quiet and the next continuous section was
drawn over the box. Fields now count as visible content when the paragraph
layout shows them as something other than their result: their code, a legacy
check box, or a drop-down's entry (the same test as the layout's `field`).

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

---------

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
authored and GitHub committed 2026-11-08 04:48:48 +01:00
1 parent b9936609dc
commit d3fcf10bcc
4 files changed
+104 -10

No files matched your search

+2
View File
@@ -240,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
- 2026-11-03: Layout: a line can break after a hyphen that a slash and a letter follow ("Dataprotection-" / "/Secrecyviolation"), as Word does ("fitness-/1589", with a digit after the slash, stays whole), where UAX #14 forbids a break before the slash; an auto-fit table column holding such a compound wraps it instead of widening and taking the width from its neighbours. (#3408)
- 2026-11-03: Layout: a section's closing paragraph whose mark is hidden and that holds only an in-line picture (or a note reference) is no longer laid out as an empty hidden mark. The picture keeps its room, so the next continuous section's text starts below it instead of being drawn over it. A paragraph with only floating objects and a hidden mark still takes no room. Not observed in Word. (#5288)
- 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged.
- 2026-11-03: Language lists (CP4.3): a language installed from a Language pack (Bulgarian, say) can now be chosen. Set Proofing Language lists every built-in language and every installed or available pack's, each once and sorted by name; Review › Language and the status bar's menu add the installed packs' languages to the short list, with one way of working out the check mark, by tag. A pack's language is stored as a tag that finds the pack again (`sr-Latn-RS` is proofed by the Latin `sr-latn` pack, not the Cyrillic one; Valencian is Word's `ca-ES-valencia`). Every pack in language-packs-2 now has an English name (Belarusian used to show its own name, Беларуская). Readiness unchanged.
- 2026-11-03: Language packs (CP4.3): on a fresh install (or after an upgrade, with the list cached for an older pin) the missing-pack bar still offers a document language that has no dictionary, "Turkish proofing isn't installed. Install": the person's click turns on downloading, fetches the list and installs the pack if it lists one (if not, the bar goes). With downloading already on, a missing or stale list is fetched once in the background. Choosing a language with only the caret counts before anything is typed, and the spelling context menu's heading (Possible spelling mistake) is in the interface language, not the text's. Closing the bar is remembered per document, per pack and, before the list is known, per language tag. Readiness unchanged.
@@ -907,6 +908,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
| 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) |
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
| 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) |
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
+27 -9
View File
@@ -2,7 +2,7 @@ use std::collections::VecDeque;
use super::cache::{LayoutCache, Totals, env_hash, hash_of};
use super::ctx::{Ctx, Pass, with_pct_base};
use super::floats::{EarlyFloats, WrapArea, early_covered};
use super::floats::{EarlyFloats, WrapArea, early_covered, left_out_objects};
use super::flow::{OutlinePass, place_outline_para, place_para};
use super::headers::{HfLinks, bookmark_pages, headers_footers, headers_footers_refer_to_pages, marks_key, mirror_even_pages, section_side};
use super::notes::{MAX_NOTE_PAGES, NoteSeps, note_info, place_endnotes};
@@ -10,9 +10,10 @@ use super::page::{PageBuilder, body_top_for, foot_top_for, item_bottom};
use super::placed::Placed;
use super::table_flow::place_table;
use super::{
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, LayoutOptions, Page, Path, SectionProps, SectionStart, StoryRef, TextDirection,
ViewMode, balloons, para, vertical,
Arc, Block, Counters, DocLayout, Document, FieldCtx, HashMap, InlineObject, LayoutOptions, Page, Paragraph, Path, SectionProps, SectionStart,
StoryRef, TextDirection, ViewMode, balloons, para, vertical,
};
use wordcraft_doc::legacy_form::LegacyForm;
/// How the view lays the document out: `web` for the continuous views, `reflow` for those and
/// the reading view, which both ignore the sections' page geometry.
@@ -127,18 +128,35 @@ fn section_marks(doc: &Document, opts: &LayoutOptions, sections: &[(usize, &Sect
end.checked_sub(1).and_then(|i| doc.body.get(i)).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some() && p.text.is_empty())
});
if !opts.show_hidden {
quiet.extend(
doc.body
.iter()
.enumerate()
.filter_map(|(i, b)| b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty()).map(|_| i)),
);
quiet.extend(doc.body.iter().enumerate().filter_map(|(i, b)| {
b.as_para().filter(|p| p.mark.hidden == Some(true) && p.plain_text().is_empty() && !has_inline_content(doc, opts, p)).map(|_| i)
}));
}
quiet.sort_unstable();
quiet.dedup();
(stacked, quiet)
}
/// An in-line picture, shape or chart, a note reference mark, or a field shown as something other
/// than its result, is visible content with no plain text, so its line keeps its room even when
/// the paragraph mark is hidden (#5288). Not one the layout leaves out (a hidden drawing, or one
/// in hidden or deleted text).
fn has_inline_content(doc: &Document, opts: &LayoutOptions, p: &Paragraph) -> bool {
let left_out = left_out_objects(doc, p, None, opts.show_hidden, opts.hides_deleted(StoryRef::Body));
// As the paragraph layout shows a field: its code, a legacy check box, a drop-down's entry.
let shown_field = |o: &InlineObject| match o {
InlineObject::Field { code, .. } => {
*code != opts.field_codes
|| o.legacy_form().is_some_and(|f| matches!(f, LegacyForm::CheckBox { .. }) || f.shown_text().is_some_and(|t| !t.is_empty()))
}
_ => false,
};
p.objects
.iter()
.enumerate()
.any(|(k, o)| !left_out(k) && (matches!(o, InlineObject::NoteRef { .. }) || (o.frame().is_some() && !o.is_floating()) || shown_field(o)))
}
/// The page the continuous views lay out on: the first section's, as wide as the window (and as
/// high in the reading view), with small margins and one column.
fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
+73
View File
@@ -10330,6 +10330,79 @@ fn an_empty_continuous_section_mark_takes_no_room() {
assert_eq!(new_page[1].0, 1, "a next-page section starts a page: {new_page:?}");
}
/// A hidden mark closing a section before a continuous one, holding only an in-line picture:
/// the picture keeps its line, so the next section starts below it, not over it (#5288). A
/// floating picture there still leaves the mark quiet, and so does a hidden one or one in hidden
/// text.
#[test]
fn a_hidden_mark_with_an_inline_picture_keeps_its_room() {
use wordcraft_doc::para::{Float, ShapeKind};
use wordcraft_doc::section::{SectionProps, SectionStart};
// Where Pb starts, and the bottom of the closing mark's picture (none: no picture).
let tops = |float: Option<Float>, run: CharProps| {
let mut d = Document::from_text("Pa\n\nPb");
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
closer.mark.hidden = Some(true);
closer.section = Some(Box::new(SectionProps::default()));
d.last_section.start = SectionStart::Continuous;
if let Some(float) = float {
let shape = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
d.insert_object(&Pos::body(1, 0), shape, &run).unwrap();
}
let l = lay(&d);
let bottom = l.object(&Pos::body(1, 0), 0).map(|o| o.rect.bottom());
(l.caret(&Pos::body(2, 0)).unwrap(), bottom)
};
let wrapped = |wrap| Some(Float { wrap, ..Default::default() });
let (empty, _) = tops(None, CharProps::default());
let (pb, bottom) = tops(wrapped(Wrap::Inline), CharProps::default());
let bottom = bottom.unwrap();
assert_eq!(pb.page, 0, "the continuous section stays on the page: {pb:?}");
assert!(pb.top >= empty.top + 72.0 - 0.01, "Pb starts below the 72 pt picture: {pb:?} vs {empty:?}");
assert!(pb.top >= bottom - 0.01, "Pb starts below the picture's frame: {pb:?} vs {bottom}");
let (floating, drawn) = tops(wrapped(Wrap::InFrontOfText), CharProps::default());
assert!(drawn.is_some(), "the floating picture is drawn");
assert!((floating.top - empty.top).abs() < 0.01, "a floating picture leaves the mark quiet: {floating:?} vs {empty:?}");
let (hidden, _) = tops(Some(Float { hidden: true, ..Default::default() }), CharProps::default());
assert!((hidden.top - empty.top).abs() < 0.01, "a hidden picture leaves the mark quiet: {hidden:?} vs {empty:?}");
let (in_hidden_text, _) = tops(wrapped(Wrap::Inline), CharProps { hidden: Some(true), ..Default::default() });
assert!((in_hidden_text.top - empty.top).abs() < 0.01, "a picture in hidden text leaves the mark quiet: {in_hidden_text:?} vs {empty:?}");
}
/// A hidden closing mark holding only a legacy check box (a FORMCHECKBOX field, empty result):
/// the box is drawn with a line's room, so the next section starts below it (#5288).
#[test]
fn a_hidden_mark_with_a_check_box_keeps_its_room() {
use wordcraft_doc::section::{SectionProps, SectionStart};
let top = |boxed: bool| {
let mut d = Document::from_text("Pa\n\nPb");
let closer = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
closer.mark.hidden = Some(true);
closer.section = Some(Box::new(SectionProps::default()));
d.last_section.start = SectionStart::Continuous;
if boxed {
let form = r#"<w:ffData><w:name w:val="Box"/><w:checkBox><w:sizeAuto/></w:checkBox></w:ffData>"#.to_string();
let field = InlineObject::Field { instr: "FORMCHECKBOX".into(), result: String::new(), locked: false, dirty: false, code: false, form };
d.insert_object(&Pos::body(1, 0), field, &Default::default()).unwrap();
}
lay(&d).caret(&Pos::body(2, 0)).unwrap()
};
let (empty, boxed) = (top(false), top(true));
assert!(boxed.top >= empty.top + 8.0, "Pb starts a line below the check box: {boxed:?} vs {empty:?}");
}
/// Empty marks closing continuous sections one after another each keep their line while it
/// fits; only one right after text is quiet. Past the page's bottom they stay there, so the text
/// after the run starts the next page. Measured in Word (#3907): "Start", N such marks, "End":
+2 -1
View File
@@ -91,7 +91,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| Bidirectional paragraphs, mixed-direction lines; RTL tables and sections | 🟡 | #207; RTL tables (#362): grid columns from the right edge, start alignment and indent from the right margin, cell margins and left/right borders mirrored, merged and turned cells and nested tables included; RTL sections (#362): newspaper columns right to left with the line between mirrored. Right-hand page gutter (`w:rtlGutter`) missing | 1 |
| Equations: display layout, numbering, wrapping long display equations | ✅ | #191; long display equations break at top-level operators (`m:brkBin`, `m:brkBinSub`), manual breaks and `m:alnAt`, continuation lines indented by `m:wrapIndent` or set right (`m:wrapRight`) (#326); an n-ary operator, delimiter, accent or group character the math font lacks is drawn from a fallback font instead of a missing-glyph box (#1273); an equation-array row without `&` is centred among aligned rows, as in Word (#2617) | — |
| Hidden text excluded from breaking and hyphenation | ✅ | #173 | — |
| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - |
| Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. Not observed in Word: when such a paragraph closes a section before a continuous one and holds only an in-line picture (or a note reference, or a legacy check box), the picture keeps its room and the next section starts below it instead of over it (`a_hidden_mark_with_an_inline_picture_keeps_its_room`, #5288). A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - |
| Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — |
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 |
| Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — |
@@ -323,6 +323,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| 2026-10-29 | trivial | A missing font is drawn in an installed metric twin before any other substitute (`fonts::word::METRIC_TWINS`, also what the missing-font warning calls metric-compatible): Arial for Helvetica on Windows, Arial for Liberation Sans on a Mac, Tinos before macOS Times for Times New Roman, Cousine before Menlo for Courier New. Documents in fonts with no installed twin lay out as before |
| 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. |
| 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) |
| 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) |
| 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) |
| 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) |
| 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word |