From afec07579a9a9bb762dff58cdfd5c08643cf7aeb Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 16 Aug 2026 12:07:07 +0200 Subject: [PATCH] fix: a deletion's text keeps its formatting through ODT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted runs are hoisted through every serialization pass and spliced into the tracked-changes registry as styled runs (as LibreOffice stores them); import converts them back as real inline content. One point marker per change instead of one per fragment. A deletion's marker also no longer makes a formula beside it display (aloneInParagraph counts text:change). applyComments now declares dc:/loext: only when absent — beside a revision the duplicate xmlns:dc made content.xml invalid. --- src/lib/export/odt.ts | 43 +++++++++++++++++++++++++++++++++---------- src/lib/import/odt.ts | 31 ++++++++++++++++++++++--------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 5082443..190786d 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -576,10 +576,13 @@ function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]): // One recorded revision. Runs of the same change share an id, so the registry holds one // entry per id and a deletion's text is the concatenation of its runs. -type RevisionExport = { kind: 'insertion' | 'deletion'; id: string; author: string; date: string; text: string }; +type RevisionExport = { kind: 'insertion' | 'deletion'; id: string; author: string; date: string; text: string; nodes: TiptapNode[] }; // Bracket every insertion run with TCI sentinels and cut every deletion run out, leaving // a TCD sentinel where it was — ODF keeps a deletion's text in the registry, not inline. +// The deleted runs are hoisted into a marker paragraph at document end so they ride every +// serialization pass (styles, tabs, effects); applyRevisions splices them into the +// registry, which is how the deleted text keeps its formatting (LibreOffice does the same). function replaceRevisions(node: TiptapNode, out: Map): TiptapNode { if (!node.content?.length) return node; const content: TiptapNode[] = []; @@ -591,11 +594,12 @@ function replaceRevisions(node: TiptapNode, out: Map): T const a = mark.attrs ?? {}; const id = String(a.id ?? ''); const kind = mark.type as 'insertion' | 'deletion'; + const known = out.has(id); const entry = out.get(id) ?? { kind, id, author: String(a.author ?? '') || GENERATOR, date: typeof a.date === 'string' && a.date ? a.date : new Date().toISOString(), - text: '', + text: '', nodes: [], }; entry.text += child.text ?? ''; out.set(id, entry); @@ -603,13 +607,22 @@ function replaceRevisions(node: TiptapNode, out: Map): T // would otherwise split the paragraph's runs around it. const rest = child.marks?.filter((m) => m !== mark); if (kind === 'deletion') { - content.push({ type: 'text', text: `${TCD}${id}${TCD}` }); + entry.nodes.push({ ...child, ...(rest?.length ? { marks: rest } : { marks: undefined }) }); + // One point marker per change: its registry region holds every fragment. + if (!known) content.push({ type: 'text', text: `${TCD}${id}${TCD}` }); continue; } content.push({ type: 'text', text: `${TCI}${id}${TCI}` }); content.push({ ...child, ...(rest?.length ? { marks: rest } : { marks: undefined }) }); content.push({ type: 'text', text: `${TCI}${id}${TCI}` }); } + if (node.type === 'doc') { + for (const entry of out.values()) { + if (entry.kind !== 'deletion' || !entry.nodes.length) continue; + content.push({ type: 'paragraph', + content: [{ type: 'text', text: `${TCD}#${entry.id}${TCD}` }, ...entry.nodes] }); + } + } return { ...node, content }; } @@ -625,6 +638,13 @@ function applyRevisions(odtBytes: Uint8Array, list: Map, let content = strFromU8(contentBytes); // ODF ids are per document; the editor's are opaque strings. const odfId = new Map([...list.keys()].map((id, i) => [id, `ct${i + 1}`])); + // Cut the hoisted deletion paragraphs (before the point-marker pass, whose regex would + // eat their markers) and keep their serialized runs for the registry. + const hoisted = new Map(); + content = content.replace( + new RegExp(`]*>\\s*${TCD}#([^${TCD}]*)${TCD}([\\s\\S]*?)`, 'g'), + (_m, id: string, inner: string) => { hoisted.set(id, inner); return ''; }, + ); let open = new Map(); content = content .replace(new RegExp(`${TCI}([^${TCI}]*)${TCI}`, 'g'), (_m, id: string) => { @@ -645,8 +665,9 @@ function applyRevisions(odtBytes: Uint8Array, list: Map, const ct = odfId.get(r.id)!; const info = `${escapeXml(r.author)}` + `${escapeXml(r.date.replace(/\.\d+Z?$/, ''))}`; + const runs = hoisted.get(r.id) ?? escapeXml(r.text); const body = r.kind === 'deletion' - ? `${info}${escapeXml(r.text)}` + ? `${info}${runs}` : `${info}`; return `${body}`; }).join(''); @@ -4294,12 +4315,14 @@ function applyComments(odtBytes: Uint8Array, list: CommentExport[]): Uint8Array .replace(new RegExp(`${CME}(\\d+)${CME}`, 'g'), (_m, idx: string) => { const c = list[Number(idx)]; return c ? `` : ''; - }) - .replace( - '>; // Recorded revisions: the registry by change id, and the // insertions whose has been seen but not their end. - revisions: Map; + revisions: Map; openInsertions: Map>; // Footnotes/endnotes in anchor order: the file stores each note's text at its anchor, // the editor keeps them in one section at the document end (notes.ts). @@ -476,6 +476,8 @@ function aloneInParagraph(frame: Element): boolean { const p = frame.parentElement; if (!p || p.namespaceURI !== NS.text || (p.localName !== 'p' && p.localName !== 'h')) return false; if ((p.textContent ?? '').trim()) return false; + // A deletion's point marker counts: rejecting it restores text beside the formula. + if (p.getElementsByTagNameNS(NS.text, 'change')[0]) return false; return Array.from(p.children).filter(c => c.namespaceURI === NS.draw).length === 1; } @@ -1769,20 +1771,21 @@ function odfRecordChanges(body: Element): boolean { // The registry: one entry per change id. A deletion carries the // text it removed (the body has only a point marker), an insertion only who and when. -function odfRevisions(body: Element): Map { - const out = new Map(); +function odfRevisions(body: Element): Map { + const out = new Map(); const registry = body.getElementsByTagNameNS(NS.text, 'tracked-changes')[0]; for (const region of Array.from(registry?.children ?? [])) { const id = region.getAttributeNS(NS.text, 'id') ?? region.getAttribute('xml:id'); const kindEl = Array.from(region.children).find((c) => c.localName === 'insertion' || c.localName === 'deletion'); if (!id || !kindEl) continue; const info = kindEl.getElementsByTagNameNS(NS.office, 'change-info')[0]; - const paras = Array.from(kindEl.children).filter((c) => c.namespaceURI === NS.text && c.localName === 'p'); + const paras = Array.from(kindEl.children).filter((c): c is Element => c.namespaceURI === NS.text && c.localName === 'p'); out.set(id, { kind: kindEl.localName === 'deletion' ? 'deletion' : 'insertion', author: info?.getElementsByTagNameNS(NS.dc, 'creator')[0]?.textContent?.trim() ?? '', date: info?.getElementsByTagNameNS(NS.dc, 'date')[0]?.textContent?.trim() ?? '', text: paras.map((n) => n.textContent ?? '').join('\n'), + paras, }); } return out; @@ -2015,13 +2018,23 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl } case 'change': { // A deletion is a point marker: its text lives only in the registry, and the - // editor keeps it in the document marked as removed. + // editor keeps it in the document marked as removed. The registry paragraphs + // are real styled runs, so they convert like any inline content. const id = e.getAttributeNS(NS.text, 'change-id'); const rev = id ? ctx.revisions.get(id) : undefined; - if (!hfFields && id && rev?.kind === 'deletion' && rev.text) { - const marks = marksFor(props, ctx.resolver, defaults); - marks.push({ type: 'deletion', attrs: { id, author: rev.author, date: rev.date } }); - out.push({ type: 'text', text: rev.text, marks }); + if (!hfFields && id && rev?.kind === 'deletion' && (rev.paras.length || rev.text)) { + const del = { type: 'deletion', attrs: { id, author: rev.author, date: rev.date } }; + if (!rev.paras.length) { + out.push({ type: 'text', text: rev.text, marks: [...marksFor(props, ctx.resolver, defaults), del] }); + continue; + } + rev.paras.forEach((p, i) => { + if (i) out.push({ type: 'text', text: '\n', marks: [del] }); + for (const n of convertInline(p, ctx, props, defaults)) { + if (n.type === 'text') n.marks = [...(n.marks ?? []), del]; + out.push(n); + } + }); } continue; }