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:
1 parent
aac93e22e8
commit
9a6282fce9
4 files changed
+21
-6
No files matched your search
@@ -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
@@ -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>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user