mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
fix(layout): prevent list labels from inflating line height (#601)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+2
-1
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user