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.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:42 +00:00
1 parent aac93e22e8
commit 9a6282fce9
4 files changed
+21 -6

No files matched your search

+3
View File
@@ -60,6 +60,9 @@ double arrow.
an undeclared `draw:` prefix there makes the whole file unreadable.
- **DOCX** keeps the frame and flips it (`<a:xfrm flipV="1">`), the heads riding the
shape's `<a:ln>` 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,
+10 -2
View File
@@ -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}">` +
`<wp:simplePos x="0" y="0"/>` +
`<wp:positionH relativeFrom="margin">${posH}</wp:positionH>` +
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>${emu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>` +
`<wp:positionV relativeFrom="paragraph"><wp:posOffset>${paraOffsetEmu(box.offsetYCm ?? 0)}</wp:posOffset></wp:positionV>` +
`${extent}${wrapEl}${docPr}${graphic}</wp:anchor></w:drawing>`
);
}
+5 -1
View File
@@ -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
+3 -3
View File
@@ -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]);