Layout: contextual spacing drops the space after a cell's last Normal paragraph (#5450)

Word treats the end-of-cell mark as a paragraph in the default (Normal)
style, so a cell's last paragraph with contextual spacing in that style
(or in an undefined style, formatted as Normal) loses its space after, as
beside any same-style neighbour; one in another style keeps it.
`layout_box` kept the space after of every last cell paragraph except
automatic spacing, so the row grew by it and the text below the table sat
that much lower (docxide-pdf cell-end-text-context, cell-end-empty-context:
8 pt).

A middle or bottom cell of a row split across pages lines up with the
other cells' kept text, which `align_in_part` counted with its full space
after; it now stops at the cell's content end, so a dropped space after
(contextual or automatic) no longer pushes the aligned cell down.

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 06:27:07 +01:00
1 parent 85434d2c20
commit a891d861b7
6 files changed
+175 -4

No files matched your search

+2
View File
@@ -150,6 +150,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged.
- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, `&#13;` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged.
- 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged.
- 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged.
- 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save.
- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break.
@@ -869,6 +870,7 @@ MATH glyphs that reference the same construction now share its immutable variant
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) |
| 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) |
| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
+6 -2
View File
@@ -366,8 +366,12 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
items.splice(at..at, back);
items.extend(front);
}
// Automatic space after: none after the last paragraph of a cell or box.
let after = if pl.rp.after_auto && i + 1 == blocks.len() { 0.0 } else { pl.rp.space_after };
// Automatic space after: none after the last paragraph of a cell or box. Nor
// contextual spacing after a cell's last paragraph in the default style (or an
// undefined one, formatted as Normal): the end-of-cell mark is a Normal paragraph.
let last = i + 1 == blocks.len();
let normal = || pl.rp.style == "Normal" || ctx.doc.styles.get(&pl.rp.style).is_none();
let after = if last && (pl.rp.after_auto || (in_cell && ctxl && normal())) { 0.0 } else { pl.rp.space_after };
y += edges.top + pl.height + edges.bottom + after;
prev_after = after;
prev_after_auto = pl.rp.after_auto;
+4 -2
View File
@@ -1201,14 +1201,16 @@ fn align_in_part(
}
let limit = cut - row.bottom.clamp(0.0, cut);
let mut kept = f32::MIN;
for ((items, splits), _) in cells.iter().zip(splits).zip(realign).filter(|(_, al)| al.is_none()) {
for (((items, splits), _), end) in cells.iter().zip(splits).zip(realign).zip(ends.iter()).filter(|((_, al), _)| al.is_none()) {
// No further than the cell's content: its last paragraph may have dropped its space after.
let end = end.unwrap_or(f32::MAX);
for (it, &split) in items.iter().zip(splits) {
let bottom = match (it.turned_bounds(), it) {
(Some(r), _) => Some(r.bottom()),
// A paragraph kept whole ends with its space after.
(None, Placed::Lines { para, l0, y, .. }) if split > *l0 => crate::item_bottom(*y, para, *l0, split)
.filter(|&b| b <= limit + 0.01)
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(limit) } else { b }),
.map(|b| if split == para.lines.len() { (b + para.rp.space_after).min(end.max(b)).min(limit) } else { b }),
(None, Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. }) => {
Some(rect.bottom())
}
+158
View File
@@ -298,6 +298,82 @@ fn legacy_table_contextual_spacing_flag_is_scoped_to_cells_and_legacy_mode() {
}
}
/// Baseline of the paragraph after a one-row table, less that of the first cell's last
/// paragraph, which has 12 pt after in `style`, contextual or not. `next_cell` adds a second cell
/// whose paragraph has that style; `after_table` styles the paragraph after the table; `text` is
/// the last paragraph's text.
fn cell_end_gap(style: &str, contextual: bool) -> f32 {
cell_end_gap_in(Document::new(), style, contextual, None, None, "Beta probe")
}
fn cell_end_gap_in(mut d: Document, style: &str, contextual: bool, next_cell: Option<&str>, after_table: Option<&str>, text: &str) -> f32 {
use wordcraft_doc::props::LineSpacing;
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "Other".into(),
name: "Other".into(),
based_on: Some("Normal".into()),
..Default::default()
});
let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() };
let props = ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(24.0)), ..Default::default() };
let para = |text: &str, props: ParaProps| {
let mut p = wordcraft_doc::Paragraph::with_text(text, chr.clone());
p.props = props;
wordcraft_doc::para_block(p)
};
let styled = |s: Option<&str>| ParaProps { style: s.map(Into::into), ..props.clone() };
let mut table = Table::new(1, if next_cell.is_some() { 2 } else { 1 }, 468.0);
table.props.style = None;
table.props.cell_margins = Some([0.0; 4]);
let last = ParaProps { style: Some(style.into()), space_after: Some(12.0), contextual_spacing: Some(contextual), ..props.clone() };
table.rows[0].cells[0].blocks = vec![para("Alpha probe", props.clone()), para(text, last)];
if let Some(cell) = table.rows[0].cells.get_mut(1) {
cell.blocks = vec![para("Delta probe", styled(next_cell))];
}
d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table)), para("Gamma probe", styled(after_table))];
let l = lay(&d);
let baseline = |at: &[u32]| {
l.pages[0].items.iter().find_map(|i| match i {
Placed::Lines { para, y, path, .. } if path.0 == at => Some(y + para.lines[0].baseline),
_ => None,
})
};
let (Some(cell), Some(after)) = (baseline(&[0, 0, 0, 1]), baseline(&[1])) else { panic!("probe lines missing") };
after - cell
}
#[test]
fn contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph() {
// The end-of-cell mark is a Normal paragraph: contextual spacing drops the 12 pt after a
// Normal last paragraph (docxide-pdf `cell-end-text-context`, `cell-end-empty-context`), not
// after one in another style (`cell-end-other-style`) or without contextual spacing.
let kept = cell_end_gap("Normal", false);
assert!((cell_end_gap("Other", true) - kept).abs() < 0.01);
let dropped = cell_end_gap("Normal", true);
assert!((kept - dropped - 12.0).abs() < 0.01, "kept {kept}, dropped {dropped}");
// An undefined style is formatted as Normal, and drops it too.
assert!((cell_end_gap("Missing", true) - dropped).abs() < 0.01);
// The mark decides, not a neighbour: another style in the next cell or after the table
// leaves the drop as it is.
for (next_cell, after_table) in [(Some("Other"), None), (None, Some("Other"))] {
let gap = cell_end_gap_in(Document::new(), "Normal", true, next_cell, after_table, "Beta probe");
assert!((gap - dropped).abs() < 0.01, "{next_cell:?} {after_table:?}: {gap}, dropped {dropped}");
}
// And the reverse: another style keeps its space beside Normal neighbours.
let gap = cell_end_gap_in(Document::new(), "Other", true, Some("Normal"), Some("Normal"), "Beta probe");
assert!((gap - kept).abs() < 0.01, "other beside Normal: {gap}, kept {kept}");
// An empty last paragraph drops it too (`cell-end-empty-context`).
let gap = cell_end_gap_in(Document::new(), "Normal", true, None, None, "");
assert!((gap - dropped).abs() < 0.01, "empty: {gap}, dropped {dropped}");
// Where `allowSpaceOfSameStyleInTable` keeps same-style space in cells (modes 11/12/14), the
// 12 pt stays.
let mut legacy = Document::new();
legacy.settings.compat_mode = 14;
legacy.settings.allow_space_of_same_style_in_table = true;
let gap = cell_end_gap_in(legacy, "Normal", true, None, None, "Beta probe");
assert!((gap - kept).abs() < 0.01, "legacy {gap}, kept {kept}");
}
fn contextual_gap(after: f32, before: f32, first: bool, second: bool, different_style: bool, header: bool) -> f32 {
use wordcraft_doc::props::LineSpacing;
let mut d = Document::new();
@@ -12186,6 +12262,88 @@ fn a_middle_or_bottom_cell_of_a_split_row_lines_up_on_the_first_page() {
assert_eq!(pages[1][0].len(), 25 - labels.len(), "{pages:?}");
}
/// The line tops on the first page of a row split across pages, by cell: a label aligned with
/// `valign`, 30 lines that split, and a 160 pt spacer above `note`.
fn split_row_beside_a_note(valign: wordcraft_doc::props::VAlign, note: &ParaProps) -> [Vec<f32>; 3] {
use wordcraft_doc::props::LineSpacing;
let plain = split_note_plain();
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, d.last_section.page_h) = (400.0, 360.0);
d.last_section.margin_top = 36.0;
d.last_section.margin_bottom = 36.0;
let mut t = Table::new(1, 3, 328.0);
t.props.style = None;
t.props.cell_margins = Some([0.0; 4]);
t.rows[0].cells[0].props.valign = Some(valign);
t.rows[0].cells[0].blocks = vec![para("Label", plain.clone())];
t.rows[0].cells[1].blocks = (0..30).map(|i| para(&format!("Line {i}"), plain.clone())).collect();
let spacer = ParaProps { line_spacing: Some(LineSpacing::Exactly(160.0)), ..plain.clone() };
t.rows[0].cells[2].blocks = vec![para("Spacer", spacer), para("Note", note.clone())];
d.body = vec![para("Filler", ParaProps { line_spacing: Some(LineSpacing::Exactly(100.0)), ..plain }), Arc::new(Block::Table(t))];
let l = lay(&d);
let tops = |cell: u32| -> Vec<f32> {
l.pages[0]
.items
.iter()
.filter_map(|item| match item {
Placed::Lines { path, y, .. } if path.0.len() == 4 && path.0[0] == 1 && path.0[2] == cell => Some(*y),
_ => None,
})
.collect()
};
let cells = [tops(0), tops(1), tops(2)];
assert!(cells[1].len() > 5 && cells[1].len() < 30, "the row splits: {cells:?}");
assert_eq!(cells[2].len(), 2, "the note stays on the page: {cells:?}");
cells
}
/// Single 12 pt lines with no space around them.
fn split_note_plain() -> ParaProps {
ParaProps {
space_before: Some(0.0),
space_after: Some(0.0),
line_spacing: Some(wordcraft_doc::props::LineSpacing::Exactly(12.0)),
widow_control: Some(false),
..Default::default()
}
}
#[test]
fn a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// The note's space after (contextual 300 pt, or automatic) drops at the cell's end and would
// otherwise reach below the split lines: a bottom label lines up with those, a middle one is
// centred between their top and bottom.
let plain = split_note_plain();
let contextual = ParaProps { space_after: Some(300.0), contextual_spacing: Some(true), ..plain.clone() };
let automatic = ParaProps { space_after_auto: Some(true), ..plain };
for note in [contextual, automatic] {
let [labels, lines, _] = split_row_beside_a_note(VAlign::Bottom, &note);
assert_eq!(labels.last(), lines.last(), "{note:?}: labels {labels:?}, lines {lines:?}");
let [labels, lines, _] = split_row_beside_a_note(VAlign::Center, &note);
let (Some(&first), Some(&last), Some(&label)) = (lines.first(), lines.last(), labels.first()) else { panic!("{labels:?} {lines:?}") };
let want = first + (last - first) / 2.0;
assert!((label - want).abs() < 0.01, "{note:?}: centred label at {label}, want {want}");
}
}
#[test]
fn a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it() {
use wordcraft_doc::props::VAlign;
// A note that keeps its 14 pt after (no contextual spacing) still ends there: the bottom
// label's line ends 14 pt below the note's.
let note = ParaProps { space_after: Some(14.0), ..split_note_plain() };
let [labels, lines, notes] = split_row_beside_a_note(VAlign::Bottom, &note);
let (Some(&label), Some(&note_top), Some(&line)) = (labels.last(), notes.last(), lines.last()) else { panic!("{labels:?} {notes:?}") };
assert!(note_top + 14.0 > line, "the note's space after reaches below the split lines: {notes:?} {lines:?}");
assert!((label - (note_top + 14.0)).abs() < 0.01, "label at {label}, note at {note_top}");
}
fn row_keep_pages(d: &Document) -> Vec<usize> {
let l = lay(d);
(0..d.table(StoryRef::Body, &Path::top(1)).unwrap().rows.len())
+3
View File
@@ -88,6 +88,8 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | In-line pictures raised or lowered (`w:position`) set their line's height as in Word, past their own height too (#3984, #5796). SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; a cell's last Normal paragraph drops its contextual space after (#5450); [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; a cell's last Normal paragraph drops its contextual space after (#5450); [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, so a TOC entry's page number keeps its leader (#1840). A floating object anchored on a later page of a long paragraph is drawn on that page, also when footnotes or widow control move its line on (#4250); one anchored right after a page break, before text, is drawn on the new page (#4262). Fonts the document uses that aren't installed are flagged in the font box and status bar, except those drawn in a metric-compatible substitute (Carlito for Calibri, Liberation Sans for Arial) and symbol-font text (Symbol, Wingdings, Webdings), so the user knows when line breaks can differ from where the document was made; `font.missing` lists the substituted fonts in the text the current view lays out (capped, for hostile documents), marking the metric-compatible ones `metricCompatible`. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
@@ -195,6 +197,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | trivial | #10 narrowed: a turned inline picture with no recorded effect extent keeps its line to its frame (exchanged near a quarter turn in Word 2013+ layout), cut at the line; gap stays open |
| 2026-11-08 | trivial | #4 narrowed: a cell's last Normal paragraph drops its contextual space after, the end-of-cell mark counting as a Normal paragraph (#5450) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed |
+2
View File
@@ -46,6 +46,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
| List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — |
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; a cell's last paragraph with contextual spacing, in the Normal style (or an undefined one), drops its space after, the end-of-cell mark counting as a Normal paragraph beside it (not where `allowSpaceOfSameStyleInTable` keeps same-style space in modes 11/12/14); this rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures, and a Word comparison that tells it apart from the neighbouring paragraph's style is still open; a middle or bottom cell of a split row lines up without that dropped space (`contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`, `a_bottom_cell_of_a_split_row_lines_up_with_a_kept_space_after_beside_it`); list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 |
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
| OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 |
| Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — |
@@ -305,6 +306,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc
| Date | Change | Summary |
|---|---|---|
| 2026-11-08 | trivial | Turned inline pictures with no recorded effect extent keep their line to the frame (exchanged near a quarter turn in Word 2013+ layout) and overhang it, cut at the line's top and bottom, as in Word |
| 2026-11-08 | trivial | Contextual spacing drops the space after a table cell's last paragraph in the Normal style, the end-of-cell mark counting as a Normal paragraph; another style keeps it, and a middle or bottom cell of a split row lines up without the dropped space (#5450) (`layout::tests::contextual_spacing_drops_the_space_after_a_cells_last_normal_paragraph`, `a_bottom_cell_of_a_split_row_ignores_a_dropped_space_after_beside_it`). |
| 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context |
| 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement |
| 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). |