From fdb456ffe6d1422bfd91579f3ef0d5d407083e1f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 16 Aug 2026 11:21:20 +0200 Subject: [PATCH] fix: ODT sentinel passes anchor at the block tag, effects ride past annotations A text box's anchor paragraph nests its children, so the STY/PBX passes' scan to the closing tag started at the anchor and stole the first box child's spec (its indent, direction, shading and borders). Both passes now match the sentinel right after the opening tag, where it is emitted. The TEF fallback also accepts bookmark markers and comment annotations inside a run (tempered to the annotation's first close tag, -end kept distinct), instead of cutting the effect short at them. --- src/lib/export/odt.ts | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index ff0f387..5082443 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1931,19 +1931,18 @@ function applyParagraphStyles(odtBytes: Uint8Array): Uint8Array { return name; }; - const styRe = new RegExp(`${STY}([^${STY}]*)${STY}`); + // Anchored right after the opening tag (where the sentinel is emitted): a scan to the + // closing tag would start at a text box's anchor paragraph and steal the first box + // child's sentinel — nested (box children, note bodies) make spans unsafe. content = content.replace( - new RegExp(`]*[^/>])?)>([\\s\\S]*?)`, 'g'), - (m, tag: string, attrs: string, inner: string) => { - const sm = styRe.exec(inner); - if (!sm) return m; - const cleaned = inner.replace(styRe, ''); + new RegExp(`]*[^/>])?)>${STY}([^${STY}]*)${STY}`, 'g'), + (_m, tag: string, attrs: string, styleName: string) => { const srcM = /text:style-name="([^"]*)"/.exec(attrs); - const name = styleFor(srcM ? srcM[1] : '', sm[1]); + const name = styleFor(srcM ? srcM[1] : '', styleName); const newAttrs = srcM ? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`) : ` text:style-name="${name}"${attrs}`; - return `${cleaned}`; + return ``; }, ); content = content.replace(new RegExp(`${STY}[^${STY}]*${STY}`, 'g'), ''); @@ -1989,19 +1988,18 @@ function applyParagraphBoxes(odtBytes: Uint8Array): Uint8Array { return name; }; - const pbxRe = new RegExp(`${PBX}([^${PBX}]*)${PBX}`); + // Anchored right after the opening tag, over an optional STY run (its pass runs later; + // emission order is FSZ STY PBX). A scan to the closing tag would start at a text box's + // anchor paragraph and steal the first box child's sentinel. content = content.replace( - new RegExp(`]*[^/>])?)>([\\s\\S]*?)`, 'g'), - (m, tag: string, attrs: string, inner: string) => { - const sm = pbxRe.exec(inner); - if (!sm) return m; - const cleaned = inner.replace(pbxRe, ''); + new RegExp(`]*[^/>])?)>((?:${STY}[^${STY}]*${STY})?)${PBX}([^${PBX}]*)${PBX}`, 'g'), + (_m, tag: string, attrs: string, sty: string, spec: string) => { const srcM = /text:style-name="([^"]*)"/.exec(attrs); - const name = boxStyleFor(srcM ? srcM[1] : '', sm[1]); + const name = boxStyleFor(srcM ? srcM[1] : '', spec); const newAttrs = srcM ? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`) : ` text:style-name="${name}"${attrs}`; - return `${cleaned}`; + return `${sty}`; }, ); // Drop any sentinels not consumed above (defensive — never legitimate text). @@ -3191,10 +3189,17 @@ function resolveTextEffects(xml: string, mint: (styleXml: string) => void, prefi }, ); // A run whose only formatting is such an effect never got a span: give it one, up to - // the next real element — a tab/line-break/space inside the run rides along, or the - // text behind it would fall out of the span and lose the effect. + // the next real element — a tab/line-break/space, a bookmark marker or a comment's + // annotation inside the run rides along, or the text behind it would fall out of the + // span and lose the effect. + // The annotation block is tempered to its first close tag, and (?=[\s>]) keeps it off + // ]*/>` + + `|])(?:(?!)[\\s\\S])*` + + `|]*/>`; out = out.replace( - new RegExp(`${TEF}([^${TEF}]+)${TEF}((?:[^<${TEF}]|]*/>)*)(?:${TEF}${TEF})?`, 'g'), + new RegExp(`${TEF}([^${TEF}]+)${TEF}((?:[^<${TEF}]|${inline})*)(?:${TEF}${TEF})?`, 'g'), (_m, extra: string, text: string) => `${text}`, ); // Drop any sentinels not consumed above (defensive — never legitimate text).