diff --git a/ROADMAP.md b/ROADMAP.md index 29761221a..b42b33787 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index c3aed221a..8a64d82e7 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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, @@ -1471,10 +1473,15 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { } let mut glyphs = Vec::new(); let mut runs: Vec = 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 { + 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); + } + } +} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 691d4be5d..e591bf2a6 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 = ¶.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"); diff --git a/docs/gaps.md b/docs/gaps.md index 1342df781..4b0349b81 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 9f0741ac0..701aff355 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -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) |