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 - @@ -3695,7 +3782,7 @@

So funktioniert’s

  1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
  2. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
  3. -
  4. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.
  5. +
  6. Ergebnis sichern, wenn Sie es brauchen Als 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.

Tastenkürzel

@@ -4246,6 +4357,7 @@

Ihre Daten

exportTitleFmt: 'Vergleich: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', + exportCountsFmtOne: '1 Änderung ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', exportReviewFmt: '{k} von {n} geprüft ({a} angenommen, {r} abgelehnt)', exportLabelOriginal: 'Alte Fassung', exportLabelRevised: 'Neue Fassung', @@ -4257,9 +4369,9 @@

Ihre Daten

exportLabelDetail: 'Angabe', exportScopeFull: 'Vollständiges Dokument', exportScopeChanges: 'Nur Änderungen (Filter „Nur Änderungen“ aktiv, unveränderte Absätze ausgelassen)', - exportHfIgnored: 'ausgeblendet (nicht verglichen)', - exportHfIgnoredCount: 'ausgeblendet ({n} Zeilen nicht verglichen)', - exportHfIncluded: 'mitverglichen', + exportHfIgnored: 'unveränderte ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIgnoredCount: '{n} unveränderte Zeilen ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIncluded: 'vollständig mitverglichen', exportSummaryHeading: 'Zusammenfassung', exportCountTotal: 'Änderungen gesamt', exportChangesHeading: 'Änderungsübersicht', @@ -4280,8 +4392,10 @@

Ihre Daten

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 @@

Ihre Daten

movedFromHere: 'Moved – from here', movedToHere: 'Moved – to here', moreDeltas: 'more changes', + moreDeltasOne: 'more change', pageLabel: 'Page', pageShort: 'p.', pageOf: 'of', @@ -4530,7 +4664,11 @@

Reading

Reviewing

-

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.

Keyboard shortcuts

@@ -4619,6 +4757,7 @@

Your data

exportTitleFmt: 'Comparison: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} changes ({mod} changed, {ins} added, {del} removed)', + exportCountsFmtOne: '1 change ({mod} changed, {ins} added, {del} removed)', exportReviewFmt: '{k} of {n} reviewed ({a} accepted, {r} rejected)', exportLabelOriginal: 'Old version', exportLabelRevised: 'New version', @@ -4630,9 +4769,9 @@

Your data

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 @@

Your data

sectionRef: currentSectionRef, text: line, kind, + headingMarker, indent: entry.indent, isHeading, isHeaderFooter, @@ -6380,9 +6528,11 @@

Your data

} } - const cleaned = mergedNums - .filter(tok => tok.value.length > 0) - .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })); + const cleaned = mergeFragmentedChanges( + mergedNums + .filter(tok => tok.value.length > 0) + .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })) + ); if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned; // Never show a diff that does not reproduce the source texts: fall back to the raw diff. @@ -6391,6 +6541,84 @@

Your data

); } +/** + * Joins changes that only an unchanged hyphen or space separates into one replacement, so a + * rewritten phrase reads as one change instead of misleading word pairs: + * "[Standard]→[Echtzeit]-[Berichtswesen]→[Nachtragsmanagement]" becomes + * "[Standard-Berichtswesen]→[Echtzeit-Nachtragsmanagement]". The joined glue goes into both + * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of + * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared + * by both sides at the edges stays unchanged. + */ +function mergeFragmentedChanges(parts) { + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const glued = []; + for (let i = 0; i < parts.length; i++) { + const cur = parts[i]; + const isGlue = + !isChange(cur) && + isChange(glued[glued.length - 1]) && + isChange(parts[i + 1]) && + (/^\s+$/.test(cur.value) || !/\s/.test(cur.value)); + if (isGlue) { + glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true }); + } else { + glued.push(cur); + } + } + + const out = []; + const pushEqual = value => { + if (!value) return; + const last = out[out.length - 1]; + if (last && !isChange(last)) last.value += value; + else out.push({ value }); + }; + let i = 0; + while (i < glued.length) { + if (!isChange(glued[i])) { + pushEqual(glued[i].value); + i++; + continue; + } + let rem = ''; + let add = ''; + while (i < glued.length && isChange(glued[i])) { + if (glued[i].removed) rem += glued[i].value; + else add += glued[i].value; + i++; + } + let lead = ''; + let trail = ''; + if (rem && add) { + const leadRem = rem.match(/^\s*/)[0]; + const leadAdd = add.match(/^\s*/)[0]; + while (lead.length < Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) { + lead += leadRem[lead.length]; + } + rem = rem.slice(lead.length); + add = add.slice(lead.length); + const trailRem = rem.match(/\s*$/)[0]; + const trailAdd = add.match(/\s*$/)[0]; + let n = 0; + while ( + n < Math.min(trailRem.length, trailAdd.length) && + trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n] + ) { + n++; + } + trail = n ? rem.slice(rem.length - n) : ''; + rem = rem.slice(0, rem.length - n); + add = add.slice(0, add.length - n); + } + pushEqual(lead); + if (rem) out.push({ value: rem, removed: true }); + if (add) out.push({ value: add, added: true }); + pushEqual(trail); + } + return out; +} + function coalesceSideTokens(sideParts, prop) { const out = []; for (let i = 0; i < sideParts.length; i++) { @@ -7410,6 +7638,7 @@

Your data

state.rightName = t('sampleRightName'); state.rightFormat = 'DEMO'; state.rightText = sample.v2; + state.docSources = { left: null, right: null }; resetEditHistory(); runComparison(); } @@ -7420,11 +7649,13 @@

Your data

state.leftText = ''; state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; document.getElementById('file-input-left').value = ''; } else { state.rightText = ''; state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; resetEditHistory(); document.getElementById('file-input-right').value = ''; } @@ -7797,6 +8028,11 @@

Your data

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 @@

Your data

const pageLabel = pages.length > 1 ? `${t('pageShort')} ${Math.min(...pages)}–${Math.max(...pages)}` : `${t('pageShort')} ${page}`; const head = item.members ? `${ref} · ${pageLabel}` : [ref, pageLabel].filter(Boolean).join(' · '); const delta = renderSidebarDeltaHtml(item); - const moreHtml = delta.more > 0 ? `, ${delta.more} ${escapeHtml(t('moreDeltas'))}` : ''; + const moreHtml = delta.more > 0 ? `, ${delta.more} ${escapeHtml(moreDeltasText(delta.more))}` : ''; return ``; }) .join('') @@ -8022,7 +8258,7 @@

Your data

// Replaced at build time by scripts/build.py with jsdiff + 04-core.js + 04a-align.js + // src/worker/compare-worker.js as a string literal. -const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n return metaLine(meta.join(';'), block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n if (!isHeaderFooter) {\n seenContentBlock = true;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }));\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; +const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n // A heading marker (\"## \") from the source stays, so a rewritten text keeps its structure.\n return metaLine(meta.join(';'), (block.headingMarker || '') + block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n let headingMarker = '';\n if (!isHeaderFooter) {\n seenContentBlock = true;\n const m = line.match(/^#{1,3}\\s+/);\n if (m) headingMarker = `${m[0].trim()} `;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n headingMarker,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergeFragmentedChanges(\n mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }))\n );\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\n/**\n * Joins changes that only an unchanged hyphen or space separates into one replacement, so a\n * rewritten phrase reads as one change instead of misleading word pairs:\n * \"[Standard]\u2192[Echtzeit]-[Berichtswesen]\u2192[Nachtragsmanagement]\" becomes\n * \"[Standard-Berichtswesen]\u2192[Echtzeit-Nachtragsmanagement]\". The joined glue goes into both\n * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of\n * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared\n * by both sides at the edges stays unchanged.\n */\nfunction mergeFragmentedChanges(parts) {\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n const glued = [];\n for (let i = 0; i \u003c parts.length; i++) {\n const cur = parts[i];\n const isGlue =\n !isChange(cur) &&\n isChange(glued[glued.length - 1]) &&\n isChange(parts[i + 1]) &&\n (/^\\s+$/.test(cur.value) || !/\\s/.test(cur.value));\n if (isGlue) {\n glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true });\n } else {\n glued.push(cur);\n }\n }\n\n const out = [];\n const pushEqual = value =\u003e {\n if (!value) return;\n const last = out[out.length - 1];\n if (last && !isChange(last)) last.value += value;\n else out.push({ value });\n };\n let i = 0;\n while (i \u003c glued.length) {\n if (!isChange(glued[i])) {\n pushEqual(glued[i].value);\n i++;\n continue;\n }\n let rem = '';\n let add = '';\n while (i \u003c glued.length && isChange(glued[i])) {\n if (glued[i].removed) rem += glued[i].value;\n else add += glued[i].value;\n i++;\n }\n let lead = '';\n let trail = '';\n if (rem && add) {\n const leadRem = rem.match(/^\\s*/)[0];\n const leadAdd = add.match(/^\\s*/)[0];\n while (lead.length \u003c Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) {\n lead += leadRem[lead.length];\n }\n rem = rem.slice(lead.length);\n add = add.slice(lead.length);\n const trailRem = rem.match(/\\s*$/)[0];\n const trailAdd = add.match(/\\s*$/)[0];\n let n = 0;\n while (\n n \u003c Math.min(trailRem.length, trailAdd.length) &&\n trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n]\n ) {\n n++;\n }\n trail = n ? rem.slice(rem.length - n) : '';\n rem = rem.slice(0, rem.length - n);\n add = add.slice(0, add.length - n);\n }\n pushEqual(lead);\n if (rem) out.push({ value: rem, removed: true });\n if (add) out.push({ value: add, added: true });\n pushEqual(trail);\n }\n return out;\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; // Small inputs are compared synchronously: alignment takes a few milliseconds and the result is // on screen in the same frame (no flash of the previous comparison, and synchronous callers such @@ -9766,6 +10002,7 @@

Your data

serializeBlockLine({ text: line.trim(), kind: template ? template.kind : 'p', + headingMarker: template ? template.headingMarker : '', indent: template ? template.indent : 0, isHeaderFooter: Boolean(target && target.rightBlock && target.rightBlock.isHeaderFooter), }) @@ -9827,6 +10064,7 @@

Your data

serializeBlockLine({ text: line.trim(), kind: row.rightBlock.kind, + headingMarker: row.rightBlock.headingMarker, indent: row.rightBlock.indent, isHeaderFooter: Boolean(row.rightBlock.isHeaderFooter), }) @@ -10300,32 +10538,34 @@

Your data

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 @@

Your data

return docxRun(tok.value, false); }) .join(''); - paragraphs.push(`${pPr}${runs}`); + paragraphs.push({ pPr, runs }); + } + if (!paragraphs.length) paragraphs.push({ pPr: '', runs: '' }); + + if (meta.comment && meta.comment.length) { + const first = paragraphs[0]; + first.runs = + '' + + first.runs + + ''; } const sectPr = @@ -10362,7 +10611,23 @@

Your data

'\n' + '' + - `${paragraphs.join('')}${sectPr}` + `${paragraphs.map(p => `${p.pPr}${p.runs}`).join('')}${sectPr}` + ); +} + +/** The Word comment with the summary of a comparison (one paragraph per line). */ +function buildDocxCommentsXml(meta) { + const date = toWordDate(meta.date); + const paras = meta.comment + .map((line, i) => + `${i === 0 ? '' : ''}${docxRun(line, false, i === 0 ? '' : '')}` + ) + .join(''); + return ( + '\n' + + '' + + `${paras}` + + '' ); } @@ -10371,9 +10636,7 @@

Your data

'\n' + '' + - '' + - docxRun(meta.footer) + - '' + + '' + docxRun(`${meta.pageLabel} `) + '1' + docxRun(` ${meta.pageOfLabel} `) + @@ -10417,7 +10680,8 @@

Your data

function buildDocxPackageFiles(rows, meta) { const langTag = meta.lang === 'de' ? 'de-DE' : 'en-US'; const created = toWordDate(meta.date); - return [ + const hasComment = Boolean(meta.comment && meta.comment.length); + const files = [ { name: '[Content_Types].xml', data: @@ -10429,6 +10693,7 @@

Your data

'' + '' + '' + + (hasComment ? '' : '') + '' + '' + '', @@ -10452,6 +10717,7 @@

Your data

'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' + `${escapeXml(meta.title)}` + `${escapeXml(meta.subject)}` + + (meta.description ? `${escapeXml(meta.description)}` : '') + `${DOCX_AUTHOR}` + `${DOCX_AUTHOR}` + `${langTag}` + @@ -10474,6 +10740,7 @@

Your data

'' + '' + '' + + (hasComment ? '' : '') + '', }, { name: 'word/document.xml', data: buildDocxDocumentXml(rows, meta) }, @@ -10489,37 +10756,66 @@

Your data

}, { 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 @@

Your data

if (p.ins) return renderReportTokensHtml([{ value: p.ins, added: true }]); return renderReportTokensHtml([{ value: p.del, removed: true }]); }); - if (more) items.push(`+${more} ${escapeHtml(t('moreDeltas'))}`); + if (more) items.push(`+${more} ${escapeHtml(moreDeltasText(more))}`); return items.map(html => `
${html}
`).join(''); } @@ -10914,13 +11211,22 @@

Your data

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 @@

Your data

.map((item, i) => { const n = i + 1; const ref = getExportItemRef(item) || `#${n}`; - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; - const target = item.ghost && item.type === 'inserted' ? 0 : anchorIndex.get(item.rowId); + const target = itemMembers(item).some(m => anchorIndex.get(m.rowId) === n) ? n : 0; const refHtml = target ? `${escapeHtml(ref)}` : escapeHtml(ref); - return `${n}${refHtml}
${escapeHtml(t('pageShort'))} ${page}
${escapeHtml(getRowTypeLabel(item))}${escapeHtml(getExportStatusLabel(item.status))}${renderReportSummaryPairsHtml(item)}`; + return `${n}${refHtml}
${escapeHtml(getExportItemPageLabel(item))}
${escapeHtml(getRowTypeLabel(item))}${escapeHtml(getExportStatusLabel(item.status))}${renderReportSummaryPairsHtml(item)}`; }) .join('\n')} @@ -10951,7 +11256,7 @@

Your data

const inner = renderReportTokensHtml(getExportDiffTokens(row)); const tag = kind === 'h1' ? 'h3' : kind === 'h2' ? 'h4' : kind === 'h3' ? 'h5' : 'p'; const tagText = getRowReportTag(row); - const idAttr = anchorIndex.has(row.id) ? ` id="${anchorFor(row)}"` : ''; + const idAttr = anchorFor(row); const tagHtml = tagText ? `${escapeHtml(tagText)}` : ''; return `
${tagHtml}<${tag}>${inner}
`; }) @@ -11092,7 +11397,7 @@

${escapeHtml(t('exportRedlineHeading'))}

const counts = getExportCounts(items); const changeIndex = new Map(); items.forEach((item, i) => { - if (!item.ghost && !changeIndex.has(item.rowId)) changeIndex.set(item.rowId, i + 1); + for (const m of itemMembers(item)) if (!m.ghost && !changeIndex.has(m.rowId)) changeIndex.set(m.rowId, i + 1); }); const reviewLine = getExportReviewLine(counts); const lines = [ @@ -11126,11 +11431,10 @@

${escapeHtml(t('exportRedlineHeading'))}

if (p.ins) return renderMarkdownTokens([{ value: p.ins, added: true }]); return renderMarkdownTokens([{ value: p.del, removed: true }]); }) - .concat(more ? [`+${more} ${escapeMarkdownInline(t('moreDeltas'))}`] : []) + .concat(more ? [`+${more} ${escapeMarkdownInline(moreDeltasText(more))}`] : []) .join('
'); - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; lines.push( - `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${page} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` + `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${getExportItemPages(item)} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` ); } } else { @@ -11176,11 +11480,16 @@

${escapeHtml(t('exportRedlineHeading'))}

return `${html}`; } -/** 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)}`; + } + const indent = block.indent > 0 ? ` style="margin-left:${Math.min(block.indent, 6) * 1.6}em"` : ''; + const split = extractListMarker(text, kind); + if (split) { + return `

${escapeHtml(split.marker)}${escapeHtml(split.body)}

`; + } + return `

${escapeHtml(text)}

`; + }) + .join(''); + return `
${html}
`; +} + +/** Blocks of the new version as it stands now, without running headers and footers. */ +function getFinalBlocks() { + const blocks = state.alignedRows.length + ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock) + : parseIntoBlocks(state.rightText || ''); + return blocks.filter(b => !b.isHeaderFooter && b.kind !== 'hf' && !isInternalMarkerLine(b.text)); +} + +// The print view requested from the export menu; Ctrl/Cmd+P prints the current view. +let requestedPrintKind = null; + function preparePrintView() { if (typeof ensureComparisonCurrent === 'function') ensureComparisonCurrent(); if (!state.alignedRows.length) return; + const kind = requestedPrintKind || (state.viewMode === 'inline' ? 'inline' : 'split'); const date = new Date(); let view = document.getElementById('print-view'); if (!view) { @@ -11272,7 +11616,7 @@

${escapeHtml(t('exportRedlineHeading'))}

view.setAttribute('aria-hidden', 'true'); document.body.appendChild(view); } - setSafeContainerHtml(view, buildPrintViewHtml(date)); + setSafeContainerHtml(view, buildPrintViewHtml(date, kind)); let pageStyle = document.getElementById('print-page-style'); if (!pageStyle) { @@ -11280,15 +11624,18 @@

${escapeHtml(t('exportRedlineHeading'))}

pageStyle.id = 'print-page-style'; document.head.appendChild(pageStyle); } - const footerLeft = `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; - const orientation = state.viewMode === 'inline' ? 'portrait' : 'landscape'; - pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: 14mm 12mm 16mm; + const footerLeft = + kind === 'final' ? getDisplayDocName('right') : `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; + const orientation = kind === 'split' ? 'landscape' : 'portrait'; + const margin = kind === 'final' ? '22mm 20mm 20mm 24mm' : '14mm 12mm 16mm'; + pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: ${margin}; @bottom-left { content: ${cssString(footerLeft)}; font: 7.5pt sans-serif; color: #444; } @bottom-right { content: ${cssString(`${t('pageLabel')} `)} counter(page) ${cssString(` ${t('pageOf')} `)} counter(pages); font: 7.5pt sans-serif; color: #444; } } }`; document.body.classList.add('has-print-view'); } function cleanupPrintView() { + requestedPrintKind = null; document.body.classList.remove('has-print-view'); const view = document.getElementById('print-view'); if (view) view.remove(); @@ -11344,6 +11691,55 @@

${escapeHtml(t('exportRedlineHeading'))}

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 + 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 `); + 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 - ``.length) + escapeXml(item.text) + ``; + } 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 @@

${escapeHtml(t('exportRedlineHeading'))}

} else if (kind === 'docx') { result = await extractDocxText(buffer); result.format = 'DOCX'; + result.sourceBytes = bytes; } else { const { text, warnings } = decodeTextFile(bytes, ext); const looksLikeHtml = /^\s*(?:])/i.test(text.slice(0, 2048)); @@ -12124,7 +13447,7 @@

${escapeHtml(t('exportRedlineHeading'))}

fnLabel.textContent = `${t('readingFile')} ${file.name}…`; try { - const { text, format, warnings } = await readDocumentFile(file); + const { text, format, warnings, sourceBytes } = await readDocumentFile(file); if (isStale()) { if (seq === uploadSeq[side]) restoreLabel(); return false; @@ -12139,6 +13462,7 @@

${escapeHtml(t('exportRedlineHeading'))}

state.rightFormat = format; state.rightText = text; } + state.docSources[side] = sourceBytes ? { name: file.name, bytes: sourceBytes, importedText: text } : null; resetEditHistory(); if (warnings && warnings.length) { showImportNotice(`${file.name}: ${warnings.join(' ')}`, 'warning'); @@ -12328,6 +13652,7 @@

${escapeHtml(t('exportRedlineHeading'))}

trigger = trigger || document.getElementById(MENU_TRIGGERS[id]); trigger.setAttribute('aria-expanded', 'true'); openMenuState = { id, trigger }; + if (id === 'export-menu') updateExportMenuTexts(); document.body.classList.toggle('menu-sheet-open', isPhoneWidth()); menuItems(menu).forEach(item => (item.tabIndex = -1)); if (focus) { @@ -12773,6 +14098,7 @@

${escapeHtml(t('exportRedlineHeading'))}

state.rightName = ''; state.leftFormat = ''; state.rightFormat = ''; + state.docSources = { left: null, right: null }; state.activeRowId = null; state.hoveredRowId = null; state.sidebarFilter = 'all'; @@ -12846,6 +14172,7 @@

${escapeHtml(t('exportRedlineHeading'))}

[state.leftText, state.rightText] = [state.rightText, state.leftText]; [state.leftName, state.rightName] = [state.rightName, state.leftName]; [state.leftFormat, state.rightFormat] = [state.rightFormat, state.leftFormat]; + state.docSources = { left: state.docSources.right, right: state.docSources.left }; resetEditHistory(); runComparison(); btnFiles.focus(); @@ -12880,11 +14207,13 @@

${escapeHtml(t('exportRedlineHeading'))}

state.leftText = sanitizeExternalText(textareaLeft.value); state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; } if (rightChanged) { state.rightText = sanitizeExternalText(textareaRight.value); state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; } if (leftChanged || rightChanged) resetEditHistory(); if (state.leftText.trim() && !state.leftName) { @@ -13155,7 +14484,7 @@

${escapeHtml(t('exportRedlineHeading'))}

* entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.5.0'; +const DOCDIFF_APP_VERSION = '1.6.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -13500,6 +14829,20 @@

${escapeHtml(t('exportRedlineHeading'))}

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 @@

${escapeHtml(t('exportRedlineHeading'))}

name: state.leftName, format: state.leftFormat, text: state.leftText, + source: sessionSourceOf('left'), }, right: { name: state.rightName, format: state.rightFormat, originalText: sessionTracker.originalRightText, text: state.rightText, + source: sessionSourceOf('right'), }, }, view: { @@ -13640,6 +14985,22 @@

${escapeHtml(t('exportRedlineHeading'))}

}); } +function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Optional original .docx of a side: { kind: 'docx', data (base64), importedText }. +function expectSource(value, name) { + if (value === undefined) return null; + if (!isPlainObject(value) || value.kind !== 'docx') throw new SessionFileError('sessionErrorInvalid'); + const data = expectString(value.data, SESSION_MAX_FILE_BYTES); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) throw new SessionFileError('sessionErrorInvalid'); + return { name, bytes: base64ToBytes(data), importedText: expectString(value.importedText, SESSION_MAX_FILE_BYTES) }; +} + // Returns a fresh, fully validated object; throws SessionFileError otherwise. function validateSessionData(raw) { if (!isPlainObject(raw) || raw.format !== SESSION_FORMAT_ID) { @@ -13684,12 +15045,14 @@

${escapeHtml(t('exportRedlineHeading'))}

name: expectString(docs.left.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.left.format), text: expectString(docs.left.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.left.source, docs.left.name), }, right: { name: expectString(docs.right.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.right.format), originalText: expectString(docs.right.originalText, SESSION_MAX_FILE_BYTES), text: expectString(docs.right.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.right.source, docs.right.name), }, view: { mode: view.mode === 'pages' ? 'split' : view.mode, @@ -13737,6 +15100,7 @@

${escapeHtml(t('exportRedlineHeading'))}

state.rightName = session.right.name; state.rightFormat = session.right.format; state.rightText = session.right.text; + state.docSources = { left: session.left.source || null, right: session.right.source || null }; state.reviews = cloneReviews(session.reviews); state.undoStack = session.history.undo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); state.redoStack = session.history.redo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); diff --git a/docs/launch/1-review.html b/docs/launch/1-review.html new file mode 100644 index 0000000..9372f7d --- /dev/null +++ b/docs/launch/1-review.html @@ -0,0 +1,92 @@ + + + + + + + + + +
+
Doc Diffdocdiff.app
+

Review every change like in Word.

+

Go through the changes one by one: accept, reject or rewrite. The new version updates as you decide.

+
+
✓Accept what is fine
+
✕Reject and get the old wording back
+
✎Edit the text right in place
+
+
+
+
+
+ Contract_v1.pdf → Contract_v2.docx +
12 of 20 reviewed
+
+
+
+

1. Scope of Services

+
(1) Delivery, training and documentation as per the specification dated April 18, 2026.Accepted
+
(2) Change requests must be made in writing.Rejected · old wording kept
+

2. Fees

+
+
✓ Accept✕ Reject✎ Edit
+ (1) Fixed price of EUR 145,000.00 EUR 168,500.00 net, payable within 14 30 days of invoice. +
+
(2) Travel expenses are reimbursed at cost up to EUR 2,000 per quarter.
+

4. Liability

+
(3) Liability for slight negligence is limited to twice the contract value.
+
+
+
Changes 20
+
1. (1) · p. 1
…, training and documentation…
✓
+
1. (2) · p. 1
…made by email
✕
+
2. (1) · p. 2
Fixed price of EUR 145,000.00 EUR 168,500.00
+
2. (2) · p. 2
…at cost up to EUR 2,000…
+
4. (3) · p. 3
…limited to twice the…
+
5. (1) · p. 3
Liability for consequential…
+
+
+
+ + diff --git a/docs/launch/1-review.png b/docs/launch/1-review.png new file mode 100644 index 0000000..e368297 Binary files /dev/null and b/docs/launch/1-review.png differ diff --git a/docs/launch/2-export.html b/docs/launch/2-export.html new file mode 100644 index 0000000..154cce9 --- /dev/null +++ b/docs/launch/2-export.html @@ -0,0 +1,93 @@ + + + + + + + + + +
+
+
Doc Diffdocdiff.app
+

Export the final version in your original Word format.

+

Your decisions go straight into your own .docx: styles, numbering, links and footnotes stay as they are.

+
+
+
+
+
DOCXFinal versionclean
+
+

Framework Agreement

+
1. Scope of Services
+

(1) Delivery, training and documentation as per the specification dated April 18, 2026.1

+
  • Module A: quantity take-off
  • Module B: cost estimate
+
2. Fees
+

(1) Fixed price of EUR 168,500.00 net, payable within 30 days of invoice.

+

(2) Travel expenses are reimbursed at cost up to EUR 2,000 per quarter.

+
+

Ready to send. Accepted, rejected and edited changes, nothing else.

+
+
+
DOCXTracked changesredline
+
+

Framework Agreement

+
1. Scope of Services
+

(1) Delivery, training and documentation as per the specification dated March 12April 18, 2026.1

+
  • Module A: quantity take-off
  • Module B: cost estimate
+
2. Fees
+

(1) Fixed price of EUR 145,000.00EUR 168,500.00 net, payable within 1430 days of invoice.

+

(2) Travel expenses are reimbursed at cost up to EUR 2,000 per quarter.

+
+

Open in Word and accept or reject what is still open.

+
+
+
PDFComparisonside by side or inline
+
+
+
+
Old · Contract_v1.pdf
+
1. (1) Delivery and documentation as per the specification dated March 12, 2026.
+
2. (1) Fixed price of EUR 145,000.00 net, payable within 14 days.
+
4. (3) Liability for consequential damages is excluded.
+
+
+
New · Contract_v2.docx
+
1. (1) Delivery, training and documentation as per the specification dated April 18, 2026.
+
2. (1) Fixed price of EUR 168,500.00 net, payable within 30 days.
+
2. (4) Adjusted to the CPI from month 13 on.
+
+
+
+

For the record. Every change marked, with page and clause numbers.

+
+
+ + diff --git a/docs/launch/2-export.png b/docs/launch/2-export.png new file mode 100644 index 0000000..c960913 Binary files /dev/null and b/docs/launch/2-export.png differ diff --git a/docs/launch/3-private.html b/docs/launch/3-private.html new file mode 100644 index 0000000..83fc8dd --- /dev/null +++ b/docs/launch/3-private.html @@ -0,0 +1,65 @@ + + + + + + + + + +
+
Doc Diffdocdiff.app
+

Your contracts never leave your browser.

+

Doc Diff reads and compares everything on your device. No upload, no account, no tracking. Safe for confidential documents.

+
+
Works offlineOne HTML file: download it and use it without internet.
+
Open sourceMIT licensed. Read every line of the code yourself.
+
FreeNo sign-up, no limits, no paywall.
+
Save your reviewContinue later from a .docdiff file on your disk.
+
+
+
+
+
+
+
docdiff.app
+
+
+
Network while comparing Contract_v1.pdf ↔ v2.docx
+
0bytes of your documents sent anywhere
+
File uploadnone
+
Analytics & cookiesnone
+
Third-party requestsblocked by policy
+
+
+
PDFDOCXTXTMDHTML
+

Compare any two of them, even a PDF against a Word file.

+
+ + diff --git a/docs/launch/3-private.png b/docs/launch/3-private.png new file mode 100644 index 0000000..cfc788c Binary files /dev/null and b/docs/launch/3-private.png differ diff --git a/docs/launch/README.md b/docs/launch/README.md new file mode 100644 index 0000000..f67d0d5 --- /dev/null +++ b/docs/launch/README.md @@ -0,0 +1,17 @@ +# Launch gallery images + +Gallery images for product launch listings (1270x760, rendered at 2x = 2540x1520). + +| Image | Source | Message | +|---|---|---| +| `1-review.png` | `1-review.html` | Review every change: accept, reject, edit | +| `2-export.png` | `2-export.html` | Export in the original Word format, tracked changes, PDF | +| `3-private.png` | `3-private.html` | Runs locally: no upload, offline, open source | + +Render (macOS, from this folder): + +```bash +for f in 1-review 2-export 3-private; do "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --hide-scrollbars --allow-file-access-from-files --force-device-scale-factor=2 --window-size=1270,760 --screenshot="$PWD/$f.png" "file://$PWD/$f.html"; done +``` + +The link preview images are `../og-image.html` and `../og-image-en.html`. diff --git a/docs/launch/launch.css b/docs/launch/launch.css new file mode 100644 index 0000000..e651d6d --- /dev/null +++ b/docs/launch/launch.css @@ -0,0 +1,37 @@ +/* Shared styles of the product launch gallery images (docs/launch/*.html). + Each page is 1270x760 CSS pixels; render with device scale factor 2 (see README.md). */ +@font-face { font-family: Geist; src: url('../../vendor/fonts/Geist-Variable.woff2') format('woff2'); font-weight: 100 900; } +@font-face { font-family: 'Geist Mono'; src: url('../../vendor/fonts/GeistMono-Variable.woff2') format('woff2'); font-weight: 100 900; } +:root { + --bg: hsl(0 0% 97.5%); --fg: hsl(215 20% 16%); --muted: hsl(215 20% 30.2%); --faint: hsl(215 12% 46%); --border: hsl(0 0% 87.4%); + --primary: hsl(212.8 100% 36%); + --del-bg: hsl(347.4 100% 93.5%); --del-ink: hsl(351 100% 34%); --del-ring: hsl(347.4 100% 35% / .22); + --ins-bg: hsl(134.9 61.7% 93.5%); --ins-ink: hsl(138.7 77.3% 22%); --ins-ring: hsl(134.9 61.7% 43.3% / .25); +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { width: 1270px; height: 760px; overflow: hidden; } +body { + font-family: Geist, system-ui, sans-serif; color: var(--fg); background: var(--bg); + background-image: radial-gradient(hsl(215 20% 16% / .09) 1px, transparent 1px); background-size: 24px 24px; + -webkit-font-smoothing: antialiased; padding: 56px 64px; +} +.brand { display: flex; align-items: center; gap: 12px; font-size: 24px; font-weight: 700; letter-spacing: -0.02em; } +.brand img { width: 36px; height: 36px; } +.brand .url { font: 500 15px 'Geist Mono', monospace; color: var(--primary); padding: 3px 9px; border: 1px solid hsl(212.8 100% 36% / .25); background: hsl(212.8 100% 36% / .06); border-radius: 8px; margin-left: 4px; } +.kicker { font: 600 15px 'Geist Mono', monospace; letter-spacing: .04em; text-transform: uppercase; color: var(--primary); } +h1 { font-size: 54px; line-height: 1.12; font-weight: 720; letter-spacing: -0.035em; } +.sub { font-size: 20px; line-height: 1.45; color: var(--muted); } +del, ins { padding: 0 3px; border-radius: 3px; } +del { background: var(--del-bg); color: var(--del-ink); box-shadow: inset 0 0 0 1px var(--del-ring); text-decoration: line-through; } +ins { background: var(--ins-bg); color: var(--ins-ink); box-shadow: inset 0 0 0 1px var(--ins-ring); text-decoration: none; font-weight: 600; } +h1 del, h1 ins { padding: 0 .12em; border-radius: .12em; box-shadow: inset 0 0 0 2px var(--del-ring); } +h1 ins { box-shadow: inset 0 0 0 2px var(--ins-ring); } +h1 del { font-weight: 520; text-decoration-thickness: .07em; } +.chips { display: flex; gap: 10px; flex-wrap: wrap; } +.chips span { white-space: nowrap; font-size: 15px; font-weight: 500; padding: 7px 13px; border: 1px solid var(--border); background: #fff; border-radius: 999px; } +.chips span::before { content: '✓'; color: var(--ins-ink); margin-right: 7px; font-weight: 700; } +.win { background: #fff; border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 28px 70px -28px hsl(215 30% 20% / .32); overflow: hidden; } +.bar { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 14px; } +.dots { display: flex; gap: 6px; } .dots i { width: 10px; height: 10px; border-radius: 50%; background: hsl(0 0% 85%); display: block; } +.btn { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; font-weight: 600; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff; } +.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } diff --git a/docs/og-image-en.html b/docs/og-image-en.html new file mode 100644 index 0000000..89abc93 --- /dev/null +++ b/docs/og-image-en.html @@ -0,0 +1,132 @@ + + + + + + + + +
+
Doc Diffdocdiff.app
+

Compare
documents
by hand easily.

+

PDF, Word or text: every change is marked word by word. Everything runs locally in your browser.

+
Free & open sourceNo uploadsNo tracking
+
+
+
+
+
4 changes
+
Side by sideStacked
+
+
+
Old versionContract_v1.pdf
+
New versionContract_v2.docx
+
+
+
+

1. Scope of Services

(1) Delivery and documentation as per the specification dated March 12, 2026. Change requests must be made in writing.
+

2. Fees

(1) Fixed price of EUR 145,000.00 net, payable within 14 days.
+

4. (3) Liability

Liability for consequential damages is excluded.
+
.
+
+
+

1. Scope of Services

(1) Delivery, training and documentation as per the specification dated April 18, 2026. Change requests must be made by email.
+

2. Fees

(1) Fixed price of EUR 168,500.00 net, payable within 30 days.
+
.
+

2. (4) Price Adjustment

Adjusted to the CPI from month 13 on.
+
+
+
+ + diff --git a/docs/og-image.html b/docs/og-image.html index cfaf10b..7db485c 100644 --- a/docs/og-image.html +++ b/docs/og-image.html @@ -106,7 +106,7 @@

Dokumente
mühsam einfach
vergleichen.

4 Änderungen
-
SeitenNebeneinanderUntereinander
+
NebeneinanderUntereinander
Alte FassungVertrag_v1.pdf
diff --git a/docs/releases/v1.6.0.md b/docs/releases/v1.6.0.md new file mode 100644 index 0000000..40c1f63 --- /dev/null +++ b/docs/releases/v1.6.0.md @@ -0,0 +1,32 @@ +# v1.6.0 — Final version in your own Word format, trustworthy exports + +This release is about what you take away from a review: the final version, ready to send, in the format of your own document. + +## Export the final version + +- **Word in the original format.** When the new version is a `.docx`, Doc Diff writes your decisions straight into that file: accepted changes stay, rejected changes get the old wording back, and your own edits are applied. Styles, numbering, links, footnotes, tables and page layout stay as they are; paragraphs you did not touch stay unchanged. Before the file is saved, Doc Diff reads it back and checks the text. If a change cannot be written safely (for example inside a footnote or a text box), it creates a clean Word document instead and tells you. +- **Word with tracked changes on your original.** The same, with every still-open change as a real Word revision: accept or reject the rest in Word or LibreOffice. "Reject all" gives back the old version, "Accept all" the final one. +- **PDF of the final version**: a clean document without marks. +- **Copy or save as text**: the final version as plain text. + +## Export the comparison + +- **PDF side by side** (landscape, old and new in two columns) or **PDF inline** (portrait, changes marked in the text). +- **Clearer export menu**: "Final version" and "Comparison", each with a one-line explanation of what you get. HTML report, Markdown and text are under "More formats". + +## Trust + +- **Exports count exactly like the change list.** A header or footer change that repeats on every page is one change everywhere, with its page range ("p. 1–3"). +- **Word documents contain only your document.** The summary and header/footer changes go into a Word comment instead of the text, so "Accept all" leaves a clean document. +- **No misleading pairs.** Changes joined only by a hyphen or a space now read as one replacement ("Standard-Berichtswesen" → "Echtzeit-Nachtragsmanagement, Preisspiegel") instead of fragments paired word by word. +- **Headings stay headings** after you reject a change. +- **Saved work keeps the Word file**, so the original-format export still works after you open a `.docdiff` file again. + +## Text + +- Fixed a broken sentence in the help, added a help section on exporting, and updated the FAQ and export wording (German and English). +- New link preview images for the German and the English page. + +## Offline version + +Download `DocDiff.html` below and open it from disk. It works without an internet connection. diff --git a/scripts/build.py b/scripts/build.py index 2930119..774b0cf 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -52,6 +52,7 @@ "asset_base": "", "og_locale": "de_DE", "og_locale_alt": "en_US", + "og_image": SITE_URL + "og-image.png?v=3", "outputs": [os.path.join("site", "index.html"), "DocDiff.html"], }, "en": { @@ -60,6 +61,7 @@ "asset_base": "../", "og_locale": "en_US", "og_locale_alt": "de_DE", + "og_image": SITE_URL + "og-image-en.png?v=1", "outputs": [os.path.join("site", "en", "index.html")], }, } @@ -169,7 +171,7 @@ def json_ld(template: str, lang: str, strings: dict, version: str) -> str: "featureList": [strings[key] for key in features], "downloadUrl": OFFLINE_URL, "softwareHelp": {"@type": "CreativeWork", "url": REPO_URL + "#readme"}, - "image": SITE_URL + "og-image.png?v=2", + "image": page["og_image"], "author": author, "sameAs": [REPO_URL], } @@ -220,6 +222,7 @@ def render_page(template: str, lang: str, i18n: dict, version: str) -> str: .replace("__ASSET_BASE__", page["asset_base"]) .replace("__OG_LOCALE__", page["og_locale"]) .replace("__OG_LOCALE_ALT__", page["og_locale_alt"]) + .replace("__OG_IMAGE__", page["og_image"]) .replace("__LANG_LABEL__", "English" if lang == "de" else "Deutsch") .replace("__JSON_LD__", json_ld(template, lang, strings, version)) ) diff --git a/scripts/check.mjs b/scripts/check.mjs index 177b303..7a461df 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -130,7 +130,7 @@ for (const doc of ['README.md', 'SECURITY.md']) { assert.ok(appJsCode.includes('isEvalSupported: false'), 'PDF.js getDocument() must pass isEvalSupported: false'); // 5. Verify site legal and domain files for docdiff.app -for (const siteFile of ['CNAME', 'og-image.png', 'impressum.html', 'datenschutz.html', 'legal.css', 'robots.txt', 'sitemap.xml', '.nojekyll']) { +for (const siteFile of ['CNAME', 'og-image.png', 'og-image-en.png', 'impressum.html', 'datenschutz.html', 'legal.css', 'robots.txt', 'sitemap.xml', '.nojekyll']) { const p = path.join(root, 'site', siteFile); assert.ok(fs.existsSync(p), `Missing required site file: site/${siteFile}`); } @@ -180,7 +180,10 @@ for (const page of [ const alternates = Object.fromEntries([...head.matchAll(/ [m[1], m[2]])); assert.deepEqual(alternates, { de: SITE, en: `${SITE}en/`, 'x-default': SITE }, `${where}: hreflang alternates`); assert.equal(metaContent(head, 'property', 'og:url'), url, `${where}: og:url`); - assert.equal(metaContent(head, 'property', 'og:image'), `${SITE}og-image.png?v=2`, `${where}: og:image`); + // Each language has its own link preview image. + const ogImage = lang === 'en' ? `${SITE}og-image-en.png?v=1` : `${SITE}og-image.png?v=3`; + assert.equal(metaContent(head, 'property', 'og:image'), ogImage, `${where}: og:image`); + assert.equal(metaContent(head, 'name', 'twitter:image'), ogImage, `${where}: twitter:image`); // Title and description: from i18n, sensible lengths const title = decodeEntities(head.match(/([^<]*)<\/title>/)[1]); diff --git a/scripts/test-app-state.mjs b/scripts/test-app-state.mjs index 242124d..e9bd3ed 100644 --- a/scripts/test-app-state.mjs +++ b/scripts/test-app-state.mjs @@ -270,7 +270,7 @@ let groups = 0; // 8. Saved work: version 2 carries the review status; version 1 files still open. { const session = read('11-session.js'); - const ctx = {}; + const ctx = { atob }; vm.createContext(ctx); const consts = session.split('\n').filter(l => /^const (SESSION_|REVIEW_TYPES).*;$/.test(l)).join('\n'); const start = session.indexOf('class SessionFileError'); @@ -320,6 +320,14 @@ let groups = 0; bad({ ...v2, reviews: [{ ...rejected, pageNum: 0 }] }); bad({ ...v2, reviews: [{ ...rejected, leftText: 5 }] }); bad({ ...v2, reviews: 'x' }); + // The original .docx of a side travels along (Word export in the original format). + const withSource = side => ({ ...v2, documents: { ...v2.documents, [side]: { ...v2.documents[side], source: { kind: 'docx', data: 'UEsDBA==', importedText: 'B' } } } }); + const rs = ok(withSource('right')); + assert.deepEqual([...rs.right.source.bytes], [0x50, 0x4b, 3, 4], 'source bytes decoded'); + assert.equal(rs.right.source.name, 'b', 'source belongs to its document'); + assert.equal(rs.left.source, null, 'no source on the other side'); + bad({ ...withSource('left'), documents: { ...v2.documents, left: { ...v2.documents.left, source: { kind: 'pdf', data: '', importedText: '' } } } }); + bad({ ...withSource('left'), documents: { ...v2.documents, left: { ...v2.documents.left, source: { kind: 'docx', data: 'no base64!', importedText: '' } } } }); assert.equal(({}).status, undefined, 'no prototype pollution'); groups++; } diff --git a/scripts/test-core.mjs b/scripts/test-core.mjs index 86038dd..99b4ecf 100644 --- a/scripts/test-core.mjs +++ b/scripts/test-core.mjs @@ -91,6 +91,36 @@ test('word diff: delete-only next to a number keeps the number unchanged', () => assert.ok(parts.some(p => !p.removed && !p.added && p.value.includes('14'))); }); +test('word diff: changes joined only by a hyphen or a space read as one replacement', () => { + const parts = assertInvariant( + 'Modul C: Standard-Berichtswesen und Preisspiegel-Export als PDF-Dokument', + 'Modul C: Echtzeit-Nachtragsmanagement, Preisspiegel und automatisierter Soll-Ist-Abgleich' + ); + assert.deepEqual(plain(parts), [ + { value: 'Modul C: ' }, + { value: 'Standard-Berichtswesen', removed: true }, + { value: 'Echtzeit-Nachtragsmanagement, Preisspiegel', added: true }, + { value: ' und ' }, + { value: 'Preisspiegel-Export als PDF-Dokument', removed: true }, + { value: 'automatisierter Soll-Ist-Abgleich', added: true }, + ]); +}); + +test('word diff: a single replaced word next to unchanged punctuation stays a single word', () => { + assert.deepEqual(plain(assertInvariant('Gerichtsstand ist Berlin.', 'Gerichtsstand ist München.')), [ + { value: 'Gerichtsstand ist ' }, + { value: 'Berlin', removed: true }, + { value: 'München', added: true }, + { value: '.' }, + ]); + assert.deepEqual(plain(assertInvariant('EKT-Aufgliederung und Zuschlag', 'EKT-Kalkulation und Zuschlag')), [ + { value: 'EKT-' }, + { value: 'Aufgliederung', removed: true }, + { value: 'Kalkulation', added: true }, + { value: ' und Zuschlag' }, + ]); +}); + test('word diff: partial number replacements expand to the whole number', () => { const cases = [ ['Betrag 145.000,00 EUR', 'Betrag 146.000,00 EUR', '145.000,00', '146.000,00'], @@ -706,6 +736,14 @@ test('hf filter: identical running lines stay hidden, filter off shows everythin assert.equal(compare(a, b, false).changes.length, 1); }); +test('a rewritten text keeps the heading markers of the source (no heading turns into a paragraph)', () => { + const blocks = core.parseIntoBlocks('# Vertrag\n\n## Allgemeines\n\nText.\n\nOhne Marker'); + const lines = blocks.map(b => core.serializeBlockLine(b)); + assert.deepEqual([...lines.slice(0, 3)], ['# Vertrag', '## Allgemeines', 'Text.']); + assert.ok(!lines[3].startsWith('#'), 'no marker is added where the source had none'); + assert.equal(core.parseIntoBlocks(lines.join("\n\n")).map(b => b.kind).join(), blocks.map(b => b.kind).join()); +}); + for (const f of failures) console.error(f); if (failures.length) { console.error(`\n${failures.length} core test(s) failed, ${passed} passed.`); diff --git a/scripts/test-export.mjs b/scripts/test-export.mjs index 6100c4e..30b4e68 100644 --- a/scripts/test-export.mjs +++ b/scripts/test-export.mjs @@ -22,6 +22,7 @@ const sources = [ 'src/js/06d-review.js', 'src/js/08-export-docx.js', 'src/js/08-export.js', + 'src/js/08b-export-docx-source.js', ]; const harness = ` @@ -31,6 +32,7 @@ const state = { filteredHfCount: 0, viewMode: 'split', rightAllBlocks: [], }; function t(key) { return I18N[state.lang][key] || I18N.de[key] || key; } +function formatMsg(key, params = {}) { return t(key).replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : '')); } function compare(leftText, rightText, opts = {}) { Object.assign(state, { onlyChanges: false, ignoreHeadersFooters: true }, opts); const l = parseIntoBlocks(leftText); @@ -50,6 +52,10 @@ function compare(leftText, rightText, opts = {}) { const context = { console, TextEncoder, + TextDecoder, + Blob, + Response, + DecompressionStream, URL, Intl, navigator: { language: 'de-DE', languages: ['de-DE'] }, @@ -96,8 +102,8 @@ function readZip(bytes) { /** Text of each body paragraph after accepting (or rejecting) all tracked changes. */ function resolveRevisions(documentXml, mode) { - const body = documentXml.split('<w:body>')[1]; - const paragraphs = body.match(/<w:p>[\s\S]*?<\/w:p>/g) || []; + const body = documentXml.split(/<w:body>/)[1]; + const paragraphs = body.match(/<w:p(?: [^>]*)?>[\s\S]*?<\/w:p>/g) || []; const out = []; for (let p of paragraphs) { const markDeleted = /<w:pPr>.*?<w:rPr><w:del /.test(p); @@ -116,8 +122,7 @@ function resolveRevisions(documentXml, mode) { } const normalizeSpace = s => s.replace(/\s+/g, ' ').trim(); -/** Title + "DocDiffMeta" paragraphs before the document body. */ -const metaParagraphCount = documentXml => 1 + (documentXml.match(/<w:pStyle w:val="DocDiffMeta"\/>/g) || []).length; +const isHf = block => Boolean(block && (block.isHeaderFooter || block.kind === 'hf')); let checks = 0; /* ── File names ───────────────────────────────────────────────────────────── */ @@ -156,7 +161,7 @@ for (const lang of ['de', 'en']) { // DOCX: valid ZIP, required parts, accept-all == new version, reject-all == old version. const files = readZip(api.buildDocxRedline(new Date('2026-10-22T12:00:00Z'))); - for (const part of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/styles.xml', 'word/settings.xml', 'word/_rels/document.xml.rels', 'word/footer1.xml', 'docProps/core.xml', 'docProps/app.xml']) { + for (const part of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/styles.xml', 'word/settings.xml', 'word/_rels/document.xml.rels', 'word/footer1.xml', 'word/comments.xml', 'docProps/core.xml', 'docProps/app.xml']) { assert.ok(files.has(part), `${label}: docx part ${part}`); assert.ok(files.get(part).startsWith('<?xml'), `${label}: ${part} has XML declaration`); } @@ -166,15 +171,28 @@ for (const lang of ['de', 'en']) { assert.match(doc, /<w:pStyle w:val="Heading2"\/>/); const ids = [...doc.matchAll(/<w:(?:ins|del) w:id="(\d+)"/g)].map(m => m[1]); assert.equal(new Set(ids).size, ids.length, `${label}: revision ids are unique`); - const metaParagraphs = metaParagraphCount(doc); - assert.equal(metaParagraphs, 1 + 5 + 1, `${label}: title, five facts, note (nothing reviewed)`); - const accepted = resolveRevisions(doc, 'accept').slice(metaParagraphs).map(normalizeSpace); - const rejected = resolveRevisions(doc, 'reject').slice(metaParagraphs).map(normalizeSpace); + // The body is the document text only: no cover block, no running headers/footers. + assert.ok(!doc.includes('DocDiffMeta'), `${label}: no summary block in the body`); + const accepted = resolveRevisions(doc, 'accept').map(normalizeSpace); + const rejected = resolveRevisions(doc, 'reject').map(normalizeSpace); // Arrays created inside the vm realm have a different prototype; copy them for deepEqual. - const expectNew = [...state.alignedRows.filter(r => r.rightBlock).map(r => normalizeSpace(r.rightBlock.text))]; - const expectOld = [...state.alignedRows.filter(r => r.leftBlock).map(r => normalizeSpace(r.leftBlock.text))]; + const bodyRows = state.alignedRows.filter(r => !isHf(r.rightBlock || r.leftBlock)); + const expectNew = [...bodyRows.filter(r => r.rightBlock).map(r => normalizeSpace(r.rightBlock.text))]; + const expectOld = [...bodyRows.filter(r => r.leftBlock).map(r => normalizeSpace(r.leftBlock.text))]; assert.deepEqual(accepted, expectNew, `${label}: Accept all yields the new version`); assert.deepEqual(rejected, expectOld, `${label}: Reject all yields the old version`); + // The summary (and the header/footer changes, which are not body text) is a Word comment. + const comments = files.get('word/comments.xml'); + assert.ok(comments.includes(api.escapeXml(api.formatExportCountsLine(api.getExportCounts()))), `${label}: counts in the comment`); + assert.equal(doc.includes('<w:commentReference w:id="0"/>'), true, `${label}: comment anchored in the body`); + assert.ok(files.get('[Content_Types].xml').includes('/word/comments.xml'), `${label}: comment part registered`); + const hfChanged = state.changeRows.some(r => isHf(r.rightBlock || r.leftBlock)); + assert.equal(comments.includes(api.escapeXml(api.t('docxHfHeading'))), hfChanged, `${label}: header/footer changes listed in the comment`); + // Final version: no marks, no comment, exactly the new body text. + const finalFiles = readZip(api.buildGeneratedDocx('final', new Date('2026-10-22T12:00:00Z'))); + const finalDoc = finalFiles.get('word/document.xml'); + assert.ok(!/<w:(ins|del) /.test(finalDoc) && !finalFiles.has('word/comments.xml'), `${label}: final version has no marks`); + assert.deepEqual(resolveRevisions(finalDoc, 'accept').map(normalizeSpace), expectNew, `${label}: final version text`); // HTML report. for (const onlyChanges of [false, true]) { @@ -185,10 +203,14 @@ for (const lang of ['de', 'en']) { assert.ok(!/<script/i.test(html), 'report contains no script'); assert.ok(!/https?:\/\//.test(html), 'report contains no external URL'); assert.ok(html.includes(api.t(onlyChanges ? 'exportScopeChanges' : 'exportScopeFull')), 'report states scope'); - for (const row of state.changeRows) { - const n = state.changeRows.indexOf(row) + 1; + const items = api.getExportItems(); + assert.equal(items.length, state.listItems.length, `${label}: report lists the changes as the list does`); + items.forEach((item, i) => { + const n = i + 1; assert.ok(html.includes(`href="#c${n}"`) && html.includes(`id="c${n}"`), `report anchor c${n}`); - } + }); + assert.equal(new Set([...html.matchAll(/ id="(c\d+)"/g)].map(m => m[1])).size, [...html.matchAll(/ id="(c\d+)"/g)].length, `${label}: anchor ids unique`); + assert.ok(html.includes(api.formatExportCountsLine(api.getExportCounts())) || html.includes(`<strong>${items.length}</strong>`), `${label}: count as in the list`); assert.ok(html.includes(`<span class="sr">${api.t('a11yRemovedStart')} </span>`)); const tagBalance = tag => (html.match(new RegExp(`<${tag}>`, 'g')) || []).length === (html.match(new RegExp(`</${tag}>`, 'g')) || []).length; assert.ok(tagBalance('del') && tagBalance('ins'), 'balanced del/ins'); @@ -300,10 +322,10 @@ checks += 2; vm.runInContext('state.reviews = {};', context); api.__compareFull(sample.v1, sample.v2); const date = new Date('2026-10-22T12:00:00Z'); - // Exports count every occurrence (the list merges identical header/footer changes). + // Exports count the changes as the list does (identical header/footer changes are one). const total = api.getExportItems().length; - assert.equal(total, state.changeRows.length, `${lang}: one export item per change row`); - assert.ok(state.listItems.length < total, `${lang}: repeated header/footer changes are one list item`); + assert.equal(total, state.listItems.length, `${lang}: one export item per list item`); + assert.ok(total < state.changeRows.length, `${lang}: repeated header/footer changes are one change`); assert.ok(state.listItems.some(i => i.members && i.members.length > 1), `${lang}: merged header/footer item`); // Nothing reviewed: identical to an export without review semantics. @@ -326,12 +348,12 @@ checks += 2; const files = readZip(api.buildDocxRedline(date)); const doc = files.get('word/document.xml'); - const metaCount = metaParagraphCount(doc); - assert.equal(metaCount, 1 + 6 + 1, `${lang}: review line among the facts`); - assert.ok(doc.includes(api.escapeXml(api.tf('exportReviewFmt', { k: 7, n: total, a: 4, r: 3 }))), `${lang}: review summary in the Word file`); - const acceptAll = resolveRevisions(doc, 'accept').slice(metaCount).map(normalizeSpace); - const rejectAll = resolveRevisions(doc, 'reject').slice(metaCount).map(normalizeSpace); - const rows = [...state.alignedRows]; + const counts2 = api.getExportCounts(); + const reviewLine = api.tf('exportReviewFmt', { k: counts2.accepted + counts2.rejected, n: total, a: counts2.accepted, r: counts2.rejected }); + assert.ok(files.get('word/comments.xml').includes(api.escapeXml(reviewLine)), `${lang}: review summary in the Word comment`); + const acceptAll = resolveRevisions(doc, 'accept').map(normalizeSpace); + const rejectAll = resolveRevisions(doc, 'reject').map(normalizeSpace); + const rows = [...state.alignedRows].filter(r => !isHf(r.rightBlock || r.leftBlock)); const expectAccept = rows.filter(r => r.rightBlock).map(r => normalizeSpace(r.rightBlock.text)); const expectReject = []; for (const r of rows) { @@ -368,4 +390,70 @@ checks += 2; } } +/* ── Word export in the original format (the new version is a .docx) ─────────────────── + * The file is the base: untouched paragraphs stay byte for byte, changed ones keep the run + * formatting of the words that stay; the result is verified before it is returned. */ +await (async () => { + const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; + const P = { + heading: '<w:p w14:paraId="1"><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Vertrag</w:t></w:r></w:p>', + bold: '<w:p><w:r><w:t xml:space="preserve">Der </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>Auftragnehmer</w:t></w:r><w:r><w:t xml:space="preserve"> liefert bis 12.03.2026.</w:t></w:r></w:p>', + link: '<w:p><w:bookmarkStart w:id="7" w:name="lh"/><w:hyperlink r:id="rIdLink"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>Lastenheft</w:t></w:r></w:hyperlink><w:bookmarkEnd w:id="7"/><w:r><w:t xml:space="preserve"> gilt.</w:t></w:r></w:p>', + numbered: '<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>Leistungen werden erbracht.</w:t></w:r></w:p>', + tab: '<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Preis:</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>100 EUR</w:t></w:r></w:p>', + table: '<w:tbl><w:tblPr/><w:tr><w:tc><w:p><w:r><w:t>A</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>B</w:t></w:r></w:p></w:tc></w:tr></w:tbl>', + }; + const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<w:document ${W} xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"><w:body>${Object.values(P).join('')}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body></w:document>`; + const bytes = api.buildZipArchive([ + { name: '[Content_Types].xml', data: '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>' }, + { name: 'word/document.xml', data: documentXml }, + { name: 'word/media/image1.png', data: new Uint8Array([137, 80, 78, 71, 0, 1, 2]) }, + ]); + // What the reader makes of it: list numbers and headings are added as text. + const imported = ['# Vertrag', 'Der Auftragnehmer liefert bis 12.03.2026.', 'Lastenheft gilt.', '(1) Leistungen werden erbracht.', 'Preis: 100 EUR', 'A · B'].join('\n\n'); + const old = ['# Vertrag', 'Der Auftragnehmer liefert bis 01.02.2026.', 'Lastenheft gilt.', 'Alt entfällt.', '(1) Leistungen werden erbracht.', 'Preis: 90 EUR', 'A · C'].join('\n\n'); + // Edited one paragraph, rejected the price change (the old wording is back). + const current = imported.replace('Lastenheft gilt.', 'Lastenheft gilt ab sofort.').replace('Preis: 100 EUR', 'Preis: 90 EUR'); + const finalBlocks = api.parseIntoBlocks(current); + const rows = api.alignDocumentBlocks(api.parseIntoBlocks(old), finalBlocks); + const source = { bytes, importedText: imported }; + const date = new Date('2026-10-22T12:00:00Z'); + + const read = async out => { + const entries = await api.readZipEntries(out); + return { doc: new TextDecoder().decode(entries.find(e => e.name === 'word/document.xml').data), entries }; + }; + const paraTexts = (doc, mode) => resolveRevisions(doc, mode).map(normalizeSpace).filter(Boolean); + + // Final version. + const fin = await read(await api.buildDocxFromSource(source, finalBlocks, rows, 'final', date)); + assert.ok(fin.entries.some(e => e.name === 'word/media/image1.png' && e.data.length === 7), 'other parts are kept'); + for (const key of ['heading', 'bold', 'numbered', 'table']) assert.ok(fin.doc.includes(P[key]), `final: unchanged ${key} paragraph is byte-identical`); + assert.ok(!/<w:(ins|del) /.test(fin.doc), 'final: no revision marks'); + assert.deepEqual(paraTexts(fin.doc, 'accept'), ['Vertrag', 'Der Auftragnehmer liefert bis 12.03.2026.', 'Lastenheft gilt ab sofort.', 'Leistungen werden erbracht.', 'Preis: 90 EUR', 'A', 'B'], 'final: text'); + assert.match(fin.doc, /<w:hyperlink r:id="rIdLink"><w:r><w:rPr><w:rStyle w:val="Hyperlink"\/><\/w:rPr><w:t xml:space="preserve">Lastenheft<\/w:t><\/w:r><\/w:hyperlink>/, 'final: the link stays a link'); + assert.ok(fin.doc.includes('<w:bookmarkStart w:id="7" w:name="lh"/>'), 'final: bookmarks stay'); + assert.match(fin.doc, /<w:i\/><\/w:rPr><w:t xml:space="preserve">Preis:<\/w:t><\/w:r><w:r><w:tab\/>(?:<\/w:r><w:r>)?<w:t xml:space="preserve">90 EUR/, 'final: italics and the tab stay'); + assert.ok(fin.doc.includes('<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr>'), 'final: page layout stays'); + + // Tracked changes on the original. + const tr = await read(await api.buildDocxFromSource(source, finalBlocks, rows, 'tracked', date)); + assert.ok(tr.doc.includes(P.heading) && tr.doc.includes(P.numbered), 'tracked: unchanged paragraphs are byte-identical'); + assert.deepEqual(paraTexts(tr.doc, 'accept'), ['Vertrag', 'Der Auftragnehmer liefert bis 12.03.2026.', 'Lastenheft gilt ab sofort.', 'Leistungen werden erbracht.', 'Preis: 90 EUR', 'A', 'B'], 'tracked: Accept all = final version'); + assert.deepEqual(paraTexts(tr.doc, 'reject'), ['Vertrag', 'Der Auftragnehmer liefert bis 01.02.2026.', 'Lastenheft gilt.', 'Alt entfällt.', 'Leistungen werden erbracht.', 'Preis: 90 EUR', 'A', 'C'], 'tracked: Reject all = old version'); + assert.match(tr.doc, /<w:b\/><\/w:rPr><w:t xml:space="preserve">Auftragnehmer<\/w:t>/, 'tracked: bold word keeps its formatting'); + assert.match(tr.doc, /<w:del w:id="\d+" w:author="Doc Diff" w:date="2026-10-22T12:00:00Z"><w:r><w:delText xml:space="preserve">01\.02\.2026<\/w:delText>/, 'tracked: deletion'); + assert.match(tr.doc, /<w:pPr><w:rPr><w:del [^>]*\/><\/w:rPr><\/w:pPr><w:del [^>]*><w:r><w:delText xml:space="preserve">Alt entfällt\.<\/w:delText>/, 'tracked: removed paragraph with deleted paragraph mark'); + const ids = [...tr.doc.matchAll(/<w:(?:ins|del) w:id="(\d+)"/g)].map(m => m[1]); + assert.equal(new Set(ids).size, ids.length, 'tracked: revision ids unique'); + assert.ok(ids.every(id => Number(id) > 7), 'tracked: revision ids do not clash with bookmark ids'); + + // A change the file cannot carry (text that is not in the body) is refused, never dropped. + const withNote = `${imported}\n\nFußnotentext.`; + const noteFinal = api.parseIntoBlocks(`${current}\n\nGeänderter Fußnotentext.`); + const noteRows = api.alignDocumentBlocks(api.parseIntoBlocks(old), noteFinal); + await assert.rejects(api.buildDocxFromSource({ bytes, importedText: withNote }, noteFinal, noteRows, 'final', date), /outside the body/); + checks += 6; +})(); + console.log(`✓ Export builder tests passed (${checks} check groups).`); diff --git a/site/en/index.html b/site/en/index.html index d66f008..644c2ff 100644 --- a/site/en/index.html +++ b/site/en/index.html @@ -45,16 +45,16 @@ <meta property="og:locale:alternate" content="de_DE" /> <meta property="og:title" content="Doc Diff – Compare PDF and Word documents without uploading" /> <meta property="og:description" content="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." /> - <meta property="og:image" content="https://docdiff.app/og-image.png?v=2" /> + <meta property="og:image" content="https://docdiff.app/og-image-en.png?v=1" /> <meta property="og:image:type" content="image/png" /> <meta property="og:image:width" content="2400" /> <meta property="og:image:height" content="1260" /> - <meta property="og:image:alt" content="Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green (German interface)." /> + <meta property="og:image:alt" content="Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green." /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="Doc Diff – Compare PDF and Word documents without uploading" /> <meta name="twitter:description" content="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." /> - <meta name="twitter:image" content="https://docdiff.app/og-image.png?v=2" /> - <meta name="twitter:image:alt" content="Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green (German interface)." /> + <meta name="twitter:image" content="https://docdiff.app/og-image-en.png?v=1" /> + <meta name="twitter:image:alt" content="Doc Diff: two versions of a contract side by side, removed text struck through in red, added text marked green." /> <!-- Structured data (schema.org). A data block: browsers never execute it. --> <script type="application/ld+json"> @@ -93,7 +93,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-en.png?v=1", "author": { "@type": "Person", "name": "Tobias Schnorpfeil", @@ -102,7 +102,7 @@ "sameAs": [ "https://github.com/tschnorpfeil/docdiff" ], - "softwareVersion": "1.5.0" + "softwareVersion": "1.6.0" }, { "@type": "SoftwareSourceCode", @@ -158,7 +158,7 @@ "name": "Can I keep working on the changes in Word?", "acceptedAnswer": { "@type": "Answer", - "text": "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." + "text": "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." } }, { @@ -861,6 +861,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; @@ -3340,6 +3379,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 */ @@ -3566,16 +3643,26 @@ <h2 class="sr-only" id="files-pop-title" data-i18n="filesTitle">Documents</h2> <span id="i18n-btn-export" data-i18n="exportBtn">Export</span> <svg class="icon tb-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg> </button> - <div class="menu menu-right" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> - <button type="button" class="menu-item" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span data-i18n="expWord">Word with tracked changes (.docx)</span></button> - <button type="button" class="menu-item" data-export="print" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect width="12" height="8" x="6" y="14"/></svg><span data-i18n="expPdf">Print or save as PDF</span></button> - <button type="button" class="menu-item" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span data-i18n="expCopy">Copy text of the new version</span></button> + <div class="menu menu-right menu-export" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> + <div class="menu-group" role="group" aria-labelledby="exp-sec-final"> + <div class="menu-label menu-label-2line" id="exp-sec-final"><span data-i18n="expSecFinal">Final version</span><span class="menu-label-desc" id="exp-sec-final-desc" data-i18n="expSecFinalDesc">The new version with your decisions, without marks</span></div> + <button type="button" class="menu-item mi-2line" data-export="word-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWordFinal">Word (.docx)</span><span class="mi-desc" id="exp-desc-word-final"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15h6"/><path d="M9 18h4"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfFinal">PDF</span><span class="mi-desc" id="exp-desc-print-final" data-i18n="expPdfFinalDesc">A clean document; choose “Save as PDF” in the print dialog</span></span></button> + <button type="button" class="menu-item mi-2line" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expCopy">Copy text of the new version</span><span class="mi-desc" id="exp-desc-copy" data-i18n="expCopyDesc">Plain text for email or other apps</span></span></button> + </div> + <div class="menu-sep" role="separator"></div> + <div class="menu-group" role="group" aria-labelledby="exp-sec-compare"> + <div class="menu-label menu-label-2line" id="exp-sec-compare"><span data-i18n="expSecCompare">Comparison</span><span class="menu-label-desc" data-i18n="expSecCompareDesc">Old → new with all marks, to review or pass on</span></div> + <button type="button" class="menu-item mi-2line" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWord">Word with tracked changes (.docx)</span><span class="mi-desc" id="exp-desc-word"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-split" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="16" x="3" y="4" rx="2"/><path d="M12 4v16"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfSplit">PDF side by side</span><span class="mi-desc" id="exp-desc-print-split" data-i18n="expPdfSplitDesc">Landscape, old and new version in two columns</span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-inline" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="18" x="4" y="3" rx="2"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfInline">PDF as running text</span><span class="mi-desc" id="exp-desc-print-inline" data-i18n="expPdfInlineDesc">Portrait, changes marked in the text</span></span></button> + </div> <div class="menu-sep" role="separator"></div> <button type="button" class="menu-item" role="menuitem" id="btn-more-formats" aria-expanded="false" aria-controls="more-formats"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg><span data-i18n="moreFormats">More formats</span><svg class="icon mi-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg></button> <div class="menu-sub" id="more-formats" role="group" aria-labelledby="btn-more-formats" hidden> <button type="button" class="menu-item" data-export="html" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20"/></svg><span data-i18n="expHtml">Report as web page (.html)</span></button> <button type="button" class="menu-item" data-export="md" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span data-i18n="expMd">Report as Markdown (.md)</span></button> - <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">New version as text file (.txt)</span></button> + <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">Final version as text file (.txt)</span></button> </div> </div> </div> @@ -3698,7 +3785,7 @@ <h2 id="how-title" data-i18n="howTitle">How it works</h2> <ol class="how-steps"> <li><span class="how-num" aria-hidden="true">1</span><span><strong data-i18n="how1Title">Choose two versions</strong> <span data-i18n="how1Text">As PDF, Word file or pasted text. The files are only read inside this browser tab.</span></span></li> <li><span class="how-num" aria-hidden="true">2</span><span><strong data-i18n="how2Title">Go through the changes</strong> <span data-i18n="how2Text">Removed text is struck through in red, added text is marked green. You can accept or reject each change.</span></span></li> - <li><span class="how-num" aria-hidden="true">3</span><span><strong data-i18n="how3Title">Save the result if you need it</strong> <span data-i18n="how3Text">As a Word file with tracked changes, as PDF or as a report.</span></span></li> + <li><span class="how-num" aria-hidden="true">3</span><span><strong data-i18n="how3Title">Save the result if you need it</strong> <span data-i18n="how3Text">As a clean final version (Word in the original format, or PDF) or as a comparison with tracked changes.</span></span></li> </ol> </section> @@ -3750,7 +3837,7 @@ <h2 id="faq-title" data-i18n="faqTitle">Frequently asked questions</h2> <details class="faq-item"> <summary data-i18n="faqWordQ">Can I keep working on the changes in Word?</summary> <div class="faq-answer" data-i18n-html="faqWordA"> - <p>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.</p> + <p>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.</p> </div> </details> <details class="faq-item"> @@ -4035,11 +4122,28 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> 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', @@ -4058,6 +4162,8 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> // ── 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}', @@ -4068,7 +4174,7 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> 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.', @@ -4121,6 +4227,7 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> movedFromHere: 'Verschoben – von hier', movedToHere: 'Verschoben – hierher', moreDeltas: 'weitere Änderungen', + moreDeltasOne: 'weitere Änderung', pageLabel: 'Seite', pageShort: 'S.', pageOf: 'von', @@ -4160,7 +4267,11 @@ <h3>Lesen</h3> </section> <section class="help-sec"> <h3>Prüfen</h3> - <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück. Im Word-Export sind angenommene Änderungen übernommen, abgelehnte zurückgenommen und offene als nachverfolgte Änderungen eingetragen.</p> + <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück.</p> + </section> + <section class="help-sec"> + <h3>Exportieren</h3> + <p><strong>Endfassung</strong>: 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. <strong>Vergleich</strong>: 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.</p> </section> <section class="help-sec"> <h3>Tastenkürzel</h3> @@ -4249,6 +4360,7 @@ <h3>Ihre Daten</h3> exportTitleFmt: 'Vergleich: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', + exportCountsFmtOne: '1 Änderung ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', exportReviewFmt: '{k} von {n} geprüft ({a} angenommen, {r} abgelehnt)', exportLabelOriginal: 'Alte Fassung', exportLabelRevised: 'Neue Fassung', @@ -4260,9 +4372,9 @@ <h3>Ihre Daten</h3> exportLabelDetail: 'Angabe', exportScopeFull: 'Vollständiges Dokument', exportScopeChanges: 'Nur Änderungen (Filter „Nur Änderungen“ aktiv, unveränderte Absätze ausgelassen)', - exportHfIgnored: 'ausgeblendet (nicht verglichen)', - exportHfIgnoredCount: 'ausgeblendet ({n} Zeilen nicht verglichen)', - exportHfIncluded: 'mitverglichen', + exportHfIgnored: 'unveränderte ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIgnoredCount: '{n} unveränderte Zeilen ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIncluded: 'vollständig mitverglichen', exportSummaryHeading: 'Zusammenfassung', exportCountTotal: 'Änderungen gesamt', exportChangesHeading: 'Änderungsübersicht', @@ -4283,8 +4395,10 @@ <h3>Ihre Daten</h3> 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', @@ -4314,7 +4428,7 @@ <h3>Ihre Daten</h3> 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', @@ -4346,7 +4460,7 @@ <h3>Ihre Daten</h3> `, faqWordQ: 'Kann ich die Änderungen in Word weiterbearbeiten?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, metaTitle: 'Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload', otherPageHint: 'Diese Seite gibt es auch auf Deutsch →', @@ -4408,11 +4522,28 @@ <h3>Ihre Daten</h3> 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', @@ -4431,6 +4562,8 @@ <h3>Ihre Daten</h3> // ── 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}', @@ -4441,7 +4574,7 @@ <h3>Ihre Daten</h3> 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.', @@ -4494,6 +4627,7 @@ <h3>Ihre Daten</h3> movedFromHere: 'Moved – from here', movedToHere: 'Moved – to here', moreDeltas: 'more changes', + moreDeltasOne: 'more change', pageLabel: 'Page', pageShort: 'p.', pageOf: 'of', @@ -4533,7 +4667,11 @@ <h3>Reading</h3> </section> <section class="help-sec"> <h3>Reviewing</h3> - <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back. In the Word export, accepted changes are applied, rejected ones are reverted and open ones are recorded as tracked changes.</p> + <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back.</p> + </section> + <section class="help-sec"> + <h3>Exporting</h3> + <p><strong>Final version</strong>: 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. <strong>Comparison</strong>: 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.</p> </section> <section class="help-sec"> <h3>Keyboard shortcuts</h3> @@ -4622,6 +4760,7 @@ <h3>Your data</h3> exportTitleFmt: 'Comparison: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} changes ({mod} changed, {ins} added, {del} removed)', + exportCountsFmtOne: '1 change ({mod} changed, {ins} added, {del} removed)', exportReviewFmt: '{k} of {n} reviewed ({a} accepted, {r} rejected)', exportLabelOriginal: 'Old version', exportLabelRevised: 'New version', @@ -4633,9 +4772,9 @@ <h3>Your data</h3> 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', @@ -4656,8 +4795,10 @@ <h3>Your data</h3> 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', @@ -4687,7 +4828,7 @@ <h3>Your data</h3> 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', @@ -4719,12 +4860,12 @@ <h3>Your data</h3> `, faqWordQ: 'Can I keep working on the changes in Word?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, 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', @@ -5066,6 +5207,8 @@ <h3>Your data</h3> 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: [], @@ -5336,7 +5479,8 @@ <h3>Your data</h3> 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) { @@ -5958,8 +6102,11 @@ <h3>Your data</h3> (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; } @@ -5991,6 +6138,7 @@ <h3>Your data</h3> sectionRef: currentSectionRef, text: line, kind, + headingMarker, indent: entry.indent, isHeading, isHeaderFooter, @@ -6383,9 +6531,11 @@ <h3>Your data</h3> } } - const cleaned = mergedNums - .filter(tok => tok.value.length > 0) - .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })); + const cleaned = mergeFragmentedChanges( + mergedNums + .filter(tok => tok.value.length > 0) + .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })) + ); if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned; // Never show a diff that does not reproduce the source texts: fall back to the raw diff. @@ -6394,6 +6544,84 @@ <h3>Your data</h3> ); } +/** + * Joins changes that only an unchanged hyphen or space separates into one replacement, so a + * rewritten phrase reads as one change instead of misleading word pairs: + * "[Standard]→[Echtzeit]-[Berichtswesen]→[Nachtragsmanagement]" becomes + * "[Standard-Berichtswesen]→[Echtzeit-Nachtragsmanagement]". The joined glue goes into both + * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of + * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared + * by both sides at the edges stays unchanged. + */ +function mergeFragmentedChanges(parts) { + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const glued = []; + for (let i = 0; i < parts.length; i++) { + const cur = parts[i]; + const isGlue = + !isChange(cur) && + isChange(glued[glued.length - 1]) && + isChange(parts[i + 1]) && + (/^\s+$/.test(cur.value) || !/\s/.test(cur.value)); + if (isGlue) { + glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true }); + } else { + glued.push(cur); + } + } + + const out = []; + const pushEqual = value => { + if (!value) return; + const last = out[out.length - 1]; + if (last && !isChange(last)) last.value += value; + else out.push({ value }); + }; + let i = 0; + while (i < glued.length) { + if (!isChange(glued[i])) { + pushEqual(glued[i].value); + i++; + continue; + } + let rem = ''; + let add = ''; + while (i < glued.length && isChange(glued[i])) { + if (glued[i].removed) rem += glued[i].value; + else add += glued[i].value; + i++; + } + let lead = ''; + let trail = ''; + if (rem && add) { + const leadRem = rem.match(/^\s*/)[0]; + const leadAdd = add.match(/^\s*/)[0]; + while (lead.length < Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) { + lead += leadRem[lead.length]; + } + rem = rem.slice(lead.length); + add = add.slice(lead.length); + const trailRem = rem.match(/\s*$/)[0]; + const trailAdd = add.match(/\s*$/)[0]; + let n = 0; + while ( + n < Math.min(trailRem.length, trailAdd.length) && + trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n] + ) { + n++; + } + trail = n ? rem.slice(rem.length - n) : ''; + rem = rem.slice(0, rem.length - n); + add = add.slice(0, add.length - n); + } + pushEqual(lead); + if (rem) out.push({ value: rem, removed: true }); + if (add) out.push({ value: add, added: true }); + pushEqual(trail); + } + return out; +} + function coalesceSideTokens(sideParts, prop) { const out = []; for (let i = 0; i < sideParts.length; i++) { @@ -7413,6 +7641,7 @@ <h3>Your data</h3> state.rightName = t('sampleRightName'); state.rightFormat = 'DEMO'; state.rightText = sample.v2; + state.docSources = { left: null, right: null }; resetEditHistory(); runComparison(); } @@ -7423,11 +7652,13 @@ <h3>Your data</h3> state.leftText = ''; state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; document.getElementById('file-input-left').value = ''; } else { state.rightText = ''; state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; resetEditHistory(); document.getElementById('file-input-right').value = ''; } @@ -7800,6 +8031,11 @@ <h3>Your data</h3> 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) { @@ -8011,7 +8247,7 @@ <h3>Your data</h3> const pageLabel = pages.length > 1 ? `${t('pageShort')} ${Math.min(...pages)}–${Math.max(...pages)}` : `${t('pageShort')} ${page}`; const head = item.members ? `${ref} · ${pageLabel}` : [ref, pageLabel].filter(Boolean).join(' · '); const delta = renderSidebarDeltaHtml(item); - const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(t('moreDeltas'))}</span></span>` : ''; + const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(moreDeltasText(delta.more))}</span></span>` : ''; return `<div class="nav-item ${cls} is-${item.status}${isActive ? ' is-active' : ''}" data-row-id="${item.rowId}" data-item-id="${item.id}"><button type="button" class="nav-item-main" tabindex="${item.id === focusId ? 0 : -1}"${isActive ? ' aria-current="true"' : ''}><span class="nav-item-head"><span class="nav-item-ref">${escapeHtml(head)}</span>${moreHtml}${statusIcon}<span class="sr-only">, ${escapeHtml(getChangeTypeLabel(item))}, ${escapeHtml(getStatusLabel(item.status))}. </span></span>${delta.html}</button></div>`; }) .join('') @@ -8025,7 +8261,7 @@ <h3>Your data</h3> // Replaced at build time by scripts/build.py with jsdiff + 04-core.js + 04a-align.js + // src/worker/compare-worker.js as a string literal. -const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n return metaLine(meta.join(';'), block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n if (!isHeaderFooter) {\n seenContentBlock = true;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }));\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; +const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n // A heading marker (\"## \") from the source stays, so a rewritten text keeps its structure.\n return metaLine(meta.join(';'), (block.headingMarker || '') + block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n let headingMarker = '';\n if (!isHeaderFooter) {\n seenContentBlock = true;\n const m = line.match(/^#{1,3}\\s+/);\n if (m) headingMarker = `${m[0].trim()} `;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n headingMarker,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergeFragmentedChanges(\n mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }))\n );\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\n/**\n * Joins changes that only an unchanged hyphen or space separates into one replacement, so a\n * rewritten phrase reads as one change instead of misleading word pairs:\n * \"[Standard]\u2192[Echtzeit]-[Berichtswesen]\u2192[Nachtragsmanagement]\" becomes\n * \"[Standard-Berichtswesen]\u2192[Echtzeit-Nachtragsmanagement]\". The joined glue goes into both\n * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of\n * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared\n * by both sides at the edges stays unchanged.\n */\nfunction mergeFragmentedChanges(parts) {\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n const glued = [];\n for (let i = 0; i \u003c parts.length; i++) {\n const cur = parts[i];\n const isGlue =\n !isChange(cur) &&\n isChange(glued[glued.length - 1]) &&\n isChange(parts[i + 1]) &&\n (/^\\s+$/.test(cur.value) || !/\\s/.test(cur.value));\n if (isGlue) {\n glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true });\n } else {\n glued.push(cur);\n }\n }\n\n const out = [];\n const pushEqual = value =\u003e {\n if (!value) return;\n const last = out[out.length - 1];\n if (last && !isChange(last)) last.value += value;\n else out.push({ value });\n };\n let i = 0;\n while (i \u003c glued.length) {\n if (!isChange(glued[i])) {\n pushEqual(glued[i].value);\n i++;\n continue;\n }\n let rem = '';\n let add = '';\n while (i \u003c glued.length && isChange(glued[i])) {\n if (glued[i].removed) rem += glued[i].value;\n else add += glued[i].value;\n i++;\n }\n let lead = '';\n let trail = '';\n if (rem && add) {\n const leadRem = rem.match(/^\\s*/)[0];\n const leadAdd = add.match(/^\\s*/)[0];\n while (lead.length \u003c Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) {\n lead += leadRem[lead.length];\n }\n rem = rem.slice(lead.length);\n add = add.slice(lead.length);\n const trailRem = rem.match(/\\s*$/)[0];\n const trailAdd = add.match(/\\s*$/)[0];\n let n = 0;\n while (\n n \u003c Math.min(trailRem.length, trailAdd.length) &&\n trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n]\n ) {\n n++;\n }\n trail = n ? rem.slice(rem.length - n) : '';\n rem = rem.slice(0, rem.length - n);\n add = add.slice(0, add.length - n);\n }\n pushEqual(lead);\n if (rem) out.push({ value: rem, removed: true });\n if (add) out.push({ value: add, added: true });\n pushEqual(trail);\n }\n return out;\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; // Small inputs are compared synchronously: alignment takes a few milliseconds and the result is // on screen in the same frame (no flash of the previous comparison, and synchronous callers such @@ -9769,6 +10005,7 @@ <h3>Your data</h3> serializeBlockLine({ text: line.trim(), kind: template ? template.kind : 'p', + headingMarker: template ? template.headingMarker : '', indent: template ? template.indent : 0, isHeaderFooter: Boolean(target && target.rightBlock && target.rightBlock.isHeaderFooter), }) @@ -9830,6 +10067,7 @@ <h3>Your data</h3> serializeBlockLine({ text: line.trim(), kind: row.rightBlock.kind, + headingMarker: row.rightBlock.headingMarker, indent: row.rightBlock.indent, isHeaderFooter: Boolean(row.rightBlock.isHeaderFooter), }) @@ -10303,32 +10541,34 @@ <h3>Your data</h3> 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) => - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/></w:pPr>${docxRun(`${label}: `, false, '<w:b/>')}${docxRun(value)}</w:p>`; - - paragraphs.push(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr>${docxRun(meta.title)}</w:p>`); - for (const [label, value] of meta.lines) paragraphs.push(metaPara(label, value)); - paragraphs.push( - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="6" w:color="A6A6A6"/></w:pBdr><w:spacing w:after="360"/></w:pPr>${docxRun(meta.note, false, '<w:i/>')}</w:p>` - ); - 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), @@ -10352,7 +10592,16 @@ <h3>Your data</h3> return docxRun(tok.value, false); }) .join(''); - paragraphs.push(`<w:p>${pPr}${runs}</w:p>`); + paragraphs.push({ pPr, runs }); + } + if (!paragraphs.length) paragraphs.push({ pPr: '', runs: '' }); + + if (meta.comment && meta.comment.length) { + const first = paragraphs[0]; + first.runs = + '<w:commentRangeStart w:id="0"/>' + + first.runs + + '<w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r>'; } const sectPr = @@ -10365,7 +10614,23 @@ <h3>Your data</h3> '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - `<w:body>${paragraphs.join('')}${sectPr}</w:body></w:document>` + `<w:body>${paragraphs.map(p => `<w:p>${p.pPr}${p.runs}</w:p>`).join('')}${sectPr}</w:body></w:document>` + ); +} + +/** The Word comment with the summary of a comparison (one paragraph per line). */ +function buildDocxCommentsXml(meta) { + const date = toWordDate(meta.date); + const paras = meta.comment + .map((line, i) => + `<w:p>${i === 0 ? '<w:r><w:annotationRef/></w:r>' : ''}${docxRun(line, false, i === 0 ? '<w:b/>' : '')}</w:p>` + ) + .join(''); + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + + `<w:comment w:id="0" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}" w:initials="DD">${paras}</w:comment>` + + '</w:comments>' ); } @@ -10374,9 +10639,7 @@ <h3>Your data</h3> '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:tabs><w:tab w:val="right" w:pos="9072"/></w:tabs></w:pPr>' + - docxRun(meta.footer) + - '<w:r><w:tab/></w:r>' + + '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="right"/></w:pPr>' + docxRun(`${meta.pageLabel} `) + '<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>' + docxRun(` ${meta.pageOfLabel} `) + @@ -10420,7 +10683,8 @@ <h3>Your data</h3> function buildDocxPackageFiles(rows, meta) { const langTag = meta.lang === 'de' ? 'de-DE' : 'en-US'; const created = toWordDate(meta.date); - return [ + const hasComment = Boolean(meta.comment && meta.comment.length); + const files = [ { name: '[Content_Types].xml', data: @@ -10432,6 +10696,7 @@ <h3>Your data</h3> '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' + '<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>' + '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' + + (hasComment ? '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>' : '') + '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' + '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' + '</Types>', @@ -10455,6 +10720,7 @@ <h3>Your data</h3> 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' + `<dc:title>${escapeXml(meta.title)}</dc:title>` + `<dc:subject>${escapeXml(meta.subject)}</dc:subject>` + + (meta.description ? `<dc:description>${escapeXml(meta.description)}</dc:description>` : '') + `<dc:creator>${DOCX_AUTHOR}</dc:creator>` + `<cp:lastModifiedBy>${DOCX_AUTHOR}</cp:lastModifiedBy>` + `<dc:language>${langTag}</dc:language>` + @@ -10477,6 +10743,7 @@ <h3>Your data</h3> '<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' + '<Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>' + '<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>' + + (hasComment ? '<Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>' : '') + '</Relationships>', }, { name: 'word/document.xml', data: buildDocxDocumentXml(rows, meta) }, @@ -10492,37 +10759,66 @@ <h3>Your data</h3> }, { 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) { @@ -10605,23 +10901,17 @@ <h3>Your data</h3> } /** - * 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, @@ -10658,13 +10948,24 @@ <h3>Your data</h3> 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) { @@ -10698,7 +10999,7 @@ <h3>Your data</h3> 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(' · '); } @@ -10812,11 +11113,7 @@ <h3>Your data</h3> * 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'); @@ -10848,7 +11145,7 @@ <h3>Your data</h3> if (p.ins) return renderReportTokensHtml([{ value: p.ins, added: true }]); return renderReportTokensHtml([{ value: p.del, removed: true }]); }); - if (more) items.push(`<span class="more">+${more} ${escapeHtml(t('moreDeltas'))}</span>`); + if (more) items.push(`<span class="more">+${more} ${escapeHtml(moreDeltasText(more))}</span>`); return items.map(html => `<div>${html}</div>`).join(''); } @@ -10917,13 +11214,22 @@ <h3>Your data</h3> 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 @@ -10935,10 +11241,9 @@ <h3>Your data</h3> .map((item, i) => { const n = i + 1; const ref = getExportItemRef(item) || `#${n}`; - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; - const target = item.ghost && item.type === 'inserted' ? 0 : anchorIndex.get(item.rowId); + const target = itemMembers(item).some(m => anchorIndex.get(m.rowId) === n) ? n : 0; const refHtml = target ? `<a href="#c${target}">${escapeHtml(ref)}</a>` : escapeHtml(ref); - return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(t('pageShort'))} ${page}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; + return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(getExportItemPageLabel(item))}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; }) .join('\n')} </tbody> @@ -10954,7 +11259,7 @@ <h3>Your data</h3> const inner = renderReportTokensHtml(getExportDiffTokens(row)); const tag = kind === 'h1' ? 'h3' : kind === 'h2' ? 'h4' : kind === 'h3' ? 'h5' : 'p'; const tagText = getRowReportTag(row); - const idAttr = anchorIndex.has(row.id) ? ` id="${anchorFor(row)}"` : ''; + const idAttr = anchorFor(row); const tagHtml = tagText ? `<span class="row-tag"><a href="#changes">${escapeHtml(tagText)}</a></span>` : ''; return `<div class="row ${row.type} kind-${kind}"${idAttr}>${tagHtml}<${tag}>${inner}</${tag}></div>`; }) @@ -11095,7 +11400,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> const counts = getExportCounts(items); const changeIndex = new Map(); items.forEach((item, i) => { - if (!item.ghost && !changeIndex.has(item.rowId)) changeIndex.set(item.rowId, i + 1); + for (const m of itemMembers(item)) if (!m.ghost && !changeIndex.has(m.rowId)) changeIndex.set(m.rowId, i + 1); }); const reviewLine = getExportReviewLine(counts); const lines = [ @@ -11129,11 +11434,10 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> if (p.ins) return renderMarkdownTokens([{ value: p.ins, added: true }]); return renderMarkdownTokens([{ value: p.del, removed: true }]); }) - .concat(more ? [`+${more} ${escapeMarkdownInline(t('moreDeltas'))}`] : []) + .concat(more ? [`+${more} ${escapeMarkdownInline(moreDeltasText(more))}`] : []) .join('<br>'); - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; lines.push( - `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${page} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` + `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${getExportItemPages(item)} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` ); } } else { @@ -11179,11 +11483,16 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> return `<td class="pv-cell kind-${kind}">${html}</td>`; } -/** 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 = ` <header class="pv-head"> <div class="pv-title">${escapeHtml(getExportTitle())}</div> @@ -11263,9 +11572,44 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> return `${head}<main class="pv-body pv-mode-${mode}">${body}${emptyHtml}</main>`; } +/** 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 `<p class="pvf-item pvf-${kind}"${indent}><span class="pvf-marker">${escapeHtml(split.marker)}</span><span class="pvf-body">${escapeHtml(split.body)}</span></p>`; + } + return `<p class="pvf-p"${indent}>${escapeHtml(text)}</p>`; + }) + .join(''); + return `<main class="pv-final" lang="${state.lang === 'de' ? 'de' : 'en'}">${html}</main>`; +} + +/** Blocks of the new version as it stands now, without running headers and footers. */ +function getFinalBlocks() { + const blocks = state.alignedRows.length + ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock) + : parseIntoBlocks(state.rightText || ''); + return blocks.filter(b => !b.isHeaderFooter && b.kind !== 'hf' && !isInternalMarkerLine(b.text)); +} + +// The print view requested from the export menu; Ctrl/Cmd+P prints the current view. +let requestedPrintKind = null; + function preparePrintView() { if (typeof ensureComparisonCurrent === 'function') ensureComparisonCurrent(); if (!state.alignedRows.length) return; + const kind = requestedPrintKind || (state.viewMode === 'inline' ? 'inline' : 'split'); const date = new Date(); let view = document.getElementById('print-view'); if (!view) { @@ -11275,7 +11619,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> view.setAttribute('aria-hidden', 'true'); document.body.appendChild(view); } - setSafeContainerHtml(view, buildPrintViewHtml(date)); + setSafeContainerHtml(view, buildPrintViewHtml(date, kind)); let pageStyle = document.getElementById('print-page-style'); if (!pageStyle) { @@ -11283,15 +11627,18 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> pageStyle.id = 'print-page-style'; document.head.appendChild(pageStyle); } - const footerLeft = `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; - const orientation = state.viewMode === 'inline' ? 'portrait' : 'landscape'; - pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: 14mm 12mm 16mm; + const footerLeft = + kind === 'final' ? getDisplayDocName('right') : `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; + const orientation = kind === 'split' ? 'landscape' : 'portrait'; + const margin = kind === 'final' ? '22mm 20mm 20mm 24mm' : '14mm 12mm 16mm'; + pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: ${margin}; @bottom-left { content: ${cssString(footerLeft)}; font: 7.5pt sans-serif; color: #444; } @bottom-right { content: ${cssString(`${t('pageLabel')} `)} counter(page) ${cssString(` ${t('pageOf')} `)} counter(pages); font: 7.5pt sans-serif; color: #444; } } }`; document.body.classList.add('has-print-view'); } function cleanupPrintView() { + requestedPrintKind = null; document.body.classList.remove('has-print-view'); const view = document.getElementById('print-view'); if (view) view.remove(); @@ -11347,6 +11694,55 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> 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'); @@ -11355,28 +11751,19 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> // 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') { @@ -11384,6 +11771,25 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> } } +/** + * 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. */ @@ -11406,6 +11812,922 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> }); } +/* ── 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)) { + const e = xml.indexOf('-->', lt + 4); + if (e < 0) throw new Error('xml: unterminated comment'); + i = e + 3; + continue; + } + if (xml.startsWith('<![CDATA[', lt)) { + const e = xml.indexOf(']]>', lt + 9); + if (e < 0) throw new Error('xml: unterminated CDATA'); + i = e + 3; + continue; + } + if (xml.startsWith('<?', lt) || xml.startsWith('<!', lt)) { + const e = xml.indexOf('>', 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(/<w:rStyle\b[^>]*\/>/g, '').replace(/<w:rPr>\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 += `<w:r>${run.rPr}${run.content}</w:r>`; + run = null; + }; + const setRev = kind => { + if (revOpen === kind) return; + flushRun(); + if (revOpen) body += revOpen === 'ins' ? '</w:ins>' : '</w:del>'; + revOpen = kind; + if (kind) body += kind === 'ins' ? `<w:ins ${rev()}>` : `<w:del ${rev()}>`; + }; + 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' + ? `<w:delText xml:space="preserve">${escapeXml(item.text)}</w:delText>` + : `<w:t xml:space="preserve">${escapeXml(item.text)}</w:t>` + : 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}</w:p>`; +} + +/** 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' ? `<w:ins ${rev()}/>` : `<w:del ${rev()}/>`; + if (!pPr) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/^<w:pPr\s*\/>$/.test(pPr)) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/<w:rPr\s*\/>/.test(pPr)) return pPr.replace(/<w:rPr\s*\/>/, `<w:rPr>${mark}</w:rPr>`); + if (/<w:rPr>/.test(pPr)) return pPr.replace(/<w:rPr>/, `<w:rPr>${mark}`); + const before = pPr.search(/<w:sectPr\b|<w:pPrChange\b|<\/w:pPr>/); + return `${pPr.slice(0, before)}<w:rPr>${mark}</w:rPr>${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(/<w:sectPr\b[\s\S]*?<\/w:sectPr>/, '') + .replace(/<w:pPrChange\b[\s\S]*?<\/w:pPrChange>/, '') + .replace(/<w:rPr>[\s\S]*?<\/w:rPr>|<w:rPr\s*\/>/, ''); + 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 = `<w:p>${pPr}</w:p>`; + 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 && /<w:numPr\b/.test(xml.slice(tpl.start, tpl.end)); + const prefix = tplNumbered && marker && finalKey.startsWith(`${marker} `) ? marker.length + 1 : 0; + insertAt(buildNewDocxParagraph(xml, ctx, tpl, step.tokens, prefix, rev, step.markPara || ''), targetText(step.tokens, prefix)); + return; + } + if (changed) { + if (unit.locked) throw new Error('docx: change in a locked unit'); + if (unit.kind === 'p') { + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, step.tokens, pu.prefix, rev, step.markPara || '')); + unitText.set(unit, targetText(step.tokens, pu.prefix)); + } else { + unitText.set(unit, rewriteDocxRow(unit, xml, ctx, step, rev, edit)); + } + } + anchor = unitEnd(unit); + }); + + // Units read in that are not part of the result anymore (a rejected insertion, a paragraph + // emptied by an edit) are removed, with anything nested in them. + const removed = []; + const removedRows = new Map(); + for (const hit of r0Map.values()) { + const unit = hit.unit; + if (usedUnits.has(unit)) continue; + if (unit.locked) throw new Error('docx: removal in a locked unit'); + if (unit.kind === 'row') { + removedRows.set(unit.table, (removedRows.get(unit.table) || 0) + 1); + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } else if (/<w:sectPr\b/.test(xml.slice(unit.node.start, unit.node.end))) { + // It carries the section layout: keep the paragraph, drop its text. + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, [], 0, rev, '')); + unitText.set(unit, ''); + } else { + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } + } + for (const [table, n] of removedRows) { + if (n < table.rows) continue; + // Every row is gone: remove the table (a table needs at least one row). + for (let k = edits.length - 1; k >= 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). @@ -12088,6 +13410,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> } else if (kind === 'docx') { result = await extractDocxText(buffer); result.format = 'DOCX'; + result.sourceBytes = bytes; } else { const { text, warnings } = decodeTextFile(bytes, ext); const looksLikeHtml = /^\s*(?:<!doctype html|<html[\s>])/i.test(text.slice(0, 2048)); @@ -12127,7 +13450,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> fnLabel.textContent = `${t('readingFile')} ${file.name}…`; try { - const { text, format, warnings } = await readDocumentFile(file); + const { text, format, warnings, sourceBytes } = await readDocumentFile(file); if (isStale()) { if (seq === uploadSeq[side]) restoreLabel(); return false; @@ -12142,6 +13465,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightFormat = format; state.rightText = text; } + state.docSources[side] = sourceBytes ? { name: file.name, bytes: sourceBytes, importedText: text } : null; resetEditHistory(); if (warnings && warnings.length) { showImportNotice(`${file.name}: ${warnings.join(' ')}`, 'warning'); @@ -12331,6 +13655,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> trigger = trigger || document.getElementById(MENU_TRIGGERS[id]); trigger.setAttribute('aria-expanded', 'true'); openMenuState = { id, trigger }; + if (id === 'export-menu') updateExportMenuTexts(); document.body.classList.toggle('menu-sheet-open', isPhoneWidth()); menuItems(menu).forEach(item => (item.tabIndex = -1)); if (focus) { @@ -12776,6 +14101,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightName = ''; state.leftFormat = ''; state.rightFormat = ''; + state.docSources = { left: null, right: null }; state.activeRowId = null; state.hoveredRowId = null; state.sidebarFilter = 'all'; @@ -12849,6 +14175,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> [state.leftText, state.rightText] = [state.rightText, state.leftText]; [state.leftName, state.rightName] = [state.rightName, state.leftName]; [state.leftFormat, state.rightFormat] = [state.rightFormat, state.leftFormat]; + state.docSources = { left: state.docSources.right, right: state.docSources.left }; resetEditHistory(); runComparison(); btnFiles.focus(); @@ -12883,11 +14210,13 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.leftText = sanitizeExternalText(textareaLeft.value); state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; } if (rightChanged) { state.rightText = sanitizeExternalText(textareaRight.value); state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; } if (leftChanged || rightChanged) resetEditHistory(); if (state.leftText.trim() && !state.leftName) { @@ -13158,7 +14487,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.5.0'; +const DOCDIFF_APP_VERSION = '1.6.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -13503,6 +14832,20 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> 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); @@ -13517,12 +14860,14 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> name: state.leftName, format: state.leftFormat, text: state.leftText, + source: sessionSourceOf('left'), }, right: { name: state.rightName, format: state.rightFormat, originalText: sessionTracker.originalRightText, text: state.rightText, + source: sessionSourceOf('right'), }, }, view: { @@ -13643,6 +14988,22 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> }); } +function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Optional original .docx of a side: { kind: 'docx', data (base64), importedText }. +function expectSource(value, name) { + if (value === undefined) return null; + if (!isPlainObject(value) || value.kind !== 'docx') throw new SessionFileError('sessionErrorInvalid'); + const data = expectString(value.data, SESSION_MAX_FILE_BYTES); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) throw new SessionFileError('sessionErrorInvalid'); + return { name, bytes: base64ToBytes(data), importedText: expectString(value.importedText, SESSION_MAX_FILE_BYTES) }; +} + // Returns a fresh, fully validated object; throws SessionFileError otherwise. function validateSessionData(raw) { if (!isPlainObject(raw) || raw.format !== SESSION_FORMAT_ID) { @@ -13687,12 +15048,14 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> name: expectString(docs.left.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.left.format), text: expectString(docs.left.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.left.source, docs.left.name), }, right: { name: expectString(docs.right.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.right.format), originalText: expectString(docs.right.originalText, SESSION_MAX_FILE_BYTES), text: expectString(docs.right.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.right.source, docs.right.name), }, view: { mode: view.mode === 'pages' ? 'split' : view.mode, @@ -13740,6 +15103,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightName = session.right.name; state.rightFormat = session.right.format; state.rightText = session.right.text; + state.docSources = { left: session.left.source || null, right: session.right.source || null }; state.reviews = cloneReviews(session.reviews); state.undoStack = session.history.undo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); state.redoStack = session.history.redo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); diff --git a/site/index.html b/site/index.html index 60e0641..13c8e5c 100644 --- a/site/index.html +++ b/site/index.html @@ -45,7 +45,7 @@ <meta property="og:locale:alternate" content="en_US" /> <meta property="og:title" content="Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload" /> <meta property="og:description" content="Zwei Fassungen eines Vertrags oder Dokuments vergleichen: PDF, Word oder Text, Wort für Wort markiert. Kostenlos, Open Source, ohne Upload." /> - <meta property="og:image" content="https://docdiff.app/og-image.png?v=2" /> + <meta property="og:image" content="https://docdiff.app/og-image.png?v=3" /> <meta property="og:image:type" content="image/png" /> <meta property="og:image:width" content="2400" /> <meta property="og:image:height" content="1260" /> @@ -53,7 +53,7 @@ <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload" /> <meta name="twitter:description" content="Zwei Fassungen eines Vertrags oder Dokuments vergleichen: PDF, Word oder Text, Wort für Wort markiert. Kostenlos, Open Source, ohne Upload." /> - <meta name="twitter:image" content="https://docdiff.app/og-image.png?v=2" /> + <meta name="twitter:image" content="https://docdiff.app/og-image.png?v=3" /> <meta name="twitter:image:alt" content="Doc Diff: Zwei Vertragsfassungen nebeneinander, entfernter Text rot durchgestrichen, neuer Text grün markiert." /> <!-- Structured data (schema.org). A data block: browsers never execute it. --> @@ -93,7 +93,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", @@ -102,7 +102,7 @@ "sameAs": [ "https://github.com/tschnorpfeil/docdiff" ], - "softwareVersion": "1.5.0" + "softwareVersion": "1.6.0" }, { "@type": "SoftwareSourceCode", @@ -158,7 +158,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." } }, { @@ -861,6 +861,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; @@ -3340,6 +3379,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 */ @@ -3566,16 +3643,26 @@ <h2 class="sr-only" id="files-pop-title" data-i18n="filesTitle">Dokumente</h2> <span id="i18n-btn-export" data-i18n="exportBtn">Exportieren</span> <svg class="icon tb-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg> </button> - <div class="menu menu-right" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> - <button type="button" class="menu-item" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span data-i18n="expWord">Word mit Änderungsverfolgung (.docx)</span></button> - <button type="button" class="menu-item" data-export="print" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect width="12" height="8" x="6" y="14"/></svg><span data-i18n="expPdf">Drucken oder als PDF sichern</span></button> - <button type="button" class="menu-item" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span data-i18n="expCopy">Text der neuen Fassung kopieren</span></button> + <div class="menu menu-right menu-export" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> + <div class="menu-group" role="group" aria-labelledby="exp-sec-final"> + <div class="menu-label menu-label-2line" id="exp-sec-final"><span data-i18n="expSecFinal">Endfassung</span><span class="menu-label-desc" id="exp-sec-final-desc" data-i18n="expSecFinalDesc">Neue Fassung mit Ihren Entscheidungen, ohne Markierungen</span></div> + <button type="button" class="menu-item mi-2line" data-export="word-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWordFinal">Word (.docx)</span><span class="mi-desc" id="exp-desc-word-final"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15h6"/><path d="M9 18h4"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfFinal">PDF</span><span class="mi-desc" id="exp-desc-print-final" data-i18n="expPdfFinalDesc">Sauberes Dokument, im Druckdialog „Als PDF sichern“</span></span></button> + <button type="button" class="menu-item mi-2line" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expCopy">Text der neuen Fassung kopieren</span><span class="mi-desc" id="exp-desc-copy" data-i18n="expCopyDesc">Reiner Text für E-Mail oder andere Programme</span></span></button> + </div> + <div class="menu-sep" role="separator"></div> + <div class="menu-group" role="group" aria-labelledby="exp-sec-compare"> + <div class="menu-label menu-label-2line" id="exp-sec-compare"><span data-i18n="expSecCompare">Vergleich</span><span class="menu-label-desc" data-i18n="expSecCompareDesc">Alt → neu mit allen Markierungen, zum Prüfen oder Weitergeben</span></div> + <button type="button" class="menu-item mi-2line" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWord">Word mit Änderungsverfolgung (.docx)</span><span class="mi-desc" id="exp-desc-word"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-split" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="16" x="3" y="4" rx="2"/><path d="M12 4v16"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfSplit">PDF nebeneinander</span><span class="mi-desc" id="exp-desc-print-split" data-i18n="expPdfSplitDesc">Querformat, alte und neue Fassung in zwei Spalten</span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-inline" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="18" x="4" y="3" rx="2"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfInline">PDF als Fließtext</span><span class="mi-desc" id="exp-desc-print-inline" data-i18n="expPdfInlineDesc">Hochformat, Änderungen im Text markiert</span></span></button> + </div> <div class="menu-sep" role="separator"></div> <button type="button" class="menu-item" role="menuitem" id="btn-more-formats" aria-expanded="false" aria-controls="more-formats"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg><span data-i18n="moreFormats">Weitere Formate</span><svg class="icon mi-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg></button> <div class="menu-sub" id="more-formats" role="group" aria-labelledby="btn-more-formats" hidden> <button type="button" class="menu-item" data-export="html" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20"/></svg><span data-i18n="expHtml">Bericht als Webseite (.html)</span></button> <button type="button" class="menu-item" data-export="md" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span data-i18n="expMd">Bericht als Markdown (.md)</span></button> - <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">Neue Fassung als Textdatei (.txt)</span></button> + <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">Endfassung als Textdatei (.txt)</span></button> </div> </div> </div> @@ -3698,7 +3785,7 @@ <h2 id="how-title" data-i18n="howTitle">So funktioniert’s</h2> <ol class="how-steps"> <li><span class="how-num" aria-hidden="true">1</span><span><strong data-i18n="how1Title">Zwei Fassungen wählen</strong> <span data-i18n="how1Text">Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.</span></span></li> <li><span class="how-num" aria-hidden="true">2</span><span><strong data-i18n="how2Title">Änderungen durchgehen</strong> <span data-i18n="how2Text">Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.</span></span></li> - <li><span class="how-num" aria-hidden="true">3</span><span><strong data-i18n="how3Title">Ergebnis sichern, wenn Sie es brauchen</strong> <span data-i18n="how3Text">Als Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.</span></span></li> + <li><span class="how-num" aria-hidden="true">3</span><span><strong data-i18n="how3Title">Ergebnis sichern, wenn Sie es brauchen</strong> <span data-i18n="how3Text">Als saubere Endfassung (Word im Originalformat oder PDF) oder als Vergleich mit Änderungsverfolgung.</span></span></li> </ol> </section> @@ -3750,7 +3837,7 @@ <h2 id="faq-title" data-i18n="faqTitle">Häufige Fragen</h2> <details class="faq-item"> <summary data-i18n="faqWordQ">Kann ich die Änderungen in Word weiterbearbeiten?</summary> <div class="faq-answer" data-i18n-html="faqWordA"> - <p>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.</p> + <p>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.</p> </div> </details> <details class="faq-item"> @@ -4035,11 +4122,28 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> 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', @@ -4058,6 +4162,8 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> // ── 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}', @@ -4068,7 +4174,7 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> 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.', @@ -4121,6 +4227,7 @@ <h2 class="dialog-title" id="session-dialog-title" tabindex="-1"></h2> movedFromHere: 'Verschoben – von hier', movedToHere: 'Verschoben – hierher', moreDeltas: 'weitere Änderungen', + moreDeltasOne: 'weitere Änderung', pageLabel: 'Seite', pageShort: 'S.', pageOf: 'von', @@ -4160,7 +4267,11 @@ <h3>Lesen</h3> </section> <section class="help-sec"> <h3>Prüfen</h3> - <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück. Im Word-Export sind angenommene Änderungen übernommen, abgelehnte zurückgenommen und offene als nachverfolgte Änderungen eingetragen.</p> + <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück.</p> + </section> + <section class="help-sec"> + <h3>Exportieren</h3> + <p><strong>Endfassung</strong>: 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. <strong>Vergleich</strong>: 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.</p> </section> <section class="help-sec"> <h3>Tastenkürzel</h3> @@ -4249,6 +4360,7 @@ <h3>Ihre Daten</h3> exportTitleFmt: 'Vergleich: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', + exportCountsFmtOne: '1 Änderung ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', exportReviewFmt: '{k} von {n} geprüft ({a} angenommen, {r} abgelehnt)', exportLabelOriginal: 'Alte Fassung', exportLabelRevised: 'Neue Fassung', @@ -4260,9 +4372,9 @@ <h3>Ihre Daten</h3> exportLabelDetail: 'Angabe', exportScopeFull: 'Vollständiges Dokument', exportScopeChanges: 'Nur Änderungen (Filter „Nur Änderungen“ aktiv, unveränderte Absätze ausgelassen)', - exportHfIgnored: 'ausgeblendet (nicht verglichen)', - exportHfIgnoredCount: 'ausgeblendet ({n} Zeilen nicht verglichen)', - exportHfIncluded: 'mitverglichen', + exportHfIgnored: 'unveränderte ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIgnoredCount: '{n} unveränderte Zeilen ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIncluded: 'vollständig mitverglichen', exportSummaryHeading: 'Zusammenfassung', exportCountTotal: 'Änderungen gesamt', exportChangesHeading: 'Änderungsübersicht', @@ -4283,8 +4395,10 @@ <h3>Ihre Daten</h3> 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', @@ -4314,7 +4428,7 @@ <h3>Ihre Daten</h3> 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', @@ -4346,7 +4460,7 @@ <h3>Ihre Daten</h3> `, faqWordQ: 'Kann ich die Änderungen in Word weiterbearbeiten?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, metaTitle: 'Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload', otherPageHint: 'Diese Seite gibt es auch auf Deutsch →', @@ -4408,11 +4522,28 @@ <h3>Ihre Daten</h3> 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', @@ -4431,6 +4562,8 @@ <h3>Ihre Daten</h3> // ── 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}', @@ -4441,7 +4574,7 @@ <h3>Ihre Daten</h3> 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.', @@ -4494,6 +4627,7 @@ <h3>Ihre Daten</h3> movedFromHere: 'Moved – from here', movedToHere: 'Moved – to here', moreDeltas: 'more changes', + moreDeltasOne: 'more change', pageLabel: 'Page', pageShort: 'p.', pageOf: 'of', @@ -4533,7 +4667,11 @@ <h3>Reading</h3> </section> <section class="help-sec"> <h3>Reviewing</h3> - <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back. In the Word export, accepted changes are applied, rejected ones are reverted and open ones are recorded as tracked changes.</p> + <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back.</p> + </section> + <section class="help-sec"> + <h3>Exporting</h3> + <p><strong>Final version</strong>: 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. <strong>Comparison</strong>: 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.</p> </section> <section class="help-sec"> <h3>Keyboard shortcuts</h3> @@ -4622,6 +4760,7 @@ <h3>Your data</h3> exportTitleFmt: 'Comparison: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} changes ({mod} changed, {ins} added, {del} removed)', + exportCountsFmtOne: '1 change ({mod} changed, {ins} added, {del} removed)', exportReviewFmt: '{k} of {n} reviewed ({a} accepted, {r} rejected)', exportLabelOriginal: 'Old version', exportLabelRevised: 'New version', @@ -4633,9 +4772,9 @@ <h3>Your data</h3> 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', @@ -4656,8 +4795,10 @@ <h3>Your data</h3> 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', @@ -4687,7 +4828,7 @@ <h3>Your data</h3> 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', @@ -4719,12 +4860,12 @@ <h3>Your data</h3> `, faqWordQ: 'Can I keep working on the changes in Word?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, 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', @@ -5066,6 +5207,8 @@ <h3>Your data</h3> 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: [], @@ -5336,7 +5479,8 @@ <h3>Your data</h3> 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) { @@ -5958,8 +6102,11 @@ <h3>Your data</h3> (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; } @@ -5991,6 +6138,7 @@ <h3>Your data</h3> sectionRef: currentSectionRef, text: line, kind, + headingMarker, indent: entry.indent, isHeading, isHeaderFooter, @@ -6383,9 +6531,11 @@ <h3>Your data</h3> } } - const cleaned = mergedNums - .filter(tok => tok.value.length > 0) - .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })); + const cleaned = mergeFragmentedChanges( + mergedNums + .filter(tok => tok.value.length > 0) + .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })) + ); if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned; // Never show a diff that does not reproduce the source texts: fall back to the raw diff. @@ -6394,6 +6544,84 @@ <h3>Your data</h3> ); } +/** + * Joins changes that only an unchanged hyphen or space separates into one replacement, so a + * rewritten phrase reads as one change instead of misleading word pairs: + * "[Standard]→[Echtzeit]-[Berichtswesen]→[Nachtragsmanagement]" becomes + * "[Standard-Berichtswesen]→[Echtzeit-Nachtragsmanagement]". The joined glue goes into both + * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of + * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared + * by both sides at the edges stays unchanged. + */ +function mergeFragmentedChanges(parts) { + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const glued = []; + for (let i = 0; i < parts.length; i++) { + const cur = parts[i]; + const isGlue = + !isChange(cur) && + isChange(glued[glued.length - 1]) && + isChange(parts[i + 1]) && + (/^\s+$/.test(cur.value) || !/\s/.test(cur.value)); + if (isGlue) { + glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true }); + } else { + glued.push(cur); + } + } + + const out = []; + const pushEqual = value => { + if (!value) return; + const last = out[out.length - 1]; + if (last && !isChange(last)) last.value += value; + else out.push({ value }); + }; + let i = 0; + while (i < glued.length) { + if (!isChange(glued[i])) { + pushEqual(glued[i].value); + i++; + continue; + } + let rem = ''; + let add = ''; + while (i < glued.length && isChange(glued[i])) { + if (glued[i].removed) rem += glued[i].value; + else add += glued[i].value; + i++; + } + let lead = ''; + let trail = ''; + if (rem && add) { + const leadRem = rem.match(/^\s*/)[0]; + const leadAdd = add.match(/^\s*/)[0]; + while (lead.length < Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) { + lead += leadRem[lead.length]; + } + rem = rem.slice(lead.length); + add = add.slice(lead.length); + const trailRem = rem.match(/\s*$/)[0]; + const trailAdd = add.match(/\s*$/)[0]; + let n = 0; + while ( + n < Math.min(trailRem.length, trailAdd.length) && + trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n] + ) { + n++; + } + trail = n ? rem.slice(rem.length - n) : ''; + rem = rem.slice(0, rem.length - n); + add = add.slice(0, add.length - n); + } + pushEqual(lead); + if (rem) out.push({ value: rem, removed: true }); + if (add) out.push({ value: add, added: true }); + pushEqual(trail); + } + return out; +} + function coalesceSideTokens(sideParts, prop) { const out = []; for (let i = 0; i < sideParts.length; i++) { @@ -7413,6 +7641,7 @@ <h3>Your data</h3> state.rightName = t('sampleRightName'); state.rightFormat = 'DEMO'; state.rightText = sample.v2; + state.docSources = { left: null, right: null }; resetEditHistory(); runComparison(); } @@ -7423,11 +7652,13 @@ <h3>Your data</h3> state.leftText = ''; state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; document.getElementById('file-input-left').value = ''; } else { state.rightText = ''; state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; resetEditHistory(); document.getElementById('file-input-right').value = ''; } @@ -7800,6 +8031,11 @@ <h3>Your data</h3> 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) { @@ -8011,7 +8247,7 @@ <h3>Your data</h3> const pageLabel = pages.length > 1 ? `${t('pageShort')} ${Math.min(...pages)}–${Math.max(...pages)}` : `${t('pageShort')} ${page}`; const head = item.members ? `${ref} · ${pageLabel}` : [ref, pageLabel].filter(Boolean).join(' · '); const delta = renderSidebarDeltaHtml(item); - const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(t('moreDeltas'))}</span></span>` : ''; + const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(moreDeltasText(delta.more))}</span></span>` : ''; return `<div class="nav-item ${cls} is-${item.status}${isActive ? ' is-active' : ''}" data-row-id="${item.rowId}" data-item-id="${item.id}"><button type="button" class="nav-item-main" tabindex="${item.id === focusId ? 0 : -1}"${isActive ? ' aria-current="true"' : ''}><span class="nav-item-head"><span class="nav-item-ref">${escapeHtml(head)}</span>${moreHtml}${statusIcon}<span class="sr-only">, ${escapeHtml(getChangeTypeLabel(item))}, ${escapeHtml(getStatusLabel(item.status))}. </span></span>${delta.html}</button></div>`; }) .join('') @@ -8025,7 +8261,7 @@ <h3>Your data</h3> // Replaced at build time by scripts/build.py with jsdiff + 04-core.js + 04a-align.js + // src/worker/compare-worker.js as a string literal. -const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n return metaLine(meta.join(';'), block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n if (!isHeaderFooter) {\n seenContentBlock = true;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }));\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; +const DIFF_WORKER_SOURCE = "self.window = self;\n;\n!function(e,n){\"object\"==typeof exports&&\"undefined\"!=typeof module?n(exports):\"function\"==typeof define&&define.amd?define([\"exports\"],n):n((e=e||self).Diff={})}(this,function(e){\"use strict\";function t(){}t.prototype={diff:function(s,a,e){var n,t=2\u003carguments.length&&void 0!==e?e:{},r=t.callback;\"function\"==typeof t&&(r=t,t={}),this.options=t;var u=this;function d(e){return r?(setTimeout(function(){r(void 0,e)},0),!0):e}s=this.castInput(s),a=this.castInput(a),s=this.removeEmpty(this.tokenize(s));var f=(a=this.removeEmpty(this.tokenize(a))).length,c=s.length,p=1,i=f+c;t.maxEditLength&&(i=Math.min(i,t.maxEditLength));var o=null!==(n=t.timeout)&&void 0!==n?n:1/0,l=Date.now()+o,h=[{oldPos:-1,lastComponent:void 0}],v=this.extractCommon(h[0],a,s,0);if(h[0].oldPos+1\u003e=c&&f\u003c=v+1)return d([{value:this.join(a),count:a.length}]);var m=-1/0,g=1/0;function w(){for(var e=Math.max(m,-p);e\u003c=Math.min(g,p);e+=2){var n=void 0,t=h[e-1],r=h[e+1];t&&(h[e-1]=void 0);var i,o=!1;r&&(i=r.oldPos-e,o=r&&0\u003c=i&&i\u003cf);var l=t&&t.oldPos+1\u003cc;if(o||l){if(n=!l||o&&t.oldPos+1\u003cr.oldPos?u.addToPath(r,!0,void 0,0):u.addToPath(t,void 0,!0,1),v=u.extractCommon(n,a,s,e),n.oldPos+1\u003e=c&&f\u003c=v+1)return d(function(e,n,t,r,i){var o,l=[];for(;n;)l.push(n),o=n.previousComponent,delete n.previousComponent,n=o;l.reverse();for(var s=0,a=l.length,u=0,d=0;s\u003ca;s++){var f,c,p=l[s];p.removed?(p.value=e.join(r.slice(d,d+p.count)),d+=p.count,s&&l[s-1].added&&(f=l[s-1],l[s-1]=l[s],l[s]=f)):(!p.added&&i?(c=(c=t.slice(u,u+p.count)).map(function(e,n){var t=r[d+n];return t.length\u003ee.length?t:e}),p.value=e.join(c)):p.value=e.join(t.slice(u,u+p.count)),u+=p.count,p.added||(d+=p.count))}var h=l[a-1];1\u003ca&&\"string\"==typeof h.value&&(h.added||h.removed)&&e.equals(\"\",h.value)&&(l[a-2].value+=h.value,l.pop());return l}(u,n.lastComponent,a,s,u.useLongestToken));(h[e]=n).oldPos+1\u003e=c&&(g=Math.min(g,e-1)),f\u003c=v+1&&(m=Math.max(m,e+1))}else h[e]=void 0}p++}if(r)!function e(){setTimeout(function(){return i\u003cp||Date.now()\u003el?r():void(w()||e())},0)}();else for(;p\u003c=i&&Date.now()\u003c=l;){var y=w();if(y)return y}},addToPath:function(e,n,t,r){var i=e.lastComponent;return i&&i.added===n&&i.removed===t?{oldPos:e.oldPos+r,lastComponent:{count:i.count+1,added:n,removed:t,previousComponent:i.previousComponent}}:{oldPos:e.oldPos+r,lastComponent:{count:1,added:n,removed:t,previousComponent:i}}},extractCommon:function(e,n,t,r){for(var i=n.length,o=t.length,l=e.oldPos,s=l-r,a=0;s+1\u003ci&&l+1\u003co&&this.equals(n[s+1],t[l+1]);)s++,l++,a++;return a&&(e.lastComponent={count:a,previousComponent:e.lastComponent}),e.oldPos=l,s},equals:function(e,n){return this.options.comparator?this.options.comparator(e,n):e===n||this.options.ignoreCase&&e.toLowerCase()===n.toLowerCase()},removeEmpty:function(e){for(var n=[],t=0;t\u003ce.length;t++)e[t]&&n.push(e[t]);return n},castInput:function(e){return e},tokenize:function(e){return e.split(\"\")},join:function(e){return e.join(\"\")}};var r=new t;function i(e,n){if(\"function\"==typeof e)n.callback=e;else if(e)for(var t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n}var o=/^[A-Za-z\\xC0-\\u02C6\\u02C8-\\u02D7\\u02DE-\\u02FF\\u1E00-\\u1EFF]+$/,l=/\\S/,s=new t;s.equals=function(e,n){return this.options.ignoreCase&&(e=e.toLowerCase(),n=n.toLowerCase()),e===n||this.options.ignoreWhitespace&&!l.test(e)&&!l.test(n)},s.tokenize=function(e){for(var n=e.split(/([^\\S\\r\\n]+|[()[\\]{}'\"\\r\\n]|\\b)/),t=0;t\u003cn.length-1;t++)!n[t+1]&&n[t+2]&&o.test(n[t])&&o.test(n[t+2])&&(n[t]+=n[t+2],n.splice(t+1,2),t--);return n};var a=new t;function L(e,n,t){return a.diff(e,n,t)}a.tokenize=function(e){this.options.stripTrailingCr&&(e=e.replace(/\\r\\n/g,\"\\n\"));var n=[],t=e.split(/(\\n|\\r\\n)/);t[t.length-1]||t.pop();for(var r=0;r\u003ct.length;r++){var i=t[r];r%2&&!this.options.newlineIsToken?n[n.length-1]+=i:(this.options.ignoreWhitespace&&(i=i.trim()),n.push(i))}return n};var u=new t;u.tokenize=function(e){return e.split(/(\\S.+?[.!?])(?=\\s+|$)/)};var d=new t;function f(e){return(f=\"function\"==typeof Symbol&&\"symbol\"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&\"function\"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?\"symbol\":typeof e})(e)}function n(n,e){var t,r=Object.keys(n);return Object.getOwnPropertySymbols&&(t=Object.getOwnPropertySymbols(n),e&&(t=t.filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable})),r.push.apply(r,t)),r}function c(i){for(var e=1;e\u003carguments.length;e++){var o=null!=arguments[e]?arguments[e]:{};e%2?n(Object(o),!0).forEach(function(e){var n,t,r;n=i,r=o[t=e],t in n?Object.defineProperty(n,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):n[t]=r}):Object.getOwnPropertyDescriptors?Object.defineProperties(i,Object.getOwnPropertyDescriptors(o)):n(Object(o)).forEach(function(e){Object.defineProperty(i,e,Object.getOwnPropertyDescriptor(o,e))})}return i}function x(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if(\"undefined\"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,n){if(!e)return;if(\"string\"==typeof e)return p(e,n);var t=Object.prototype.toString.call(e).slice(8,-1);\"Object\"===t&&e.constructor&&(t=e.constructor.name);if(\"Map\"===t||\"Set\"===t)return Array.from(e);if(\"Arguments\"===t||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t))return p(e,n)}(e)||function(){throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\")}()}function p(e,n){(null==n||n\u003ee.length)&&(n=e.length);for(var t=0,r=new Array(n);t\u003cn;t++)r[t]=e[t];return r}d.tokenize=function(e){return e.split(/([{}:;,]|\\s+)/)};var h=Object.prototype.toString,v=new t;function m(e,n,t,r,i){var o,l;for(n=n||[],t=t||[],r&&(e=r(i,e)),o=0;o\u003cn.length;o+=1)if(n[o]===e)return t[o];if(\"[object Array]\"===h.call(e)){for(n.push(e),l=new Array(e.length),t.push(l),o=0;o\u003ce.length;o+=1)l[o]=m(e[o],n,t,r,i);return n.pop(),t.pop(),l}if(e&&e.toJSON&&(e=e.toJSON()),\"object\"===f(e)&&null!==e){n.push(e),l={},t.push(l);var s,a=[];for(s in e)e.hasOwnProperty(s)&&a.push(s);for(a.sort(),o=0;o\u003ca.length;o+=1)l[s=a[o]]=m(e[s],n,t,r,s);n.pop(),t.pop()}else l=e;return l}v.useLongestToken=!0,v.tokenize=a.tokenize,v.castInput=function(e){var n=this.options,t=n.undefinedReplacement,r=n.stringifyReplacer,i=void 0===r?function(e,n){return void 0===n?t:n}:r;return\"string\"==typeof e?e:JSON.stringify(m(e,null,null,i),i,\" \")},v.equals=function(e,n){return t.prototype.equals.call(v,e.replace(/,([\\r\\n])/g,\"$1\"),n.replace(/,([\\r\\n])/g,\"$1\"))};var g=new t;function C(e){var l=1\u003carguments.length&&void 0!==arguments[1]?arguments[1]:{},s=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),a=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],i=[],u=0;function n(){var e={};for(i.push(e);u\u003cs.length;){var n=s[u];if(/^(\\-\\-\\-|\\+\\+\\+|@@)\\s/.test(n))break;var t=/^(?:Index:|diff(?: -r \\w+)+)\\s+(.+?)\\s*$/.exec(n);t&&(e.index=t[1]),u++}for(o(e),o(e),e.hunks=[];u\u003cs.length;){var r=s[u];if(/^(Index:|diff|\\-\\-\\-|\\+\\+\\+)\\s/.test(r))break;if(/^@@/.test(r))e.hunks.push(function(){var e=u,n=s[u++].split(/@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@/),t={oldStart:+n[1],oldLines:void 0===n[2]?1:+n[2],newStart:+n[3],newLines:void 0===n[4]?1:+n[4],lines:[],linedelimiters:[]};0===t.oldLines&&(t.oldStart+=1);0===t.newLines&&(t.newStart+=1);for(var r=0,i=0;u\u003cs.length&&!(0===s[u].indexOf(\"--- \")&&u+2\u003cs.length&&0===s[u+1].indexOf(\"+++ \")&&0===s[u+2].indexOf(\"@@\"));u++){var o=0==s[u].length&&u!=s.length-1?\" \":s[u][0];if(\"+\"!==o&&\"-\"!==o&&\" \"!==o&&\"\\\\\"!==o)break;t.lines.push(s[u]),t.linedelimiters.push(a[u]||\"\\n\"),\"+\"===o?r++:\"-\"===o?i++:\" \"===o&&(r++,i++)}r||1!==t.newLines||(t.newLines=0);i||1!==t.oldLines||(t.oldLines=0);if(l.strict){if(r!==t.newLines)throw new Error(\"Added line count did not match for hunk at line \"+(e+1));if(i!==t.oldLines)throw new Error(\"Removed line count did not match for hunk at line \"+(e+1))}return t}());else{if(r&&l.strict)throw new Error(\"Unknown line \"+(u+1)+\" \"+JSON.stringify(r));u++}}}function o(e){var n,t,r,i=/^(---|\\+\\+\\+)\\s+(.*)$/.exec(s[u]);i&&(n=\"---\"===i[1]?\"old\":\"new\",r=(t=i[2].split(\"\\t\",2))[0].replace(/\\\\\\\\/g,\"\\\\\"),/^\".*\"$/.test(r)&&(r=r.substr(1,r.length-2)),e[n+\"FileName\"]=r,e[n+\"Header\"]=(t[1]||\"\").trim(),u++)}for(;u\u003cs.length;)n();return i}function w(e,n){var t=2\u003carguments.length&&void 0!==arguments[2]?arguments[2]:{};if(\"string\"==typeof n&&(n=C(n)),Array.isArray(n)){if(1\u003cn.length)throw new Error(\"applyPatch only works with a single input.\");n=n[0]}var r,i,l=e.split(/\\r\\n|[\\n\\v\\f\\r\\x85]/),o=e.match(/\\r\\n|[\\n\\v\\f\\r\\x85]/g)||[],s=n.hunks,a=t.compareLine||function(e,n,t,r){return n===r},u=0,d=t.fuzzFactor||0,f=0,c=0;for(var p=0;p\u003cs.length;p++){for(var h=s[p],v=l.length-h.oldLines,m=0,g=c+h.oldStart-1,w=function(n,t,r){var i=!0,o=!1,l=!1,s=1;return function e(){if(i&&!l){if(o?s++:i=!1,n+s\u003c=r)return s;l=!0}if(!o)return l||(i=!0),t\u003c=n-s?-s++:(o=!0,e())}}(g,f,v);void 0!==m;m=w())if(function(e,n){for(var t=0;t\u003ce.lines.length;t++){var r=e.lines[t],i=0\u003cr.length?r[0]:\" \",o=0\u003cr.length?r.substr(1):r;if(\" \"===i||\"-\"===i){if(!a(n+1,l[n],i,o)&&d\u003c++u)return;n++}}return 1}(h,g+m)){h.offset=c+=m;break}if(void 0===m)return!1;f=h.offset+h.oldStart+h.oldLines}for(var y=0,L=0;L\u003cs.length;L++){var x=s[L],S=x.oldStart+x.offset+y-1;y+=x.newLines-x.oldLines;for(var b=0;b\u003cx.lines.length;b++){var k,F=x.lines[b],N=0\u003cF.length?F[0]:\" \",P=0\u003cF.length?F.substr(1):F,j=x.linedelimiters&&x.linedelimiters[b]||\"\\n\";\" \"===N?S++:\"-\"===N?(l.splice(S,1),o.splice(S,1)):\"+\"===N?(l.splice(S,0,P),o.splice(S,0,j),S++):\"\\\\\"===N&&(\"+\"===(k=x.lines[b-1]?x.lines[b-1][0]:null)?r=!0:\"-\"===k&&(i=!0))}}if(r)for(;!l[l.length-1];)l.pop(),o.pop();else i&&(l.push(\"\"),o.push(\"\\n\"));for(var O=0;O\u003cl.length-1;O++)l[O]=l[O]+o[O];return l.join(\"\")}function y(e,n,u,d,t,r,f){void 0===(f=f||{}).context&&(f.context=4);var c=L(u,d,f);if(c){c.push({value:\"\",lines:[]});for(var p=[],h=0,v=0,m=[],g=1,w=1,i=0;i\u003cc.length;i++)!function(e){var n,t,r,i,o,l,s=c[e],a=s.lines||s.value.replace(/\\n$/,\"\").split(\"\\n\");s.lines=a,s.added||s.removed?(h||(n=c[e-1],h=g,v=w,n&&(m=0\u003cf.context?y(n.lines.slice(-f.context)):[],h-=m.length,v-=m.length)),m.push.apply(m,x(a.map(function(e){return(s.added?\"+\":\"-\")+e}))),s.added?w+=a.length:g+=a.length):(h&&(a.length\u003c=2*f.context&&e\u003cc.length-2?m.push.apply(m,x(y(a))):(t=Math.min(a.length,f.context),m.push.apply(m,x(y(a.slice(0,t)))),r={oldStart:h,oldLines:g-h+t,newStart:v,newLines:w-v+t,lines:m},e\u003e=c.length-2&&a.length\u003c=f.context&&(i=/\\n$/.test(u),o=/\\n$/.test(d),l=0==a.length&&m.length\u003er.oldLines,!i&&l&&0\u003cu.length&&m.splice(r.oldLines,0,\"\\\\ No newline at end of file\"),(i||l)&&o||m.push(\"\\\\ No newline at end of file\")),p.push(r),v=h=0,m=[])),g+=a.length,w+=a.length)}(i);return{oldFileName:e,newFileName:n,oldHeader:t,newHeader:r,hunks:p}}function y(e){return e.map(function(e){return\" \"+e})}}function S(e){if(Array.isArray(e))return e.map(S).join(\"\\n\");var n=[];e.oldFileName==e.newFileName&&n.push(\"Index: \"+e.oldFileName),n.push(\"===================================================================\"),n.push(\"--- \"+e.oldFileName+(void 0===e.oldHeader?\"\":\"\\t\"+e.oldHeader)),n.push(\"+++ \"+e.newFileName+(void 0===e.newHeader?\"\":\"\\t\"+e.newHeader));for(var t=0;t\u003ce.hunks.length;t++){var r=e.hunks[t];0===r.oldLines&&--r.oldStart,0===r.newLines&&--r.newStart,n.push(\"@@ -\"+r.oldStart+\",\"+r.oldLines+\" +\"+r.newStart+\",\"+r.newLines+\" @@\"),n.push.apply(n,r.lines)}return n.join(\"\\n\")+\"\\n\"}function b(e,n,t,r,i,o,l){return S(y(e,n,t,r,i,o,l))}function k(e,n){if(n.length\u003ee.length)return!1;for(var t=0;t\u003cn.length;t++)if(n[t]!==e[t])return!1;return!0}function F(e){var n=function r(e){var i=0;var o=0;e.forEach(function(e){var n,t;\"string\"!=typeof e?(n=r(e.mine),t=r(e.theirs),void 0!==i&&(n.oldLines===t.oldLines?i+=n.oldLines:i=void 0),void 0!==o&&(n.newLines===t.newLines?o+=n.newLines:o=void 0)):(void 0===o||\"+\"!==e[0]&&\" \"!==e[0]||o++,void 0===i||\"-\"!==e[0]&&\" \"!==e[0]||i++)});return{oldLines:i,newLines:o}}(e.lines),t=n.oldLines,r=n.newLines;void 0!==t?e.oldLines=t:delete e.oldLines,void 0!==r?e.newLines=r:delete e.newLines}function N(e,n){if(\"string\"!=typeof e)return e;if(/^@@/m.test(e)||/^Index:/m.test(e))return C(e)[0];if(!n)throw new Error(\"Must provide a base reference or pass in a patch\");return y(void 0,void 0,n,e)}function P(e){return e.newFileName&&e.newFileName!==e.oldFileName}function j(e,n,t){return n===t?n:(e.conflict=!0,{mine:n,theirs:t})}function O(e,n){return e.oldStart\u003cn.oldStart&&e.oldStart+e.oldLines\u003cn.oldStart}function H(e,n){return{oldStart:e.oldStart,oldLines:e.oldLines,newStart:e.newStart+n,newLines:e.newLines,lines:e.lines}}function A(e,n,t,r){var i,o=M(n),l=function(e,n){var t=[],r=[],i=0,o=!1,l=!1;for(;i\u003cn.length&&e.index\u003ce.lines.length;){var s=e.lines[e.index],a=n[i];if(\"+\"===a[0])break;if(o=o||\" \"!==s[0],r.push(a),i++,\"+\"===s[0])for(l=!0;\"+\"===s[0];)t.push(s),s=e.lines[++e.index];a.substr(1)===s.substr(1)?(t.push(s),e.index++):l=!0}\"+\"===(n[i]||\"\")[0]&&o&&(l=!0);if(l)return t;for(;i\u003cn.length;)r.push(n[i++]);return{merged:r,changes:t}}(t,o);l.merged?(i=e.lines).push.apply(i,x(l.merged)):E(e,r?l:o,r?o:l)}function E(e,n,t){e.conflict=!0,e.lines.push({conflict:!0,mine:n,theirs:t})}function z(e,n,t){for(;n.offset\u003ct.offset&&n.index\u003cn.lines.length;){var r=n.lines[n.index++];e.lines.push(r),n.offset++}}function T(e,n){for(;n.index\u003cn.lines.length;){var t=n.lines[n.index++];e.lines.push(t)}}function M(e){for(var n=[],t=e.lines[e.index][0];e.index\u003ce.lines.length;){var r=e.lines[e.index];if(\"-\"===t&&\"+\"===r[0]&&(t=\"+\"),t!==r[0])break;n.push(r),e.index++}return n}function D(e){return e.reduce(function(e,n){return e&&\"-\"===n[0]},!0)}function I(e,n,t){for(var r=0;r\u003ct;r++){var i=n[n.length-t+r].substr(1);if(e.lines[e.index+r]!==\" \"+i)return}return e.index+=t,1}g.tokenize=function(e){return e.slice()},g.join=g.removeEmpty=function(e){return e},e.Diff=t,e.applyPatch=w,e.applyPatches=function(e,o){\"string\"==typeof e&&(e=C(e));var n=0;!function r(){var i=e[n++];if(!i)return o.complete();o.loadFile(i,function(e,n){if(e)return o.complete(e);var t=w(n,i,o);o.patched(i,t,function(e){return e?o.complete(e):void r()})})}()},e.canonicalize=m,e.convertChangesToDMP=function(e){for(var n,t,r=[],i=0;i\u003ce.length;i++)t=(n=e[i]).added?1:n.removed?-1:0,r.push([t,n.value]);return r},e.convertChangesToXML=function(e){for(var n,t=[],r=0;r\u003ce.length;r++){var i=e[r];i.added?t.push(\"\u003cins\u003e\"):i.removed&&t.push(\"\u003cdel\u003e\"),t.push((n=i.value,n.replace(/&/g,\"&\").replace(/\u003c/g,\"<\").replace(/\u003e/g,\">\").replace(/\"/g,\""\"))),i.added?t.push(\"\u003c/ins\u003e\"):i.removed&&t.push(\"\u003c/del\u003e\")}return t.join(\"\")},e.createPatch=function(e,n,t,r,i,o){return b(e,e,n,t,r,i,o)},e.createTwoFilesPatch=b,e.diffArrays=function(e,n,t){return g.diff(e,n,t)},e.diffChars=function(e,n,t){return r.diff(e,n,t)},e.diffCss=function(e,n,t){return d.diff(e,n,t)},e.diffJson=function(e,n,t){return v.diff(e,n,t)},e.diffLines=L,e.diffSentences=function(e,n,t){return u.diff(e,n,t)},e.diffTrimmedLines=function(e,n,t){var r=i(t,{ignoreWhitespace:!0});return a.diff(e,n,r)},e.diffWords=function(e,n,t){return t=i(t,{ignoreWhitespace:!0}),s.diff(e,n,t)},e.diffWordsWithSpace=function(e,n,t){return s.diff(e,n,t)},e.formatPatch=S,e.merge=function(e,n,t){e=N(e,t),n=N(n,t);var r={};(e.index||n.index)&&(r.index=e.index||n.index),(e.newFileName||n.newFileName)&&(P(e)?P(n)?(r.oldFileName=j(r,e.oldFileName,n.oldFileName),r.newFileName=j(r,e.newFileName,n.newFileName),r.oldHeader=j(r,e.oldHeader,n.oldHeader),r.newHeader=j(r,e.newHeader,n.newHeader)):(r.oldFileName=e.oldFileName,r.newFileName=e.newFileName,r.oldHeader=e.oldHeader,r.newHeader=e.newHeader):(r.oldFileName=n.oldFileName||e.oldFileName,r.newFileName=n.newFileName||e.newFileName,r.oldHeader=n.oldHeader||e.oldHeader,r.newHeader=n.newHeader||e.newHeader)),r.hunks=[];for(var i=0,o=0,l=0,s=0;i\u003ce.hunks.length||o\u003cn.hunks.length;){var a,u=e.hunks[i]||{oldStart:1/0},d=n.hunks[o]||{oldStart:1/0};O(u,d)?(r.hunks.push(H(u,l)),i++,s+=u.newLines-u.oldLines):O(d,u)?(r.hunks.push(H(d,s)),o++,l+=d.newLines-d.oldLines):(function(e,n,t,r,i){var o,l,s={offset:n,lines:t,index:0},a={offset:r,lines:i,index:0};z(e,s,a),z(e,a,s);for(;s.index\u003cs.lines.length&&a.index\u003ca.lines.length;){var u=s.lines[s.index],d=a.lines[a.index];\"-\"!==u[0]&&\"+\"!==u[0]||\"-\"!==d[0]&&\"+\"!==d[0]?\"+\"===u[0]&&\" \"===d[0]?(o=e.lines).push.apply(o,x(M(s))):\"+\"===d[0]&&\" \"===u[0]?(l=e.lines).push.apply(l,x(M(a))):\"-\"===u[0]&&\" \"===d[0]?A(e,s,a):\"-\"===d[0]&&\" \"===u[0]?A(e,a,s,!0):u===d?(e.lines.push(u),s.index++,a.index++):E(e,M(s),M(a)):function(e,n,t){var r,i,o,l=M(n),s=M(t);if(D(l)&&D(s)){if(k(l,s)&&I(t,l,l.length-s.length))return(r=e.lines).push.apply(r,x(l));if(k(s,l)&&I(n,s,s.length-l.length))return(i=e.lines).push.apply(i,x(s))}else if(function(e,n){return e.length===n.length&&k(e,n)}(l,s))return(o=e.lines).push.apply(o,x(l));E(e,l,s)}(e,s,a)}T(e,s),T(e,a),F(e)}(a={oldStart:Math.min(u.oldStart,d.oldStart),oldLines:0,newStart:Math.min(u.newStart+l,d.oldStart+s),newLines:0,lines:[]},u.oldStart,u.lines,d.oldStart,d.lines),o++,i++,r.hunks.push(a))}return r},e.parsePatch=C,e.reversePatch=function e(n){return Array.isArray(n)?n.map(e).reverse():c(c({},n),{},{oldFileName:n.newFileName,oldHeader:n.newHeader,newFileName:n.oldFileName,newHeader:n.oldHeader,hunks:n.hunks.map(function(e){return{oldLines:e.newLines,oldStart:e.newStart,newLines:e.oldLines,newStart:e.oldStart,linedelimiters:e.linedelimiters,lines:e.lines.map(function(e){return e.startsWith(\"-\")?\"+\".concat(e.slice(1)):e.startsWith(\"+\")?\"-\".concat(e.slice(1)):e})}})})},e.structuredPatch=y,Object.defineProperty(e,\"__esModule\",{value:!0})});\n;\n/* \u2500\u2500 Document text encoding \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n * Loaded documents are kept as plain strings (state.leftText / state.rightText).\n * Structural metadata that is NOT part of the document text \u2014 page breaks,\n * header/footer lines, list nesting depth \u2014 is encoded with ASCII control\n * characters that cannot occur in user-visible text:\n *\n * DOC_PAGE_MARK + n starts page n (own line)\n * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata,\n * meta = \"hf\" | \"lvl=2\" | \"hf;lvl=1\"\n *\n * Every external input (typed/pasted text, text files, PDF and DOCX text) is\n * passed through sanitizeExternalText(), which strips these control characters,\n * so document content can never be (mis)interpreted as metadata, and\n * docTextToPlainText() removes the metadata before text is shown to the user.\n * This file must stay free of DOM access (it also runs inside a Worker).\n */\nconst DOC_PAGE_MARK = '\\u001E';\nconst DOC_META_MARK = '\\u001F';\n\nfunction sanitizeExternalText(text) {\n return String(text == null ? '' : text)\n .replace(/\\r\\n?/g, '\\n')\n .replace(/\\f/g, '\\n')\n .replace(/[\\u0000-\\u0008\\u000B\\u000E-\\u001F\\u007F]/g, '');\n}\n\nfunction pageMarkerLine(pageNum) {\n return `${DOC_PAGE_MARK}${pageNum}`;\n}\n\nfunction metaLine(meta, text) {\n return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text;\n}\n\nfunction serializeBlockLine(block) {\n const meta = [];\n if (block.kind === 'hf' || block.isHeaderFooter) meta.push('hf');\n if (block.indent \u003e 0) meta.push(`lvl=${block.indent}`);\n // A heading marker (\"## \") from the source stays, so a rewritten text keeps its structure.\n return metaLine(meta.join(';'), (block.headingMarker || '') + block.text);\n}\n\nfunction parseLineMeta(line) {\n if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line };\n const end = line.indexOf(DOC_META_MARK, 1);\n if (end \u003c 0) return { meta: null, text: line.slice(1) };\n const meta = {};\n for (const part of line.slice(1, end).split(';')) {\n const [key, value] = part.split('=');\n if (key) meta[key.trim()] = value === undefined ? true : value.trim();\n }\n return { meta, text: line.slice(end + 1) };\n}\n\n// Plain text for display/editing (paste dialog): page and header/footer metadata removed.\nfunction docTextToPlainText(docText) {\n if (!docText) return '';\n const out = [];\n for (const rawLine of String(docText).split('\\n')) {\n if (rawLine.startsWith(DOC_PAGE_MARK)) continue;\n out.push(parseLineMeta(rawLine).text);\n }\n return out\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n}\n\n/* \u2500\u2500 Header / footer detection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// A line that is nothing but a page number label (\"Seite 3 von 9\", \"- 3 -\", \"Page 2/7\").\nfunction isPageNumberLabel(line) {\n if (!line || line.length \u003e 40) return false;\n return /^(?:(?:Seite|Page|Blatt|S\\.|p\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?|-\\s*\\d+\\s*-|\\d+\\s*(?:\\/|von|of)\\s*\\d+)$/i.test(\n line.trim()\n );\n}\n\n// A page number label without a word like \"Seite\"/\"Page\" (\"3/9\", \"3 von 9\", \"- 3 -\"): the same\n// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks).\nfunction isBarePageNumberLabel(line) {\n return isPageNumberLabel(line) && !/^\\s*(?:Seite|Page|Blatt|S\\.|p\\.)/i.test(line);\n}\n\n// Text-only header/footer candidate test (used for pasted text without page geometry).\n// Pure page labels qualify on their own; longer running lines (\"Vertraulich \u00b7 Seite 2 von 9\")\n// are only treated as header/footer by parseIntoBlocks when they repeat within the document.\nfunction isLikelyHeaderOrFooterLine(line) {\n if (!line || line.length \u003e 130) return false;\n if (isPageNumberLabel(line)) return true;\n if (/(?:\\bSeite|\\bPage)\\s+\\d+\\s*(?:von|of|\\/)\\s*\\d+\\s*$/i.test(line)) return true;\n if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\\b.*(?:\\bSeite|\\bPage)\\s+\\d+/i.test(line)) {\n return true;\n }\n return false;\n}\n\n// Key for \"same running line on every page\": page numbers and other digits normalised away.\nfunction normalizeHfRepeatKey(text) {\n return String(text).toLowerCase().replace(/\\d+/g, '#').replace(/\\s+/g, ' ').trim();\n}\n\n// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised,\n// so a changed date or version label in a header is still detected as a difference.\nfunction normalizeHfCompareKey(text) {\n return String(text)\n .toLowerCase()\n .replace(/(?:\\bseite|\\bpage|\\bblatt|\\bs\\.|\\bp\\.)\\s*\\d+(?:\\s*(?:von|of|\\/)\\s*\\d+)?/g, 'seite #')\n .replace(/^\\s*-\\s*\\d+\\s*-\\s*$/, '#')\n .replace(/^\\s*\\d+\\s*$/, '#')\n .replace(/\\b\\d+\\s*(?:\\/|von|of)\\s*\\d+\\s*$/, '#')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\n/**\n * Decides which margin lines of a paginated document are running headers/footers.\n * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the\n * line's vertical position as a fraction of the page height (0 = bottom, 1 = top).\n * A candidate is a header/footer only if its digit-normalised text occurs on at least\n * 50% of the pages (and on at least 2 pages) at a similar vertical position.\n * Returns an array of boolean arrays parallel to `pages`.\n */\nfunction detectRepeatingMarginLines(pages, yTolerance = 0.03) {\n const numPages = pages.length;\n const result = pages.map(lines =\u003e lines.map(() =\u003e false));\n if (numPages \u003c 2) return result;\n const minPages = Math.max(2, Math.ceil(numPages * 0.5));\n\n const groups = new Map();\n pages.forEach((lines, p) =\u003e {\n lines.forEach((line, l) =\u003e {\n if (!line || !line.text || !line.text.trim()) return;\n const band = line.yPct \u003e= 0.5 ? 'T' : 'B';\n const key = `${band}|${normalizeHfRepeatKey(line.text)}`;\n if (!groups.has(key)) groups.set(key, []);\n groups.get(key).push({ p, l, y: line.yPct });\n });\n });\n\n for (const members of groups.values()) {\n if (new Set(members.map(m =\u003e m.p)).size \u003c minPages) continue;\n // Cluster by vertical position: keep the members close to the median y.\n const ys = members.map(m =\u003e m.y).sort((a, b) =\u003e a - b);\n const median = ys[Math.floor(ys.length / 2)];\n const close = members.filter(m =\u003e Math.abs(m.y - median) \u003c= yTolerance);\n if (new Set(close.map(m =\u003e m.p)).size \u003c minPages) continue;\n for (const m of close) result[m.p][m.l] = true;\n }\n return result;\n}\n\n// Position of a header/footer line on its page: 'T' before the page's first body line,\n// 'B' after it; '' for documents without pages. Returns one entry per header/footer block.\nfunction headerFooterSlots(blocks) {\n const out = [];\n let page = null;\n let seenBody = false;\n for (const b of blocks) {\n if (b.pageNum !== page) {\n page = b.pageNum;\n seenBody = false;\n }\n if (!b.isHeaderFooter) {\n seenBody = true;\n continue;\n }\n out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' });\n }\n return out;\n}\n\n// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean\n// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that\n// only keeps tokens hidden when both versions contain them equally often.\nfunction matchTokenSequences(a, b) {\n const matchedA = new Array(a.length).fill(false);\n const matchedB = new Array(b.length).fill(false);\n const diffLib = getDiffLibrary();\n if (diffLib && typeof diffLib.diffArrays === 'function') {\n let i = 0;\n let j = 0;\n for (const part of diffLib.diffArrays(a, b)) {\n const n = part.count !== undefined ? part.count : part.value.length;\n if (part.added) j += n;\n else if (part.removed) i += n;\n else {\n for (let k = 0; k \u003c n; k++) {\n matchedA[i++] = true;\n matchedB[j++] = true;\n }\n }\n }\n return [matchedA, matchedB];\n }\n const count = arr =\u003e arr.reduce((m, t) =\u003e m.set(t, (m.get(t) || 0) + 1), new Map());\n const ca = count(a);\n const cb = count(b);\n a.forEach((t, k) =\u003e { matchedA[k] = ca.get(t) === cb.get(t); });\n b.forEach((t, k) =\u003e { matchedB[k] = ca.get(t) === cb.get(t); });\n return [matchedA, matchedB];\n}\n\n/**\n * Change-preserving header/footer filter. Hiding running headers/footers must never hide a\n * change, so the hidden candidates of both versions are matched against each other as\n * sequences of (band, text) tokens in document order, with page numbers normalised away\n * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when\n * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one\n * (a header changed on one page, a footer changed on all pages, a repeated line deleted on\n * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and\n * diffed like body text.\n * Exceptions, which stay hidden: pure page-number labels (\"Seite 3 von 9\": they change\n * whenever the page count changes, not with the content), and the running lines of a PDF\n * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted\n * text): there is nothing to compare them with, they are layout, not a change.\n * Mutates the blocks; returns the number of header/footer blocks that remain hidden.\n */\nfunction reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) {\n const hfL = leftBlocks.filter(b =\u003e b.isHeaderFooter);\n const hfR = rightBlocks.filter(b =\u003e b.isHeaderFooter);\n for (const b of hfL.concat(hfR)) delete b.hfVisible;\n if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) {\n const paginated = blocks =\u003e blocks.some(b =\u003e b.hasExplicitPage);\n const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length \u003e 0 && hfR.length \u003e 0);\n if (comparable) {\n const candidates = blocks =\u003e\n headerFooterSlots(blocks).filter(slot =\u003e !isPageNumberLabel(slot.block.text));\n const slotsL = candidates(leftBlocks);\n const slotsR = candidates(rightBlocks);\n const token = slot =\u003e `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`;\n const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token));\n slotsL.forEach((slot, k) =\u003e { if (!matchedL[k]) slot.block.hfVisible = true; });\n slotsR.forEach((slot, k) =\u003e { if (!matchedR[k]) slot.block.hfVisible = true; });\n }\n }\n return [...hfL, ...hfR].filter(b =\u003e !b.hfVisible).length;\n}\n\nfunction isHiddenHeaderFooter(block) {\n return Boolean(block && block.isHeaderFooter && !block.hfVisible);\n}\n\n/* \u2500\u2500 Block classification \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// An ordinary sentence is never a heading, whatever it starts with (\"\u00a7 2 Der Auftragnehmer\n// erbringt die Leistung fristgerecht.\"): it ends with sentence punctuation and has several\n// words, or it is simply too long for a heading.\nfunction looksLikeSentence(line) {\n const words = line.split(/\\s+/).length;\n return (/[.;:,!?][\"\u201c\u201d)]?$/.test(line) && words \u003e= 6) || words \u003e= 14;\n}\n\nfunction classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) {\n if (isHeaderFooter) return 'hf';\n if (/^#\\s+/.test(line)) return 'h1';\n if (/^##\\s+/.test(line)) return 'h2';\n if (/^###\\s+/.test(line)) return 'h3';\n if (looksLikeSentence(line)) return classifyBodyKind(line);\n\n if (\n isFirstContentBlock &&\n line.length \u003c 105 &&\n !/^(\u00a7|\\(\\d+\\)|\\d+\\.|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s)/.test(line) &&\n (/^[A-Z\u00c4\u00d6\u00dc0-9\\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line))\n ) {\n return 'h1';\n }\n\n if (\n /^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i.test(line) &&\n line.length \u003c 115\n ) {\n return 'h2';\n }\n\n // \"1. Haftung\" is a heading; \"1. Der Auftragnehmer haftet \u2026.\" is a numbered paragraph.\n if (/^\\d+\\.\\s+[A-Z\u00c4\u00d6\u00dc]/.test(line) && line.length \u003c 115 && !/[.;:,]$/.test(line)) {\n return 'h2';\n }\n\n if (\n /^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+[A-Z\u00c4\u00d6\u00dca-z\u00e4\u00f6\u00fc]/.test(line) &&\n line.length \u003c 105 &&\n !/[.;]$/.test(line)\n ) {\n return 'h3';\n }\n\n if (/^[A-Z\u00c4\u00d6\u00dc\\s&-]{10,}$/.test(line) && line.length \u003c 105) {\n return isFirstContentBlock ? 'h1' : 'h2';\n }\n\n return classifyBodyKind(line);\n}\n\nfunction classifyBodyKind(line) {\n if (/^[\u2022\u2023\u25aa\u25e6\u2013*-]\\s+\\S/.test(line)) {\n return 'bullet';\n }\n\n if (/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+\\S/i.test(line)) {\n return 'clause';\n }\n\n return 'p';\n}\n\nfunction extractListMarker(text, kind) {\n if (!text) return null;\n if (kind === 'bullet') {\n const m = text.match(/^([\u2022\u2023\u25aa\u25e6\u2013*-])\\s+(.*)$/s);\n if (m) return { marker: '\u2022', body: m[2] };\n }\n if (kind === 'clause') {\n const m = text.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\([ivx]+\\)|\\d+\\)|[a-z]\\))\\s+(.*)$/is);\n if (m) return { marker: m[1], body: m[2] };\n }\n return null;\n}\n\n// Numbering prefix that only reflects the position in the document (list marker or\n// heading number). Used to align blocks independent of renumbering. Returns null when\n// the block has no such marker (or the remaining body would be too short to identify it).\nfunction splitNumberingMarker(text, kind) {\n let m = null;\n if (kind === 'clause' || kind === 'bullet') {\n const split = extractListMarker(text, kind);\n if (split) m = [null, kind === 'bullet' ? text.match(/^\\S+/)[0] : split.marker, split.body];\n } else if (kind === 'h2' || kind === 'h3') {\n m = text.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|\\d+(?:\\.\\d+)+\\.?|\\d+\\.)\\s+([A-Z\u00c4\u00d6\u00dc].*)$/s);\n }\n if (!m) return null;\n const body = m[2].trim();\n if (body.length \u003c 3) return null;\n return { marker: m[1], body };\n}\n\nfunction extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') {\n if (kind === 'hf') return t('typeHf');\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n if (secMatch) {\n return secMatch[1].replace(/\\s+/g, ' ').trim();\n }\n const numHeadingMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (numHeadingMatch && line.length \u003c 115) {\n return numHeadingMatch[1];\n }\n const subHeadingMatch = line.match(/^(\\d+\\.\\d+(?:\\.\\d+)*)\\s+/);\n if (subHeadingMatch && line.length \u003c 115) {\n return subHeadingMatch[1];\n }\n const subMatch = line.match(/^(\\(\\d+\\)|\\([a-z]\\)|\\d+(?:\\.\\d+)+|\\d+\\)|[a-z]\\))/i);\n if (subMatch) {\n const sub = subMatch[1];\n return currentSectionRef ? `${currentSectionRef} ${sub}` : sub;\n }\n if (kind === 'bullet' && currentSectionRef) {\n return `${currentSectionRef} \u2022`;\n }\n return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`;\n}\n\n// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so\n// \"Services\" and \"Services.\" (or \"(Zuschlag)\" and \"Zuschlag\") count as the same word.\nfunction tokenizeWords(str) {\n const words = str\n .toLowerCase()\n .split(/\\s+/)\n .map(w =\u003e w.replace(/^[^\\p{L}\\p{N}\u00a7\u20ac$%]+|[^\\p{L}\\p{N}\u00a7\u20ac$%]+$/gu, '') || w)\n .filter(Boolean);\n const counts = new Map();\n for (const w of words) counts.set(w, (counts.get(w) || 0) + 1);\n return { words, set: new Set(words), counts };\n}\n\n/* \u2500\u2500 PDF line joining \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Words after which a trailing hyphen is a genuine elision (\"Vertrags- und Zahlungs\u2026\").\nconst HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\\.?|sowie|bis|als|wie|and|or|nor|to)\\b/;\n\n// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation:\n// \"Vertrags-\" + \"strafenregelung\" -\u003e \"Vertragsstrafenregelung\" (lowercase continuation),\n// \"Ost-\" + \"West-Verbindung\" -\u003e \"Ost-West-Verbindung\" (hyphen kept, no space),\n// \"Vertrags-\" + \"und Zahlungsbedingungen\" -\u003e unchanged with a space.\nfunction joinWrappedLine(prev, next) {\n if (!prev) return next;\n if (!next) return prev;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]\u00ad$/.test(prev)) return prev.slice(0, -1) + next;\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df]-$/.test(prev) && !/\\s-$/.test(prev)) {\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next;\n if (/^[A-Z\u00c4\u00d6\u00dc]/.test(next)) return prev + next;\n }\n return `${prev} ${next}`;\n}\n\n/* \u2500\u2500 PDF text layout (pure; fed with PDF.js getTextContent() items) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Fraction of the page height at the top and bottom that may hold running headers/footers.\nconst PDF_MARGIN_BAND = 0.12;\n\n// List bullets drawn with symbol fonts come out of PDF.js as private-use code points\n// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings \u25aa \u27a2 \u2756 \u25cf \u25a0). At the start of a line they are\n// shown as \"\u2022\" so the line is recognised as a bullet item, as in the DOCX version. Other\n// symbol glyphs (e.g. checked / unchecked boxes) are left untouched.\nconst PDF_SYMBOL_BULLET = /^[\\uF0B7\\uF095\\uF0A7\\uF0D8\\uF076\\uF06C\\uF06E]\\s*(?=\\S)/;\n\n/**\n * Groups PDF.js text items of one page into lines.\n * Spaces are inserted from geometry (gap between the end of one item and the start of the\n * next, relative to the font size) and from `hasEOL`, not between every item, so a font\n * change inside a word (\"Ge|samt|preis\") does not split the word.\n * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view).\n * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order.\n */\nfunction buildPdfPageLines(items, pageView) {\n const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842];\n const yMin = view[1];\n const height = view[3] - view[1] || 842;\n const lines = [];\n let cur = null;\n\n for (const item of items || []) {\n if (!item || typeof item.str !== 'string') continue;\n const tr = item.transform || [1, 0, 0, 1, 0, 0];\n const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10;\n const x = tr[4];\n const y = tr[5];\n const str = sanitizeExternalText(item.str).replace(/\\n/g, ' ');\n if (!str) {\n if (item.hasEOL && cur) cur.eol = true;\n continue;\n }\n const hasWidth = typeof item.width === 'number' && item.width \u003e 0;\n const isNewLine = !cur || Math.abs(y - cur.y) \u003e Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize));\n if (isNewLine) {\n if (cur) lines.push(cur);\n cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) };\n continue;\n }\n let needSpace;\n if (cur.eol) {\n needSpace = true;\n } else if (cur.endX === null || !hasWidth) {\n needSpace = true; // no geometry: keep the conservative behaviour\n } else {\n const gap = x - cur.endX;\n needSpace = gap \u003e fontSize * 0.18 || gap \u003c -fontSize * 0.5;\n }\n if (/\\s$/.test(cur.text) || /^\\s/.test(str)) needSpace = false;\n cur.text += (needSpace ? ' ' : '') + str;\n cur.endX = hasWidth ? x + item.width : null;\n cur.eol = Boolean(item.hasEOL);\n }\n if (cur) lines.push(cur);\n\n const out = lines\n .map(l =\u003e ({\n text: l.text.replace(/\\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '\u2022 '),\n y: l.y,\n yPct: (l.y - yMin) / height,\n fontSize: l.fontSize,\n gapBefore: false,\n }))\n .filter(l =\u003e l.text);\n\n // Paragraph breaks: a vertical step clearly larger than the line step. The line step is\n // the small end of this page's step distribution, capped at 1.45 \u00d7 font size so pages\n // consisting only of one-line paragraphs still get their breaks.\n const steps = [];\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n if (dy \u003e 0) steps.push(dy);\n }\n steps.sort((a, b) =\u003e a - b);\n const sizes = out.map(l =\u003e l.fontSize).sort((a, b) =\u003e a - b);\n const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10;\n const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45);\n for (let i = 1; i \u003c out.length; i++) {\n const dy = out[i - 1].y - out[i].y;\n out[i].gapBefore = dy \u003e lineStep * 1.25 || dy \u003c -out[i].fontSize; // upward jump = new column/block\n }\n return out;\n}\n\n// Numbering such as \"4.\" or \"3.2\" must be followed by whitespace; amounts (\"145.000,00\",\n// \"1.000 St\u00fcck\") and dates (\"12.03.2026\") at the start of a wrapped line are not numbering.\nconst PDF_STRUCTURAL_START =\n /^(\u00a7\\s*\\d+|\\(\\d+\\)|(?!\\d{1,3}(?:\\.\\d{3})+(?:,\\d+)?\\s|\\d{1,2}\\.\\d{1,2}\\.\\d{2,4}\\b)\\d+\\.(?:\\d+\\.)*\\d*\\s|[\u2022\u2023\u25aa\u25e6\u2013*-]\\s|\\([a-z]\\)|[a-z]\\)|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i;\n\n// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page?\n// A capitalised first word (German nouns) also continues when the previous page ended in the\n// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not.\nfunction continuesAcrossPage(prev, next, prevSize, nextSize) {\n if (/[A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df][-\u00ad]$/.test(prev)) return true;\n if (/[.:;!?)\"\u201c\u201d]$/.test(prev)) return false;\n if (/^[a-z\u00e4\u00f6\u00fc\u00df]/.test(next)) return true;\n return (\n /^[A-Z\u00c4\u00d6\u00dc]/.test(next) &&\n prev.length \u003e= 60 &&\n typeof prevSize === 'number' &&\n typeof nextSize === 'number' &&\n Math.abs(prevSize - nextSize) \u003c 0.5\n );\n}\n\n/**\n * Builds the encoded document text from per-page lines (see buildPdfPageLines).\n * Running headers/footers are detected by repetition across pages and emitted as\n * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs,\n * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text).\n */\nfunction assemblePdfText(pages) {\n const candidates = pages.map(lines =\u003e\n lines.map(l =\u003e (l.yPct \u003e= 1 - PDF_MARGIN_BAND || l.yPct \u003c= PDF_MARGIN_BAND ? l : null))\n );\n const hfFlags = detectRepeatingMarginLines(candidates);\n\n const headers = pages.map(() =\u003e []);\n const footers = pages.map(() =\u003e []);\n const paragraphs = pages.map(() =\u003e []);\n let para = null;\n const flush = () =\u003e {\n if (para) paragraphs[para.page].push(para.text);\n para = null;\n };\n\n pages.forEach((lines, p) =\u003e {\n let firstBodyLine = true;\n lines.forEach((line, i) =\u003e {\n if (hfFlags[p][i]) {\n (line.yPct \u003e= 0.5 ? headers : footers)[p].push(line.text);\n return;\n }\n const structural = PDF_STRUCTURAL_START.test(line.text);\n let newPara;\n if (!para) newPara = true;\n else if (para.page !== p && firstBodyLine) {\n newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize);\n } else newPara = line.gapBefore || structural;\n firstBodyLine = false;\n if (newPara) {\n flush();\n para = { page: p, text: line.text, fontSize: line.fontSize };\n } else {\n para.text = joinWrappedLine(para.text, line.text);\n para.fontSize = line.fontSize;\n }\n });\n });\n flush();\n\n const out = [];\n const emptyPages = [];\n pages.forEach((lines, p) =\u003e {\n if (!lines.length) emptyPages.push(p + 1);\n out.push(pageMarkerLine(p + 1));\n for (const h of headers[p]) out.push(metaLine('hf', h));\n for (const text of paragraphs[p]) out.push(text);\n for (const f of footers[p]) out.push(metaLine('hf', f));\n });\n return { text: out.join('\\n\\n'), emptyPages };\n}\n\n/* \u2500\u2500 Parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst LATIN_LIGATURES = { '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl', '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'st' };\n\n/**\n * Text normalisation applied to both documents before they are compared, so that differences\n * a reader cannot see are not reported as changes:\n * - Unicode NFC: \"Gr\u00f6\u00dfe\" with a precomposed \"\u00f6\" equals \"Gro\u0308\u00dfe\" (decomposed, common in PDFs\n * and text from macOS); canonically equivalent strings are the same text by definition;\n * - Latin ligatures from PDFs (\"\ufb01\" -\u003e \"fi\");\n * - no-break, narrow no-break and other fixed-width spaces -\u003e plain space;\n * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM;\n * - CR / CRLF and Unicode line/paragraph separators -\u003e \"\\n\".\n * Visible characters are never changed (no NFKC: \"m\u00b2\" stays \"m\u00b2\", \"\u00bd\" stays \"\u00bd\").\n * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs.\n */\nfunction normalizeComparableText(text) {\n return text\n .replace(/\\r\\n?|[\\u2028\\u2029\\u0085]/g, '\\n')\n .normalize('NFC')\n .replace(/[\\uFB00-\\uFB06]/g, ch =\u003e LATIN_LIGATURES[ch])\n .replace(/[\\u00A0\\u2000-\\u200A\\u202F\\u205F\\u3000]/g, ' ')\n .replace(/[\\u00AD\\u200B\\u2060\\uFEFF]/g, '');\n}\n\nfunction parseIntoBlocks(rawText) {\n const normalized = normalizeComparableText(String(rawText || '')).trim();\n if (!normalized) return [];\n\n const rawLines = normalized.split(/\\n+/);\n const entries = [];\n let currentPage = 1;\n let hasExplicitPageMarkers = false;\n\n for (let i = 0; i \u003c rawLines.length; i++) {\n const rawLine = rawLines[i].trim();\n if (!rawLine) continue;\n\n if (rawLine.startsWith(DOC_PAGE_MARK)) {\n const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10);\n currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1);\n hasExplicitPageMarkers = true;\n continue;\n }\n\n const { meta, text } = parseLineMeta(rawLine);\n const line = text.trim();\n if (!line) continue;\n entries.push({\n line,\n page: currentPage,\n hfMarked: Boolean(meta && meta.hf),\n indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0,\n });\n }\n\n // Text-only header/footer candidates count only when they repeat within the document.\n const repeatCounts = new Map();\n for (const e of entries) {\n if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) {\n const key = normalizeHfRepeatKey(e.line);\n repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1);\n }\n }\n\n // Bare number labels (\"2/3\", \"3 von 9\", \"- 4 -\") also occur as content (a table cell \"2/3\",\n // a share). Unless the PDF reader already marked them as running footer lines (by position\n // and repetition), they are only page numbers when they form a running sequence: 2+ labels\n // of the same shape with increasing numbers. Otherwise they are compared like any other line.\n const barePageLabelKeys = new Set();\n const firstNums = new Map();\n for (const e of entries) {\n if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue;\n const key = normalizeHfRepeatKey(e.line);\n if (!firstNums.has(key)) firstNums.set(key, []);\n firstNums.get(key).push(parseInt(e.line.match(/\\d+/)[0], 10));\n }\n for (const [key, nums] of firstNums) {\n if (nums.length \u003e= 2 && nums.every((n, i) =\u003e i === 0 || n \u003e nums[i - 1])) barePageLabelKeys.add(key);\n }\n const isPageLabel = line =\u003e\n isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line)));\n\n const blocks = [];\n let currentSectionRef = '';\n let seenContentBlock = false;\n\n for (const entry of entries) {\n let line = entry.line;\n const isHeaderFooter =\n entry.hfMarked ||\n isPageLabel(line) ||\n (isLikelyHeaderOrFooterLine(line) &&\n (!isBarePageNumberLabel(line) || isPageLabel(line)) &&\n (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) \u003e= 2);\n\n const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter);\n let headingMarker = '';\n if (!isHeaderFooter) {\n seenContentBlock = true;\n const m = line.match(/^#{1,3}\\s+/);\n if (m) headingMarker = `${m[0].trim()} `;\n line = line.replace(/^#{1,3}\\s+/, '').trim();\n if (!line) continue;\n }\n\n const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3';\n\n // The section reference (\"1.\" / \"\u00a7 5\") follows the numbering in the text, not the derived\n // block kind: an edit that turns \"1. Scope of Services\" into a paragraph must not change\n // the references (and thus the alignment) of every clause below it.\n if (kind === 'h2' || kind === 'p' || kind === 'h1') {\n const secMatch = line.match(/^(\u00a7\\s*\\d+[a-z]?|Artikel\\s+\\d+|Anlage\\s+\\d+|Section\\s+\\d+|Exhibit\\s+[A-Z0-9]+)/i);\n const numMatch = line.match(/^(\\d+\\.)\\s+[A-Z\u00c4\u00d6\u00dc]/);\n if (secMatch) {\n currentSectionRef = secMatch[1].replace(/\\s+/g, ' ').trim();\n } else if (numMatch) {\n currentSectionRef = numMatch[1];\n }\n }\n\n const idx = blocks.length + 1;\n const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind);\n const alignText = numbering ? numbering.body : line;\n const { words, set, counts } = tokenizeWords(alignText);\n blocks.push({\n index: idx,\n pageNum: entry.page,\n hasExplicitPage: hasExplicitPageMarkers,\n refCode: extractClauseRef(line, idx, currentSectionRef, kind),\n sectionRef: currentSectionRef,\n text: line,\n kind,\n headingMarker,\n indent: entry.indent,\n isHeading,\n isHeaderFooter,\n numberingMarker: numbering ? numbering.marker : '',\n // Whitespace and the table-cell separator \" \u00b7 \" (DOCX/HTML intake; a PDF has spaces there) do not\n // make two paragraphs different: such rows are shown as unchanged, like renumbering.\n alignKey: alignText.replace(/\\s+/g, ' ').replace(/ \\u00B7 /g, ' '),\n words,\n wordSet: set,\n wordCounts: counts,\n });\n }\n\n // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length\n if (!hasExplicitPageMarkers && blocks.length \u003e 0) {\n let autoPage = 1;\n let blocksOnCurrentPage = 0;\n let charsOnCurrentPage = 0;\n for (let i = 0; i \u003c blocks.length; i++) {\n const b = blocks[i];\n const startsNewMajorSection =\n b.kind === 'h2' && blocksOnCurrentPage \u003e= 5 && charsOnCurrentPage \u003e= 650;\n const exceedsPageCapacity = blocksOnCurrentPage \u003e= 10 || charsOnCurrentPage \u003e= 1850;\n if (i \u003e 0 && (startsNewMajorSection || exceedsPageCapacity)) {\n autoPage++;\n blocksOnCurrentPage = 0;\n charsOnCurrentPage = 0;\n }\n b.pageNum = autoPage;\n blocksOnCurrentPage++;\n charsOnCurrentPage += b.text.length;\n }\n }\n\n return blocks;\n}\n\n/**\n * Parses both documents and applies the header/footer filter.\n * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines.\n */\nfunction prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) {\n const allLeftBlocks = parseIntoBlocks(leftText);\n const allRightBlocks = parseIntoBlocks(rightText);\n const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks);\n const keep = b =\u003e !(ignoreHeadersFooters && isHiddenHeaderFooter(b));\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks: allLeftBlocks.filter(keep),\n rightBlocks: allRightBlocks.filter(keep),\n hfCount,\n };\n}\n\n/* \u2500\u2500 Alignment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction blockAlignKey(block) {\n return block.alignKey !== undefined ? block.alignKey : block.text;\n}\n\nfunction blockWordCounts(block) {\n if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts;\n return block.wordCounts;\n}\n\nfunction computeBlockSimilarity(blockA, blockB) {\n if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0;\n if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0;\n // The block kind is re-derived from the text, so it may change with an edit (\"1. Scope of\n // Services\" + a sentence is no longer a heading). Kind alone never prevents pairing; a short\n // heading and a long paragraph are kept apart by the length bound below.\n const lenA = blockA.words.length;\n const lenB = blockB.words.length;\n if (!lenA || !lenB) return 0;\n\n const sameSpecificRef =\n blockA.refCode &&\n blockA.refCode === blockB.refCode &&\n !blockA.refCode.startsWith('#') &&\n !blockA.refCode.includes('#');\n\n const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB);\n if (maxPossible \u003c 0.28 && !sameSpecificRef) return 0;\n\n // Multiset intersection: every occurrence of a word can be matched at most once.\n const countsA = blockWordCounts(blockA);\n const countsB = blockWordCounts(blockB);\n const [small, large] = countsA.size \u003c= countsB.size ? [countsA, countsB] : [countsB, countsA];\n let intersection = 0;\n for (const [word, n] of small) {\n const m = large.get(word);\n if (m) intersection += Math.min(n, m);\n }\n const wordSim = (2.0 * intersection) / (lenA + lenB);\n return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim;\n}\n\n// Minimum length for a deleted/inserted pair to be reported as a move.\nconst MIN_MOVE_TEXT_LENGTH = 12;\n\n// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace\n// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every\n// consumer that switches on row.type keeps working; `moved` and `movedToRowId` /\n// `movedFromRowId` describe the relocation.\nfunction markMovedRows(rows) {\n const deletedByKey = new Map();\n for (const row of rows) {\n if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue;\n const key = blockAlignKey(row.leftBlock).trim();\n if (key.length \u003c MIN_MOVE_TEXT_LENGTH) continue;\n if (!deletedByKey.has(key)) deletedByKey.set(key, []);\n deletedByKey.get(key).push(row);\n }\n for (const row of rows) {\n if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue;\n const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim());\n if (!candidates || !candidates.length) continue;\n const from = candidates.shift();\n from.moved = true;\n from.movedToRowId = row.id;\n row.moved = true;\n row.movedFromRowId = from.id;\n }\n return rows;\n}\n\nfunction alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) {\n const totalN = leftBlocks.length;\n const totalM = rightBlocks.length;\n if (totalN === 0 && totalM === 0) return [];\n\n const sameKey = (a, b) =\u003e a.text === b.text || blockAlignKey(a) === blockAlignKey(b);\n\n let prefixLen = 0;\n while (\n prefixLen \u003c totalN &&\n prefixLen \u003c totalM &&\n sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen])\n ) {\n prefixLen++;\n }\n\n let suffixLen = 0;\n while (\n suffixLen \u003c totalN - prefixLen &&\n suffixLen \u003c totalM - prefixLen &&\n sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen])\n ) {\n suffixLen++;\n }\n\n const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen);\n const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen);\n const n = midLeft.length;\n const m = midRight.length;\n\n const rawPairs = [];\n for (let p = 0; p \u003c prefixLen; p++) {\n rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] });\n }\n\n if (n \u003e 0 || m \u003e 0) {\n for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair);\n }\n\n for (let s = 0; s \u003c suffixLen; s++) {\n rawPairs.push({\n left: leftBlocks[totalN - suffixLen + s],\n right: rightBlocks[totalM - suffixLen + s],\n });\n }\n\n let monotonicPage = 1;\n const rows = rawPairs.map((pair, idx) =\u003e {\n const rawPage =\n (pair.right && pair.right.pageNum) ||\n (pair.left && pair.left.pageNum) ||\n monotonicPage;\n monotonicPage = Math.max(monotonicPage, rawPage);\n return buildAlignedRow(pair, idx, monotonicPage);\n });\n\n return markMovedRows(rows);\n}\n\nfunction buildAlignedRow(pair, idx, pageNum) {\n const rowId = `row-${idx + 1}`;\n if (pair.left && pair.right) {\n if (pair.left.text === pair.right.text) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.left.text }],\n };\n }\n if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) {\n // Only the list marker / heading number differs (renumbering). Such rows are not\n // counted as changes; the renderer shows the old and new marker in a muted style.\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'equal',\n markerOnly: true,\n oldMarker: pair.left.numberingMarker,\n newMarker: pair.right.numberingMarker,\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'modified',\n leftBlock: pair.left,\n rightBlock: pair.right,\n wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text),\n };\n }\n if (pair.left && !pair.right) {\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'deleted',\n leftBlock: pair.left,\n rightBlock: null,\n wordDiffs: [{ value: pair.left.text, removed: true }],\n };\n }\n return {\n id: rowId,\n rowNumber: idx + 1,\n pageNum,\n type: 'inserted',\n leftBlock: null,\n rightBlock: pair.right,\n wordDiffs: [{ value: pair.right.text, added: true }],\n };\n}\n\nfunction escapeHtml(str) {\n return str\n .replace(/&/g, '&')\n .replace(/\u003c/g, '<')\n .replace(/\u003e/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/* \u2500\u2500 Word diff \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n// Invariant of every word diff: the unchanged + removed parts spell the old text exactly,\n// and the unchanged + added parts spell the new text exactly.\nfunction wordDiffPreservesTexts(parts, oldText, newText) {\n let oldOut = '';\n let newOut = '';\n for (const part of parts) {\n if (!part.added) oldOut += part.value;\n if (!part.removed) newOut += part.value;\n }\n return oldOut === oldText && newOut === newText;\n}\n\nfunction getDiffLibrary() {\n if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff;\n if (typeof window !== 'undefined' && window.Diff) return window.Diff;\n return null;\n}\n\n// Upper bound for the work of one word diff (Myers: tokens \u00d7 edit length). Without it two long,\n// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB\n// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff.\nconst WORD_DIFF_MAX_WORK = 1.5e7;\n\n// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced.\nfunction coarseWordDiff(oldText, newText) {\n const a = oldText.split(/(\\s+)/);\n const b = newText.split(/(\\s+)/);\n let pre = 0;\n while (pre \u003c a.length && pre \u003c b.length && a[pre] === b[pre]) pre++;\n let suf = 0;\n while (suf \u003c a.length - pre && suf \u003c b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;\n const parts = [\n { value: a.slice(0, pre).join('') },\n { value: a.slice(pre, a.length - suf).join(''), removed: true },\n { value: b.slice(pre, b.length - suf).join(''), added: true },\n { value: a.slice(a.length - suf).join('') },\n ];\n return parts.filter(p =\u003e p.value.length \u003e 0);\n}\n\nfunction computeCleanWordDiff(oldText, newText) {\n const diffLib = getDiffLibrary();\n if (!diffLib) {\n return [\n { value: oldText, removed: true },\n { value: newText, added: true },\n ];\n }\n\n // diffWordsWithSpace yields about two tokens (word + whitespace) per word.\n const tokens = 2 * ((oldText.match(/\\S+/g) || []).length + (newText.match(/\\S+/g) || []).length) + 2;\n const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens));\n const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength });\n if (!raw) return coarseWordDiff(oldText, newText);\n\n // An unchanged number sandwiched between two changes is shown as replaced as well, so\n // \"1.000 EUR\" -\u003e \"2.000 USD\" reads as one change instead of three fragments.\n const mergedNums = [];\n for (let i = 0; i \u003c raw.length; i++) {\n const cur = raw[i];\n if (\n !cur.added &&\n !cur.removed &&\n /^[.,\\d]+$/.test(cur.value) &&\n mergedNums.length \u003e 0 &&\n i + 1 \u003c raw.length\n ) {\n const prev = mergedNums[mergedNums.length - 1];\n const next = raw[i + 1];\n if ((prev.removed || prev.added) && (next.removed || next.added)) {\n mergedNums.push({ value: cur.value, removed: true });\n mergedNums.push({ value: cur.value, added: true });\n continue;\n }\n }\n mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) });\n }\n\n // Expand partial numeric/date replacements to the whole number:\n // \"0,[5]\" -\u003e \"[0,5]\", \"[145.000],00\" -\u003e \"[145.000,00]\", \"[12.03].2026\" -\u003e \"[12.03.2026]\".\n // Only for groups that both remove and add text: the unchanged digits are then moved into\n // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched\n // (\"Frist 14 Tage\" -\u003e \"Frist 7 14 Tage\" must show \"7 \" inserted and \"14\" unchanged).\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n let idx = 0;\n while (idx \u003c mergedNums.length) {\n if (!isChange(mergedNums[idx])) {\n idx++;\n continue;\n }\n const start = idx;\n while (idx \u003c mergedNums.length && isChange(mergedNums[idx])) {\n idx++;\n }\n const end = idx - 1;\n const group = mergedNums.slice(start, idx);\n const firstRem = group.find(tok =\u003e tok.removed);\n const firstAdd = group.find(tok =\u003e tok.added);\n if (!firstRem || !firstAdd) continue;\n if (!group.some(tok =\u003e /\\d/.test(tok.value))) continue;\n // Only expand when both sides of the change start/end with a digit at the boundary.\n const remText = group.filter(tok =\u003e tok.removed).map(tok =\u003e tok.value).join('');\n const addText = group.filter(tok =\u003e tok.added).map(tok =\u003e tok.value).join('');\n\n if (start \u003e 0 && !isChange(mergedNums[start - 1]) && /^[\\d.,]/.test(remText) && /^[\\d.,]/.test(addText)) {\n const prevTok = mergedNums[start - 1];\n const prefixMatch = prevTok.value.match(/((?:\\d+[.,])+\\d*|[.,]\\d+)$/);\n if (prefixMatch) {\n const numPrefix = prefixMatch[1];\n prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length);\n firstRem.value = numPrefix + firstRem.value;\n firstAdd.value = numPrefix + firstAdd.value;\n }\n }\n\n if (idx \u003c mergedNums.length && !isChange(mergedNums[idx]) && /[\\d.,]$/.test(remText) && /[\\d.,]$/.test(addText)) {\n const nextTok = mergedNums[idx];\n const suffixMatch = nextTok.value.match(/^([.,]\\d+(?:[.,]\\d+)*|\\d+(?:[.,]\\d+)*)/);\n if (suffixMatch) {\n const numSuffix = suffixMatch[1];\n nextTok.value = nextTok.value.slice(numSuffix.length);\n let lastRem = null;\n let lastAdd = null;\n for (let k = start; k \u003c= end; k++) {\n if (mergedNums[k].removed) lastRem = mergedNums[k];\n if (mergedNums[k].added) lastAdd = mergedNums[k];\n }\n lastRem.value = lastRem.value + numSuffix;\n lastAdd.value = lastAdd.value + numSuffix;\n }\n }\n }\n\n const cleaned = mergeFragmentedChanges(\n mergedNums\n .filter(tok =\u003e tok.value.length \u003e 0)\n .map(tok =\u003e (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }))\n );\n\n if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned;\n // Never show a diff that does not reproduce the source texts: fall back to the raw diff.\n return raw.map(tok =\u003e\n tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value }\n );\n}\n\n/**\n * Joins changes that only an unchanged hyphen or space separates into one replacement, so a\n * rewritten phrase reads as one change instead of misleading word pairs:\n * \"[Standard]\u2192[Echtzeit]-[Berichtswesen]\u2192[Nachtragsmanagement]\" becomes\n * \"[Standard-Berichtswesen]\u2192[Echtzeit-Nachtragsmanagement]\". The joined glue goes into both\n * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of\n * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared\n * by both sides at the edges stays unchanged.\n */\nfunction mergeFragmentedChanges(parts) {\n const isChange = tok =\u003e Boolean(tok && (tok.removed || tok.added));\n const glued = [];\n for (let i = 0; i \u003c parts.length; i++) {\n const cur = parts[i];\n const isGlue =\n !isChange(cur) &&\n isChange(glued[glued.length - 1]) &&\n isChange(parts[i + 1]) &&\n (/^\\s+$/.test(cur.value) || !/\\s/.test(cur.value));\n if (isGlue) {\n glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true });\n } else {\n glued.push(cur);\n }\n }\n\n const out = [];\n const pushEqual = value =\u003e {\n if (!value) return;\n const last = out[out.length - 1];\n if (last && !isChange(last)) last.value += value;\n else out.push({ value });\n };\n let i = 0;\n while (i \u003c glued.length) {\n if (!isChange(glued[i])) {\n pushEqual(glued[i].value);\n i++;\n continue;\n }\n let rem = '';\n let add = '';\n while (i \u003c glued.length && isChange(glued[i])) {\n if (glued[i].removed) rem += glued[i].value;\n else add += glued[i].value;\n i++;\n }\n let lead = '';\n let trail = '';\n if (rem && add) {\n const leadRem = rem.match(/^\\s*/)[0];\n const leadAdd = add.match(/^\\s*/)[0];\n while (lead.length \u003c Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) {\n lead += leadRem[lead.length];\n }\n rem = rem.slice(lead.length);\n add = add.slice(lead.length);\n const trailRem = rem.match(/\\s*$/)[0];\n const trailAdd = add.match(/\\s*$/)[0];\n let n = 0;\n while (\n n \u003c Math.min(trailRem.length, trailAdd.length) &&\n trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n]\n ) {\n n++;\n }\n trail = n ? rem.slice(rem.length - n) : '';\n rem = rem.slice(0, rem.length - n);\n add = add.slice(0, add.length - n);\n }\n pushEqual(lead);\n if (rem) out.push({ value: rem, removed: true });\n if (add) out.push({ value: add, added: true });\n pushEqual(trail);\n }\n return out;\n}\n\nfunction coalesceSideTokens(sideParts, prop) {\n const out = [];\n for (let i = 0; i \u003c sideParts.length; i++) {\n const cur = sideParts[i];\n if (\n !cur[prop] &&\n /^\\s+$/.test(cur.value) &&\n out.length \u003e 0 &&\n out[out.length - 1][prop] &&\n i + 1 \u003c sideParts.length &&\n sideParts[i + 1][prop]\n ) {\n out[out.length - 1].value += cur.value;\n continue;\n }\n if (cur[prop] && out.length \u003e 0 && out[out.length - 1][prop]) {\n out[out.length - 1].value += cur.value;\n } else {\n out.push({ value: cur.value, [prop]: Boolean(cur[prop]) });\n }\n }\n return out;\n}\n\n;\n// Alignment engine and comparison pipeline.\n//\n// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it,\n// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off\n// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very\n// same functions also run on the main thread as the synchronous fallback.\n\nconst ALIGN_GAP_PENALTY = -0.22;\n// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass,\n// exactly as before (~500 x 500 paragraphs, \u2248 20 pages against 20 pages).\nconst ALIGN_FULL_DP_MAX_CELLS = 250000;\n// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP\n// so memory stays bounded (the exact DP needs 5 bytes per cell).\nconst ALIGN_EXACT_DP_MAX_CELLS = 4000000;\n\n// Global Needleman-Wunsch alignment of two block lists (O(n\u00b7m) time and memory).\nfunction alignBlocksExact(midLeft, midRight, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const dp = Array.from({ length: n + 1 }, () =\u003e new Float32Array(m + 1));\n const ptr = Array.from({ length: n + 1 }, () =\u003e new Uint8Array(m + 1));\n const GAP_PENALTY = ALIGN_GAP_PENALTY;\n\n for (let i = 1; i \u003c= n; i++) {\n dp[i][0] = i * GAP_PENALTY;\n ptr[i][0] = 2;\n }\n for (let j = 1; j \u003c= m; j++) {\n dp[0][j] = j * GAP_PENALTY;\n ptr[0][j] = 3;\n }\n\n const reportEvery = Math.max(1, Math.floor(250000 / m));\n for (let i = 1; i \u003c= n; i++) {\n const rowPrev = dp[i - 1];\n const row = dp[i];\n const ptrRow = ptr[i];\n const leftBlock = midLeft[i - 1];\n for (let j = 1; j \u003c= m; j++) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n const matchScore = sim \u003e= 0.28 ? sim : -0.5;\n const scoreDiag = rowPrev[j - 1] + matchScore;\n const scoreUp = rowPrev[j] + GAP_PENALTY;\n const scoreLeft = row[j - 1] + GAP_PENALTY;\n\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n row[j] = scoreDiag;\n ptrRow[j] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n row[j] = scoreUp;\n ptrRow[j] = 2;\n } else {\n row[j] = scoreLeft;\n ptrRow[j] = 3;\n }\n }\n if (onCells && i % reportEvery === 0) onCells(reportEvery * m);\n }\n if (onCells) onCells((n % reportEvery) * m);\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[i][j];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\nfunction alignBlocksTrivial(midLeft, midRight) {\n const pairs = [];\n for (const b of midLeft) pairs.push({ left: b, right: null });\n for (const b of midRight) pairs.push({ left: null, right: b });\n return pairs;\n}\n\n// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are\n// O((n + m) \u00b7 band). Only used for gaps that are too large for the exact DP and have no anchors.\nfunction alignBlocksBanded(midLeft, midRight, maxCells, onCells) {\n const n = midLeft.length;\n const m = midRight.length;\n if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight);\n\n const slope = m / n;\n const minHalf = Math.ceil(Math.max(slope, 1)) + 2;\n const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1))));\n const width = 2 * half + 1;\n const lo = new Int32Array(n + 1);\n const hi = new Int32Array(n + 1);\n for (let i = 0; i \u003c= n; i++) {\n const center = Math.round(i * slope);\n lo[i] = Math.max(0, center - half);\n hi[i] = Math.min(m, center + half);\n }\n // Make consecutive bands overlap so a monotone path always exists.\n for (let i = 1; i \u003c= n; i++) {\n if (lo[i] \u003e hi[i - 1]) lo[i] = hi[i - 1];\n }\n\n const NEG = -Infinity;\n const score = new Float32Array((n + 1) * width).fill(NEG);\n const ptr = new Uint8Array((n + 1) * width);\n const at = (i, j) =\u003e (j \u003c lo[i] || j \u003e hi[i] ? -1 : i * width + (j - lo[i]));\n\n for (let j = lo[0]; j \u003c= hi[0]; j++) {\n score[at(0, j)] = j * ALIGN_GAP_PENALTY;\n ptr[at(0, j)] = 3;\n }\n\n for (let i = 1; i \u003c= n; i++) {\n const leftBlock = midLeft[i - 1];\n for (let j = lo[i]; j \u003c= hi[i]; j++) {\n const k = at(i, j);\n if (j === 0) {\n score[k] = i * ALIGN_GAP_PENALTY;\n ptr[k] = 2;\n continue;\n }\n const kDiag = at(i - 1, j - 1);\n const kUp = at(i - 1, j);\n const kLeft = at(i, j - 1);\n let scoreDiag = NEG;\n if (kDiag \u003e= 0 && score[kDiag] !== NEG) {\n const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]);\n scoreDiag = score[kDiag] + (sim \u003e= 0.28 ? sim : -0.5);\n }\n const scoreUp = kUp \u003e= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG;\n const scoreLeft = kLeft \u003e= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG;\n if (scoreDiag \u003e= scoreUp && scoreDiag \u003e= scoreLeft) {\n score[k] = scoreDiag;\n ptr[k] = 1;\n } else if (scoreUp \u003e= scoreLeft) {\n score[k] = scoreUp;\n ptr[k] = 2;\n } else {\n score[k] = scoreLeft;\n ptr[k] = 3;\n }\n }\n if (onCells) onCells(hi[i] - lo[i] + 1);\n }\n\n const pairs = [];\n let i = n;\n let j = m;\n while (i \u003e 0 || j \u003e 0) {\n const move = ptr[at(i, j)];\n if (i \u003e 0 && j \u003e 0 && move === 1) {\n pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] });\n i--;\n j--;\n } else if (i \u003e 0 && (j === 0 || move === 2)) {\n pairs.push({ left: midLeft[i - 1], right: null });\n i--;\n } else {\n pairs.push({ left: null, right: midRight[j - 1] });\n j--;\n }\n }\n pairs.reverse();\n return pairs;\n}\n\n// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the\n// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...].\nfunction findUniqueBlockAnchors(midLeft, midRight) {\n const leftCount = new Map();\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n leftCount.set(key, leftCount.has(key) ? -1 : i);\n }\n const rightIndex = new Map();\n for (let j = 0; j \u003c midRight.length; j++) {\n const key = blockAlignKey(midRight[j]);\n rightIndex.set(key, rightIndex.has(key) ? -1 : j);\n }\n\n const candidates = [];\n for (let i = 0; i \u003c midLeft.length; i++) {\n const key = blockAlignKey(midLeft[i]);\n if (leftCount.get(key) !== i) continue;\n const j = rightIndex.get(key);\n if (j === undefined || j \u003c 0) continue;\n if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue;\n candidates.push([i, j]);\n }\n if (candidates.length === 0) return [];\n\n // Longest increasing subsequence on the right index (left indices are already increasing).\n const tails = [];\n const prev = new Int32Array(candidates.length).fill(-1);\n for (let c = 0; c \u003c candidates.length; c++) {\n const j = candidates[c][1];\n let loIdx = 0;\n let hiIdx = tails.length;\n while (loIdx \u003c hiIdx) {\n const mid = (loIdx + hiIdx) \u003e\u003e 1;\n if (candidates[tails[mid]][1] \u003c j) loIdx = mid + 1;\n else hiIdx = mid;\n }\n if (loIdx \u003e 0) prev[c] = tails[loIdx - 1];\n tails[loIdx] = c;\n }\n const chain = [];\n for (let c = tails[tails.length - 1]; c \u003e= 0; c = prev[c]) chain.push(candidates[c]);\n chain.reverse();\n return chain;\n}\n\n// Aligns the differing middle of two documents. Small and medium middles use one global DP\n// (identical to the historical behaviour); very long ones are first split at unique identical\n// blocks so the DP only runs inside the gaps between them.\nfunction alignMiddleBlocks(midLeft, midRight, onProgress) {\n const n = midLeft.length;\n const m = midRight.length;\n const totalCells = n * m;\n let doneCells = 0;\n let lastReported = -1;\n const onCells = onProgress\n ? cells =\u003e {\n doneCells += cells;\n const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1;\n if (frac - lastReported \u003e= 0.02) {\n lastReported = frac;\n onProgress(frac);\n }\n }\n : null;\n\n if (totalCells \u003c= ALIGN_FULL_DP_MAX_CELLS) {\n return alignBlocksExact(midLeft, midRight, onCells);\n }\n\n const anchors = findUniqueBlockAnchors(midLeft, midRight);\n // Progress is measured against the cells that will actually be computed.\n let plannedCells = 0;\n let pi = 0;\n let pj = 0;\n for (const [ai, aj] of anchors.concat([[n, m]])) {\n const cells = (ai - pi) * (aj - pj);\n plannedCells += cells \u003c= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2);\n pi = ai + 1;\n pj = aj + 1;\n }\n const scale = plannedCells \u003e 0 ? totalCells / plannedCells : 1;\n const scaledOnCells = onCells ? cells =\u003e onCells(cells * scale) : null;\n const alignGapScaled = (gapLeft, gapRight) =\u003e {\n const cells = gapLeft.length * gapRight.length;\n if (cells \u003c= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells);\n return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells);\n };\n\n const pairs = [];\n let prevI = 0;\n let prevJ = 0;\n for (const [ai, aj] of anchors) {\n for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p);\n pairs.push({ left: midLeft[ai], right: midRight[aj] });\n prevI = ai + 1;\n prevJ = aj + 1;\n }\n for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p);\n if (onProgress) onProgress(1);\n return pairs;\n}\n\n// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align.\n// Pure and structured-clone friendly; this is what the worker runs and what the synchronous\n// fallback runs on the main thread.\nfunction computeComparison(request, onProgress) {\n const report = typeof onProgress === 'function' ? onProgress : null;\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n if (report) report(0.05);\n\n const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared;\n let alignedRows = [];\n if (leftBlocks.length \u003e 0 && rightBlocks.length \u003e 0) {\n alignedRows = alignDocumentBlocks(\n leftBlocks,\n rightBlocks,\n report ? frac =\u003e report(0.05 + frac * 0.9) : undefined\n );\n }\n if (report) report(1);\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n };\n}\n\nfunction countPages(blocks) {\n return blocks.reduce((mx, b) =\u003e Math.max(mx, b.pageNum || 1), 0);\n}\n\n// Tokenized word lists are only needed for alignment; dropping them keeps the result small\n// when it is posted from the worker to the page.\nconst ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts'];\n\nfunction stripAlignmentFeatures(block) {\n for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key];\n}\n\nfunction blocksEquivalent(a, b, ignoreText) {\n for (const key of Object.keys(a)) {\n if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue;\n if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' ||\n key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) {\n continue;\n }\n if (a[key] !== b[key]) return false;\n }\n return true;\n}\n\nconst MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId'];\n\n// Fast path for edits, reverts, undo and redo of long documents: when the left text and the\n// options are unchanged\n// and the new right text differs from the previous one in exactly one block (same block count;\n// every other block identical, including page, section and header/footer metadata), the previous\n// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null\n// when a full re-alignment is required. This is an approximation of a full comparison, so the\n// page re-checks it with a full comparison in the worker (see runComparison).\nfunction computeIncrementalComparison(prev, request) {\n if (!prev || !prev.request || !prev.result) return null;\n const pr = prev.request;\n if (\n pr.leftText !== request.leftText ||\n pr.ignoreHeadersFooters !== request.ignoreHeadersFooters ||\n (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf)\n ) {\n return null;\n }\n const old = prev.result;\n if (!old.alignedRows.length || !old.allLeftBlocks) return null;\n\n const prepared = prepareComparisonBlocks(\n request.leftText || '',\n request.rightText || '',\n request.ignoreHeadersFooters\n );\n const { allLeftBlocks, allRightBlocks } = prepared;\n if (\n allLeftBlocks.length !== old.allLeftBlocks.length ||\n allRightBlocks.length !== old.allRightBlocks.length ||\n prepared.hfCount !== old.filteredHfCount\n ) {\n return null;\n }\n // Header/footer reconciliation looks at both documents, so the left side must be re-checked.\n for (let i = 0; i \u003c allLeftBlocks.length; i++) {\n if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null;\n }\n let changedIdx = -1;\n for (let i = 0; i \u003c allRightBlocks.length; i++) {\n const a = old.allRightBlocks[i];\n const b = allRightBlocks[i];\n if (a.text === b.text) {\n if (!blocksEquivalent(a, b, false)) return null;\n continue;\n }\n if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null;\n changedIdx = i;\n }\n\n const blockMap = new Map();\n for (let i = 0; i \u003c allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]);\n for (let i = 0; i \u003c allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]);\n const leftBlocks = old.leftBlocks.map(b =\u003e blockMap.get(b));\n const rightBlocks = old.rightBlocks.map(b =\u003e blockMap.get(b));\n if (leftBlocks.some(b =\u003e !b) || rightBlocks.some(b =\u003e !b)) return null;\n\n const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx];\n let changedRowId = null;\n let unmatchedEdit = false;\n const alignedRows = old.alignedRows.map((row, idx) =\u003e {\n const left = row.leftBlock ? blockMap.get(row.leftBlock) : null;\n const right = row.rightBlock ? blockMap.get(row.rightBlock) : null;\n if (changedBlock && right === changedBlock) {\n // Only a matched pair keeps its row; an edited insertion may now match a deleted block.\n if (!left) unmatchedEdit = true;\n changedRowId = row.id;\n return buildAlignedRow({ left, right }, idx, row.pageNum);\n }\n const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right });\n for (const key of MOVE_KEYS) delete copy[key];\n return copy;\n });\n\n if (changedBlock) {\n const changedRow = alignedRows.find(r =\u003e r.id === changedRowId);\n if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block\n // Keep the old pairing only if the alignment would still consider the blocks a match.\n if (\n changedRow.leftBlock &&\n changedRow.type === 'modified' &&\n computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) \u003c 0.28\n ) {\n return null;\n }\n }\n\n markMovedRows(alignedRows);\n const changedRowIds = [];\n for (let i = 0; i \u003c alignedRows.length; i++) {\n const row = alignedRows[i];\n const before = old.alignedRows[i];\n if (row.id === changedRowId || MOVE_KEYS.some(key =\u003e row[key] !== before[key])) {\n changedRowIds.push(row.id);\n }\n }\n\n for (const b of allLeftBlocks) stripAlignmentFeatures(b);\n for (const b of allRightBlocks) stripAlignmentFeatures(b);\n\n return {\n changedRowIds,\n result: {\n allLeftBlocks,\n allRightBlocks,\n leftBlocks,\n rightBlocks,\n filteredHfCount: prepared.hfCount,\n leftPages: countPages(leftBlocks),\n rightPages: countPages(rightBlocks),\n alignedRows,\n },\n };\n}\n\n;\n// Runtime of the comparison Web Worker.\n//\n// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file\n// into one classic worker script and embeds it in the page as a string; the page starts it from a\n// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage.\n//\n// Protocol (page -\u003e worker): { type: 'compare', id, request }\n// (worker -\u003e page): { type: 'ready' }\n// { type: 'progress', id, value } value in [0, 1]\n// { type: 'result', id, result }\n// { type: 'error', id, message }\n\nlet workerLabels = {};\n\n// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t().\nfunction t(key) {\n return workerLabels[key] || key;\n}\n\nself.onmessage = event =\u003e {\n const msg = event.data || {};\n if (msg.type !== 'compare') return;\n const { id, request } = msg;\n try {\n workerLabels = request.labels || {};\n let lastSent = -1;\n const result = computeComparison(request, value =\u003e {\n if (value - lastSent \u003e= 0.02 || value === 1) {\n lastSent = value;\n self.postMessage({ type: 'progress', id, value });\n }\n });\n self.postMessage({ type: 'result', id, result });\n } catch (err) {\n self.postMessage({ type: 'error', id, message: String((err && err.message) || err) });\n }\n};\n\nself.postMessage({ type: 'ready' });\n"; // Small inputs are compared synchronously: alignment takes a few milliseconds and the result is // on screen in the same frame (no flash of the previous comparison, and synchronous callers such @@ -9769,6 +10005,7 @@ <h3>Your data</h3> serializeBlockLine({ text: line.trim(), kind: template ? template.kind : 'p', + headingMarker: template ? template.headingMarker : '', indent: template ? template.indent : 0, isHeaderFooter: Boolean(target && target.rightBlock && target.rightBlock.isHeaderFooter), }) @@ -9830,6 +10067,7 @@ <h3>Your data</h3> serializeBlockLine({ text: line.trim(), kind: row.rightBlock.kind, + headingMarker: row.rightBlock.headingMarker, indent: row.rightBlock.indent, isHeaderFooter: Boolean(row.rightBlock.isHeaderFooter), }) @@ -10303,32 +10541,34 @@ <h3>Your data</h3> 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) => - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/></w:pPr>${docxRun(`${label}: `, false, '<w:b/>')}${docxRun(value)}</w:p>`; - - paragraphs.push(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr>${docxRun(meta.title)}</w:p>`); - for (const [label, value] of meta.lines) paragraphs.push(metaPara(label, value)); - paragraphs.push( - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="6" w:color="A6A6A6"/></w:pBdr><w:spacing w:after="360"/></w:pPr>${docxRun(meta.note, false, '<w:i/>')}</w:p>` - ); - 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), @@ -10352,7 +10592,16 @@ <h3>Your data</h3> return docxRun(tok.value, false); }) .join(''); - paragraphs.push(`<w:p>${pPr}${runs}</w:p>`); + paragraphs.push({ pPr, runs }); + } + if (!paragraphs.length) paragraphs.push({ pPr: '', runs: '' }); + + if (meta.comment && meta.comment.length) { + const first = paragraphs[0]; + first.runs = + '<w:commentRangeStart w:id="0"/>' + + first.runs + + '<w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r>'; } const sectPr = @@ -10365,7 +10614,23 @@ <h3>Your data</h3> '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - `<w:body>${paragraphs.join('')}${sectPr}</w:body></w:document>` + `<w:body>${paragraphs.map(p => `<w:p>${p.pPr}${p.runs}</w:p>`).join('')}${sectPr}</w:body></w:document>` + ); +} + +/** The Word comment with the summary of a comparison (one paragraph per line). */ +function buildDocxCommentsXml(meta) { + const date = toWordDate(meta.date); + const paras = meta.comment + .map((line, i) => + `<w:p>${i === 0 ? '<w:r><w:annotationRef/></w:r>' : ''}${docxRun(line, false, i === 0 ? '<w:b/>' : '')}</w:p>` + ) + .join(''); + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + + `<w:comment w:id="0" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}" w:initials="DD">${paras}</w:comment>` + + '</w:comments>' ); } @@ -10374,9 +10639,7 @@ <h3>Your data</h3> '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:tabs><w:tab w:val="right" w:pos="9072"/></w:tabs></w:pPr>' + - docxRun(meta.footer) + - '<w:r><w:tab/></w:r>' + + '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="right"/></w:pPr>' + docxRun(`${meta.pageLabel} `) + '<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>' + docxRun(` ${meta.pageOfLabel} `) + @@ -10420,7 +10683,8 @@ <h3>Your data</h3> function buildDocxPackageFiles(rows, meta) { const langTag = meta.lang === 'de' ? 'de-DE' : 'en-US'; const created = toWordDate(meta.date); - return [ + const hasComment = Boolean(meta.comment && meta.comment.length); + const files = [ { name: '[Content_Types].xml', data: @@ -10432,6 +10696,7 @@ <h3>Your data</h3> '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' + '<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>' + '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' + + (hasComment ? '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>' : '') + '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' + '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' + '</Types>', @@ -10455,6 +10720,7 @@ <h3>Your data</h3> 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' + `<dc:title>${escapeXml(meta.title)}</dc:title>` + `<dc:subject>${escapeXml(meta.subject)}</dc:subject>` + + (meta.description ? `<dc:description>${escapeXml(meta.description)}</dc:description>` : '') + `<dc:creator>${DOCX_AUTHOR}</dc:creator>` + `<cp:lastModifiedBy>${DOCX_AUTHOR}</cp:lastModifiedBy>` + `<dc:language>${langTag}</dc:language>` + @@ -10477,6 +10743,7 @@ <h3>Your data</h3> '<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' + '<Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>' + '<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>' + + (hasComment ? '<Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>' : '') + '</Relationships>', }, { name: 'word/document.xml', data: buildDocxDocumentXml(rows, meta) }, @@ -10492,37 +10759,66 @@ <h3>Your data</h3> }, { 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) { @@ -10605,23 +10901,17 @@ <h3>Your data</h3> } /** - * 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, @@ -10658,13 +10948,24 @@ <h3>Your data</h3> 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) { @@ -10698,7 +10999,7 @@ <h3>Your data</h3> 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(' · '); } @@ -10812,11 +11113,7 @@ <h3>Your data</h3> * 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'); @@ -10848,7 +11145,7 @@ <h3>Your data</h3> if (p.ins) return renderReportTokensHtml([{ value: p.ins, added: true }]); return renderReportTokensHtml([{ value: p.del, removed: true }]); }); - if (more) items.push(`<span class="more">+${more} ${escapeHtml(t('moreDeltas'))}</span>`); + if (more) items.push(`<span class="more">+${more} ${escapeHtml(moreDeltasText(more))}</span>`); return items.map(html => `<div>${html}</div>`).join(''); } @@ -10917,13 +11214,22 @@ <h3>Your data</h3> 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 @@ -10935,10 +11241,9 @@ <h3>Your data</h3> .map((item, i) => { const n = i + 1; const ref = getExportItemRef(item) || `#${n}`; - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; - const target = item.ghost && item.type === 'inserted' ? 0 : anchorIndex.get(item.rowId); + const target = itemMembers(item).some(m => anchorIndex.get(m.rowId) === n) ? n : 0; const refHtml = target ? `<a href="#c${target}">${escapeHtml(ref)}</a>` : escapeHtml(ref); - return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(t('pageShort'))} ${page}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; + return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(getExportItemPageLabel(item))}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; }) .join('\n')} </tbody> @@ -10954,7 +11259,7 @@ <h3>Your data</h3> const inner = renderReportTokensHtml(getExportDiffTokens(row)); const tag = kind === 'h1' ? 'h3' : kind === 'h2' ? 'h4' : kind === 'h3' ? 'h5' : 'p'; const tagText = getRowReportTag(row); - const idAttr = anchorIndex.has(row.id) ? ` id="${anchorFor(row)}"` : ''; + const idAttr = anchorFor(row); const tagHtml = tagText ? `<span class="row-tag"><a href="#changes">${escapeHtml(tagText)}</a></span>` : ''; return `<div class="row ${row.type} kind-${kind}"${idAttr}>${tagHtml}<${tag}>${inner}</${tag}></div>`; }) @@ -11095,7 +11400,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> const counts = getExportCounts(items); const changeIndex = new Map(); items.forEach((item, i) => { - if (!item.ghost && !changeIndex.has(item.rowId)) changeIndex.set(item.rowId, i + 1); + for (const m of itemMembers(item)) if (!m.ghost && !changeIndex.has(m.rowId)) changeIndex.set(m.rowId, i + 1); }); const reviewLine = getExportReviewLine(counts); const lines = [ @@ -11129,11 +11434,10 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> if (p.ins) return renderMarkdownTokens([{ value: p.ins, added: true }]); return renderMarkdownTokens([{ value: p.del, removed: true }]); }) - .concat(more ? [`+${more} ${escapeMarkdownInline(t('moreDeltas'))}`] : []) + .concat(more ? [`+${more} ${escapeMarkdownInline(moreDeltasText(more))}`] : []) .join('<br>'); - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; lines.push( - `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${page} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` + `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${getExportItemPages(item)} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` ); } } else { @@ -11179,11 +11483,16 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> return `<td class="pv-cell kind-${kind}">${html}</td>`; } -/** 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 = ` <header class="pv-head"> <div class="pv-title">${escapeHtml(getExportTitle())}</div> @@ -11263,9 +11572,44 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> return `${head}<main class="pv-body pv-mode-${mode}">${body}${emptyHtml}</main>`; } +/** 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 `<p class="pvf-item pvf-${kind}"${indent}><span class="pvf-marker">${escapeHtml(split.marker)}</span><span class="pvf-body">${escapeHtml(split.body)}</span></p>`; + } + return `<p class="pvf-p"${indent}>${escapeHtml(text)}</p>`; + }) + .join(''); + return `<main class="pv-final" lang="${state.lang === 'de' ? 'de' : 'en'}">${html}</main>`; +} + +/** Blocks of the new version as it stands now, without running headers and footers. */ +function getFinalBlocks() { + const blocks = state.alignedRows.length + ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock) + : parseIntoBlocks(state.rightText || ''); + return blocks.filter(b => !b.isHeaderFooter && b.kind !== 'hf' && !isInternalMarkerLine(b.text)); +} + +// The print view requested from the export menu; Ctrl/Cmd+P prints the current view. +let requestedPrintKind = null; + function preparePrintView() { if (typeof ensureComparisonCurrent === 'function') ensureComparisonCurrent(); if (!state.alignedRows.length) return; + const kind = requestedPrintKind || (state.viewMode === 'inline' ? 'inline' : 'split'); const date = new Date(); let view = document.getElementById('print-view'); if (!view) { @@ -11275,7 +11619,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> view.setAttribute('aria-hidden', 'true'); document.body.appendChild(view); } - setSafeContainerHtml(view, buildPrintViewHtml(date)); + setSafeContainerHtml(view, buildPrintViewHtml(date, kind)); let pageStyle = document.getElementById('print-page-style'); if (!pageStyle) { @@ -11283,15 +11627,18 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> pageStyle.id = 'print-page-style'; document.head.appendChild(pageStyle); } - const footerLeft = `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; - const orientation = state.viewMode === 'inline' ? 'portrait' : 'landscape'; - pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: 14mm 12mm 16mm; + const footerLeft = + kind === 'final' ? getDisplayDocName('right') : `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; + const orientation = kind === 'split' ? 'landscape' : 'portrait'; + const margin = kind === 'final' ? '22mm 20mm 20mm 24mm' : '14mm 12mm 16mm'; + pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: ${margin}; @bottom-left { content: ${cssString(footerLeft)}; font: 7.5pt sans-serif; color: #444; } @bottom-right { content: ${cssString(`${t('pageLabel')} `)} counter(page) ${cssString(` ${t('pageOf')} `)} counter(pages); font: 7.5pt sans-serif; color: #444; } } }`; document.body.classList.add('has-print-view'); } function cleanupPrintView() { + requestedPrintKind = null; document.body.classList.remove('has-print-view'); const view = document.getElementById('print-view'); if (view) view.remove(); @@ -11347,6 +11694,55 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> 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'); @@ -11355,28 +11751,19 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> // 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') { @@ -11384,6 +11771,25 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> } } +/** + * 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. */ @@ -11406,6 +11812,922 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> }); } +/* ── 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)) { + const e = xml.indexOf('-->', lt + 4); + if (e < 0) throw new Error('xml: unterminated comment'); + i = e + 3; + continue; + } + if (xml.startsWith('<![CDATA[', lt)) { + const e = xml.indexOf(']]>', lt + 9); + if (e < 0) throw new Error('xml: unterminated CDATA'); + i = e + 3; + continue; + } + if (xml.startsWith('<?', lt) || xml.startsWith('<!', lt)) { + const e = xml.indexOf('>', 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(/<w:rStyle\b[^>]*\/>/g, '').replace(/<w:rPr>\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 += `<w:r>${run.rPr}${run.content}</w:r>`; + run = null; + }; + const setRev = kind => { + if (revOpen === kind) return; + flushRun(); + if (revOpen) body += revOpen === 'ins' ? '</w:ins>' : '</w:del>'; + revOpen = kind; + if (kind) body += kind === 'ins' ? `<w:ins ${rev()}>` : `<w:del ${rev()}>`; + }; + 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' + ? `<w:delText xml:space="preserve">${escapeXml(item.text)}</w:delText>` + : `<w:t xml:space="preserve">${escapeXml(item.text)}</w:t>` + : 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}</w:p>`; +} + +/** 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' ? `<w:ins ${rev()}/>` : `<w:del ${rev()}/>`; + if (!pPr) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/^<w:pPr\s*\/>$/.test(pPr)) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/<w:rPr\s*\/>/.test(pPr)) return pPr.replace(/<w:rPr\s*\/>/, `<w:rPr>${mark}</w:rPr>`); + if (/<w:rPr>/.test(pPr)) return pPr.replace(/<w:rPr>/, `<w:rPr>${mark}`); + const before = pPr.search(/<w:sectPr\b|<w:pPrChange\b|<\/w:pPr>/); + return `${pPr.slice(0, before)}<w:rPr>${mark}</w:rPr>${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(/<w:sectPr\b[\s\S]*?<\/w:sectPr>/, '') + .replace(/<w:pPrChange\b[\s\S]*?<\/w:pPrChange>/, '') + .replace(/<w:rPr>[\s\S]*?<\/w:rPr>|<w:rPr\s*\/>/, ''); + 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 = `<w:p>${pPr}</w:p>`; + 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 && /<w:numPr\b/.test(xml.slice(tpl.start, tpl.end)); + const prefix = tplNumbered && marker && finalKey.startsWith(`${marker} `) ? marker.length + 1 : 0; + insertAt(buildNewDocxParagraph(xml, ctx, tpl, step.tokens, prefix, rev, step.markPara || ''), targetText(step.tokens, prefix)); + return; + } + if (changed) { + if (unit.locked) throw new Error('docx: change in a locked unit'); + if (unit.kind === 'p') { + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, step.tokens, pu.prefix, rev, step.markPara || '')); + unitText.set(unit, targetText(step.tokens, pu.prefix)); + } else { + unitText.set(unit, rewriteDocxRow(unit, xml, ctx, step, rev, edit)); + } + } + anchor = unitEnd(unit); + }); + + // Units read in that are not part of the result anymore (a rejected insertion, a paragraph + // emptied by an edit) are removed, with anything nested in them. + const removed = []; + const removedRows = new Map(); + for (const hit of r0Map.values()) { + const unit = hit.unit; + if (usedUnits.has(unit)) continue; + if (unit.locked) throw new Error('docx: removal in a locked unit'); + if (unit.kind === 'row') { + removedRows.set(unit.table, (removedRows.get(unit.table) || 0) + 1); + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } else if (/<w:sectPr\b/.test(xml.slice(unit.node.start, unit.node.end))) { + // It carries the section layout: keep the paragraph, drop its text. + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, [], 0, rev, '')); + unitText.set(unit, ''); + } else { + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } + } + for (const [table, n] of removedRows) { + if (n < table.rows) continue; + // Every row is gone: remove the table (a table needs at least one row). + for (let k = edits.length - 1; k >= 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). @@ -12088,6 +13410,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> } else if (kind === 'docx') { result = await extractDocxText(buffer); result.format = 'DOCX'; + result.sourceBytes = bytes; } else { const { text, warnings } = decodeTextFile(bytes, ext); const looksLikeHtml = /^\s*(?:<!doctype html|<html[\s>])/i.test(text.slice(0, 2048)); @@ -12127,7 +13450,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> fnLabel.textContent = `${t('readingFile')} ${file.name}…`; try { - const { text, format, warnings } = await readDocumentFile(file); + const { text, format, warnings, sourceBytes } = await readDocumentFile(file); if (isStale()) { if (seq === uploadSeq[side]) restoreLabel(); return false; @@ -12142,6 +13465,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightFormat = format; state.rightText = text; } + state.docSources[side] = sourceBytes ? { name: file.name, bytes: sourceBytes, importedText: text } : null; resetEditHistory(); if (warnings && warnings.length) { showImportNotice(`${file.name}: ${warnings.join(' ')}`, 'warning'); @@ -12331,6 +13655,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> trigger = trigger || document.getElementById(MENU_TRIGGERS[id]); trigger.setAttribute('aria-expanded', 'true'); openMenuState = { id, trigger }; + if (id === 'export-menu') updateExportMenuTexts(); document.body.classList.toggle('menu-sheet-open', isPhoneWidth()); menuItems(menu).forEach(item => (item.tabIndex = -1)); if (focus) { @@ -12776,6 +14101,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightName = ''; state.leftFormat = ''; state.rightFormat = ''; + state.docSources = { left: null, right: null }; state.activeRowId = null; state.hoveredRowId = null; state.sidebarFilter = 'all'; @@ -12849,6 +14175,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> [state.leftText, state.rightText] = [state.rightText, state.leftText]; [state.leftName, state.rightName] = [state.rightName, state.leftName]; [state.leftFormat, state.rightFormat] = [state.rightFormat, state.leftFormat]; + state.docSources = { left: state.docSources.right, right: state.docSources.left }; resetEditHistory(); runComparison(); btnFiles.focus(); @@ -12883,11 +14210,13 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.leftText = sanitizeExternalText(textareaLeft.value); state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; } if (rightChanged) { state.rightText = sanitizeExternalText(textareaRight.value); state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; } if (leftChanged || rightChanged) resetEditHistory(); if (state.leftText.trim() && !state.leftName) { @@ -13158,7 +14487,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.5.0'; +const DOCDIFF_APP_VERSION = '1.6.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -13503,6 +14832,20 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> 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); @@ -13517,12 +14860,14 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> name: state.leftName, format: state.leftFormat, text: state.leftText, + source: sessionSourceOf('left'), }, right: { name: state.rightName, format: state.rightFormat, originalText: sessionTracker.originalRightText, text: state.rightText, + source: sessionSourceOf('right'), }, }, view: { @@ -13643,6 +14988,22 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> }); } +function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Optional original .docx of a side: { kind: 'docx', data (base64), importedText }. +function expectSource(value, name) { + if (value === undefined) return null; + if (!isPlainObject(value) || value.kind !== 'docx') throw new SessionFileError('sessionErrorInvalid'); + const data = expectString(value.data, SESSION_MAX_FILE_BYTES); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) throw new SessionFileError('sessionErrorInvalid'); + return { name, bytes: base64ToBytes(data), importedText: expectString(value.importedText, SESSION_MAX_FILE_BYTES) }; +} + // Returns a fresh, fully validated object; throws SessionFileError otherwise. function validateSessionData(raw) { if (!isPlainObject(raw) || raw.format !== SESSION_FORMAT_ID) { @@ -13687,12 +15048,14 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> name: expectString(docs.left.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.left.format), text: expectString(docs.left.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.left.source, docs.left.name), }, right: { name: expectString(docs.right.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.right.format), originalText: expectString(docs.right.originalText, SESSION_MAX_FILE_BYTES), text: expectString(docs.right.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.right.source, docs.right.name), }, view: { mode: view.mode === 'pages' ? 'split' : view.mode, @@ -13740,6 +15103,7 @@ <h2 id="redline">${escapeHtml(t('exportRedlineHeading'))}</h2> state.rightName = session.right.name; state.rightFormat = session.right.format; state.rightText = session.right.text; + state.docSources = { left: session.left.source || null, right: session.right.source || null }; state.reviews = cloneReviews(session.reviews); state.undoStack = session.history.undo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); state.redoStack = session.history.redo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); diff --git a/site/og-image-en.png b/site/og-image-en.png new file mode 100644 index 0000000..16c1c52 Binary files /dev/null and b/site/og-image-en.png differ diff --git a/site/og-image.png b/site/og-image.png index 78d34dd..f0c087a 100644 Binary files a/site/og-image.png and b/site/og-image.png differ diff --git a/src/js/01-i18n.js b/src/js/01-i18n.js index 92428ce..053b4c2 100644 --- a/src/js/01-i18n.js +++ b/src/js/01-i18n.js @@ -54,11 +54,28 @@ const I18N = { 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', @@ -77,6 +94,8 @@ const I18N = { // ── 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}', @@ -87,7 +106,7 @@ const I18N = { 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.', @@ -140,6 +159,7 @@ const I18N = { movedFromHere: 'Verschoben – von hier', movedToHere: 'Verschoben – hierher', moreDeltas: 'weitere Änderungen', + moreDeltasOne: 'weitere Änderung', pageLabel: 'Seite', pageShort: 'S.', pageOf: 'von', @@ -179,7 +199,11 @@ const I18N = { </section> <section class="help-sec"> <h3>Prüfen</h3> - <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück. Im Word-Export sind angenommene Änderungen übernommen, abgelehnte zurückgenommen und offene als nachverfolgte Änderungen eingetragen.</p> + <p><strong>Annehmen</strong> behält die neue Fassung, <strong>Ablehnen</strong> stellt für diesen Absatz die alte Fassung wieder her. Die Symbole <strong>✓</strong> (annehmen), <strong>✗</strong> (ablehnen) und <strong>✎</strong> (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 (<strong>Rückgängig</strong>) nimmt die Prüfung zurück.</p> + </section> + <section class="help-sec"> + <h3>Exportieren</h3> + <p><strong>Endfassung</strong>: 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. <strong>Vergleich</strong>: 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.</p> </section> <section class="help-sec"> <h3>Tastenkürzel</h3> @@ -268,6 +292,7 @@ const I18N = { exportTitleFmt: 'Vergleich: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', + exportCountsFmtOne: '1 Änderung ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', exportReviewFmt: '{k} von {n} geprüft ({a} angenommen, {r} abgelehnt)', exportLabelOriginal: 'Alte Fassung', exportLabelRevised: 'Neue Fassung', @@ -279,9 +304,9 @@ const I18N = { exportLabelDetail: 'Angabe', exportScopeFull: 'Vollständiges Dokument', exportScopeChanges: 'Nur Änderungen (Filter „Nur Änderungen“ aktiv, unveränderte Absätze ausgelassen)', - exportHfIgnored: 'ausgeblendet (nicht verglichen)', - exportHfIgnoredCount: 'ausgeblendet ({n} Zeilen nicht verglichen)', - exportHfIncluded: 'mitverglichen', + exportHfIgnored: 'unveränderte ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIgnoredCount: '{n} unveränderte Zeilen ausgeblendet; geänderte sind als Änderung aufgeführt', + exportHfIncluded: 'vollständig mitverglichen', exportSummaryHeading: 'Zusammenfassung', exportCountTotal: 'Änderungen gesamt', exportChangesHeading: 'Änderungsübersicht', @@ -302,8 +327,10 @@ const I18N = { 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', @@ -333,7 +360,7 @@ const I18N = { 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', @@ -365,7 +392,7 @@ const I18N = { `, faqWordQ: 'Kann ich die Änderungen in Word weiterbearbeiten?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, metaTitle: 'Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload', otherPageHint: 'Diese Seite gibt es auch auf Deutsch →', @@ -427,11 +454,28 @@ const I18N = { 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', @@ -450,6 +494,8 @@ const I18N = { // ── 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}', @@ -460,7 +506,7 @@ const I18N = { 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.', @@ -513,6 +559,7 @@ const I18N = { movedFromHere: 'Moved – from here', movedToHere: 'Moved – to here', moreDeltas: 'more changes', + moreDeltasOne: 'more change', pageLabel: 'Page', pageShort: 'p.', pageOf: 'of', @@ -552,7 +599,11 @@ const I18N = { </section> <section class="help-sec"> <h3>Reviewing</h3> - <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back. In the Word export, accepted changes are applied, rejected ones are reverted and open ones are recorded as tracked changes.</p> + <p><strong>Accept</strong> keeps the new wording, <strong>Reject</strong> restores the old wording of that paragraph. The symbols <strong>✓</strong> (accept), <strong>✗</strong> (reject) and <strong>✎</strong> (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 (<strong>Undo</strong>) takes the review back.</p> + </section> + <section class="help-sec"> + <h3>Exporting</h3> + <p><strong>Final version</strong>: 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. <strong>Comparison</strong>: 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.</p> </section> <section class="help-sec"> <h3>Keyboard shortcuts</h3> @@ -641,6 +692,7 @@ const I18N = { exportTitleFmt: 'Comparison: {left} → {right}', exportVs: 'vs.', exportCountsFmt: '{total} changes ({mod} changed, {ins} added, {del} removed)', + exportCountsFmtOne: '1 change ({mod} changed, {ins} added, {del} removed)', exportReviewFmt: '{k} of {n} reviewed ({a} accepted, {r} rejected)', exportLabelOriginal: 'Old version', exportLabelRevised: 'New version', @@ -652,9 +704,9 @@ const I18N = { 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', @@ -675,8 +727,10 @@ const I18N = { 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', @@ -706,7 +760,7 @@ const I18N = { 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', @@ -738,12 +792,12 @@ const I18N = { `, faqWordQ: 'Can I keep working on the changes in Word?', faqWordA: ` - <p>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.</p> + <p>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.</p> `, 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', diff --git a/src/js/03-state.js b/src/js/03-state.js index 5edc839..b2a1bc6 100644 --- a/src/js/03-state.js +++ b/src/js/03-state.js @@ -37,6 +37,8 @@ const state = { 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: [], diff --git a/src/js/04-core.js b/src/js/04-core.js index dc52328..9733646 100644 --- a/src/js/04-core.js +++ b/src/js/04-core.js @@ -36,7 +36,8 @@ function serializeBlockLine(block) { 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) { @@ -658,8 +659,11 @@ function parseIntoBlocks(rawText) { (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; } @@ -691,6 +695,7 @@ function parseIntoBlocks(rawText) { sectionRef: currentSectionRef, text: line, kind, + headingMarker, indent: entry.indent, isHeading, isHeaderFooter, @@ -1083,9 +1088,11 @@ function computeCleanWordDiff(oldText, newText) { } } - const cleaned = mergedNums - .filter(tok => tok.value.length > 0) - .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })); + const cleaned = mergeFragmentedChanges( + mergedNums + .filter(tok => tok.value.length > 0) + .map(tok => (tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value })) + ); if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned; // Never show a diff that does not reproduce the source texts: fall back to the raw diff. @@ -1094,6 +1101,84 @@ function computeCleanWordDiff(oldText, newText) { ); } +/** + * Joins changes that only an unchanged hyphen or space separates into one replacement, so a + * rewritten phrase reads as one change instead of misleading word pairs: + * "[Standard]→[Echtzeit]-[Berichtswesen]→[Nachtragsmanagement]" becomes + * "[Standard-Berichtswesen]→[Echtzeit-Nachtragsmanagement]". The joined glue goes into both + * sides, so the invariant (unchanged + removed = old, unchanged + added = new) holds. Each run of + * adjacent changes ends up as at most one removal followed by one insertion; whitespace shared + * by both sides at the edges stays unchanged. + */ +function mergeFragmentedChanges(parts) { + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const glued = []; + for (let i = 0; i < parts.length; i++) { + const cur = parts[i]; + const isGlue = + !isChange(cur) && + isChange(glued[glued.length - 1]) && + isChange(parts[i + 1]) && + (/^\s+$/.test(cur.value) || !/\s/.test(cur.value)); + if (isGlue) { + glued.push({ value: cur.value, removed: true }, { value: cur.value, added: true }); + } else { + glued.push(cur); + } + } + + const out = []; + const pushEqual = value => { + if (!value) return; + const last = out[out.length - 1]; + if (last && !isChange(last)) last.value += value; + else out.push({ value }); + }; + let i = 0; + while (i < glued.length) { + if (!isChange(glued[i])) { + pushEqual(glued[i].value); + i++; + continue; + } + let rem = ''; + let add = ''; + while (i < glued.length && isChange(glued[i])) { + if (glued[i].removed) rem += glued[i].value; + else add += glued[i].value; + i++; + } + let lead = ''; + let trail = ''; + if (rem && add) { + const leadRem = rem.match(/^\s*/)[0]; + const leadAdd = add.match(/^\s*/)[0]; + while (lead.length < Math.min(leadRem.length, leadAdd.length) && leadRem[lead.length] === leadAdd[lead.length]) { + lead += leadRem[lead.length]; + } + rem = rem.slice(lead.length); + add = add.slice(lead.length); + const trailRem = rem.match(/\s*$/)[0]; + const trailAdd = add.match(/\s*$/)[0]; + let n = 0; + while ( + n < Math.min(trailRem.length, trailAdd.length) && + trailRem[trailRem.length - 1 - n] === trailAdd[trailAdd.length - 1 - n] + ) { + n++; + } + trail = n ? rem.slice(rem.length - n) : ''; + rem = rem.slice(0, rem.length - n); + add = add.slice(0, add.length - n); + } + pushEqual(lead); + if (rem) out.push({ value: rem, removed: true }); + if (add) out.push({ value: add, added: true }); + pushEqual(trail); + } + return out; +} + function coalesceSideTokens(sideParts, prop) { const out = []; for (let i = 0; i < sideParts.length; i++) { diff --git a/src/js/06-compare.js b/src/js/06-compare.js index fc245ce..ea0dcb2 100644 --- a/src/js/06-compare.js +++ b/src/js/06-compare.js @@ -182,6 +182,7 @@ function loadSampleComparison() { state.rightName = t('sampleRightName'); state.rightFormat = 'DEMO'; state.rightText = sample.v2; + state.docSources = { left: null, right: null }; resetEditHistory(); runComparison(); } @@ -192,11 +193,13 @@ function clearLandingSide(side) { state.leftText = ''; state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; document.getElementById('file-input-left').value = ''; } else { state.rightText = ''; state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; resetEditHistory(); document.getElementById('file-input-right').value = ''; } @@ -569,6 +572,11 @@ function extractDeltaPairs(wordDiffs) { 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) { @@ -780,7 +788,7 @@ function renderSidebarList(bothLoaded) { const pageLabel = pages.length > 1 ? `${t('pageShort')} ${Math.min(...pages)}–${Math.max(...pages)}` : `${t('pageShort')} ${page}`; const head = item.members ? `${ref} · ${pageLabel}` : [ref, pageLabel].filter(Boolean).join(' · '); const delta = renderSidebarDeltaHtml(item); - const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(t('moreDeltas'))}</span></span>` : ''; + const moreHtml = delta.more > 0 ? `<span class="nav-more"><span aria-hidden="true">+${delta.more}</span><span class="sr-only">, ${delta.more} ${escapeHtml(moreDeltasText(delta.more))}</span></span>` : ''; return `<div class="nav-item ${cls} is-${item.status}${isActive ? ' is-active' : ''}" data-row-id="${item.rowId}" data-item-id="${item.id}"><button type="button" class="nav-item-main" tabindex="${item.id === focusId ? 0 : -1}"${isActive ? ' aria-current="true"' : ''}><span class="nav-item-head"><span class="nav-item-ref">${escapeHtml(head)}</span>${moreHtml}${statusIcon}<span class="sr-only">, ${escapeHtml(getChangeTypeLabel(item))}, ${escapeHtml(getStatusLabel(item.status))}. </span></span>${delta.html}</button></div>`; }) .join('') diff --git a/src/js/07-interactions.js b/src/js/07-interactions.js index a676f34..c301229 100644 --- a/src/js/07-interactions.js +++ b/src/js/07-interactions.js @@ -409,6 +409,7 @@ function rebuildRightTextFromRows(targetRowId, replacementText) { serializeBlockLine({ text: line.trim(), kind: template ? template.kind : 'p', + headingMarker: template ? template.headingMarker : '', indent: template ? template.indent : 0, isHeaderFooter: Boolean(target && target.rightBlock && target.rightBlock.isHeaderFooter), }) @@ -470,6 +471,7 @@ function rebuildRightTextReplacingRows(replacements) { serializeBlockLine({ text: line.trim(), kind: row.rightBlock.kind, + headingMarker: row.rightBlock.headingMarker, indent: row.rightBlock.indent, isHeaderFooter: Boolean(row.rightBlock.isHeaderFooter), }) diff --git a/src/js/08-export-docx.js b/src/js/08-export-docx.js index 4412107..515dc94 100644 --- a/src/js/08-export-docx.js +++ b/src/js/08-export-docx.js @@ -163,32 +163,34 @@ function docxApplyMarkerTab(tokens, block) { 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) => - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/></w:pPr>${docxRun(`${label}: `, false, '<w:b/>')}${docxRun(value)}</w:p>`; - - paragraphs.push(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr>${docxRun(meta.title)}</w:p>`); - for (const [label, value] of meta.lines) paragraphs.push(metaPara(label, value)); - paragraphs.push( - `<w:p><w:pPr><w:pStyle w:val="DocDiffMeta"/><w:pBdr><w:bottom w:val="single" w:sz="6" w:space="6" w:color="A6A6A6"/></w:pBdr><w:spacing w:after="360"/></w:pPr>${docxRun(meta.note, false, '<w:i/>')}</w:p>` - ); - 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), @@ -212,7 +214,16 @@ function buildDocxDocumentXml(rows, meta) { return docxRun(tok.value, false); }) .join(''); - paragraphs.push(`<w:p>${pPr}${runs}</w:p>`); + paragraphs.push({ pPr, runs }); + } + if (!paragraphs.length) paragraphs.push({ pPr: '', runs: '' }); + + if (meta.comment && meta.comment.length) { + const first = paragraphs[0]; + first.runs = + '<w:commentRangeStart w:id="0"/>' + + first.runs + + '<w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r>'; } const sectPr = @@ -225,7 +236,23 @@ function buildDocxDocumentXml(rows, meta) { '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - `<w:body>${paragraphs.join('')}${sectPr}</w:body></w:document>` + `<w:body>${paragraphs.map(p => `<w:p>${p.pPr}${p.runs}</w:p>`).join('')}${sectPr}</w:body></w:document>` + ); +} + +/** The Word comment with the summary of a comparison (one paragraph per line). */ +function buildDocxCommentsXml(meta) { + const date = toWordDate(meta.date); + const paras = meta.comment + .map((line, i) => + `<w:p>${i === 0 ? '<w:r><w:annotationRef/></w:r>' : ''}${docxRun(line, false, i === 0 ? '<w:b/>' : '')}</w:p>` + ) + .join(''); + return ( + '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + + '<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' + + `<w:comment w:id="0" w:author="${escapeXml(DOCX_AUTHOR)}" w:date="${date}" w:initials="DD">${paras}</w:comment>` + + '</w:comments>' ); } @@ -234,9 +261,7 @@ function buildDocxFooterXml(meta) { '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + '<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' + 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' + - '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:tabs><w:tab w:val="right" w:pos="9072"/></w:tabs></w:pPr>' + - docxRun(meta.footer) + - '<w:r><w:tab/></w:r>' + + '<w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="right"/></w:pPr>' + docxRun(`${meta.pageLabel} `) + '<w:fldSimple w:instr=" PAGE "><w:r><w:t>1</w:t></w:r></w:fldSimple>' + docxRun(` ${meta.pageOfLabel} `) + @@ -280,7 +305,8 @@ function buildDocxStylesXml(langTag) { function buildDocxPackageFiles(rows, meta) { const langTag = meta.lang === 'de' ? 'de-DE' : 'en-US'; const created = toWordDate(meta.date); - return [ + const hasComment = Boolean(meta.comment && meta.comment.length); + const files = [ { name: '[Content_Types].xml', data: @@ -292,6 +318,7 @@ function buildDocxPackageFiles(rows, meta) { '<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' + '<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>' + '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>' + + (hasComment ? '<Override PartName="/word/comments.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"/>' : '') + '<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' + '<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' + '</Types>', @@ -315,6 +342,7 @@ function buildDocxPackageFiles(rows, meta) { 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' + `<dc:title>${escapeXml(meta.title)}</dc:title>` + `<dc:subject>${escapeXml(meta.subject)}</dc:subject>` + + (meta.description ? `<dc:description>${escapeXml(meta.description)}</dc:description>` : '') + `<dc:creator>${DOCX_AUTHOR}</dc:creator>` + `<cp:lastModifiedBy>${DOCX_AUTHOR}</cp:lastModifiedBy>` + `<dc:language>${langTag}</dc:language>` + @@ -337,6 +365,7 @@ function buildDocxPackageFiles(rows, meta) { '<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' + '<Relationship Id="rIdSettings" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>' + '<Relationship Id="rIdFooter1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>' + + (hasComment ? '<Relationship Id="rIdComments" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>' : '') + '</Relationships>', }, { name: 'word/document.xml', data: buildDocxDocumentXml(rows, meta) }, @@ -352,33 +381,62 @@ function buildDocxPackageFiles(rows, meta) { }, { 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); +} diff --git a/src/js/08-export.js b/src/js/08-export.js index ec419ef..1d9e282 100644 --- a/src/js/08-export.js +++ b/src/js/08-export.js @@ -80,23 +80,17 @@ function getExportTitle() { } /** - * 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, @@ -133,13 +127,24 @@ function getExportStatusLabel(status) { 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) { @@ -173,7 +178,7 @@ function getRowReportTag(row) { 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(' · '); } @@ -287,11 +292,7 @@ function getRowChangeSummary(rowOrItem, maxLen = 70) { * 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'); @@ -323,7 +324,7 @@ function renderReportSummaryPairsHtml(row) { if (p.ins) return renderReportTokensHtml([{ value: p.ins, added: true }]); return renderReportTokensHtml([{ value: p.del, removed: true }]); }); - if (more) items.push(`<span class="more">+${more} ${escapeHtml(t('moreDeltas'))}</span>`); + if (more) items.push(`<span class="more">+${more} ${escapeHtml(moreDeltasText(more))}</span>`); return items.map(html => `<div>${html}</div>`).join(''); } @@ -392,13 +393,22 @@ function buildExportHtmlReport(date = new Date()) { 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 @@ -410,10 +420,9 @@ ${items .map((item, i) => { const n = i + 1; const ref = getExportItemRef(item) || `#${n}`; - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; - const target = item.ghost && item.type === 'inserted' ? 0 : anchorIndex.get(item.rowId); + const target = itemMembers(item).some(m => anchorIndex.get(m.rowId) === n) ? n : 0; const refHtml = target ? `<a href="#c${target}">${escapeHtml(ref)}</a>` : escapeHtml(ref); - return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(t('pageShort'))} ${page}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; + return `<tr><td class="num">${n}</td><td class="ref">${refHtml}<div class="more">${escapeHtml(getExportItemPageLabel(item))}</div></td><td><span class="badge ${item.type}">${escapeHtml(getRowTypeLabel(item))}</span></td><td class="status ${item.status}">${escapeHtml(getExportStatusLabel(item.status))}</td><td>${renderReportSummaryPairsHtml(item)}</td></tr>`; }) .join('\n')} </tbody> @@ -429,7 +438,7 @@ ${items const inner = renderReportTokensHtml(getExportDiffTokens(row)); const tag = kind === 'h1' ? 'h3' : kind === 'h2' ? 'h4' : kind === 'h3' ? 'h5' : 'p'; const tagText = getRowReportTag(row); - const idAttr = anchorIndex.has(row.id) ? ` id="${anchorFor(row)}"` : ''; + const idAttr = anchorFor(row); const tagHtml = tagText ? `<span class="row-tag"><a href="#changes">${escapeHtml(tagText)}</a></span>` : ''; return `<div class="row ${row.type} kind-${kind}"${idAttr}>${tagHtml}<${tag}>${inner}</${tag}></div>`; }) @@ -570,7 +579,7 @@ function buildExportMarkdown(date = new Date()) { const counts = getExportCounts(items); const changeIndex = new Map(); items.forEach((item, i) => { - if (!item.ghost && !changeIndex.has(item.rowId)) changeIndex.set(item.rowId, i + 1); + for (const m of itemMembers(item)) if (!m.ghost && !changeIndex.has(m.rowId)) changeIndex.set(m.rowId, i + 1); }); const reviewLine = getExportReviewLine(counts); const lines = [ @@ -604,11 +613,10 @@ function buildExportMarkdown(date = new Date()) { if (p.ins) return renderMarkdownTokens([{ value: p.ins, added: true }]); return renderMarkdownTokens([{ value: p.del, removed: true }]); }) - .concat(more ? [`+${more} ${escapeMarkdownInline(t('moreDeltas'))}`] : []) + .concat(more ? [`+${more} ${escapeMarkdownInline(moreDeltasText(more))}`] : []) .join('<br>'); - const page = item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; lines.push( - `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${page} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` + `| ${n} | ${escapeMarkdownInline(getExportItemRef(item) || `#${n}`)} | ${getExportItemPages(item)} | ${escapeMarkdownInline(getRowTypeLabel(item))} | ${escapeMarkdownInline(getExportStatusLabel(item.status))} | ${escapeMarkdownTableCell(summary)} |` ); } } else { @@ -654,11 +662,16 @@ function renderPrintCellHtml(html, block) { return `<td class="pv-cell kind-${kind}">${html}</td>`; } -/** 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 = ` <header class="pv-head"> <div class="pv-title">${escapeHtml(getExportTitle())}</div> @@ -738,9 +751,44 @@ function buildPrintViewHtml(date = new Date()) { return `${head}<main class="pv-body pv-mode-${mode}">${body}${emptyHtml}</main>`; } +/** 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 `<p class="pvf-item pvf-${kind}"${indent}><span class="pvf-marker">${escapeHtml(split.marker)}</span><span class="pvf-body">${escapeHtml(split.body)}</span></p>`; + } + return `<p class="pvf-p"${indent}>${escapeHtml(text)}</p>`; + }) + .join(''); + return `<main class="pv-final" lang="${state.lang === 'de' ? 'de' : 'en'}">${html}</main>`; +} + +/** Blocks of the new version as it stands now, without running headers and footers. */ +function getFinalBlocks() { + const blocks = state.alignedRows.length + ? state.alignedRows.filter(r => r.rightBlock).map(r => r.rightBlock) + : parseIntoBlocks(state.rightText || ''); + return blocks.filter(b => !b.isHeaderFooter && b.kind !== 'hf' && !isInternalMarkerLine(b.text)); +} + +// The print view requested from the export menu; Ctrl/Cmd+P prints the current view. +let requestedPrintKind = null; + function preparePrintView() { if (typeof ensureComparisonCurrent === 'function') ensureComparisonCurrent(); if (!state.alignedRows.length) return; + const kind = requestedPrintKind || (state.viewMode === 'inline' ? 'inline' : 'split'); const date = new Date(); let view = document.getElementById('print-view'); if (!view) { @@ -750,7 +798,7 @@ function preparePrintView() { view.setAttribute('aria-hidden', 'true'); document.body.appendChild(view); } - setSafeContainerHtml(view, buildPrintViewHtml(date)); + setSafeContainerHtml(view, buildPrintViewHtml(date, kind)); let pageStyle = document.getElementById('print-page-style'); if (!pageStyle) { @@ -758,15 +806,18 @@ function preparePrintView() { pageStyle.id = 'print-page-style'; document.head.appendChild(pageStyle); } - const footerLeft = `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; - const orientation = state.viewMode === 'inline' ? 'portrait' : 'landscape'; - pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: 14mm 12mm 16mm; + const footerLeft = + kind === 'final' ? getDisplayDocName('right') : `${getDisplayDocName('right')} ${t('exportVs')} ${getDisplayDocName('left')}`; + const orientation = kind === 'split' ? 'landscape' : 'portrait'; + const margin = kind === 'final' ? '22mm 20mm 20mm 24mm' : '14mm 12mm 16mm'; + pageStyle.textContent = `@media print { @page { size: A4 ${orientation}; margin: ${margin}; @bottom-left { content: ${cssString(footerLeft)}; font: 7.5pt sans-serif; color: #444; } @bottom-right { content: ${cssString(`${t('pageLabel')} `)} counter(page) ${cssString(` ${t('pageOf')} `)} counter(pages); font: 7.5pt sans-serif; color: #444; } } }`; document.body.classList.add('has-print-view'); } function cleanupPrintView() { + requestedPrintKind = null; document.body.classList.remove('has-print-view'); const view = document.getElementById('print-view'); if (view) view.remove(); @@ -822,6 +873,55 @@ function downloadWithToast(content, mimeType, filename, toastKey) { 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'); @@ -830,28 +930,19 @@ function handleExportAction(type) { // 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') { @@ -859,6 +950,25 @@ function handleExportAction(type) { } } +/** + * 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. */ diff --git a/src/js/08b-export-docx-source.js b/src/js/08b-export-docx-source.js new file mode 100644 index 0000000..b2f538e --- /dev/null +++ b/src/js/08b-export-docx-source.js @@ -0,0 +1,915 @@ +/* ── 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)) { + const e = xml.indexOf('-->', lt + 4); + if (e < 0) throw new Error('xml: unterminated comment'); + i = e + 3; + continue; + } + if (xml.startsWith('<![CDATA[', lt)) { + const e = xml.indexOf(']]>', lt + 9); + if (e < 0) throw new Error('xml: unterminated CDATA'); + i = e + 3; + continue; + } + if (xml.startsWith('<?', lt) || xml.startsWith('<!', lt)) { + const e = xml.indexOf('>', 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(/<w:rStyle\b[^>]*\/>/g, '').replace(/<w:rPr>\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 += `<w:r>${run.rPr}${run.content}</w:r>`; + run = null; + }; + const setRev = kind => { + if (revOpen === kind) return; + flushRun(); + if (revOpen) body += revOpen === 'ins' ? '</w:ins>' : '</w:del>'; + revOpen = kind; + if (kind) body += kind === 'ins' ? `<w:ins ${rev()}>` : `<w:del ${rev()}>`; + }; + 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' + ? `<w:delText xml:space="preserve">${escapeXml(item.text)}</w:delText>` + : `<w:t xml:space="preserve">${escapeXml(item.text)}</w:t>` + : 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}</w:p>`; +} + +/** 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' ? `<w:ins ${rev()}/>` : `<w:del ${rev()}/>`; + if (!pPr) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/^<w:pPr\s*\/>$/.test(pPr)) return `<w:pPr><w:rPr>${mark}</w:rPr></w:pPr>`; + if (/<w:rPr\s*\/>/.test(pPr)) return pPr.replace(/<w:rPr\s*\/>/, `<w:rPr>${mark}</w:rPr>`); + if (/<w:rPr>/.test(pPr)) return pPr.replace(/<w:rPr>/, `<w:rPr>${mark}`); + const before = pPr.search(/<w:sectPr\b|<w:pPrChange\b|<\/w:pPr>/); + return `${pPr.slice(0, before)}<w:rPr>${mark}</w:rPr>${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(/<w:sectPr\b[\s\S]*?<\/w:sectPr>/, '') + .replace(/<w:pPrChange\b[\s\S]*?<\/w:pPrChange>/, '') + .replace(/<w:rPr>[\s\S]*?<\/w:rPr>|<w:rPr\s*\/>/, ''); + 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 = `<w:p>${pPr}</w:p>`; + 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 && /<w:numPr\b/.test(xml.slice(tpl.start, tpl.end)); + const prefix = tplNumbered && marker && finalKey.startsWith(`${marker} `) ? marker.length + 1 : 0; + insertAt(buildNewDocxParagraph(xml, ctx, tpl, step.tokens, prefix, rev, step.markPara || ''), targetText(step.tokens, prefix)); + return; + } + if (changed) { + if (unit.locked) throw new Error('docx: change in a locked unit'); + if (unit.kind === 'p') { + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, step.tokens, pu.prefix, rev, step.markPara || '')); + unitText.set(unit, targetText(step.tokens, pu.prefix)); + } else { + unitText.set(unit, rewriteDocxRow(unit, xml, ctx, step, rev, edit)); + } + } + anchor = unitEnd(unit); + }); + + // Units read in that are not part of the result anymore (a rejected insertion, a paragraph + // emptied by an edit) are removed, with anything nested in them. + const removed = []; + const removedRows = new Map(); + for (const hit of r0Map.values()) { + const unit = hit.unit; + if (usedUnits.has(unit)) continue; + if (unit.locked) throw new Error('docx: removal in a locked unit'); + if (unit.kind === 'row') { + removedRows.set(unit.table, (removedRows.get(unit.table) || 0) + 1); + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } else if (/<w:sectPr\b/.test(xml.slice(unit.node.start, unit.node.end))) { + // It carries the section layout: keep the paragraph, drop its text. + edit(unit.node.start, unit.node.end, rewriteDocxParagraph(unit.node, xml, ctx, [], 0, rev, '')); + unitText.set(unit, ''); + } else { + edit(unit.node.start, unit.node.end, ''); + removed.push([unit.node.start, unit.node.end]); + } + } + for (const [table, n] of removedRows) { + if (n < table.rows) continue; + // Every row is gone: remove the table (a table needs at least one row). + for (let k = edits.length - 1; k >= 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); +} diff --git a/src/js/09-import.js b/src/js/09-import.js index 7c93152..1e1da8d 100644 --- a/src/js/09-import.js +++ b/src/js/09-import.js @@ -680,6 +680,7 @@ async function readDocumentFile(file) { } else if (kind === 'docx') { result = await extractDocxText(buffer); result.format = 'DOCX'; + result.sourceBytes = bytes; } else { const { text, warnings } = decodeTextFile(bytes, ext); const looksLikeHtml = /^\s*(?:<!doctype html|<html[\s>])/i.test(text.slice(0, 2048)); @@ -719,7 +720,7 @@ async function handleFileUpload(file, side, opts = {}) { fnLabel.textContent = `${t('readingFile')} ${file.name}…`; try { - const { text, format, warnings } = await readDocumentFile(file); + const { text, format, warnings, sourceBytes } = await readDocumentFile(file); if (isStale()) { if (seq === uploadSeq[side]) restoreLabel(); return false; @@ -734,6 +735,7 @@ async function handleFileUpload(file, side, opts = {}) { state.rightFormat = format; state.rightText = text; } + state.docSources[side] = sourceBytes ? { name: file.name, bytes: sourceBytes, importedText: text } : null; resetEditHistory(); if (warnings && warnings.length) { showImportNotice(`${file.name}: ${warnings.join(' ')}`, 'warning'); diff --git a/src/js/10-controls.js b/src/js/10-controls.js index 53e98fb..a98f135 100644 --- a/src/js/10-controls.js +++ b/src/js/10-controls.js @@ -85,6 +85,7 @@ function openMenu(id, trigger, focus = 'first') { trigger = trigger || document.getElementById(MENU_TRIGGERS[id]); trigger.setAttribute('aria-expanded', 'true'); openMenuState = { id, trigger }; + if (id === 'export-menu') updateExportMenuTexts(); document.body.classList.toggle('menu-sheet-open', isPhoneWidth()); menuItems(menu).forEach(item => (item.tabIndex = -1)); if (focus) { @@ -530,6 +531,7 @@ function clearComparison() { state.rightName = ''; state.leftFormat = ''; state.rightFormat = ''; + state.docSources = { left: null, right: null }; state.activeRowId = null; state.hoveredRowId = null; state.sidebarFilter = 'all'; @@ -603,6 +605,7 @@ document.getElementById('btn-swap-docs').addEventListener('click', () => { [state.leftText, state.rightText] = [state.rightText, state.leftText]; [state.leftName, state.rightName] = [state.rightName, state.leftName]; [state.leftFormat, state.rightFormat] = [state.rightFormat, state.leftFormat]; + state.docSources = { left: state.docSources.right, right: state.docSources.left }; resetEditHistory(); runComparison(); btnFiles.focus(); @@ -637,11 +640,13 @@ document.getElementById('btn-apply-modal').addEventListener('click', () => { state.leftText = sanitizeExternalText(textareaLeft.value); state.leftName = ''; state.leftFormat = ''; + state.docSources.left = null; } if (rightChanged) { state.rightText = sanitizeExternalText(textareaRight.value); state.rightName = ''; state.rightFormat = ''; + state.docSources.right = null; } if (leftChanged || rightChanged) resetEditHistory(); if (state.leftText.trim() && !state.leftName) { diff --git a/src/js/11-session.js b/src/js/11-session.js index be942bd..f620b40 100644 --- a/src/js/11-session.js +++ b/src/js/11-session.js @@ -17,7 +17,7 @@ * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.5.0'; +const DOCDIFF_APP_VERSION = '1.6.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -362,6 +362,20 @@ function takeRecentHistory(stack, budget) { 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); @@ -376,12 +390,14 @@ function buildSessionPayload() { name: state.leftName, format: state.leftFormat, text: state.leftText, + source: sessionSourceOf('left'), }, right: { name: state.rightName, format: state.rightFormat, originalText: sessionTracker.originalRightText, text: state.rightText, + source: sessionSourceOf('right'), }, }, view: { @@ -502,6 +518,22 @@ function expectHistoryV2(value) { }); } +function base64ToBytes(b64) { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// Optional original .docx of a side: { kind: 'docx', data (base64), importedText }. +function expectSource(value, name) { + if (value === undefined) return null; + if (!isPlainObject(value) || value.kind !== 'docx') throw new SessionFileError('sessionErrorInvalid'); + const data = expectString(value.data, SESSION_MAX_FILE_BYTES); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 !== 0) throw new SessionFileError('sessionErrorInvalid'); + return { name, bytes: base64ToBytes(data), importedText: expectString(value.importedText, SESSION_MAX_FILE_BYTES) }; +} + // Returns a fresh, fully validated object; throws SessionFileError otherwise. function validateSessionData(raw) { if (!isPlainObject(raw) || raw.format !== SESSION_FORMAT_ID) { @@ -546,12 +578,14 @@ function validateSessionData(raw) { name: expectString(docs.left.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.left.format), text: expectString(docs.left.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.left.source, docs.left.name), }, right: { name: expectString(docs.right.name, SESSION_MAX_NAME_CHARS), format: expectFormatTag(docs.right.format), originalText: expectString(docs.right.originalText, SESSION_MAX_FILE_BYTES), text: expectString(docs.right.text, SESSION_MAX_FILE_BYTES), + source: expectSource(docs.right.source, docs.right.name), }, view: { mode: view.mode === 'pages' ? 'split' : view.mode, @@ -599,6 +633,7 @@ async function applySessionData(session) { state.rightName = session.right.name; state.rightFormat = session.right.format; state.rightText = session.right.text; + state.docSources = { left: session.left.source || null, right: session.right.source || null }; state.reviews = cloneReviews(session.reviews); state.undoStack = session.history.undo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); state.redoStack = session.history.redo.map(e => ({ text: e.text, reviews: cloneReviews(e.reviews) })); diff --git a/src/styles.css b/src/styles.css index 00a7c06..e7b6c2b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -683,6 +683,45 @@ body:not(.is-comparing) .topbar { .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; @@ -3162,6 +3201,44 @@ body.list-open .toast-region { 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 */ diff --git a/src/template.html b/src/template.html index 977e197..9b21084 100644 --- a/src/template.html +++ b/src/template.html @@ -45,7 +45,7 @@ <meta property="og:locale:alternate" content="__OG_LOCALE_ALT__" /> <meta property="og:title" content="__I18N:metaTitle__" /> <meta property="og:description" content="__I18N:metaDescription__" /> - <meta property="og:image" content="https://docdiff.app/og-image.png?v=2" /> + <meta property="og:image" content="__OG_IMAGE__" /> <meta property="og:image:type" content="image/png" /> <meta property="og:image:width" content="2400" /> <meta property="og:image:height" content="1260" /> @@ -53,7 +53,7 @@ <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:title" content="__I18N:metaTitle__" /> <meta name="twitter:description" content="__I18N:metaDescription__" /> - <meta name="twitter:image" content="https://docdiff.app/og-image.png?v=2" /> + <meta name="twitter:image" content="__OG_IMAGE__" /> <meta name="twitter:image:alt" content="__I18N:ogImageAlt__" /> <!-- Structured data (schema.org). A data block: browsers never execute it. --> @@ -243,16 +243,26 @@ <h2 class="sr-only" id="files-pop-title" data-i18n="filesTitle">Dokumente</h2> <span id="i18n-btn-export" data-i18n="exportBtn">Exportieren</span> <svg class="icon tb-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg> </button> - <div class="menu menu-right" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> - <button type="button" class="menu-item" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span data-i18n="expWord">Word mit Änderungsverfolgung (.docx)</span></button> - <button type="button" class="menu-item" data-export="print" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect width="12" height="8" x="6" y="14"/></svg><span data-i18n="expPdf">Drucken oder als PDF sichern</span></button> - <button type="button" class="menu-item" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span data-i18n="expCopy">Text der neuen Fassung kopieren</span></button> + <div class="menu menu-right menu-export" id="export-menu" role="menu" aria-labelledby="btn-export-menu" hidden> + <div class="menu-group" role="group" aria-labelledby="exp-sec-final"> + <div class="menu-label menu-label-2line" id="exp-sec-final"><span data-i18n="expSecFinal">Endfassung</span><span class="menu-label-desc" id="exp-sec-final-desc" data-i18n="expSecFinalDesc"></span></div> + <button type="button" class="menu-item mi-2line" data-export="word-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWordFinal"></span><span class="mi-desc" id="exp-desc-word-final"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-final" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15h6"/><path d="M9 18h4"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfFinal"></span><span class="mi-desc" id="exp-desc-print-final" data-i18n="expPdfFinalDesc"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="copy" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expCopy"></span><span class="mi-desc" id="exp-desc-copy" data-i18n="expCopyDesc"></span></span></button> + </div> + <div class="menu-sep" role="separator"></div> + <div class="menu-group" role="group" aria-labelledby="exp-sec-compare"> + <div class="menu-label menu-label-2line" id="exp-sec-compare"><span data-i18n="expSecCompare">Vergleich</span><span class="menu-label-desc" data-i18n="expSecCompareDesc"></span></div> + <button type="button" class="menu-item mi-2line" data-export="word" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="m8 13 1.5 5 2.5-4 2.5 4 1.5-5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expWord"></span><span class="mi-desc" id="exp-desc-word"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-split" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="16" x="3" y="4" rx="2"/><path d="M12 4v16"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfSplit"></span><span class="mi-desc" id="exp-desc-print-split" data-i18n="expPdfSplitDesc"></span></span></button> + <button type="button" class="menu-item mi-2line" data-export="print-inline" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="18" x="4" y="3" rx="2"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/></svg><span class="mi-text"><span class="mi-title" data-i18n="expPdfInline"></span><span class="mi-desc" id="exp-desc-print-inline" data-i18n="expPdfInlineDesc"></span></span></button> + </div> <div class="menu-sep" role="separator"></div> <button type="button" class="menu-item" role="menuitem" id="btn-more-formats" aria-expanded="false" aria-controls="more-formats"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg><span data-i18n="moreFormats">Weitere Formate</span><svg class="icon mi-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg></button> <div class="menu-sub" id="more-formats" role="group" aria-labelledby="btn-more-formats" hidden> <button type="button" class="menu-item" data-export="html" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20"/></svg><span data-i18n="expHtml">Bericht als Webseite (.html)</span></button> <button type="button" class="menu-item" data-export="md" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg><span data-i18n="expMd">Bericht als Markdown (.md)</span></button> - <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">… als Textdatei (.txt)</span></button> + <button type="button" class="menu-item" data-export="txt" role="menuitem"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg><span data-i18n="expTxt">Endfassung als Textdatei (.txt)</span></button> </div> </div> </div>