fix(layout): prevent list labels from inflating line height (#601)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Rees
2026-10-14 17:21:20 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 575c5f98df
commit 248dae4bd1
5 changed files with 175 additions and 12 deletions
+2
View File
@@ -319,6 +319,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
@@ -460,6 +461,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers |
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) |
+121 -10
View File
@@ -263,6 +263,8 @@ impl Line {
#[derive(Clone, Debug)]
pub struct Label {
pub text: String,
/// Maximum line ascent of every shaped font in the label, including its leading.
pub ascent: f32,
/// Style of the label's last run (the suffix space and the bidi tests measure with it).
pub style: u16,
pub glyphs: Vec<Glyph>,
@@ -1471,10 +1473,15 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
}
let mut glyphs = Vec::new();
let mut runs: Vec<LabelRun> = Vec::new();
let mut ascent = 0.0f32;
let mut x = 0.0;
let mut style = mark_style;
for c in &cl {
style = c.style;
if let Some(st) = b.styles.get(style as usize) {
let a = wordcraft_fonts::word::line_metrics(&st.face).0 * st.size as f64 / st.face.upem.max(1.0);
ascent = ascent.max(a as f32 + st.shift.max(0.0));
}
let g0 = glyphs.len();
for g in b.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() {
glyphs.push(Glyph { dx: g.dx + x, ..*g });
@@ -1491,7 +1498,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
x += c.adv;
}
b.glyphs.truncate(before_g);
Label { text: shown(text), style, glyphs, runs, x: 0.0, width: x }
Label { text: shown(text), ascent, style, glyphs, runs, x: 0.0, width: x }
});
let mut pl = ParaLayout {
@@ -1900,16 +1907,23 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
asc = st.ascent;
desc = st.descent;
}
// A list label can make the line taller above the baseline, never below it: Word keeps
// the text's descent (measured in Word for Windows: a Symbol bullet's taller ascent
// counts, a Courier New label's deeper descent doesn't).
let text_natural = asc + desc;
let text_pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 };
let mut label_extra = 0.0;
if first && let Some(lab) = &pl.label {
for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) {
asc = asc.max(st.ascent);
if !matches!(rp.line_spacing, LineSpacing::Exactly(_)) {
// Automatic/minimum spacing keeps text descent. Only the label's ascent
// overflow adds room, unscaled by the automatic spacing multiplier.
label_extra = (lab.ascent - asc).max(0.0);
asc = asc.max(lab.ascent);
} else {
for st in lab.runs.iter().filter_map(|r| pl.styles.get(r.style as usize)) {
asc = asc.max(st.ascent);
desc = desc.max(st.descent);
}
}
}
// Line spacing scales the text's height, never a picture's: a line is at least as tall
// as its tallest picture plus the descent below the baseline.
// Keep the picture floor independent of list-label metrics and line-spacing multipliers.
// shortcut: Word leaves less than a full descent below a line of only pictures (0.5pt to
// 1.9pt in docxide-pdf's header fixtures case121/case123, where the descent is 3.3pt), and
// no rule tried so far (none, only the text's descent) matched both; it needs Word probes.
@@ -1919,8 +1933,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
let (height, centred) = match (rp.line_spacing, grid) {
(LineSpacing::Multiple(m), Some(g)) => ((g.snap_height(natural) * m).max(g.snap_height(pictures)), true),
(LineSpacing::AtLeast(v), Some(g)) => (g.snap_height(natural.max(pictures)).max(v), true),
(LineSpacing::Multiple(m), None) => ((natural * m).max(pictures), false),
(LineSpacing::AtLeast(v), None) => (natural.max(pictures).max(v), false),
(LineSpacing::Multiple(m), None) => ((text_natural * m + label_extra).max(text_pictures), false),
(LineSpacing::AtLeast(v), None) => (natural.max(text_pictures).max(v), false),
(LineSpacing::Exactly(v), _) => (v, false),
};
let baseline = if centred {
@@ -2569,3 +2583,100 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
}
out
}
#[cfg(test)]
mod label_spacing_tests {
use super::*;
// Inject measured metric pairs after shaping so these regressions do not depend on
// the fonts installed on the test host. Word for Mac 16.113.3, original Roboto probes.
fn measured_label_lines(spacing: LineSpacing, label_metrics: (f32, f32), label_ascent: f32, wrap: bool) -> Vec<Line> {
let doc = Document::new();
let fields = FieldCtx::default();
let level = Level { chr: CharProps { size: Some(24.0), ..Default::default() }, suffix: LevelSuffix::Nothing, ..Default::default() };
let env = ParaEnv {
doc: &doc,
fields: &fields,
width: 100.0,
label: Some(("•".into(), level.clone())),
show_hidden: false,
hide_deleted: false,
table: None,
proofing: None,
field_codes: false,
grid: Grid::default(),
exclusions: &[],
eq_number: 0,
numeral: crate::NumeralMode::default(),
read_line_spacing: None,
fit_objects: false,
};
let mut p = Paragraph::with_text(if wrap { "Alpha probe synthetic business text repeated" } else { "Alpha probe" }, Default::default());
p.props.line_spacing = Some(spacing);
let mut pl = layout_para(&p, &env);
let label = pl.label.as_mut().unwrap();
label.ascent = label_ascent;
let label_style = label.style;
for (i, st) in pl.styles.iter_mut().enumerate() {
(st.ascent, st.descent) = if i == label_style as usize { label_metrics } else { (11.1328125, 2.9296875) };
}
let mark_style = pl.clusters[0].style;
break_lines(&mut pl, &env, mark_style, Some(&level));
pl.lines
}
#[test]
fn large_label_overflow_is_not_multiplied() {
for (multiple, expected) in [(1.0, 25.20), (1.25, 28.80), (2.0, 39.36)] {
let lines = measured_label_lines(LineSpacing::Multiple(multiple), (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() <= 0.25, "{multiple}: {} vs {expected}", lines[0].height);
if multiple == 1.0 {
assert!((lines[0].height - lines[0].baseline - 2.9296875).abs() < 0.001, "label descent must not move the text baseline");
}
}
}
#[test]
fn small_label_descent_does_not_inflate_text_spacing() {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (8.499023, 3.5009766), 9.0, false);
assert!((lines[0].height - 17.52).abs() <= 0.25, "{}", lines[0].height);
}
#[test]
fn label_leading_and_earlier_font_ascent_are_retained() {
// Apple Symbols 24 pt: full leading gives an 18 pt label ascent. The text
// pitch is still Roboto 12 pt. A shorter final fallback run must not hide it.
for metrics in [(16.998047, 7.001953), (8.0, 3.0)] {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), metrics, 18.0, false);
assert!((lines[0].height - 24.48).abs() <= 0.25, "{}", lines[0].height);
}
}
#[test]
fn minimum_spacing_keeps_text_descent_and_label_leading() {
for (metrics, ascent, expected) in
[((22.265625, 5.859375), 22.265625, 25.20), ((8.499023, 3.5009766), 9.0, 13.92), ((16.998047, 7.001953), 18.0, 20.88)]
{
let lines = measured_label_lines(LineSpacing::AtLeast(12.0), metrics, ascent, false);
assert!((lines[0].height - expected).abs() <= 0.25, "{}", lines[0].height);
}
}
#[test]
fn labels_keep_fixed_and_minimum_spacing_rules() {
for (spacing, expected) in [(LineSpacing::Exactly(24.0), 24.0), (LineSpacing::AtLeast(30.0), 30.0)] {
let lines = measured_label_lines(spacing, (22.265625, 5.859375), 22.265625, false);
assert!((lines[0].height - expected).abs() < 0.001);
}
}
#[test]
fn label_overflow_only_affects_the_first_wrapped_line() {
let lines = measured_label_lines(LineSpacing::Multiple(1.25), (22.265625, 5.859375), 22.265625, true);
assert!(lines.len() > 1);
assert!((lines[0].height - 28.80).abs() <= 0.25);
for line in &lines[1..] {
assert!((line.height - 17.52).abs() <= 0.25, "{}", line.height);
}
}
}
+20
View File
@@ -2813,6 +2813,26 @@ fn multiple_line_spacing_adds_its_leading_below_the_text() {
assert!((line.top + line.height - line.baseline - para.styles[0].descent).abs() < 0.01);
}
#[test]
fn list_label_does_not_inflate_an_inline_picture_floor() {
use wordcraft_doc::props::{LineSpacing, NumRef};
for spacing in [LineSpacing::Multiple(1.25), LineSpacing::AtLeast(12.0)] {
let mut d = Document::from_text("");
let id = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
d.numbering.abstracts[0].levels[0].chr.size = Some(24.0);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.line_spacing = Some(spacing);
p.props.numbering = Some(NumRef { num: id, level: 0 });
p.insert_object(0, picture(144.0, 144.0, Default::default()), &Default::default()).unwrap();
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let line = &para.lines[0];
let desc = para.styles[para.clusters[0].style as usize].descent;
assert!((line.height - (144.0 + desc)).abs() < 0.01, "{spacing:?}: {}", line.height);
assert!((line.baseline - line.top - 144.0).abs() < 0.01);
}
}
#[test]
fn body_starts_right_below_a_tall_header() {
let mut d = Document::from_text("Body");
+2 -1
View File
@@ -20,7 +20,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170, open), a crash on macOS 12.7.6 Intel (#979, open) (lag on GPUs without a DirectX 12 driver fixed, #316; 32-bit Windows exited at launch because DirectX 12 graphics pipeline creation failed, now OpenGL), freezes (#59 Thai fonts and #31 font selector, both open; #77 closed), broken shape tool (#142, open); the Windows 11 no-write-without-admin (#218) and unmovable images (#82) reports are closed; memory growth on every font change fixed (#848: the font list no longer loads each family it previews) | B | 197 open issues (2026-10-12), among them the crash and freeze reports #170, #979, #59 and #31 | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
| 1 | **DOCX never tested on real-world files.** No corpus of files from Word, Google Docs, LibreOffice and Pages has been opened, rendered and round-tripped and compared with Word page by page | B | No corpus in the repo; `crates/docx/tests` are synthetic fixtures (including omitted/empty paragraph defaults and imported TOC range preservation); "DOCX fidelity corpus: not started" since 2026-10-06. Field reports fixed one by one: a cover page's theme-coloured shapes (#611), freeforms inside groups and text-box margins and Resize to fit (#743), cover-page positions relative to the page and freeforms drawn back to their start (#743) | Everyone: the files people receive are the files that break | 30–50 | [file-format-parity.md](file-format-parity.md) |
| 2 | **Remaining native ink forms are dropped on open and lost on save, silently**; OLE objects show only their picture | B | Drawing-level native ink, its optional picture fallback, and direct run-level content parts are preserved; ink nested in groups/canvases remains unsupported. 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) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve remaining native ink forms; 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; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. 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 and table/section changes made in WordCraft. 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 | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [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; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. 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 and table/section changes made in WordCraft. 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) |
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | 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 | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-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); driver tray/options and pages per sheet 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) |
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
@@ -140,6 +140,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 |
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-11 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; rendering and editing InkML remain missing |
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
| 2026-10-11 | minor | #4: body/header contextual spacing corrected; ordinary overlap remains #108 and pagination remains a beta gap |
| 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth |
| 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h |
+30 -1
View File
@@ -27,7 +27,7 @@ font can't match Word's metrics.
| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 |
| Symbol fonts (Symbol, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | — |
| 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; 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; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); 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` | — |
| 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` | — |
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534); a paragraph ending with a page break keeps its space after on the old page, so the next page starts at the top (compatibility modes 12 to 15, `layout::tests::space_before_at_the_top_of_a_page_like_word`) | — |
@@ -67,6 +67,34 @@ font can't match Word's metrics.
| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | mode 15 behaviour; the ~60 legacy compat options are mostly ignored | 10–20 |
| Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 |
## List-label line spacing
Controlled original DOCX probes against Word for Mac 16.113.3 (26092714), macOS 26.6.2,
compatibility mode 15, zero paragraph gaps, Roboto Regular 12 pt body text: a label's positive
ascent overflow adds to the text line pitch without being multiplied by automatic spacing.
Label descent does not enlarge automatic or AtLeast spacing. A label's ascent includes positive
hhea leading. The original base split text leading across ascent/descent; current main assigns
its full gap to ascent too, so labels now use that shared metric. The table records the original
measurements, not a new native comparison of this rebase. These are
observations of synthetic documents, not a claim about Word's internal implementation.
| Label / spacing | Word baseline gap | Fixed WordCraft | Before fix |
|---|---:|---:|---:|
| Roboto 24 pt / single | 25.20 pt | 25.1953 pt | 28.125 pt |
| Roboto 24 pt / 1.25 | 28.80 pt | 28.7109 pt | 35.1563 pt |
| Roboto 24 pt / double | 39.36 pt | 39.2578 pt | 56.25 pt |
| Apple Symbols 12 pt / AtLeast 12 pt | 13.92 pt | 14.0625 pt | 14.6338 pt |
| Apple Symbols 24 pt / AtLeast 12 pt | 20.88 pt | 20.9297 pt | 24.0 pt |
Actual font metrics and original-input provenance were checked locally; Word-derived exports
remain uncommitted. Tolerance is 0.25 pt; this evidence covers Latin text with the tested symbol
and decimal labels, automatic single/1.25/double spacing, AtLeast controls, Exactly 24 pt,
wrapped paragraphs and save/reopen. Regression tests inject the independently measured metrics
so CI does not require those installed fonts. Inline pictures retain their existing height floor.
Picture bullets are not implemented, and the private Linux Symbol substitution report has not
been reproduced. Absolute baseline-leading placement remains the separate #108 contribution.
This narrows gap #4 without raising the overall readiness estimate.
## Contextual paragraph spacing
**Observed, 2026-10-11:** original synthetic DOCX probes in Word for Mac 16.113.3
@@ -145,6 +173,7 @@ No overall parity estimate changes from these probes.
| 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) |
| 2026-10-10 | trivial | The document's own note separators and continuation notice are drawn (#352) |
| 2026-10-11 | trivial | Newspaper columns balance before a continuous section break (#350) |
| 2026-10-12 | minor | Measured synthetic list-label line height; automatic and AtLeast spacing keep text descent and unscaled label ascent overflow |
| 2026-10-11 | minor | Body/header contextual spacing corrected from original Word probes; ordinary overlap remains #108 |
| 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) |
| 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) |