diff --git a/docs/ooxml_corner_cases.md b/docs/ooxml_corner_cases.md index 077e7073..bf30653a 100644 --- a/docs/ooxml_corner_cases.md +++ b/docs/ooxml_corner_cases.md @@ -13,6 +13,7 @@ This document tracks edge cases and quirks in Open XML document processing where - [Misleading Deflate Hints Cause Compression Loss](#misleading-deflate-hints-cause-compression-loss) 4. [Paragraph Layout](#paragraph-layout) - [`w:lineRule="auto"` is a multiple of the FONT's line box, not of font-size](#wlineruleauto-is-a-multiple-of-the-fonts-line-box-not-of-font-size) + - [An accumulated line-spacing error can resemble a top-margin deviation](#an-accumulated-line-spacing-error-can-resemble-a-top-margin-deviation) - [LibreOffice ignores the `Hyperlink` character style on TOC field results](#libreoffice-ignores-the-hyperlink-character-style-on-toc-field-results) 5. [Theme Colors](#theme-colors) - [`w:color`/`w:fill` are a CACHE; `w:themeColor`/`w:themeFill` are the authority](#wcolorwfill-are-a-cache-wthemecolorwthemefill-are-the-authority) @@ -1288,6 +1289,59 @@ which is what pagination parity needed at the time; populated paragraphs kept th fallback. Issues #396 (DrawingML textbox auto-fit height) and #397 (TOC line height) were both traced to that remaining fallback. +### An accumulated line-spacing error can resemble a top-margin deviation + +#### Symptom + +In `DB012-Lists-With-Different-Numberings.docx`, later list lines once appeared about 28px lower +in Docxodus than in LibreOffice. Because the section declares a 1701-twip top margin and the first +content is a list, the shift was initially attributed to different top-margin import behavior. + +#### Relevant XML + +```xml + + + + +``` + +At 96 DPI, 1701 twips is 113.4px. Glyph ink begins a few pixels below that content edge because +the ink bounds measure the font's painted pixels, not the top of its line box. + +#### Renderer comparison + +The fixture was rendered through Microsoft Graph's Word DOCX-to-PDF conversion and rasterized +under the benchmark's 96-DPI contract. The current Docxodus and LibreOffice artifacts use the +same page size and font-substitution contract. + +| Renderer | Page (px) | First ink row | Last ink row | +|---|---:|---:|---:| +| Microsoft Graph Word conversion | 794 × 1123 | **117** | 365 | +| LibreOffice 25.8.7.3 | 794 × 1123 | **117** | 365 | +| Docxodus | 794 × 1123 | **118** | 365 | + +The one-row first-glyph difference is rasterization, not layout. Docxodus and LibreOffice have +exact tolerant ink geometry (F1 1.00000), while Word independently confirms the same page-top +position. There is no top-margin deviation to emulate or renderer fix to make. + +#### Analysis + +The apparent 28px displacement grew with each line. That is the signature of the automatic line +spacing defect described above, not a constant page-origin offset. Once `w:lineRule="auto"` was +measured against the font's native line box, the accumulated displacement disappeared without any +change to `w:pgMar` handling. The `numbered-lists` corpus case is therefore an `environment` +residual (substituted-font rasterization), not a `reference-deviation`. + +#### Evidence and tests + +- `npm/tests/visual-parity/word-reference.json` records the Word page geometry, ink bounds, + fixture hash, capture environment, and first-line measurement. +- `npm/tests/visual-parity-word-reference.spec.ts` validates that the corpus disposition cannot + cite Word evidence unless the corresponding measurement is committed. +- `npm/tests/visual-parity/ratchet.json` records the current Docxodus/LibreOffice F1 of 1.00000. + ### LibreOffice ignores the `Hyperlink` character style on TOC field results #### Symptom diff --git a/npm/tests/visual-parity-word-reference.spec.ts b/npm/tests/visual-parity-word-reference.spec.ts index 6ce6c019..b5b227a7 100644 --- a/npm/tests/visual-parity-word-reference.spec.ts +++ b/npm/tests/visual-parity-word-reference.spec.ts @@ -28,6 +28,17 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '../..'); +// Synthetic records below intentionally measure only one or two cases. Strip the committed +// corpus's real citations here; the dedicated citation test adds its own, while the committed- +// record test validates the real corpus unchanged. +const UNIT_CORPUS: VisualCorpusEntry[] = VISUAL_PARITY_CORPUS.map(entry => ({ + ...entry, + disposition: { + kind: entry.disposition.kind, + rationale: entry.disposition.rationale, + ...(entry.disposition.reference ? { reference: entry.disposition.reference } : {}), + }, +})); function image(width: number, height: number, draw?: (data: Uint8Array) => void): RgbaImage { const data = new Uint8Array(width * height * 4).fill(255); @@ -55,7 +66,7 @@ function measuredCase(overrides: Partial = {}): WordReference function recordWith(cases: WordReferenceCase[]): WordReferenceRecord { let record: WordReferenceRecord = { - ...emptyRecord(VISUAL_PARITY_CORPUS), + ...emptyRecord(UNIT_CORPUS), environment: { word: 'Word 365', os: 'Windows 11' }, }; for (const entry of cases) record = upsertCase(record, entry); @@ -90,24 +101,24 @@ test.describe('word-reference measurement', () => { test.describe('word-reference validation', () => { test('the seeded record validates against the corpus', () => { - expect(validateWordReference(emptyRecord(VISUAL_PARITY_CORPUS), VISUAL_PARITY_CORPUS)) + expect(validateWordReference(emptyRecord(UNIT_CORPUS), UNIT_CORPUS)) .toEqual([]); }); test('a measured case validates and coexists with pending rows', () => { - expect(validateWordReference(recordWith([measuredCase()]), VISUAL_PARITY_CORPUS)).toEqual([]); + expect(validateWordReference(recordWith([measuredCase()]), UNIT_CORPUS)).toEqual([]); }); test('a missing corpus row is a problem — coverage cannot silently shrink', () => { - const record = emptyRecord(VISUAL_PARITY_CORPUS); + const record = emptyRecord(UNIT_CORPUS); record.cases = record.cases.filter(entry => entry.id !== 'nested-table'); - expect(validateWordReference(record, VISUAL_PARITY_CORPUS).join(' ')) + expect(validateWordReference(record, UNIT_CORPUS).join(' ')) .toContain('nested-table has no word-reference row'); }); test('a row matching no corpus case is a problem — evidence cannot outlive its case', () => { const record = recordWith([measuredCase({ id: 'retired-case' } as WordReferenceCase)]); - expect(validateWordReference(record, VISUAL_PARITY_CORPUS).join(' ')) + expect(validateWordReference(record, UNIT_CORPUS).join(' ')) .toContain('retired-case matches no corpus case'); }); @@ -115,30 +126,30 @@ test.describe('word-reference validation', () => { const record = recordWith([ { id: 'shape', status: 'pending', pageCount: 1 } as WordReferenceCase, ]); - expect(validateWordReference(record, VISUAL_PARITY_CORPUS).join(' ')) + expect(validateWordReference(record, UNIT_CORPUS).join(' ')) .toContain('shape is pending but carries measurement fields'); }); test('measured rows need a fixture hash and internally consistent pages', () => { expect(validateWordReference( - recordWith([measuredCase({ fixtureSha256: undefined })]), VISUAL_PARITY_CORPUS).join(' ')) + recordWith([measuredCase({ fixtureSha256: undefined })]), UNIT_CORPUS).join(' ')) .toContain('without a valid fixtureSha256'); expect(validateWordReference( - recordWith([measuredCase({ pageCount: 2 })]), VISUAL_PARITY_CORPUS).join(' ')) + recordWith([measuredCase({ pageCount: 2 })]), UNIT_CORPUS).join(' ')) .toContain('pageCount/pages disagree'); expect(validateWordReference( recordWith([measuredCase({ pages: [{ page: 1, width: 816, height: 1056, inkBounds: { left: 500, top: 0, right: 10, bottom: 5 }, inkPixelRatio: 0.01 }], - })]), VISUAL_PARITY_CORPUS).join(' ')) + })]), UNIT_CORPUS).join(' ')) .toContain('ink bounds are inconsistent'); }); test('a wordEvidence citation without a measurement is a problem — the #402 contract', () => { - const corpusWithCitation: VisualCorpusEntry[] = VISUAL_PARITY_CORPUS.map(entry => + const corpusWithCitation: VisualCorpusEntry[] = UNIT_CORPUS.map(entry => entry.id === 'nested-table' ? { ...entry, disposition: { ...entry.disposition, wordEvidence: 'Word suppresses the spacing' } } : entry); - expect(validateWordReference(emptyRecord(VISUAL_PARITY_CORPUS), corpusWithCitation).join(' ')) + expect(validateWordReference(emptyRecord(UNIT_CORPUS), corpusWithCitation).join(' ')) .toContain('nested-table disposition cites wordEvidence but the word-reference row is pending'); // The same citation is fine once the row is measured. expect(validateWordReference( @@ -147,7 +158,7 @@ test.describe('word-reference validation', () => { test('measured cases without a recorded Word/OS environment are a problem', () => { const record = { ...recordWith([measuredCase()]), environment: null }; - expect(validateWordReference(record, VISUAL_PARITY_CORPUS).join(' ')) + expect(validateWordReference(record, UNIT_CORPUS).join(' ')) .toContain('no environment'); }); diff --git a/npm/tests/visual-parity/BASELINE.md b/npm/tests/visual-parity/BASELINE.md index 76d454fb..9469e1ad 100644 --- a/npm/tests/visual-parity/BASELINE.md +++ b/npm/tests/visual-parity/BASELINE.md @@ -282,7 +282,10 @@ The corpus rerun also dissolved a standing attribution. `numbered-lists` was `re on the reading that "the whole content is about 28px lower, so LibreOffice must import the 1701-twip top margin differently". It was the accumulated line-spacing error: the case is now close with **exact ink geometry (F1 1.00000)**, so the two engines never disagreed about the margin. Issue #398 -was opened to obtain Word evidence for that margin; the premise is gone. +obtained the independent Word evidence: the Microsoft Graph conversion's first ink is row 117 at +96 DPI, exactly matching LibreOffice and within one raster row of Docxodus (118). The premise is +gone, and the case is correctly attributed to the substituted-font environment rather than a +reference deviation. **One regression, found and fixed by the change itself.** With taller line boxes, `footnote`'s ink F1 fell 0.70330 → 0.58697: CA008's single body line sat 2px lower, and on a page carrying ~1230 ink diff --git a/npm/tests/visual-parity/corpus.ts b/npm/tests/visual-parity/corpus.ts index 50315e21..6264a656 100644 --- a/npm/tests/visual-parity/corpus.ts +++ b/npm/tests/visual-parity/corpus.ts @@ -122,6 +122,9 @@ export const VISUAL_PARITY_CORPUS: VisualCorpusEntry[] = [ 'growing amount down the page. With auto spacing measured against the font line box the ' + 'case is close and ink geometry is exact (F1 1.00000), so the margin was never in ' + 'disagreement. The residual is substituted-font rasterization.', + reference: 'https://github.com/JSv4/Docxodus/issues/398', + wordEvidence: 'At 96 DPI, the Microsoft Graph conversion starts first-line ink at row 117, ' + + 'matching LibreOffice exactly and lying within one raster row of Docxodus (118).', }, }, { diff --git a/npm/tests/visual-parity/word-reference.json b/npm/tests/visual-parity/word-reference.json index d3cdc93f..e9af3a8c 100644 --- a/npm/tests/visual-parity/word-reference.json +++ b/npm/tests/visual-parity/word-reference.json @@ -2,7 +2,10 @@ "schemaVersion": 1, "description": "Word-reference evidence store (issue #402). Numbers only: page counts, page geometry, ink extents, and named per-case measurements taken from Microsoft Word PDF exports of the tracked corpus fixtures, rasterized under the same 96-DPI Poppler contract as the benchmark. No binaries and no image corpus are ever committed. Word renders with the genuine Office fonts (not the issue-#379 substitutes), so these measurements decide STRUCTURAL questions; pixel scores against Word are advisory. See WORD_REFERENCE.md for the capture procedure.", "procedure": "npm/tests/visual-parity/WORD_REFERENCE.md", - "environment": null, + "environment": { + "word": "Microsoft Graph v1.0 DOCX-to-PDF service (build undisclosed)", + "os": "Remote conversion service (server OS undisclosed)" + }, "cases": [ { "id": "chart", "status": "pending" }, { "id": "chart-line", "status": "pending" }, @@ -17,7 +20,31 @@ { "id": "merged-table", "status": "pending" }, { "id": "multi-section", "status": "pending" }, { "id": "nested-table", "status": "pending" }, - { "id": "numbered-lists", "status": "pending" }, + { + "id": "numbered-lists", + "status": "measured", + "fixtureSha256": "eb7474e7c8971176c8462f57c2f4fe7d64169f44a408e4f67d92209c4b6fe78e", + "pageCount": 1, + "pages": [ + { + "page": 1, + "width": 794, + "height": 1123, + "inkBounds": { + "left": 100, + "top": 117, + "right": 130, + "bottom": 365 + }, + "inkPixelRatio": 0.00072 + } + ], + "keyMeasurements": { + "firstLineInkTopPx": 117 + }, + "notes": "The first line begins at the same ink row as LibreOffice and within one raster row of Docxodus; the former 28 px claim was accumulated automatic-line-spacing error, not top-margin import.", + "capturedAt": "2026-08-13" + }, { "id": "running-content", "status": "pending" }, { "id": "shape", "status": "pending" }, { "id": "text-formatting", "status": "pending" },