diff --git a/DocDiff.html b/DocDiff.html
index 3f54b2b..8dff029 100644
--- a/DocDiff.html
+++ b/DocDiff.html
@@ -42,7 +42,7 @@
-
+
@@ -50,7 +50,7 @@
-
+
@@ -90,7 +90,7 @@
"@type": "CreativeWork",
"url": "https://github.com/tschnorpfeil/docdiff#readme"
},
- "image": "https://docdiff.app/og-image.png?v=2",
+ "image": "https://docdiff.app/og-image.png?v=3",
"author": {
"@type": "Person",
"name": "Tobias Schnorpfeil",
@@ -99,7 +99,7 @@
"sameAs": [
"https://github.com/tschnorpfeil/docdiff"
],
- "softwareVersion": "1.5.0"
+ "softwareVersion": "1.6.0"
},
{
"@type": "SoftwareSourceCode",
@@ -155,7 +155,7 @@
"name": "Kann ich die Änderungen in Word weiterbearbeiten?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Übernommen wird nur der Text, nicht die ursprüngliche Formatierung."
+ "text": "Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Ist die neue Fassung eine Word-Datei, behält der Export deren Formatierung: Formatvorlagen, Nummerierung, Tabellen und Bilder bleiben erhalten. Aus PDF oder Text wird ein neu gesetztes Word-Dokument. Die Endfassung ohne Markierungen gibt es ebenso als Word-Datei."
}
},
{
@@ -858,6 +858,45 @@
.menu-label + .menu-seg {
margin-top: 2px;
}
+/* Export menu: section titles and items with a one-line explanation */
+.menu-export {
+ width: 340px;
+ max-width: calc(100vw - 16px);
+}
+.menu-label-2line {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 8px 10px 4px;
+ color: hsl(var(--foreground));
+}
+.menu-label-desc {
+ font-size: var(--text-ui-sm);
+ font-weight: 400;
+ line-height: 1.35;
+ color: hsl(var(--muted-foreground));
+}
+.menu-item.mi-2line {
+ align-items: flex-start;
+ white-space: normal;
+}
+.menu-item.mi-2line > svg {
+ margin-top: 2px;
+ flex-shrink: 0;
+}
+.mi-text {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+.mi-desc {
+ font-size: var(--text-ui-sm);
+ font-weight: 400;
+ line-height: 1.35;
+ color: hsl(var(--muted-foreground));
+ overflow-wrap: anywhere;
+}
.menu-sep {
height: 1px;
margin: 5px 4px;
@@ -3337,6 +3376,44 @@
font-style: italic;
color: #444;
}
+ /* Final version: a clean document, no marks */
+ #print-view .pv-final {
+ font-size: 10.5pt;
+ line-height: 1.5;
+ hyphens: auto;
+ }
+ #print-view .pv-final h1 {
+ font-size: 16pt;
+ line-height: 1.25;
+ margin: 0 0 12pt;
+ break-after: avoid;
+ }
+ #print-view .pv-final h2 {
+ font-size: 12pt;
+ margin: 16pt 0 6pt;
+ break-after: avoid;
+ }
+ #print-view .pv-final h3 {
+ font-size: 10.5pt;
+ margin: 12pt 0 4pt;
+ break-after: avoid;
+ }
+ #print-view .pv-final p {
+ margin: 0 0 6pt;
+ orphans: 3;
+ widows: 3;
+ }
+ #print-view .pv-final .pvf-item {
+ display: flex;
+ gap: 0;
+ }
+ #print-view .pv-final .pvf-marker {
+ flex: 0 0 2.4em;
+ }
+ #print-view .pv-final .pvf-body {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
}
/* Landing page on phones: one column, nothing wider than the screen */
@@ -3563,16 +3640,26 @@
Dokumente
Exportieren
-
-
-
-
+
+
+
EndfassungNeue Fassung mit Ihren Entscheidungen, ohne Markierungen
+
+
+
+
+
+
+
VergleichAlt → neu mit allen Markierungen, zum Prüfen oder Weitergeben
+
+
+
+
-
+
@@ -3695,7 +3782,7 @@
So funktioniert’s
1Zwei Fassungen wählenAls PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
2Änderungen durchgehenEntferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
-
3Ergebnis sichern, wenn Sie es brauchenAls Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.
+
3Ergebnis sichern, wenn Sie es brauchenAls saubere Endfassung (Word im Originalformat oder PDF) oder als Vergleich mit Änderungsverfolgung.
@@ -3747,7 +3834,7 @@
Häufige Fragen
Kann ich die Änderungen in Word weiterbearbeiten?
-
Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Übernommen wird nur der Text, nicht die ursprüngliche Formatierung.
+
Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Ist die neue Fassung eine Word-Datei, behält der Export deren Formatierung: Formatvorlagen, Nummerierung, Tabellen und Bilder bleiben erhalten. Aus PDF oder Text wird ein neu gesetztes Word-Dokument. Die Endfassung ohne Markierungen gibt es ebenso als Word-Datei.
@@ -4032,11 +4119,28 @@
listBtnLabel: 'Änderungsliste ({n} Änderungen)',
exportBtn: 'Exportieren',
expWord: 'Word mit Änderungsverfolgung (.docx)',
- expPdf: 'Drucken oder als PDF sichern',
expHtml: 'Bericht als Webseite (.html)',
expMd: 'Bericht als Markdown (.md)',
expCopy: 'Text der neuen Fassung kopieren',
- expTxt: 'Neue Fassung als Textdatei (.txt)',
+ expTxt: 'Endfassung als Textdatei (.txt)',
+ expSecFinal: 'Endfassung',
+ expSecFinalDesc: 'Neue Fassung mit Ihren Entscheidungen, ohne Markierungen',
+ expSecFinalOpen: 'Neue Fassung mit Ihren Entscheidungen – {n} offene Änderungen sind wie angenommen enthalten',
+ expSecFinalOpenOne: 'Neue Fassung mit Ihren Entscheidungen – 1 offene Änderung ist wie angenommen enthalten',
+ expSecCompare: 'Vergleich',
+ expSecCompareDesc: 'Alt → neu mit allen Markierungen, zum Prüfen oder Weitergeben',
+ expWordFinal: 'Word (.docx)',
+ expWordFinalDescOriginal: 'Im Format Ihrer Datei „{name}“',
+ expWordFinalDescNew: 'Neu gesetzt, mit Überschriften und Nummerierung',
+ expPdfFinal: 'PDF',
+ expPdfFinalDesc: 'Sauberes Dokument, im Druckdialog „Als PDF sichern“',
+ expCopyDesc: 'Reiner Text für E-Mail oder andere Programme',
+ expWordDesc: 'Offene Änderungen in Word annehmen oder ablehnen',
+ expWordDescOriginal: 'Im Format von „{name}“, offene Änderungen in Word annehmen oder ablehnen',
+ expPdfSplit: 'PDF nebeneinander',
+ expPdfSplitDesc: 'Querformat, alte und neue Fassung in zwei Spalten',
+ expPdfInline: 'PDF als Fließtext',
+ expPdfInlineDesc: 'Hochformat, Änderungen im Text markiert',
moreLabel: 'Weitere Aktionen',
clearBtn: 'Neuer Vergleich',
textBtn: 'Text einfügen/bearbeiten',
@@ -4055,6 +4159,8 @@
// ── Toasts & announcements ───────────────────────────────────────────
toastWord: 'Word-Datei gespeichert: {name}',
+ toastWordOriginal: 'Word-Datei im Originalformat gespeichert: {name}',
+ noticeWordFallback: 'Gespeichert: {name}. Das Originalformat ließ sich für diese Datei nicht sicher übernehmen (z. B. wegen Änderungen in Fußnoten oder Textfeldern). Die Word-Datei wurde deshalb neu gesetzt; der Text ist vollständig.',
toastHtml: 'Bericht gespeichert: {name}',
toastMd: 'Markdown-Bericht gespeichert: {name}',
toastTxt: 'Textdatei gespeichert: {name}',
@@ -4065,7 +4171,7 @@
toastUndo: 'Rückgängig',
toastUndone: 'Rückgängig gemacht',
toastRedone: 'Wiederhergestellt',
- copyFailed: 'Kopieren in die Zwischenablage ist hier nicht möglich. Bitte nutzen Sie stattdessen „Neue Fassung als Textdatei (.txt)“ unter „Weitere Formate“.',
+ copyFailed: 'Kopieren in die Zwischenablage ist hier nicht möglich. Bitte nutzen Sie stattdessen „Endfassung als Textdatei (.txt)“ unter „Weitere Formate“.',
announceCompared: 'Vergleich fertig: {n} Änderungen. Mit J und K springen Sie von Änderung zu Änderung.',
announceComparedOne: 'Vergleich fertig: 1 Änderung.',
announceIdentical: 'Vergleich fertig: keine Textunterschiede.',
@@ -4118,6 +4224,7 @@
movedFromHere: 'Verschoben – von hier',
movedToHere: 'Verschoben – hierher',
moreDeltas: 'weitere Änderungen',
+ moreDeltasOne: 'weitere Änderung',
pageLabel: 'Seite',
pageShort: 'S.',
pageOf: 'von',
@@ -4157,7 +4264,11 @@
Lesen
Prüfen
-
Annehmen behält die neue Fassung, Ablehnen stellt für diesen Absatz die alte Fassung wieder her. Die Symbole ✓ (annehmen), ✗ (ablehnen) und ✎ (bearbeiten) stehen oben rechts am aktuellen Absatz. Annehmen und Ablehnen markieren die Änderung als geprüft, springen zur nächsten offenen Änderung und Ein geprüfter Absatz trägt rechts ein ✓ oder ✗; der Pfeil darunter (Rückgängig) nimmt die Prüfung zurück. Im Word-Export sind angenommene Änderungen übernommen, abgelehnte zurückgenommen und offene als nachverfolgte Änderungen eingetragen.
+
Annehmen behält die neue Fassung, Ablehnen stellt für diesen Absatz die alte Fassung wieder her. Die Symbole ✓ (annehmen), ✗ (ablehnen) und ✎ (bearbeiten) stehen oben rechts am aktuellen Absatz. Annehmen und Ablehnen markieren die Änderung als geprüft und springen zur nächsten offenen Änderung. Ein geprüfter Absatz trägt rechts ein ✓ oder ✗; der Pfeil darunter (Rückgängig) nimmt die Prüfung zurück.
+
+
+
Exportieren
+
Endfassung: die neue Fassung mit allen Entscheidungen und Bearbeitungen, ohne Markierungen – als Word-Datei (bei einer Word-Datei als neuer Fassung in deren Originalformat), als PDF oder als Text. Noch offene Änderungen sind darin wie angenommen enthalten. Vergleich: alt → neu mit Markierungen – als Word-Datei mit Änderungsverfolgung (angenommene Änderungen übernommen, abgelehnte zurückgenommen, offene zum Annehmen oder Ablehnen) oder als PDF nebeneinander bzw. als Fließtext.
a11yAddedStart: '[hinzugefügt:',
a11yAddedEnd: ']',
docxSubject: 'Vergleich mit nachverfolgten Änderungen',
- docxNote: 'Offene Abweichungen sind als nachverfolgte Änderungen (Autor „Doc Diff“) eingetragen. In Doc Diff angenommene Änderungen sind bereits übernommen, abgelehnte bereits zurückgenommen. In Word unter „Überprüfen“ einzeln oder alle auf einmal annehmen bzw. ablehnen.',
- exportFileFinal: 'Neue_Fassung',
+ docxSubjectFinal: 'Endfassung',
+ docxNote: 'Offene Änderungen sind als nachverfolgte Änderungen (Autor „Doc Diff“) eingetragen, angenommene bereits übernommen, abgelehnte zurückgenommen. In Word unter „Überprüfen“ einzeln oder alle auf einmal annehmen bzw. ablehnen. Diesen Kommentar können Sie löschen.',
+ docxHfHeading: 'Geänderte Kopf-/Fußzeilen (nicht im Fließtext):',
+ exportFileFinal: 'Endfassung',
exportFileRedline: 'Vergleich',
exportFileReport: 'Bericht',
sampleLeftName: 'Vertrag_v1_Vorher.docx',
@@ -4311,7 +4425,7 @@
Ihre Daten
how2Title: 'Änderungen durchgehen',
how2Text: 'Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.',
how3Title: 'Ergebnis sichern, wenn Sie es brauchen',
- how3Text: 'Als Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.',
+ how3Text: 'Als saubere Endfassung (Word im Originalformat oder PDF) oder als Vergleich mit Änderungsverfolgung.',
scopeTitle: 'Was es kann – und was nicht',
scopeCanTitle: 'Kann',
scopeCan1: 'PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen – auch gemischt, etwa PDF mit Word',
@@ -4343,7 +4457,7 @@
Ihre Daten
`,
faqWordQ: 'Kann ich die Änderungen in Word weiterbearbeiten?',
faqWordA: `
-
Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Übernommen wird nur der Text, nicht die ursprüngliche Formatierung.
+
Ja. Der Export „Word mit Änderungsverfolgung“ erzeugt eine .docx-Datei, in der jeder noch offene Unterschied als nachverfolgte Änderung steht. In Word oder LibreOffice können Sie die Änderungen einzeln annehmen oder ablehnen. Ist die neue Fassung eine Word-Datei, behält der Export deren Formatierung: Formatvorlagen, Nummerierung, Tabellen und Bilder bleiben erhalten. Aus PDF oder Text wird ein neu gesetztes Word-Dokument. Die Endfassung ohne Markierungen gibt es ebenso als Word-Datei.
`,
metaTitle: 'Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload',
otherPageHint: 'Diese Seite gibt es auch auf Deutsch →',
@@ -4405,11 +4519,28 @@
Ihre Daten
listBtnLabel: 'List of changes ({n} changes)',
exportBtn: 'Export',
expWord: 'Word with tracked changes (.docx)',
- expPdf: 'Print or save as PDF',
expHtml: 'Report as web page (.html)',
expMd: 'Report as Markdown (.md)',
expCopy: 'Copy text of the new version',
- expTxt: 'New version as text file (.txt)',
+ expTxt: 'Final version as text file (.txt)',
+ expSecFinal: 'Final version',
+ expSecFinalDesc: 'The new version with your decisions, without marks',
+ expSecFinalOpen: 'The new version with your decisions – {n} open changes are included as accepted',
+ expSecFinalOpenOne: 'The new version with your decisions – 1 open change is included as accepted',
+ expSecCompare: 'Comparison',
+ expSecCompareDesc: 'Old → new with all marks, to review or pass on',
+ expWordFinal: 'Word (.docx)',
+ expWordFinalDescOriginal: 'In the format of your file “{name}”',
+ expWordFinalDescNew: 'Newly typeset, with headings and numbering',
+ expPdfFinal: 'PDF',
+ expPdfFinalDesc: 'A clean document; choose “Save as PDF” in the print dialog',
+ expCopyDesc: 'Plain text for email or other apps',
+ expWordDesc: 'Accept or reject the open changes in Word',
+ expWordDescOriginal: 'In the format of “{name}”; accept or reject the open changes in Word',
+ expPdfSplit: 'PDF side by side',
+ expPdfSplitDesc: 'Landscape, old and new version in two columns',
+ expPdfInline: 'PDF as running text',
+ expPdfInlineDesc: 'Portrait, changes marked in the text',
moreLabel: 'More actions',
clearBtn: 'New comparison',
textBtn: 'Paste or edit text',
@@ -4428,6 +4559,8 @@
Ihre Daten
// ── Toasts & announcements ───────────────────────────────────────────
toastWord: 'Word file saved: {name}',
+ toastWordOriginal: 'Word file saved in the original format: {name}',
+ noticeWordFallback: 'Saved: {name}. The original format could not be kept reliably for this file (for example because of changes in footnotes or text boxes), so the Word file was typeset anew; the text is complete.',
toastHtml: 'Report saved: {name}',
toastMd: 'Markdown report saved: {name}',
toastTxt: 'Text file saved: {name}',
@@ -4438,7 +4571,7 @@
Ihre Daten
toastUndo: 'Undo',
toastUndone: 'Undone',
toastRedone: 'Redone',
- copyFailed: 'Copying to the clipboard is not possible here. Please use “New version as text file (.txt)” under “More formats” instead.',
+ copyFailed: 'Copying to the clipboard is not possible here. Please use “Final version as text file (.txt)” under “More formats” instead.',
announceCompared: 'Comparison ready: {n} changes. Press J and K to move between changes.',
announceComparedOne: 'Comparison ready: 1 change.',
announceIdentical: 'Comparison ready: no text differences.',
@@ -4491,6 +4624,7 @@
Accept keeps the new wording, Reject restores the old wording of that paragraph. The symbols ✓ (accept), ✗ (reject) and ✎ (edit) sit at the top right of the current paragraph. Accepting and rejecting mark the change as reviewed, move on to the next open change A reviewed paragraph carries a ✓ or ✗ on the right; the arrow below it (Undo) takes the review back. In the Word export, accepted changes are applied, rejected ones are reverted and open ones are recorded as tracked changes.
+
Accept keeps the new wording, Reject restores the old wording of that paragraph. The symbols ✓ (accept), ✗ (reject) and ✎ (edit) sit at the top right of the current paragraph. Accepting and rejecting mark the change as reviewed and move on to the next open change. A reviewed paragraph carries a ✓ or ✗ on the right; the arrow below it (Undo) takes the review back.
+
+
+
Exporting
+
Final version: the new version with every decision and edit applied, without marks – as a Word file (in the original format when the new version is a Word file), as PDF or as text. Changes that are still open are included as if accepted. Comparison: old → new with marks – as a Word file with tracked changes (accepted changes applied, rejected ones reverted, open ones to accept or reject) or as PDF side by side or as running text.
exportLabelDetail: 'Detail',
exportScopeFull: 'Full document',
exportScopeChanges: 'Changes only (“Changes only” filter active, unchanged paragraphs omitted)',
- exportHfIgnored: 'hidden (not compared)',
- exportHfIgnoredCount: 'hidden ({n} lines not compared)',
- exportHfIncluded: 'included in the comparison',
+ exportHfIgnored: 'unchanged ones hidden; changed ones are listed as changes',
+ exportHfIgnoredCount: '{n} unchanged lines hidden; changed ones are listed as changes',
+ exportHfIncluded: 'fully compared',
exportSummaryHeading: 'Summary',
exportCountTotal: 'Total changes',
exportChangesHeading: 'List of changes',
@@ -4653,8 +4792,10 @@
Your data
a11yAddedStart: '[added:',
a11yAddedEnd: ']',
docxSubject: 'Comparison with tracked changes',
- docxNote: 'Open differences are recorded as tracked changes (author “Doc Diff”). Changes accepted in Doc Diff are already applied, rejected ones already reverted. In Word, use Review › Accept / Reject to process them one by one or all at once.',
- exportFileFinal: 'New_version',
+ docxSubjectFinal: 'Final version',
+ docxHfHeading: 'Changed headers/footers (not in the body text):',
+ docxNote: 'Open changes are recorded as tracked changes (author “Doc Diff”); accepted ones are already applied, rejected ones reverted. In Word, use Review › Accept / Reject one by one or all at once. You can delete this comment.',
+ exportFileFinal: 'final',
exportFileRedline: 'Comparison',
exportFileReport: 'Report',
sampleLeftName: 'Contract_v1_Before.docx',
@@ -4684,7 +4825,7 @@
Your data
how2Title: 'Go through the changes',
how2Text: 'Removed text is struck through in red, added text is marked green. You can accept or reject each change.',
how3Title: 'Save the result if you need it',
- how3Text: 'As a Word file with tracked changes, as PDF or as a report.',
+ how3Text: 'As a clean final version (Word in the original format, or PDF) or as a comparison with tracked changes.',
scopeTitle: 'What it can do – and what it can’t',
scopeCanTitle: 'Can',
scopeCan1: 'Compare PDFs with a text layer, Word files (.docx) and plain text – also mixed, e.g. a PDF against a Word file',
@@ -4716,12 +4857,12 @@
Your data
`,
faqWordQ: 'Can I keep working on the changes in Word?',
faqWordA: `
-
Yes. The “Word with tracked changes” export creates a .docx file in which every difference that is still open is a tracked change. In Word or LibreOffice you can accept or reject each change. Only the text is carried over, not the original formatting.
+
Yes. The “Word with tracked changes” export creates a .docx file in which every difference that is still open is a tracked change. In Word or LibreOffice you can accept or reject each change. When the new version is a Word file, the export keeps its formatting: styles, numbering, tables and images stay as they were. From a PDF or text, a newly typeset Word document is created. The final version without marks is available as a Word file, too.
`,
metaTitle: 'Doc Diff – Compare PDF and Word documents without uploading',
otherPageHint: 'This page is also available in English →',
metaDescription: 'Compare two versions of a contract or document: PDF, Word or text, marked word by word. Free, open source, runs in your browser without uploads.',
- ogImageAlt: 'Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green (German interface).',
+ ogImageAlt: 'Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green.',
footerSource: 'Source code on GitHub',
githubMenu: 'Source code on GitHub',
footerLicense: 'MIT licence',
@@ -5063,6 +5204,8 @@
Your data
rightName: '',
leftFormat: '',
rightFormat: '',
+ // The .docx files as loaded ({ name, bytes, importedText }): the Word export keeps their format.
+ docSources: { left: null, right: null },
alignedRows: [],
changeRows: [],
listItems: [],
@@ -5333,7 +5476,8 @@
Your data
const meta = [];
if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');
if (block.indent > 0) meta.push(`lvl=${block.indent}`);
- return metaLine(meta.join(';'), block.text);
+ // A heading marker ("## ") from the source stays, so a rewritten text keeps its structure.
+ return metaLine(meta.join(';'), (block.headingMarker || '') + block.text);
}
function parseLineMeta(line) {
@@ -5955,8 +6099,11 @@
Your data
(repeatCounts.get(normalizeHfRepeatKey(line)) || 0) >= 2);
const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);
+ let headingMarker = '';
if (!isHeaderFooter) {
seenContentBlock = true;
+ const m = line.match(/^#{1,3}\s+/);
+ if (m) headingMarker = `${m[0].trim()} `;
line = line.replace(/^#{1,3}\s+/, '').trim();
if (!line) continue;
}
@@ -5988,6 +6135,7 @@
return pairs;
}
+// "1 weitere Änderung" / "2 weitere Änderungen" (the number is written by the caller).
+function moreDeltasText(n) {
+ return n === 1 ? t('moreDeltasOne') : t('moreDeltas');
+}
+
// Word diffs of a list item: the row's own, or (rejected change) recomputed from what was rejected.
const ghostDiffCache = new WeakMap();
function getItemWordDiffs(item) {
@@ -8008,7 +8244,7 @@
return replaced.concat(tokens.slice(1));
}
+function isHeaderFooterBlock(block) {
+ return Boolean(block && (block.isHeaderFooter || block.kind === 'hf'));
+}
+
/**
- * Builds the WordprocessingML body for the comparison. Pure function of `rows` and `meta`
- * so it can be unit-tested without a browser.
+ * Builds the WordprocessingML body. Pure function of `rows` and `meta` so it can be unit-tested
+ * without a browser.
+ * meta.kind 'tracked' (default): open changes as tracked changes, accepted ones applied
+ * meta.kind 'final': the new version only, without any marks
+ * The body is the document text and nothing else: running headers and footers of the source
+ * (page furniture of a PDF) are left out, so "Accept all" in Word yields a clean document.
+ * meta.comment (lines of text) becomes a Word comment on the first paragraph.
*/
function buildDocxDocumentXml(rows, meta) {
const date = toWordDate(meta.date);
+ const final = meta.kind === 'final';
let revId = 1;
const revAttrs = () => `w:id="${revId++}" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}"`;
const paragraphs = [];
- const metaPara = (label, value) =>
- `${docxRun(`${label}: `, false, '')}${docxRun(value)}`;
-
- paragraphs.push(`${docxRun(meta.title)}`);
- for (const [label, value] of meta.lines) paragraphs.push(metaPara(label, value));
- paragraphs.push(
- `${docxRun(meta.note, false, '')}`
- );
-
for (const row of rows) {
const block = row.rightBlock || row.leftBlock;
+ if (isHeaderFooterBlock(block)) continue;
const style = docxStyleForBlock(block);
// Word semantics: a change accepted in Doc Diff is plain text (an accepted deletion is
// simply gone); open changes become tracked changes. Rejected changes are already reverted
- // in the rows (they compare as unchanged).
- const accepted = row.type !== 'equal' && meta.reviewStatusOf && meta.reviewStatusOf(row) === 'accepted';
+ // in the rows (they compare as unchanged). The final version applies every change.
+ const accepted = final || (row.type !== 'equal' && meta.reviewStatusOf && meta.reviewStatusOf(row) === 'accepted');
if (accepted && !row.rightBlock) continue;
const tokens = docxApplyMarkerTab(
accepted ? [{ value: cleanExportText(row.rightBlock.text) }] : getExportDiffTokens(row),
@@ -10349,7 +10589,16 @@
},
{ name: 'word/footer1.xml', data: buildDocxFooterXml(meta) },
];
+ if (hasComment) files.push({ name: 'word/comments.xml', data: buildDocxCommentsXml(meta) });
+ return files;
}
-/** Builds the complete .docx file (Uint8Array) for the current comparison state. */
-function buildDocxRedline(date = new Date()) {
- const counts = getExportCounts();
+/**
+ * Summary of the comparison for the Word comment of the tracked export: the documents, the
+ * counts, the review status, how to work with the marks, and the header/footer changes (they
+ * are not in the body text).
+ */
+function buildDocxSummaryLines(date) {
+ const items = getExportItems();
+ const counts = getExportCounts(items);
const lines = [
- [t('exportLabelOriginal'), getDisplayDocName('left')],
- [t('exportLabelRevised'), getDisplayDocName('right')],
- [t('exportLabelCreated'), formatExportDate(date)],
- [t('exportLabelChanges'), formatExportCountsLine(counts)],
+ getExportTitle(),
+ `${t('exportLabelCreated')}: ${formatExportDate(date)}`,
+ `${t('exportLabelChanges')}: ${formatExportCountsLine(counts)}`,
];
- const review = getExportReviewLine();
- if (review) lines.push([t('exportLabelReview'), review]);
- lines.push([t('exportLabelHf'), state.ignoreHeadersFooters ? t('exportHfIgnored') : t('exportHfIncluded')]);
+ const review = getExportReviewLine(counts);
+ if (review) lines.push(`${t('exportLabelReview')}: ${review}`);
+ lines.push(t('docxNote'));
+ const hf = items.filter(item => isHeaderFooterItem(item) && item.status !== 'rejected');
+ if (hf.length) {
+ lines.push(t('docxHfHeading'));
+ for (const item of hf) {
+ const { pairs } = getRowChangeSummary(item, 80);
+ const change = pairs.map(p => (p.del && p.ins ? `${p.del} → ${p.ins}` : p.ins || p.del)).join('; ');
+ lines.push(`${getExportItemRef(item)} (${getExportItemPageLabel(item)}, ${getExportStatusLabel(item.status)}): ${change}`);
+ }
+ }
+ return lines;
+}
+
+/**
+ * The newly typeset Word file (Uint8Array) for the current comparison state:
+ * 'tracked' – "Accept all" in Word yields the new version (with the rejections applied),
+ * "Reject all" the old version with the accepted changes applied
+ * 'final' – the new version with every decision and edit applied, no marks
+ */
+function buildGeneratedDocx(kind, date = new Date()) {
+ const counts = getExportCounts();
const meta = {
+ kind,
lang: state.lang,
date,
- title: getExportTitle(),
- subject: t('docxSubject'),
- lines,
+ title: kind === 'final' ? getDisplayDocName('right') : getExportTitle(),
+ subject: t(kind === 'final' ? 'docxSubjectFinal' : 'docxSubject'),
+ description: kind === 'final' ? '' : formatExportCountsLine(counts),
reviewStatusOf: row => row.reviewStatus || '',
- note: t('docxNote'),
- footer: `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`,
+ comment: kind === 'final' ? null : buildDocxSummaryLines(date),
pageLabel: t('pageLabel'),
pageOfLabel: t('pageOf'),
};
- // The document is always complete: "Accept all" in Word yields the new version (with the
- // rejections applied), "Reject all" the old version with the accepted changes applied.
return buildZipArchive(buildDocxPackageFiles(state.alignedRows, meta), date);
}
+// Kept for callers and tests written against the earlier name.
+function buildDocxRedline(date = new Date()) {
+ return buildGeneratedDocx('tracked', date);
+}
+
/* ── Client-Side Multi-Format Export Engine (copy, .txt, .docx, print/PDF, HTML, Markdown) ── */
function downloadBlob(content, mimeType, filename) {
@@ -10602,23 +10898,17 @@
Your data
}
/**
- * The changes of the comparison with their review status, in document order: the list items
- * (including rejected changes) when the review index is current, otherwise the change rows.
+ * The changes of the comparison with their review status, exactly as the change list shows them
+ * (same order, same count: an identical header/footer change on several pages is one change),
+ * including rejected changes. Falls back to the change rows while the review index is not current.
*/
function getExportItems() {
if (
Array.isArray(state.listItems) &&
state.listItems.length &&
- state.listItems.every(i => state.alignedRows.includes(i.row))
+ state.listItems.every(i => itemMembers(i).every(m => state.alignedRows.includes(m.row)))
) {
- // Exports follow the document: every occurrence of a merged header/footer change, in
- // document order (the list shows those after the body changes).
- const pos = new Map(state.alignedRows.map((r, i) => [r.id, i]));
- return state.listItems
- .flatMap(item => (item.members ? item.members : [item]))
- .map(item => ({ ...item, members: undefined }))
- .sort((a, b) => pos.get(a.rowId) - pos.get(b.rowId) || (a.sub || 0) - (b.sub || 0))
- .map((item, i) => ({ ...item, index: i + 1 }));
+ return state.listItems.map((item, i) => ({ ...item, index: i + 1 }));
}
return state.changeRows.map((row, i) => ({
id: row.id,
@@ -10655,13 +10945,24 @@
Your data
return status === 'accepted' ? t('statusAccepted') : status === 'rejected' ? t('statusRejected') : t('statusOpen');
}
+/** Reference as the change list shows it ("§ 1 (2)", "Kopf-/Fußzeile – auf 3 Seiten …"). */
function getExportItemRef(item) {
- if (item.ghost) return item.ghost.refCode || '';
- return getRowRefCode(item.row);
+ return getItemRef(item);
+}
+
+/** "S. 2" or, for a change on several pages, "S. 1–3". */
+function getExportItemPages(item) {
+ const pages = itemMembers(item).map(m => getItemPage(m));
+ const min = Math.min(...pages);
+ const max = Math.max(...pages);
+ return min === max ? String(min) : `${min}–${max}`;
+}
+function getExportItemPageLabel(item) {
+ return `${t('pageShort')} ${getExportItemPages(item)}`;
}
function formatExportCountsLine(c) {
- return tf('exportCountsFmt', { total: c.total, mod: c.modified, ins: c.inserted, del: c.deleted });
+ return tf(c.total === 1 ? 'exportCountsFmtOne' : 'exportCountsFmt', { total: c.total, mod: c.modified, ins: c.inserted, del: c.deleted });
}
function formatExportDate(date) {
@@ -10695,7 +10996,7 @@
Your data
if (row.type === 'equal') {
return row.ghostNote ? `${t('statusRejected')} · ${t('exportRejectedNote')}` : '';
}
- const parts = [getRowTypeLabel(row), getRowRefCode(row)].filter(Boolean);
+ const parts = [getRowTypeLabel(row), getDisplayRef(row)].filter(Boolean);
if (row.reviewStatus === 'accepted') parts.push(t('statusAccepted'));
return parts.join(' · ');
}
@@ -10809,11 +11110,7 @@
Your data
* furniture and are always left out, whether or not the header/footer filter is active.
*/
function buildFinalText() {
- const blocks = state.alignedRows.length
- ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock)
- : parseIntoBlocks(state.rightText || '');
- return blocks
- .filter(b => !b.isHeaderFooter && !isInternalMarkerLine(b.text))
+ return getFinalBlocks()
.map(b => cleanExportText(b.text).trim())
.filter(Boolean)
.join('\n\n');
@@ -10845,7 +11142,7 @@
const items = getExportItems();
const counts = getExportCounts(items);
const title = getExportTitle();
- // Anchors: every changed row, and the row a rejected change was restored to.
+ // Anchors: every changed row (all pages of a merged header/footer change), and the row a
+ // rejected change was restored to. The first row of a change carries its id.
const anchorIndex = new Map();
items.forEach((item, i) => {
- if (item.ghost && item.type === 'inserted') return;
- if (!anchorIndex.has(item.rowId)) anchorIndex.set(item.rowId, i + 1);
+ for (const m of itemMembers(item)) {
+ if (m.ghost && m.type === 'inserted') continue;
+ if (!anchorIndex.has(m.rowId)) anchorIndex.set(m.rowId, i + 1);
+ }
});
- const anchorFor = row => `c${anchorIndex.get(row.id)}`;
+ const anchorUsed = new Set();
+ const anchorFor = row => {
+ const n = anchorIndex.get(row.id);
+ if (!n || anchorUsed.has(n)) return '';
+ anchorUsed.add(n);
+ return ` id="c${n}"`;
+ };
const reviewLine = getExportReviewLine(counts);
const changeListHtml = items.length
@@ -10932,10 +11238,9 @@
`;
}
-/** Builds a print-only, paginated rendering of the comparison for the current view mode. */
-function buildPrintViewHtml(date = new Date()) {
+/**
+ * Builds a print-only, paginated rendering. kind: 'split' (old and new side by side, landscape),
+ * 'inline' (one column with the marks in the text, portrait) or 'final' (the new version with
+ * every decision applied, as a clean document).
+ */
+function buildPrintViewHtml(date = new Date(), kind = state.viewMode) {
+ if (kind === 'final') return buildFinalPrintHtml();
const rows = getExportBodyRows();
const counts = getExportCounts();
- const mode = state.viewMode;
+ const mode = kind === 'inline' ? 'inline' : 'split';
const head = `
${escapeHtml(getExportTitle())}
@@ -11260,9 +11569,44 @@
${escapeHtml(t('exportRedlineHeading'))}
return `${head}${body}${emptyHtml}`;
}
+/** The new version as a clean document: every decision and edit applied, no marks. */
+function buildFinalPrintHtml() {
+ const blocks = getFinalBlocks();
+ const html = blocks
+ .map(block => {
+ const text = cleanExportText(block.text).trim();
+ if (!text) return '';
+ const kind = block.kind || 'p';
+ if (kind === 'h1' || kind === 'h2' || kind === 'h3') {
+ const tag = kind === 'h1' ? 'h1' : kind === 'h2' ? 'h2' : 'h3';
+ return `<${tag} class="pvf-${kind}">${escapeHtml(text)}${tag}>`;
+ }
+ const indent = block.indent > 0 ? ` style="margin-left:${Math.min(block.indent, 6) * 1.6}em"` : '';
+ const split = extractListMarker(text, kind);
+ if (split) {
+ return `
if (typeof showToast === 'function') showToast(tf(toastKey, { name: filename }));
}
+/** "Vertrag_v2_Endfassung.docx": named after the new version, which it is. */
+function buildFinalFileName(ext) {
+ const base = sanitizeFileNamePart(state.rightName, 'DocDiff', 100);
+ return `${base}_${sanitizeFileNamePart(t('exportFileFinal'), 'Final')}.${ext}`;
+}
+
+/** The .docx the new version was read from, if it is still the loaded new version. */
+function getRightDocxSource() {
+ const src = state.docSources && state.docSources.right;
+ if (!src || state.rightFormat !== 'DOCX' || src.name !== state.rightName || !src.bytes) return null;
+ return src;
+}
+
+const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+
+/**
+ * Word export. With a .docx as the new version, the file itself is the base (original format);
+ * if that cannot be done reliably, the newly typeset document is used and the user is told.
+ */
+async function exportWord(kind) {
+ const date = new Date();
+ const source = getRightDocxSource();
+ let bytes = null;
+ let fellBack = false;
+ if (source) {
+ try {
+ bytes = await buildDocxFromSource(source, parseIntoBlocks(state.rightText), state.alignedRows, kind, date);
+ } catch (err) {
+ fellBack = true;
+ if (typeof console !== 'undefined') console.warn('Doc Diff: original-format Word export not possible:', err && err.message);
+ }
+ }
+ if (!bytes) bytes = buildGeneratedDocx(kind, date);
+ const filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx');
+ downloadBlob(bytes, DOCX_MIME, filename);
+ if (fellBack && typeof showImportNotice === 'function') {
+ showImportNotice(tf('noticeWordFallback', { name: filename }), 'warning');
+ } else if (typeof showToast === 'function') {
+ showToast(tf(source ? 'toastWordOriginal' : 'toastWord', { name: filename }));
+ }
+}
+
+function startPrint(kind) {
+ if (!state.alignedRows.length) return;
+ requestedPrintKind = kind;
+ preparePrintView();
+ window.print();
+}
+
function handleExportAction(type) {
// Commit an inline edit that is still open so the export reflects it.
const openEditor = document.querySelector('.clause-inline-editor');
@@ -11352,28 +11748,19 @@
${escapeHtml(t('exportRedlineHeading'))}
// the current text, not the rows from before.
ensureComparisonCurrent();
- if (type === 'print') {
- if (!state.alignedRows.length) return;
- preparePrintView();
- window.print();
- return;
- }
-
// Exports need a finished comparison. The menu is disabled until then; never substitute
// sample content for (or overwrite) a document the user has loaded.
if (!state.alignedRows.length) return;
- if (type === 'copy') {
+ if (type === 'print' || type === 'print-split' || type === 'print-inline' || type === 'print-final') {
+ const kind = type === 'print' ? (state.viewMode === 'inline' ? 'inline' : 'split') : type.slice(6);
+ startPrint(kind);
+ } else if (type === 'copy') {
copyFinalTextToClipboard();
} else if (type === 'txt') {
- downloadWithToast(`${buildFinalText()}\n`, 'text/plain;charset=utf-8', buildExportFileName('exportFileFinal', 'txt'), 'toastTxt');
- } else if (type === 'word') {
- downloadWithToast(
- buildDocxRedline(),
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- buildExportFileName('exportFileRedline', 'docx'),
- 'toastWord'
- );
+ downloadWithToast(`${buildFinalText()}\n`, 'text/plain;charset=utf-8', buildFinalFileName('txt'), 'toastTxt');
+ } else if (type === 'word' || type === 'word-final') {
+ exportWord(type === 'word' ? 'tracked' : 'final');
} else if (type === 'html') {
downloadWithToast(buildExportHtmlReport(), 'text/html;charset=utf-8', buildExportFileName('exportFileReport', 'html'), 'toastHtml');
} else if (type === 'md') {
@@ -11381,6 +11768,25 @@
${escapeHtml(t('exportRedlineHeading'))}
}
}
+/**
+ * The descriptions in the export menu that depend on the documents: whether the Word file keeps
+ * the original format, and that open changes are part of the final version.
+ */
+function updateExportMenuTexts() {
+ const set = (id, text) => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = text;
+ };
+ const source = getRightDocxSource();
+ set('exp-desc-word-final', source ? tf('expWordFinalDescOriginal', { name: source.name }) : t('expWordFinalDescNew'));
+ set('exp-desc-word', source ? tf('expWordDescOriginal', { name: source.name }) : t('expWordDesc'));
+ const open = state.listItems.filter(item => item.status === 'open').length;
+ set(
+ 'exp-sec-final-desc',
+ open === 0 ? t('expSecFinalDesc') : open === 1 ? t('expSecFinalOpenOne') : tf('expSecFinalOpen', { n: open })
+ );
+}
+
/* ── Export availability ─────────────────────────────────────────────────── */
/** Really disables the export triggers (not just visually) while no comparison is available. */
@@ -11403,6 +11809,922 @@
${escapeHtml(t('exportRedlineHeading'))}
});
}
+/* ── Word export in the original format ────────────────────────────────────────────────────
+ * When the new version was loaded from a .docx, the Word exports start from that very file
+ * instead of a newly typeset document. Every paragraph whose text is unchanged is copied byte
+ * for byte, so styles, numbering, tables, images, headers, footers and the page layout stay as
+ * they were. Only paragraphs whose text differs are rewritten, run by run: words that stay keep
+ * their character formatting, new words take the formatting of their neighbours.
+ *
+ * Two kinds:
+ * 'final' – the new version with every review decision and edit applied, no marks
+ * 'tracked' – the same, plus every open change as a Word tracked change (old → new)
+ *
+ * The result is read back and compared with the expected text before it is offered. If
+ * anything does not match (an unusual document structure, a change inside a text box), the
+ * caller falls back to the newly typeset document. Pure string processing, no DOM, so it can be
+ * unit-tested in Node.
+ */
+
+/* ── ZIP reading (all entries, as bytes) ─────────────────────────────────── */
+
+async function inflateRaw(bytes) {
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
+ return new Uint8Array(await new Response(stream).arrayBuffer());
+}
+
+/** Reads every entry of a ZIP archive: [{ name, data: Uint8Array }] in archive order. */
+async function readZipEntries(input) {
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ let eocd = -1;
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) {
+ if (dv.getUint32(i, true) === 0x06054b50) {
+ eocd = i;
+ break;
+ }
+ }
+ if (eocd < 0) throw new Error('zip: no central directory');
+ const count = dv.getUint16(eocd + 10, true);
+ let p = dv.getUint32(eocd + 16, true);
+ const utf8 = new TextDecoder('utf-8');
+ const out = [];
+ for (let n = 0; n < count; n++) {
+ if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory');
+ const method = dv.getUint16(p + 10, true);
+ const compSize = dv.getUint32(p + 20, true);
+ const nameLen = dv.getUint16(p + 28, true);
+ const extraLen = dv.getUint16(p + 30, true);
+ const commentLen = dv.getUint16(p + 32, true);
+ const localOffset = dv.getUint32(p + 42, true);
+ const name = utf8.decode(bytes.subarray(p + 46, p + 46 + nameLen));
+ p += 46 + nameLen + extraLen + commentLen;
+ const dataStart = localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true);
+ const raw = bytes.subarray(dataStart, dataStart + compSize);
+ let data;
+ if (method === 0) data = raw.slice();
+ else if (method === 8) data = await inflateRaw(raw);
+ else throw new Error(`zip: unsupported method ${method}`);
+ out.push({ name, data });
+ }
+ return out;
+}
+
+/* ── A minimal XML reader that keeps source offsets ─────────────────────────
+ * Elements: { name, start, openEnd, closeStart, end, selfClosing, children }; text between
+ * elements is not needed (Word keeps text inside w:t, read from the source slice). Offsets let
+ * untouched parts be copied verbatim.
+ */
+function parseXmlTree(xml) {
+ const root = { name: '#root', start: 0, openEnd: 0, closeStart: xml.length, end: xml.length, children: [] };
+ const stack = [root];
+ let i = 0;
+ const n = xml.length;
+ while (i < n) {
+ const lt = xml.indexOf('<', i);
+ if (lt < 0) break;
+ if (xml.startsWith('', lt + 4);
+ if (e < 0) throw new Error('xml: unterminated comment');
+ i = e + 3;
+ continue;
+ }
+ if (xml.startsWith('', lt + 9);
+ if (e < 0) throw new Error('xml: unterminated CDATA');
+ i = e + 3;
+ continue;
+ }
+ if (xml.startsWith('', lt) || xml.startsWith('', lt + 2);
+ if (e < 0) throw new Error('xml: unterminated declaration');
+ i = e + 1;
+ continue;
+ }
+ // Find the end of the tag, honouring quoted attribute values (they may contain '>').
+ let j = lt + 1;
+ let quote = '';
+ while (j < n) {
+ const c = xml[j];
+ if (quote) {
+ if (c === quote) quote = '';
+ } else if (c === '"' || c === "'") {
+ quote = c;
+ } else if (c === '>') {
+ break;
+ }
+ j++;
+ }
+ if (j >= n) throw new Error('xml: unterminated tag');
+ const tagEnd = j + 1;
+ if (xml[lt + 1] === '/') {
+ const name = xml.slice(lt + 2, j).trim();
+ const node = stack.pop();
+ if (!node || node.name !== name) throw new Error(`xml: mismatched ${name}>`);
+ node.closeStart = lt;
+ node.end = tagEnd;
+ } else {
+ const m = /^[^\s/>]+/.exec(xml.slice(lt + 1, j));
+ if (!m) throw new Error('xml: bad tag');
+ const selfClosing = xml[j - 1] === '/';
+ const node = { name: m[0], start: lt, openEnd: tagEnd, closeStart: tagEnd, end: tagEnd, selfClosing, children: [] };
+ stack[stack.length - 1].children.push(node);
+ if (!selfClosing) stack.push(node);
+ }
+ i = tagEnd;
+ }
+ if (stack.length !== 1) throw new Error('xml: unclosed element');
+ return root;
+}
+
+function xmlLocalName(node) {
+ const k = node.name.indexOf(':');
+ return k < 0 ? node.name : node.name.slice(k + 1);
+}
+
+function decodeXmlText(s) {
+ return s.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|amp|quot|apos);/g, (m, e) => {
+ if (e === 'lt') return '<';
+ if (e === 'gt') return '>';
+ if (e === 'amp') return '&';
+ if (e === 'quot') return '"';
+ if (e === 'apos') return "'";
+ const code = e[1] === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
+ try {
+ return String.fromCodePoint(code);
+ } catch (err) {
+ return '';
+ }
+ });
+}
+
+/* ── Paragraph content as "pieces" ─────────────────────────────────────────
+ * A paragraph is flattened into pieces in reading order:
+ * atom – one character of text (or a tab / line break / non-breaking hyphen element) with
+ * the run properties and the wrappers (hyperlink, content control, …) it sits in
+ * opaque – anything without text (bookmarks, images, field characters, comments, …); kept
+ * in place, never dropped
+ * `cmp` is the comparable text of a piece (whitespace as one space, soft hyphens as nothing).
+ */
+
+const DOCX_WRAPPERS = new Set(['hyperlink', 'smartTag', 'customXml', 'fldSimple', 'dir', 'bdo']);
+const DOCX_TEXT_LIKE = { tab: ' ', ptab: ' ', br: ' ', cr: ' ', noBreakHyphen: '-', softHyphen: '' };
+
+function docxCmpChar(ch) {
+ if (/^\s$/u.test(ch) || /[ - ]/.test(ch)) return ' ';
+ if (/[]/.test(ch)) return '';
+ if (typeof LATIN_LIGATURES !== 'undefined' && LATIN_LIGATURES[ch]) return LATIN_LIGATURES[ch];
+ return ch;
+}
+
+function flattenParagraph(p, xml, ctx) {
+ const pieces = [];
+ let pPr = '';
+ const chainIds = new Map();
+ const chainFor = (parent, wrapper) => {
+ const ids = parent.map(w => w.id).concat(wrapper.id);
+ const key = ids.join('/');
+ if (!chainIds.has(key)) chainIds.set(key, parent.concat(wrapper));
+ return chainIds.get(key);
+ };
+ const outer = node => xml.slice(node.start, node.end);
+
+ const handleRun = (r, chain) => {
+ const rPrNode = r.children.find(c => xmlLocalName(c) === 'rPr');
+ const rPr = rPrNode ? outer(rPrNode) : '';
+ for (const c of r.children) {
+ const name = xmlLocalName(c);
+ if (name === 'rPr') continue;
+ if (name === 't') {
+ const text = c.selfClosing ? '' : decodeXmlText(xml.slice(c.openEnd, c.closeStart));
+ for (const ch of text) pieces.push({ type: 'atom', ch, rPr, chain, cmp: docxCmpChar(ch) });
+ } else if (name in DOCX_TEXT_LIKE) {
+ pieces.push({ type: 'atom', elem: outer(c), rPr, chain, cmp: DOCX_TEXT_LIKE[name] });
+ } else if (name === 'footnoteReference' || name === 'endnoteReference') {
+ pieces.push({ type: 'atom', elem: outer(c), rPr, chain, cmp: ctx.noteLabels.get(c.start) || '' });
+ } else {
+ // Field characters, field codes, drawings, comment references, …: kept as they are.
+ pieces.push({ type: 'opaque', runChild: outer(c), rPr, chain, cmp: '' });
+ }
+ }
+ };
+
+ const walk = (node, chain) => {
+ for (const c of node.children) {
+ const name = xmlLocalName(c);
+ if (node === p && name === 'pPr') {
+ pPr = outer(c);
+ continue;
+ }
+ if (name === 'r') handleRun(c, chain);
+ else if (DOCX_WRAPPERS.has(name) && !c.selfClosing) {
+ walk(c, chainFor(chain, { id: `w${c.start}`, open: xml.slice(c.start, c.openEnd), close: xml.slice(c.closeStart, c.end) }));
+ } else if (name === 'sdt') {
+ const content = c.children.find(x => xmlLocalName(x) === 'sdtContent');
+ if (!content || content.selfClosing) pieces.push({ type: 'opaque', raw: outer(c), chain, cmp: '' });
+ else walk(content, chainFor(chain, { id: `s${c.start}`, open: xml.slice(c.start, content.openEnd), close: xml.slice(content.closeStart, c.end) }));
+ } else if ((name === 'ins' || name === 'moveTo') && !c.selfClosing) {
+ // Text inserted by an earlier tracked change counts as text (as when it is read in).
+ walk(c, chain);
+ } else {
+ pieces.push({ type: 'opaque', raw: outer(c), chain, cmp: '' });
+ }
+ }
+ };
+ walk(p, []);
+
+ // One comparable space per whitespace run; none at the edges.
+ let prevWs = true;
+ for (const piece of pieces) {
+ if (!piece.cmp) continue;
+ if (piece.cmp === ' ') {
+ if (prevWs) piece.cmp = '';
+ prevWs = true;
+ } else {
+ prevWs = false;
+ }
+ }
+ for (let k = pieces.length - 1; k >= 0; k--) {
+ if (!pieces[k].cmp) continue;
+ if (pieces[k].cmp === ' ') pieces[k].cmp = '';
+ else break;
+ }
+ return { pPr, pieces, text: pieces.map(x => x.cmp).join('') };
+}
+
+/* ── Units: what the reader turned into one block ───────────────────────────
+ * A paragraph outside tables is one unit; a table row is one unit (its cells joined with
+ * " · ", as when the file is read in). Paragraphs nested in text boxes and rows of nested tables
+ * are "locked": they are matched, but never rewritten.
+ */
+function collectDocxUnits(xml, tree) {
+ const noteLabels = new Map();
+ let fn = 0;
+ let en = 0;
+ const noteRe = /<([A-Za-z0-9_]+:)?(footnoteReference|endnoteReference)\b/g;
+ let m;
+ while ((m = noteRe.exec(xml))) {
+ noteLabels.set(m.index, `[${m[2] === 'footnoteReference' ? ++fn : ++en}]`);
+ }
+ const ctx = { noteLabels };
+ const units = [];
+ const paraText = p => flattenParagraph(p, xml, ctx).text;
+
+ const cellParas = (node, out, state) => {
+ for (const c of node.children) {
+ const name = xmlLocalName(c);
+ if (name === 'p') out.push(c);
+ else if (name === 'tbl') state.nested = true;
+ else if (name === 'sdt' || name === 'sdtContent' || name === 'customXml') cellParas(c, out, state);
+ }
+ return out;
+ };
+
+ const walk = (node, locked) => {
+ for (const c of node.children) {
+ const name = xmlLocalName(c);
+ if (name === 'AlternateContent') {
+ const fallback = c.children.find(x => xmlLocalName(x) === 'Fallback');
+ if (fallback) walk(fallback, true);
+ continue;
+ }
+ if (name === 'p') {
+ units.push({ kind: 'p', node: c, locked, text: paraText(c) });
+ walk(c, true);
+ continue;
+ }
+ if (name === 'tbl') {
+ walkTable(c, locked);
+ continue;
+ }
+ walk(c, locked);
+ }
+ };
+
+ const walkTable = (tbl, locked) => {
+ const rows = [];
+ const collectRows = node => {
+ for (const c of node.children) {
+ const name = xmlLocalName(c);
+ if (name === 'tr') rows.push(c);
+ else if (name === 'sdt' || name === 'sdtContent' || name === 'customXml') collectRows(c);
+ }
+ };
+ collectRows(tbl);
+ const tableInfo = { node: tbl, rows: rows.length };
+ for (const tr of rows) {
+ const cells = [];
+ const state = { nested: false };
+ const collectCells = node => {
+ for (const c of node.children) {
+ const name = xmlLocalName(c);
+ if (name === 'tc') cells.push({ node: c, paras: cellParas(c, [], state) });
+ else if (name === 'sdt' || name === 'sdtContent' || name === 'customXml') collectCells(c);
+ }
+ };
+ collectCells(tr);
+ for (const cell of cells) {
+ cell.texts = cell.paras.map(paraText);
+ // As read in: the paragraphs of a cell run together.
+ cell.text = cell.texts.join('');
+ }
+ units.push({
+ kind: 'row',
+ node: tr,
+ table: tableInfo,
+ cells,
+ locked: locked || state.nested,
+ text: cells.map(c => c.text).filter(Boolean).join(' · '),
+ });
+ // Nested tables and text boxes inside the cells (read in after their row).
+ for (const cell of cells) {
+ for (const c of cell.node.children) {
+ if (xmlLocalName(c) === 'tbl') walkTable(c, true);
+ }
+ for (const p of cell.paras) walk(p, true);
+ }
+ }
+ };
+
+ const body = (function find(node) {
+ for (const c of node.children) {
+ if (xmlLocalName(c) === 'body') return c;
+ const r = find(c);
+ if (r) return r;
+ }
+ return null;
+ })(tree);
+ if (!body) throw new Error('docx: no body');
+ walk(body, false);
+ return { units, ctx };
+}
+
+/* ── Matching blocks to units ───────────────────────────────────────────── */
+
+function docxKey(text) {
+ return normalizeComparableText(sanitizeExternalText(String(text || '')))
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/**
+ * How a block's text relates to a unit's text: { prefix } when it is the same text, possibly
+ * after a generated list number or heading number ("(1) ", "§ 1 ") that the file does not
+ * contain as text; null otherwise.
+ */
+function docxMatchBlock(blockKey, unitText) {
+ if (!unitText) return null;
+ if (blockKey === unitText) return { prefix: 0 };
+ if (blockKey.length > unitText.length && blockKey.endsWith(unitText)) {
+ const prefix = blockKey.slice(0, blockKey.length - unitText.length);
+ if (prefix.length <= 24 && / $/.test(prefix)) return { prefix: prefix.length };
+ }
+ return null;
+}
+
+/** Pairs blocks (in order) with non-empty units (in order). Returns Map(blockIndex -> { unit, prefix }). */
+function mapBlocksToUnits(blocks, units) {
+ const LOOK = 40;
+ const keys = blocks.map(b => docxKey(b.text));
+ const list = units.filter(u => u.text);
+ const out = new Map();
+ let i = 0;
+ let j = 0;
+ while (i < keys.length && j < list.length) {
+ const hit = docxMatchBlock(keys[i], list[j].text);
+ if (hit) {
+ out.set(i, { unit: list[j], prefix: hit.prefix });
+ i++;
+ j++;
+ continue;
+ }
+ let step = null;
+ for (let d = 1; d <= LOOK && !step; d++) {
+ if (j + d < list.length && docxMatchBlock(keys[i], list[j + d].text)) step = { di: 0, dj: d };
+ else if (i + d < keys.length && docxMatchBlock(keys[i + d], list[j].text)) step = { di: d, dj: 0 };
+ }
+ if (step) {
+ i += step.di;
+ j += step.dj;
+ } else {
+ i++;
+ j++;
+ }
+ }
+ return out;
+}
+
+/* ── Rewriting one paragraph ────────────────────────────────────────────── */
+
+/**
+ * The target text of a paragraph as characters with revision flags:
+ * { chars: [{ ch, added, dels: [deleted text before this char] }], tail: [deleted text at the end] }.
+ * `prefix` characters of the new wording (a generated list number) are dropped.
+ */
+function docxTargetFromTokens(tokens, prefix) {
+ let chars = [];
+ let pending = [];
+ for (const tok of tokens) {
+ if (tok.removed) {
+ if (tok.value) pending.push(tok.value);
+ continue;
+ }
+ for (const ch of tok.value) {
+ chars.push({ ch, added: Boolean(tok.added), dels: pending });
+ pending = [];
+ }
+ }
+ let tail = pending;
+ if (prefix) {
+ // The generated number is not text in the file: drop it with whatever was deleted before it.
+ const rest = chars.slice(prefix);
+ chars = rest;
+ }
+ // Whitespace as in the file's reading: one space between words, none at the edges.
+ const out = [];
+ let carry = [];
+ for (const c of chars) {
+ const ws = /\s/.test(c.ch);
+ const dels = carry.concat(c.dels);
+ carry = [];
+ if (ws && (!out.length || /\s/.test(out[out.length - 1].ch))) {
+ carry = dels;
+ continue;
+ }
+ out.push({ ch: ws ? ' ' : c.ch, added: c.added, dels });
+ }
+ while (out.length && out[out.length - 1].ch === ' ') {
+ const last = out.pop();
+ carry = last.dels.concat(carry);
+ }
+ tail = carry.concat(tail);
+ return { chars: out, tail };
+}
+
+function docxStripHyperlinkStyle(rPr) {
+ return rPr.replace(/]*\/>/g, '').replace(/\s*<\/w:rPr>/, '');
+}
+
+/**
+ * Returns the new XML of paragraph `p` whose text becomes the target described by `tokens`
+ * (diff tokens of the new wording, see getExportDiffTokens). `rev(kind)` returns the attributes
+ * of a new revision. `markPara` ('ins' | 'del' | '') marks the paragraph mark as inserted/deleted.
+ */
+function rewriteDocxParagraph(p, xml, ctx, tokens, prefix, rev, markPara = '', defaultRPr = '') {
+ const { pPr, pieces } = flattenParagraph(p, xml, ctx);
+ const target = docxTargetFromTokens(tokens, prefix);
+ const tText = target.chars.map(c => c.ch).join('');
+
+ // Comparable text of the file with a map back to the pieces.
+ let xText = '';
+ const xPiece = [];
+ pieces.forEach((piece, idx) => {
+ for (const ch of piece.cmp) {
+ xText += ch;
+ xPiece.push(idx);
+ }
+ });
+
+ const diffLib = getDiffLibrary();
+ let parts;
+ if (xText === tText) parts = [{ value: xText }];
+ else if (diffLib && xText.length + tText.length < 400000) parts = diffLib.diffWordsWithSpace(xText, tText);
+ else parts = [{ value: xText, removed: true }, { value: tText, added: true }].filter(x => x.value);
+
+ const items = []; // { kind: 'text'|'elem'|'raw', text?, xml?, rPr, chain, rev }
+ const lastAtom = { piece: null };
+ let nextPiece = 0;
+ let keptBefore = true;
+
+ const atomAfter = idx => {
+ for (let k = idx; k < pieces.length; k++) if (pieces[k].type === 'atom' && pieces[k].cmp) return pieces[k];
+ return null;
+ };
+ const contextFor = () => {
+ const prev = lastAtom.piece;
+ const next = atomAfter(nextPiece);
+ const base = prev || next;
+ if (!base) return { rPr: defaultRPr, chain: [] };
+ const shared = prev && next && prev.chain === next.chain;
+ if (shared) return { rPr: base.rPr, chain: base.chain };
+ return { rPr: base.chain.length ? docxStripHyperlinkStyle(base.rPr) : base.rPr, chain: [] };
+ };
+ const emitPiece = (piece, revKind) => {
+ if (piece.type === 'opaque') {
+ if (piece.raw) items.push({ kind: 'raw', xml: piece.raw, chain: piece.chain });
+ else items.push({ kind: 'elem', xml: piece.runChild, rPr: piece.rPr, chain: piece.chain, rev: '' });
+ return;
+ }
+ if (piece.elem) items.push({ kind: 'elem', xml: piece.elem, rPr: piece.rPr, chain: piece.chain, rev: revKind });
+ else items.push({ kind: 'text', text: piece.ch, rPr: piece.rPr, chain: piece.chain, rev: revKind });
+ lastAtom.piece = piece;
+ };
+ // Pieces without comparable text up to `limit`: opaque ones always stay; empty atoms (extra
+ // spaces, soft hyphens) stay where the text around them stays.
+ const passThrough = limit => {
+ while (nextPiece < limit) {
+ const piece = pieces[nextPiece++];
+ if (piece.type === 'opaque' || keptBefore) emitPiece(piece, '');
+ }
+ };
+ const emitDeleted = texts => {
+ if (!texts.length) return;
+ const c = contextFor();
+ for (const text of texts) items.push({ kind: 'text', text, rPr: c.rPr, chain: c.chain, rev: 'del' });
+ };
+
+ let xi = 0;
+ let ti = 0;
+ for (const part of parts) {
+ const len = part.value.length;
+ if (part.added) {
+ for (let k = 0; k < len; k++, ti++) {
+ const tc = target.chars[ti];
+ emitDeleted(tc.dels);
+ const c = contextFor();
+ items.push({ kind: 'text', text: tc.ch, rPr: c.rPr, chain: c.chain, rev: tc.added ? 'ins' : '' });
+ }
+ } else if (part.removed) {
+ for (let k = 0; k < len; k++, xi++) {
+ const idx = xPiece[xi];
+ if (idx >= nextPiece) {
+ passThrough(idx);
+ nextPiece = idx + 1;
+ keptBefore = false;
+ }
+ }
+ } else {
+ for (let k = 0; k < len; k++, xi++, ti++) {
+ const idx = xPiece[xi];
+ const tc = target.chars[ti];
+ if (idx >= nextPiece) {
+ passThrough(idx);
+ keptBefore = true;
+ emitDeleted(tc.dels);
+ emitPiece(pieces[idx], tc.added ? 'ins' : '');
+ nextPiece = idx + 1;
+ } else {
+ emitDeleted(tc.dels);
+ }
+ }
+ }
+ }
+ // Deleted text at the very end goes before trailing spaces / bookmarks.
+ emitDeleted(target.tail);
+ keptBefore = true;
+ passThrough(pieces.length);
+
+ return assembleDocxParagraph(p, xml, pPr, items, rev, markPara);
+}
+
+/** Serialises pieces into runs, grouped by wrapper chain and revision. */
+function assembleDocxParagraph(p, xml, pPr, items, rev, markPara) {
+ let body = '';
+ let chain = [];
+ let revOpen = '';
+ let run = null; // { rPr, rev, content }
+
+ const flushRun = () => {
+ if (!run) return;
+ body += `${run.rPr}${run.content}`;
+ run = null;
+ };
+ const setRev = kind => {
+ if (revOpen === kind) return;
+ flushRun();
+ if (revOpen) body += revOpen === 'ins' ? '' : '';
+ revOpen = kind;
+ if (kind) body += kind === 'ins' ? `` : ``;
+ };
+ const setChain = next => {
+ if (next === chain) return;
+ let common = 0;
+ while (common < chain.length && common < next.length && chain[common] === next[common]) common++;
+ if (common === chain.length && common === next.length) return;
+ flushRun();
+ setRev('');
+ for (let k = chain.length - 1; k >= common; k--) body += chain[k].close;
+ for (let k = common; k < next.length; k++) body += next[k].open;
+ chain = next;
+ };
+
+ for (const item of items) {
+ setChain(item.chain);
+ if (item.kind === 'raw') {
+ setRev('');
+ body += item.xml;
+ continue;
+ }
+ setRev(item.rev);
+ const content =
+ item.kind === 'text'
+ ? item.rev === 'del'
+ ? `${escapeXml(item.text)}`
+ : `${escapeXml(item.text)}`
+ : item.xml;
+ if (run && run.rPr === item.rPr && run.rev === item.rev) {
+ // Adjacent text joins one w:t.
+ if (item.kind === 'text' && /<\/w:(t|delText)>$/.test(run.content) && run.lastText) {
+ const tag = item.rev === 'del' ? 'w:delText' : 'w:t';
+ run.content = run.content.slice(0, run.content.length - `${tag}>`.length) + escapeXml(item.text) + `${tag}>`;
+ } else {
+ run.content += content;
+ }
+ } else {
+ flushRun();
+ run = { rPr: item.rPr, rev: item.rev, content };
+ }
+ run.lastText = item.kind === 'text';
+ }
+ setChain([]);
+ setRev('');
+ flushRun();
+
+ const openTag = xml.slice(p.start, p.openEnd).replace(/\/>$/, '>');
+ return `${openTag}${docxMarkParagraph(pPr, markPara, rev)}${body}`;
+}
+
+/** Adds an inserted/deleted mark to the paragraph mark (the pilcrow) in w:pPr. */
+function docxMarkParagraph(pPr, markPara, rev) {
+ if (!markPara) return pPr;
+ const mark = markPara === 'ins' ? `` : ``;
+ if (!pPr) return `${mark}`;
+ if (/^$/.test(pPr)) return `${mark}`;
+ if (//.test(pPr)) return pPr.replace(//, `${mark}`);
+ if (//.test(pPr)) return pPr.replace(//, `${mark}`);
+ const before = pPr.search(//);
+ return `${pPr.slice(0, before)}${mark}${pPr.slice(before)}`;
+}
+
+/** A new paragraph modelled on `template` (a unit paragraph) or a plain one. */
+function buildNewDocxParagraph(xml, ctx, template, tokens, prefix, rev, markPara) {
+ let pPr = '';
+ let rPr = '';
+ if (template) {
+ const tpl = flattenParagraph(template, xml, ctx);
+ pPr = tpl.pPr
+ .replace(//, '')
+ .replace(//, '')
+ .replace(/[\s\S]*?<\/w:rPr>|/, '');
+ const firstAtom = tpl.pieces.find(x => x.type === 'atom' && x.cmp && !x.chain.length);
+ rPr = firstAtom ? firstAtom.rPr : '';
+ }
+ // Rewriting an empty paragraph with the model's properties emits all text in its run format.
+ const fakeXml = `${pPr}`;
+ const fake = parseXmlTree(fakeXml).children[0];
+ return rewriteDocxParagraph(fake, fakeXml, ctx, tokens, prefix, rev, markPara, rPr);
+}
+
+/* ── The whole document ─────────────────────────────────────────────────── */
+
+/**
+ * plan: [{ block (final block; null for an open deletion), oldBlock, r0 (the block as read in from
+ * the file, or null), tokens, open, markPara }] in document order. Returns the new document.xml.
+ */
+function patchDocxDocumentXml(xml, r0Blocks, plan, opts) {
+ const tree = parseXmlTree(xml);
+ const { units, ctx } = collectDocxUnits(xml, tree);
+ const r0Map = mapBlocksToUnits(r0Blocks, units);
+ const unitOfR0 = new Map();
+ r0Blocks.forEach((b, i) => {
+ if (r0Map.has(i)) unitOfR0.set(b, r0Map.get(i));
+ });
+
+ let maxId = 0;
+ for (const mm of xml.matchAll(/\bw:id="(\d+)"/g)) maxId = Math.max(maxId, parseInt(mm[1], 10));
+ let revId = maxId + 1000;
+ const date = toWordDate(opts.date || new Date());
+ const rev = () => `w:id="${revId++}" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}"`;
+
+ const edits = []; // { start, end, text, seq }
+ let seq = 0;
+ const edit = (start, end, text) => edits.push({ start, end, text, seq: seq++ });
+
+ // What the result must read like: every unit of the file with its new text (null = removed),
+ // plus the new paragraphs at their insertion points.
+ const unitText = new Map(units.map(u => [u, u.text]));
+ const inserted = []; // { pos, seq, text }
+
+ const firstUnit = units[0];
+ if (!firstUnit) throw new Error('docx: empty body');
+ const unitStart = u => (u.kind === 'row' ? u.table.node.start : u.node.start);
+ const unitEnd = u => (u.kind === 'row' ? u.table.node.end : u.node.end);
+ let anchor = unitStart(firstUnit);
+
+ const planUnits = plan.map(step => {
+ const hit = step.r0 ? unitOfR0.get(step.r0) : null;
+ return { unit: hit ? hit.unit : null, prefix: hit ? hit.prefix : 0, kind: (step.block || step.oldBlock || {}).kind };
+ });
+ const usedUnits = new Set(planUnits.filter(pu => pu.unit).map(pu => pu.unit));
+ const templateFor = (kind, from) => {
+ const usable = entry => entry && entry.unit && entry.unit.kind === 'p' && !entry.unit.locked;
+ for (let d = 0; d < 60; d++) {
+ for (const k of [from - d, from + d]) if (usable(planUnits[k]) && planUnits[k].kind === kind) return planUnits[k].unit.node;
+ }
+ for (let d = 0; d < 60; d++) {
+ for (const k of [from - d, from + d]) if (usable(planUnits[k])) return planUnits[k].unit.node;
+ }
+ return null;
+ };
+ const targetText = (tokens, prefix) =>
+ docxTargetFromTokens(tokens.filter(x => !x.removed), prefix).chars.map(c => c.ch).join('');
+ const insertAt = (text, expectedText) => {
+ edit(anchor, anchor, text);
+ inserted.push({ pos: anchor - 0.5, seq: seq, text: expectedText });
+ };
+
+ plan.forEach((step, k) => {
+ const pu = planUnits[k];
+ const unit = pu.unit;
+ if (!step.block) {
+ // An open deletion: the old paragraph, struck through, paragraph mark included.
+ const tpl = templateFor(step.oldBlock.kind, k);
+ insertAt(buildNewDocxParagraph(xml, ctx, tpl, [{ value: step.oldBlock.text, removed: true }], 0, rev, 'del'), '');
+ return;
+ }
+ const finalKey = docxKey(step.block.text);
+ const changed = step.open || !step.r0 || docxKey(step.r0.text) !== finalKey;
+ if (!unit) {
+ if (step.r0 && changed) {
+ // A change in something that is not body text of the file (e.g. a footnote).
+ throw new Error('docx: change outside the body');
+ }
+ if (step.r0) return; // unchanged and not in the body: nothing to do
+ // A paragraph that the file does not have (a restored deletion): a new paragraph.
+ const tpl = templateFor(step.block.kind, k);
+ const marker = step.block.numberingMarker;
+ const tplNumbered = tpl && /= 0; k--) {
+ if (edits[k].start >= table.node.start && edits[k].end <= table.node.end) edits.splice(k, 1);
+ }
+ edit(table.node.start, table.node.end, '');
+ removed.push([table.node.start, table.node.end]);
+ }
+ for (const u of units) {
+ if (removed.some(([s, e]) => u.node.start >= s && u.node.end <= e)) unitText.set(u, null);
+ }
+
+ // Apply from the end; edits never overlap (each unit is touched once).
+ edits.sort((a, b) => b.start - a.start || b.seq - a.seq);
+ let out = xml;
+ let limit = Infinity;
+ for (const e of edits) {
+ if (e.end > limit) throw new Error('docx: overlapping edits');
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
+ limit = e.start;
+ }
+
+ // Read the result back: the accepted text of every unit must be exactly what we expect.
+ const norm = s => String(s || '').replace(/\s+/g, ' ').trim();
+ const want = units
+ .map(u => ({ pos: u.node.start, seq: -1, text: unitText.get(u) }))
+ .concat(inserted)
+ .sort((a, b) => a.pos - b.pos || a.seq - b.seq)
+ .map(x => norm(x.text))
+ .filter(Boolean);
+ const got = collectDocxUnits(out, parseXmlTree(out))
+ .units.map(u => norm(u.text))
+ .filter(Boolean);
+ const at = Math.max(got.length, want.length) === 0 ? -1 : got.findIndex((text, i) => text !== want[i]);
+ if (got.length !== want.length || at >= 0) {
+ const i = at >= 0 ? at : Math.min(got.length, want.length);
+ throw new Error(`docx: verification failed at unit ${i}: ${JSON.stringify(got[i])} vs ${JSON.stringify(want[i])}`);
+ }
+ return out;
+}
+
+/** Rewrites the changed cells of a table row; returns the expected row text. */
+function rewriteDocxRow(unit, xml, ctx, step, rev, edit) {
+ const newCells = docxKey(step.block.text).split(' · ');
+ const oldCells = step.oldBlock ? docxKey(step.oldBlock.text).split(' · ') : null;
+ const filled = unit.cells.filter(c => c.text);
+ const cellTokens = (oldText, newText) =>
+ step.open ? (oldText === null ? [{ value: newText, added: true }] : computeCleanWordDiff(oldText, newText)) : [{ value: newText }];
+ const writeCell = (cell, tokens) => {
+ const paras = cell.paras.filter((p, i) => cell.texts[i]);
+ const first = paras[0] || cell.paras[0];
+ if (!first) throw new Error('docx: cell without paragraph');
+ edit(first.start, first.end, rewriteDocxParagraph(first, xml, ctx, tokens, 0, rev, ''));
+ for (const other of paras.slice(1)) edit(other.start, other.end, rewriteDocxParagraph(other, xml, ctx, [], 0, rev, ''));
+ };
+ if (newCells.length === filled.length) {
+ newCells.forEach((text, i) => {
+ const cell = filled[i];
+ const oldText = oldCells && oldCells.length === newCells.length ? oldCells[i] : null;
+ if (cell.text === text && (!step.open || oldText === text)) return;
+ writeCell(cell, cellTokens(oldText, text));
+ });
+ return newCells.join(' · ');
+ }
+ // The number of filled cells changed: the whole row text goes into the first cell.
+ const target = filled[0] || unit.cells[0];
+ if (!target) throw new Error('docx: row without cells');
+ writeCell(target, step.open && step.oldBlock ? computeCleanWordDiff(docxKey(step.oldBlock.text), docxKey(step.block.text)) : [{ value: docxKey(step.block.text) }]);
+ for (const cell of filled) if (cell !== target) writeCell(cell, []);
+ return docxKey(step.block.text);
+}
+
+/* ── Entry point ────────────────────────────────────────────────────────── */
+
+/**
+ * Builds the export from the original .docx of the new version.
+ * source: { bytes, importedText }; finalBlocks: blocks of the current new version (all of them);
+ * rows: state.alignedRows (old ↔ new); kind: 'final' | 'tracked'.
+ * Returns a Uint8Array (.docx) or throws when the result cannot be verified.
+ */
+async function buildDocxFromSource(source, finalBlocks, rows, kind, date = new Date()) {
+ const entries = await readZipEntries(source.bytes);
+ const docEntry = entries.find(e => e.name === 'word/document.xml');
+ if (!docEntry) throw new Error('docx: no word/document.xml');
+ const xml = new TextDecoder('utf-8').decode(docEntry.data);
+ const r0Blocks = parseIntoBlocks(source.importedText);
+
+ // Old ↔ new rows by the position of the new block; final blocks ↔ blocks as read in.
+ const rowOfFinal = new Map();
+ for (const row of rows) {
+ if (!row.rightBlock) continue;
+ const block = finalBlocks[row.rightBlock.index - 1];
+ if (!block || block.text !== row.rightBlock.text) throw new Error('docx: comparison is not current');
+ rowOfFinal.set(row.rightBlock.index, row);
+ }
+ const r0Rows = alignDocumentBlocks(r0Blocks, finalBlocks);
+ const r0OfFinal = new Map();
+ for (const r of r0Rows) if (r.leftBlock && r.rightBlock) r0OfFinal.set(r.rightBlock, r.leftBlock);
+
+ const plan = [];
+ const pushDeleted = row => {
+ if (kind !== 'tracked' || !row || row.type !== 'deleted' || row.reviewStatus === 'accepted' || !row.leftBlock) return;
+ if (row.leftBlock.isHeaderFooter) return;
+ plan.push({ block: null, oldBlock: row.leftBlock, r0: null, open: true });
+ };
+ let rowCursor = 0;
+ for (const block of finalBlocks) {
+ const row = rowOfFinal.get(block.index);
+ if (row) {
+ // Open deletions that sit before this row in the comparison.
+ const at = rows.indexOf(row);
+ for (; rowCursor < at; rowCursor++) pushDeleted(rows[rowCursor]);
+ rowCursor = at + 1;
+ }
+ const open = kind === 'tracked' && row && row.type !== 'equal' && row.reviewStatus !== 'accepted';
+ const tokens = open ? getExportDiffTokens(row) : [{ value: cleanExportText(block.text) }];
+ plan.push({
+ block,
+ oldBlock: row ? row.leftBlock : null,
+ r0: r0OfFinal.get(block) || null,
+ tokens,
+ open,
+ markPara: open && row.type === 'inserted' ? 'ins' : '',
+ });
+ }
+ for (; rowCursor < rows.length; rowCursor++) pushDeleted(rows[rowCursor]);
+
+ const outXml = patchDocxDocumentXml(xml, r0Blocks, plan, { date });
+ const enc = new TextEncoder();
+ const files = entries.map(e => (e.name === 'word/document.xml' ? { name: e.name, data: enc.encode(outXml) } : e));
+ return buildZipArchive(files, date);
+}
+
/* ── Import errors & notices ───────────────────────────────────────────── */
// Error with a translatable message key (shown to the user instead of a raw exception).
@@ -12085,6 +13407,7 @@
return { kept, used };
}
+// The original .docx of a side (for the Word export in the original format), as base64.
+function bytesToBase64(bytes) {
+ let bin = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ return btoa(bin);
+}
+function sessionSourceOf(side) {
+ const src = state.docSources && state.docSources[side];
+ const name = side === 'left' ? state.leftName : state.rightName;
+ const format = side === 'left' ? state.leftFormat : state.rightFormat;
+ if (!src || format !== 'DOCX' || src.name !== name) return undefined;
+ return { kind: 'docx', data: bytesToBase64(src.bytes), importedText: src.importedText };
+}
+
function buildSessionPayload() {
const redo = takeRecentHistory(state.redoStack, SESSION_HISTORY_CHAR_BUDGET);
const undo = takeRecentHistory(state.undoStack, SESSION_HISTORY_CHAR_BUDGET - redo.used);
@@ -13514,12 +14857,14 @@