From 9a6282fce998857a4925e23c5d18959efe419be7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 28 Aug 2026 14:56:12 +0000 Subject: [PATCH] fix: floor a DOCX anchor's paragraph posOffset at one twip Anchored exactly on the paragraph top (posOffset 0), LibreOffice's wrap layout derails and paints a neighbouring inline text box's content outside its shape; LO itself never writes less than a twip there. Probed by bisecting a broken box against LO's own DOCX export of the same document. The importer rounds the sub-visible offset back to none, so nothing accretes on the round trip. --- docs/architecture/frames.md | 3 +++ src/lib/export/docx.ts | 12 ++++++++++-- src/lib/import/docx.ts | 6 +++++- tests/lo-render-consistency.test.ts | 6 +++--- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index b63da91..78be96d 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -60,6 +60,9 @@ double arrow. an undeclared `draw:` prefix there makes the whole file unreadable. - **DOCX** keeps the frame and flips it (``), the heads riding the shape's `` as `a:headEnd`/`a:tailEnd`, and writes no `wps:txbx` at all. +- An anchored frame's DOCX `positionV` posOffset is floored at one twip: on exactly 0, + LibreOffice's wrap layout paints a neighbouring inline box's text outside its shape + (probed; LO never writes less itself). The importer reads the twip back as no offset. ## Freeform outlines (`shapePath`) A drawing this editor offers no tool to author — a polygon, a polyline, a bezier curve, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8a96300..5463468 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -739,6 +739,14 @@ function decodeDataUri(src: string): { bytes: Uint8Array; type: 'png' | 'jpg' | } } +// A frame sitting exactly on its paragraph's top edge derails LibreOffice's wrap +// layout around a neighbouring inline box (text paints outside the box, probed); +// LO itself never writes a paragraph offset below one twip. +function paraOffsetEmu(cm: number): number { + const emu = Math.round(cm * 360000); + return emu >= 0 && emu < 635 ? 635 : emu; +} + // offsetCm places the frame in the text column (Word's posOffset); without one it is // flush to its side. offsetYCm is how far below the anchor paragraph it sits. function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | null, alignH?: string | null, distCm?: number | null, inFront?: boolean): IFloating | undefined { @@ -747,7 +755,7 @@ function floatingFor(wrap: string, offsetCm: number | null, offsetYCm: number | const margins = distCm ? { left: Math.round(distCm * 360000), right: Math.round(distCm * 360000) } : undefined; const verticalPosition = { relative: VerticalPositionRelativeFrom.PARAGRAPH, - offset: offsetYCm != null ? Math.round(offsetYCm * 360000) : 0, + offset: paraOffsetEmu(offsetYCm ?? 0), }; if (wrap === 'through') { // Word's in-front-of / behind-text: no wrap at all, and behindDoc names which side @@ -1243,7 +1251,7 @@ function textBoxDrawingXml(box: TextBoxDocx, index: number, parts: TxbxParts): s ` simplePos="0" relativeHeight="${251658240 + index}" behindDoc="${box.wrap === 'through' ? 1 : 0}" locked="0" layoutInCell="1" allowOverlap="${box.wrap === 'through' ? 1 : 0}">` + `` + `${posH}` + - `${emu(box.offsetYCm ?? 0)}` + + `${paraOffsetEmu(box.offsetYCm ?? 0)}` + `${extent}${wrapEl}${docPr}${graphic}` ); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index e0b0a83..894785a 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1994,7 +1994,11 @@ function anchorOffsetY(anchor: Element): number | null { const from = posV?.getAttribute('relativeFrom'); if (from !== 'paragraph' && from !== 'line') return null; const off = parseInt(posV?.getElementsByTagNameNS(WP, 'posOffset')[0]?.textContent ?? '', 10); - return Number.isFinite(off) && off > 0 ? round2(off / 360000) : null; + if (!Number.isFinite(off)) return null; + // The exporter floors this offset at one twip (LO derails on 0); sub-visible + // remainders round back to none, not to a 0 that would accrete as an attribute. + const cm = round2(off / 360000); + return cm > 0 ? cm : null; } // The frame's own x in the text column, cm from its left edge. null where the file diff --git a/tests/lo-render-consistency.test.ts b/tests/lo-render-consistency.test.ts index 87d0748..8db6ee9 100644 --- a/tests/lo-render-consistency.test.ts +++ b/tests/lo-render-consistency.test.ts @@ -52,9 +52,9 @@ describe.skipIf(!has('soffice') || !has('pdftoppm'))('ODT and DOCX render alike const docxPages = renderPages('doc.docx', 'docx'); expect(docxPages.length, 'both formats paginate to the same page count').toBe(odtPages.length); - // Ratchet thresholds over the measured status quo (7.6/10.8/4.4/0.3/0.2% on - // 2026-08-28; the page-2 cluster is floating frames + the styled table's row - // split) — tighten them as the cross-format deviations get fixed. + // Ratchet thresholds over the measured status quo (7.6/11.5/4.1/0.3/0.2% on + // 2026-08-28). The page-2 rest is LibreOffice-only: it paints a custom-shape's + // text at the line's pre-wrap position, and it drops OMML formulas headless. const perPage: number[] = []; for (let i = 0; i < odtPages.length; i++) { const a = pgm(odtPages[i]);