fix: a deletion's text keeps its formatting through ODT

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.
This commit is contained in:
Steffen Becker committed 2026-08-16 12:07:07 +02:00
1 parent 1c5b6d73d5
commit afec07579a
2 files changed
+55 -19

No files matched your search

+33 -10
View File
@@ -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<string, RevisionExport>): TiptapNode {
if (!node.content?.length) return node;
const content: TiptapNode[] = [];
@@ -591,11 +594,12 @@ function replaceRevisions(node: TiptapNode, out: Map<string, RevisionExport>): 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<string, RevisionExport>): 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<string, RevisionExport>,
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<string, string>();
content = content.replace(
new RegExp(`<text:p\\b[^>]*>\\s*${TCD}#([^${TCD}]*)${TCD}([\\s\\S]*?)</text:p>`, 'g'),
(_m, id: string, inner: string) => { hoisted.set(id, inner); return ''; },
);
let open = new Map<string, boolean>();
content = content
.replace(new RegExp(`${TCI}([^${TCI}]*)${TCI}`, 'g'), (_m, id: string) => {
@@ -645,8 +665,9 @@ function applyRevisions(odtBytes: Uint8Array, list: Map<string, RevisionExport>,
const ct = odfId.get(r.id)!;
const info = `<office:change-info><dc:creator>${escapeXml(r.author)}</dc:creator>`
+ `<dc:date>${escapeXml(r.date.replace(/\.\d+Z?$/, ''))}</dc:date></office:change-info>`;
const runs = hoisted.get(r.id) ?? escapeXml(r.text);
const body = r.kind === 'deletion'
? `<text:deletion>${info}<text:p text:style-name="Standard">${escapeXml(r.text)}</text:p></text:deletion>`
? `<text:deletion>${info}<text:p text:style-name="Standard">${runs}</text:p></text:deletion>`
: `<text:insertion>${info}</text:insertion>`;
return `<text:changed-region xml:id="${ct}" text:id="${ct}">${body}</text:changed-region>`;
}).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 ? `<office:annotation-end office:name="${escapeXml(c.name)}"/>` : '';
})
.replace(
'<office:document-content ',
'<office:document-content xmlns:dc="http://purl.org/dc/elements/1.1/"'
+ ' xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" ',
);
});
// applyRevisions may already have declared dc:; declaring either twice is invalid XML.
content = ensureDcNamespace(content);
if (!content.includes('xmlns:loext=')) {
content = content.replace('<office:document-content ',
'<office:document-content xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0" ');
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
+22 -9
View File
@@ -152,7 +152,7 @@ type Ctx = {
openComments: Map<string, Record<string, unknown>>;
// Recorded revisions: the <text:tracked-changes> registry by change id, and the
// insertions whose <text:change-start> has been seen but not their end.
revisions: Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }>;
revisions: Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string; paras: Element[] }>;
openInsertions: Map<string, Record<string, unknown>>;
// 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 <text:tracked-changes> 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<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }> {
const out = new Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string }>();
function odfRevisions(body: Element): Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string; paras: Element[] }> {
const out = new Map<string, { kind: 'insertion' | 'deletion'; author: string; date: string; text: string; paras: Element[] }>();
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;
}