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:
1 parent
1c5b6d73d5
commit
afec07579a
2 files changed
+55
-19
No files matched your search
+33
-10
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user