diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51e1b0a..34628ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,5 @@ jobs: run: | python3 scripts/build.py node scripts/check.mjs - git diff --exit-code site/index.html DocDiff.html + git diff --exit-code site DocDiff.html + test -z "$(git status --porcelain -- site DocDiff.html)" || { git status --short -- site DocDiff.html; echo "Commit the rebuilt files"; exit 1; } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d24ca8e..66b6c4c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,11 +18,18 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: actions/setup-node@v4 with: node-version: '22' - - name: Verify security and standalone invariants - run: node scripts/check.mjs + - name: Build and verify that the committed bundle is current + run: | + python3 scripts/build.py + node scripts/check.mjs + git diff --exit-code site DocDiff.html + test -z "$(git status --porcelain -- site DocDiff.html)" || { git status --short -- site DocDiff.html; echo "Commit the rebuilt files"; exit 1; } deploy: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..69a633d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +# Pushing a tag like v1.5.0 builds the bundle, verifies it, and publishes a +# GitHub release with DocDiff.html attached. Release notes are read from +# docs/releases/.md (title on the first line as "# "). +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Build and verify standalone bundle + run: | + python3 scripts/build.py + node scripts/check.mjs + git diff --exit-code site DocDiff.html + test -z "$(git status --porcelain -- site DocDiff.html)" || { git status --short -- site DocDiff.html; echo "Commit the rebuilt files"; exit 1; } + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + notes="docs/releases/${TAG}.md" + test -f "$notes" || { echo "Missing $notes"; exit 1; } + title="$(head -n 1 "$notes" | sed 's/^# *//')" + tail -n +2 "$notes" > /tmp/notes.md + gh release create "$TAG" DocDiff.html --title "$title" --notes-file /tmp/notes.md --verify-tag diff --git a/.gitignore b/.gitignore index 879df49..15006cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store node_modules/ *.log +.claude/worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 0b54086..2d84558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,18 @@ Everything that appears on GitHub is in English: `README.md` and docs, the repository description, release titles and release notes, commit messages, pull request titles and descriptions, issue and review comments, and code comments. Write English even when the request came in German. -The UI itself is bilingual (`de` and `en` in `src/template.html`), auto-detecting the browser language with a one-click `DE` / `EN` toggle in the header bar. Domain-specific German legal and construction terms (`Urkalkulation`, `Zuschlag`, `Pauschalfestpreis`) stay in German inside the German sample fixtures. +The UI itself is bilingual (`de` and `en` in `src/js/01-i18n.js`), auto-detecting the browser language with a one-click `DE` / `EN` toggle in the header bar. Domain-specific German legal and construction terms (`Urkalkulation`, `Zuschlag`, `Pauschalfestpreis`) stay in German inside the German sample fixtures. ## Build & Verification -Always edit `src/template.html` and run: +Always edit the sources and run the build plus checks: + +- `src/template.html`: page shell, `<head>`, and static markup +- `src/styles.css`: all app styles +- `src/js/*.js`: app code, concatenated in file-name order into one classic `<script>` (shared scope, no modules) + +Never hand-edit `DocDiff.html`, `site/index.html` or `site/en/index.html`; they are generated. The hosted pages are pinned to German (`/`) and English (`/en/`); the offline `DocDiff.html` follows the browser language. + ```sh python3 scripts/build.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7dceca4..058a340 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,12 @@ Thanks for helping improve Doc Diff. Every change is judged by two questions: does it keep the comparison fast and clear, and does it preserve the strict zero-network, single-file privacy guarantee? - **Open an issue first** for anything larger than a bug fix so we can align on the approach before you write code. -- **Edit `src/template.html`, then rebuild.** Never hand-edit `DocDiff.html` or `site/index.html` directly; both are generated by `python3 scripts/build.py`. +- **Edit the sources (`src/template.html`, `src/styles.css`, `src/js/*.js`), then rebuild.** Never hand-edit `DocDiff.html`, `site/index.html` or `site/en/index.html` directly; they are generated by `python3 scripts/build.py`. - **Keep the privacy & standalone invariants intact:** - `Content-Security-Policy` must keep `connect-src 'none'` and `form-action 'none'`. - Zero external CDN scripts, external stylesheets, or remote font URLs. - Zero persistence of user documents (`localStorage`, `sessionStorage`, `indexedDB`, or `document.cookie` are forbidden). -- **Every UI string exists in both German (`de`) and English (`en`).** Update both dictionaries in `src/template.html`. +- **Every UI string exists in both German (`de`) and English (`en`).** Update both dictionaries in `src/js/01-i18n.js`. - **Everything on GitHub is in English:** commits, pull requests, issues, docs, and release notes (see [CLAUDE.md](CLAUDE.md)). - **Run the build and verification checks before opening a pull request:** diff --git a/DocDiff.html b/DocDiff.html index bcee6a7..3f54b2b 100644 --- a/DocDiff.html +++ b/DocDiff.html @@ -21,32 +21,3884 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <!-- Hardened Content Security Policy: Physically blocks all outbound network connections (connect-src 'none') --> - <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; font-src data:; img-src 'self' data: blob:; connect-src 'none'; media-src 'none'; object-src 'none'; child-src 'none'; worker-src blob:; frame-src 'none'; form-action 'none'; base-uri 'none';" /> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; font-src data:; img-src 'self' data: blob:; manifest-src 'self'; connect-src 'none'; media-src 'none'; object-src 'none'; child-src 'none'; worker-src blob:; frame-src 'none'; form-action 'none'; base-uri 'none';" /> <meta name="referrer" content="no-referrer" /> <meta name="color-scheme" content="light dark" /> + <title>Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload + + + + + + + + - - - - Doc Diff — Dokumente einfach vergleichen - - - - + + + + + - + - - - + + + + + + + + + + + + + + + + + +
+
+ + + Doc Diff + + +
+ + +
+
+ +
+
+ + 0 von 0 + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ + +
+

Doc Diff

+
+
Alte Fassung
+
+
Alte Fassung hier ablegen
+
+
+
+ +
+
Neue Fassung
+
+
Neue Fassung hier ablegen
+
+
+
+ +
+
Alte und neue Fassung
+
+
+ + + + +
+
+ +
+

Dokumente einfach vergleichen.

+

Zwei Fassungen als PDF, Word oder Text: Jede Änderung wird Wort für Wort markiert. Ihre Dateien verlassen dabei nie Ihr Gerät.

+
    +
  • Kostenlos & Open Source (MIT)
  • +
  • Keine Uploads – alles läuft lokal im Browser
  • +
  • Keine Werbung, kein Tracking, keine Cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

- +
+ + + +
+
+ +
+

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. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen – auch gemischt, etwa PDF mit Word
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seitenumbrüche wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Verschobene Absätze als „verschoben“ kennzeichnen
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, knapp 3 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Kann ich gescannte PDFs vergleichen? +
+

Nein. Doc Diff liest nur PDFs mit Textebene, also PDFs, in denen Sie Text markieren und kopieren können. Ein Scan ist ein Bild der Seite, und eine Texterkennung (OCR) ist nicht eingebaut. Solche Dateien werden mit einem Hinweis abgelehnt. Wandeln Sie den Scan vorher mit einem OCR-Programm in ein durchsuchbares PDF um.

+
+
+
+ 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.

+
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ + +
+
+ + +
+ + + + + + + +
+
+ +
+ + + +
+

Text einfügen oder bearbeiten

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+

Hilfe & Tastenkürzel

+ +
+
+
+ +
+ +
+ + + +
+

Impressum & Datenschutz

+ +
+
+ +
+ + + +
+

+
+
+

+
+ +
+ - + diff --git a/README.md b/README.md index 3e582fc..5f7540a 100644 --- a/README.md +++ b/README.md @@ -2,119 +2,140 @@ - Doc Diff Logo + Doc Diff logo

Doc Diff

- 100% client-side, side-by-side document comparison for PDF, Word (.docx), and text.
- Compare contracts, tenders, and redlines right in your browser.
- Zero server uploads, zero external CDNs, enforced by Content-Security-Policy: connect-src 'none'.
- Ships as a single self-contained HTML file that also works offline via file://. + Compare two versions of a document (PDF, Word .docx, or plain text) in the browser and see every changed word.
+ The files are processed on your computer and are not uploaded.

- docdiff.app · - Download Standalone HTML (2.5 MB) · - free and open source (MIT) + docdiff.app (English) · + Offline version (single HTML file) · + MIT license

- Doc Diff side-by-side comparison of two contract versions showing clause alignment, inline word redlines, center minimap, and change navigation. + Two versions of a contract side by side, with removed words struck through in red, added words in green, and a list of changes on the right.

---- +## What it is -## Why Doc Diff? +Doc Diff is a small tool for one job: you have an old and a new version of a document, typically a contract or a tender, and you want to know exactly what changed. It is free, has no accounts, no ads and no tracking, and the whole app is a single HTML file you can also run offline. -Comparing confidential contracts, schedules of rates (`Urkalkulation`), or legal redlines in cloud diff tools is often impossible under GDPR (DSGVO), NDAs, or professional secrecy rules. Meanwhile, existing open-source tools on GitHub usually cover only a fraction of the workflow: +It was written with German contracts in mind (`§ 1`, `(1)`, `1.1` clause numbering), and the interface is in German and English. -| Capability | **Doc Diff** | `jamesmontemagno/pdf-diff` | `a-subhaneel/pdf-diff-viewer` | `xsukax/Word-Comparison` | Cloud SaaS Diff Tools | -| :--- | :---: | :---: | :---: | :---: | :---: | -| **PDF comparison (`.pdf`)** | **Yes** | Yes | Yes | No | Yes | -| **Word comparison (`.docx`)** | **Yes** | No | No | Yes | Yes | -| **Cross-format (`PDF` ↔ `DOCX`)** | **Yes** | No | No | No | Partial | -| **100% in-browser (`0 B` upload)** | **Yes** | Yes | Yes | No (Python server) | No (Cloud upload) | -| **Browser-enforced CSP lock (`connect-src 'none'`)** | **Yes** | No | No | No | No | -| **Single-file offline HTML (`file://` ready)** | **Yes (1 file)** | No (Vite build) | No | No | No | -| **Clause alignment (`§ 1`, `(1)`, `1.1`) + word diff** | **Yes** | No | Partial | Partial | Yes | +## How to use it ---- +1. Open [docdiff.app](https://docdiff.app), or download [`DocDiff.html`](https://github.com/tschnorpfeil/docdiff/raw/main/DocDiff.html) and open it from disk. +2. Choose the old version and the new version (file picker, drag and drop, or paste text). There is a built-in example if you just want to look around. +3. Go through the changes: removed text is struck through in red, added text is green. `J` / `K` or the arrows in the top bar jump from change to change; the list on the right shows each change in short form. +4. Accept or reject each change (`A` / `R`), optionally edit paragraphs of the new version, and export the result. -## Privacy & Security by Architecture (GDPR / DSGVO Safe) +## What it does -Doc Diff is built so that any data protection officer or security auditor can verify in seconds that data exfiltration is technically impossible: +- **Inputs:** PDF with a text layer, Word `.docx`, `.html` (text only), and UTF-8/UTF-16 text. Any combination works, e.g. PDF against DOCX. +- **Paragraph alignment, then word diff:** paragraphs of both versions are matched first, then the words inside each matched pair are compared. Inserted and deleted paragraphs show up as such. +- **Faithful output:** unchanged plus removed parts always reproduce the old text exactly, unchanged plus added parts the new text. Tests enforce this. +- **Less noise:** + - Running headers, footers and page numbers that repeat on at least half of the pages are hidden (View › "Hide headers & footers"). If a header or footer actually differs between versions, it is shown. + - Renumbering alone (`(1)` → `(2)`, `§ 4` → `§ 5`) is shown in a muted style and not counted as a change. + - Paragraphs that moved unchanged are labelled "Moved" at both positions. + - Hyphenated line breaks in PDFs are joined. +- **Views:** side by side or one column (default on phones), with or without the original page breaks. An overview bar between the two versions (next to the text in one column) shows where the changes are; click it to jump. +- **Review:** accept a change (keep the new wording) or reject it (restore the old wording of that paragraph) with the small ✓ / ✗ buttons next to the current paragraph or `A` / `R`; both mark it as reviewed, move on to the next open change and leave a quiet ✓ or ✗ in the margin, with an undo arrow below it. The same header or footer change on several pages is one decision. The list of changes can be filtered by review status or by kind of change. Paragraphs of the new version can also be edited in place. +- **Export:** + - Word `.docx` in which open changes are tracked changes (accept or reject them in Word or LibreOffice) and changes accepted in Doc Diff are already applied + - PDF via the browser's print dialog + - HTML report (self-contained, read-only) and Markdown, both with the review status of every change + - the final text as `.txt` or to the clipboard +- **Saved work:** you can save the current state (both documents, your edits and the review status) as a `.docdiff` file and open it again later. The browser asks before you leave the page with unsaved work. -1. **Browser-Enforced Network Lock (`connect-src 'none'`):** - The document embeds a strict Content Security Policy directly in ``: - ```html - - ``` - The browser engine itself blocks any outbound network request (`fetch`, `XMLHttpRequest`, `WebSocket`, `navigator.sendBeacon`, form submissions, or external image/script loads). -2. **Zero External Dependencies or CDNs:** - All parsers (`PDF.js`, `Mammoth.js`, `jsdiff`) and typography (`Geist` & `Geist Mono` as Base64 `woff2`) are embedded inside the single HTML file. Opening the hosted GitHub Pages URL downloads one static HTML file—after that, **zero network requests occur**. -3. **Volatile RAM Only (Zero Persistence):** - Document contents are never stored in `localStorage`, `sessionStorage`, `IndexedDB`, or cookies. Clicking **Clear** (`Leeren`) or closing the browser tab immediately discards all document data from memory. +## What it does not do ---- +- **No OCR.** Scanned PDFs or images without a text layer are rejected with a message. +- **No legacy `.doc` or `.rtf`.** Save them as `.docx` or PDF first. +- **Formatting is not compared.** Bold, fonts, tables and layout are ignored; only the text is compared. Table cells come through as text in reading order, which can be hard to follow. +- **Multi-column PDF layouts** are read line by line and can come out in the wrong order. +- **Moves are only detected when the moved paragraph is identical.** A paragraph that was moved and edited shows as one deletion and one insertion. +- **Very large documents take a few seconds.** See [Performance](#performance). +- It is not a legal review tool. It shows differences in the text; whether they matter is up to you. -## Key Features +## Privacy -- **Cross-Format Comparison:** Compare `PDF ↔ PDF`, `DOCX ↔ DOCX`, `PDF ↔ DOCX`, or pasted raw text side by side or in a unified inline view. -- **Responsive Across Desktop, Laptop, Tablet & Mobile:** Switch seamlessly between **Split (Side-by-Side)** and **Inline (Unified Redline)** view modes. Automatically adapts on tablets (slide-over change drawer) and smartphones (single-column inline redline with `100dvh` viewport sizing). -- **Clause-Aware Alignment:** Pre-tokenized Needleman-Wunsch dynamic programming with $O(N)$ prefix/suffix trimming aligns moved, inserted, or deleted contract clauses (`§`, `(1)`, `1.1`, `Section`) and coalesces adjacent word, number, and date changes into clean red/green inline highlights. -- **Frosted Glass Floating Action Bar & Single-Scrollbar Sync:** A translucent bottom-mid HUD dock (`backdrop-filter: saturate(190%) blur(18px)`) keeps the change stepper (`↑ 1 / 17 ↓`), "Changes only" (`Nur Änderungen`) filter, and Undo/Redo controls within immediate reach. Both panes stay 100% locked in synchronized scroll with a single outer scrollbar and bi-directional Bezier connectors across the symmetric center gutter. -- **Delta-Only Change Inspector & Click-to-Jump Rail:** The right side rail extracts only the exact modified tokens (`145.000,00 → 168.500,00`, `12.03.2026 → 18.04.2026`, `schriftlich → in Textform`) with section-aware clause references (`§ 1 (1)`, `§ 2 (1)`), category filter tabs (`Alle`, `Geändert`, `Neu`, `Entfernt`), 1-click jump-to-clause, and inline edit/revert actions. -- **Direct Inline Clause Editing, 1-Click Revert & Undo/Redo:** Double-click or click **Bearbeiten** (`Edit`) on any clause in the revised or unified pane to edit its text directly in place (`Enter` to apply, `Esc` to cancel) and re-run the diff in `< 5 ms`. Click **Vorher übernehmen** (`Use old`) to revert any clause back to the baseline version, with full **Undo / Redo** history (`Cmd+Z` / `Shift+Cmd+Z` or toolbar buttons). -- **Multi-Format Client-Side Export:** Export comparisons locally without any server roundtrip: - - **Copy Final Text:** 1-click copy of the clean, edited target text to the clipboard - - **Save Clean Text (`.txt`):** Download the final revised document text - - **PDF / Print (`Cmd+P` / `Ctrl+P`):** Clean two-column or unified redline print layout - - **Word Redline (`.doc`):** Formatted Word-compatible redline document with `` strikethroughs and `` insertions - - **HTML Report (`.html`):** Standalone, read-only HTML diff report for archiving or sharing - - **Markdown (`.md`):** Clean Markdown diff summary (`~~deleted~~` / `**inserted**`) -- **Keyboard-First Navigation & System-Aware Theme:** - - `J` / `K` — Jump to next / previous change - - `/` — Focus full-text search - - `C` — Toggle "Changes only" (`Nur Änderungen`) filter - - `Cmd+Z` / `Ctrl+Z` — Undo inline clause edits or reverts - - `Shift+Cmd+Z` / `Ctrl+Y` — Redo inline clause edits or reverts - - `Esc` — Close menus, drawers, or dialogs - - Automatic `prefers-color-scheme` Dark / Light detection with a manual header toggle. -- **Bilingual UI (`DE` / `EN`):** Auto-detects browser language with an instant `DE` / `EN` toggle in the header bar and built-in sample contracts in both German and English. +The documents never leave the browser tab. This is enforced by the browser, not only promised: ---- +- The page sets a Content Security Policy that forbids all network connections from the page (`connect-src 'none'`, `form-action 'none'`): + ```html + + ``` +- All libraries and fonts are inside the HTML file; nothing is loaded from other servers. +- Nothing is stored in the browser (no `localStorage`, `sessionStorage`, IndexedDB or cookies). Closing or reloading the tab discards everything, unless you saved a session file yourself. -## Usage +You can check this yourself: open the browser's developer tools, switch to the Network tab and compare two files. No request is made. Or use the offline file without an internet connection. -### 1. Hosted Web App (`docdiff.app`) -Open **[docdiff.app](https://docdiff.app)** in any modern browser and drop two files onto the left and right panes (or drop two files at once). +What this does not cover, to be precise: -### 2. Local Standalone File (`file://` / Air-Gapped / Offline) -Download **[`DocDiff.html`](https://github.com/tschnorpfeil/docdiff/raw/main/DocDiff.html)** to your computer, intranet share, or USB drive and double-click it to open in Chrome, Safari, Firefox, or Edge. No internet connection or local web server required. +- Opening docdiff.app is a normal page load from GitHub Pages, which sees your IP address like any web server. The page also loads its own icon and manifest from the same site. The offline file avoids this. +- Browser extensions you have installed can read any page, including this one. +- A `.docdiff` session file and every export contain the document text. Store them as carefully as the documents themselves. ---- +## Performance -## Building from Source +The comparison runs in a background worker, so the page stays usable while it works. Rough numbers measured in Chromium on a laptop-class machine (your results will differ): -Edit [`src/template.html`](src/template.html) and run the build and verification scripts: +| Input | Time to result | +| :-- | :-- | +| Two versions of a 10-page contract | well under a second | +| 2,000 paragraphs per side (about 80 pages) | about 0.5 s | +| Two 1,000-page PDFs | about 4 s (mostly reading the PDFs) | + +Reading PDFs is the slowest part. Very long single paragraphs (hundreds of thousands of characters) are compared with a bounded algorithm that can show a larger block as replaced instead of word by word. + +## Keyboard + +| Key | Action | +| :-- | :-- | +| `J` / `K` | Next / previous open change | +| `A` / `R` | Accept / reject the current change and move on to the next open one | +| `E` | Edit the current paragraph | +| `Enter` | In the list of changes: jump to the change | +| `/`, `Ctrl+F` / `Cmd+F` | Find in document | +| `Ctrl+Z` / `Cmd+Z` | Undo | +| `Ctrl+Y`, `Shift+Cmd+Z` | Redo | +| `Esc` | Close menus, search and dialogs | + +Single-key shortcuts only work while the document or the list of changes has focus, never while typing. They can be switched off in the help dialog. + +## Building from source + +The app is plain HTML, CSS and JavaScript without a framework or package manager. The sources are in [`src/`](src): + +- `src/template.html`: page shell and markup +- `src/styles.css`: styles +- `src/js/*.js`: application code, concatenated in file-name order into one inline script ```sh -python3 scripts/build.py -node scripts/check.mjs +python3 scripts/build.py # writes site/index.html, site/en/index.html and DocDiff.html +node scripts/check.mjs # invariants and tests ``` -- `scripts/build.py` bundles `src/template.html` with the pinned libraries and fonts in `vendor/` to produce `site/index.html` and `DocDiff.html`. -- `scripts/check.mjs` verifies CSP headers, zero external URLs, zero storage APIs, and 1:1 `de`/`en` translation key parity. +`scripts/check.mjs` verifies the Content Security Policy, the absence of external URLs and storage APIs, matching German and English strings, the search metadata of both language pages (title, canonical, hreflang, JSON-LD that matches the visible FAQ, sitemap, `llms.txt`), and runs the unit tests for the diff core (`scripts/test-core.mjs`) and the export builders (`scripts/test-export.mjs`). CI runs the same and also checks that the committed bundle is up to date. + +Bundled libraries: [PDF.js](https://github.com/mozilla/pdf.js), [Mammoth.js](https://github.com/mwilliamson/mammoth.js), [jsdiff](https://github.com/kpdecker/jsdiff), and the [Geist](https://vercel.com/font) fonts. Versions and licenses are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + +## Contributing and security ---- +See [CONTRIBUTING.md](CONTRIBUTING.md). Please report security issues privately as described in [SECURITY.md](SECURITY.md). ## License -MIT License — see [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +MIT, see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index b059932..20ce34f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ Doc Diff is designed for comparing confidential legal contracts, tenders, and fi 1. **Enforced Content Security Policy (`connect-src 'none'`):** The HTML document embeds a strict Content Security Policy in ``: ```html - + ``` The browser engine itself blocks any outbound network connection (`fetch`, `XMLHttpRequest`, `WebSocket`, `navigator.sendBeacon`, form submissions, or external image/script requests). 2. **Zero External Dependencies at Runtime:** diff --git a/docs/handoff/ui-polish.md b/docs/handoff/ui-polish.md new file mode 100644 index 0000000..bf1bffa --- /dev/null +++ b/docs/handoff/ui-polish.md @@ -0,0 +1,104 @@ +# Handoff: polish the Doc Diff UI before releasing v1.5.0 + +Branch: `claude/focused-mayer-3stkgw` (51 commits ahead of `main`, nothing merged, no PR yet). +Owner: tschnorpfeil. The owner reviewed the current comparison view and judged it **not releasable: unpolished and immature**. Functionality and correctness are in good shape; the job now is to make the interface feel calm, mature and obvious. Do this before anything else ships. + +## 1. Product and owner preferences + +- Doc Diff compares two versions of a document (PDF with text layer, Word `.docx`, pasted text) word by word, 100% in the browser. Users are mostly non-technical German lawyers, construction project managers and procurement staff reviewing contract or tender versions, then accepting/rejecting changes and exporting a Word file with tracked changes. +- Free, MIT, no ads, no tracking, no accounts. Tone: plain, factual, honest. No marketing language anywhere (UI, README, release notes). +- The owner's bar: "10/10, clean, minimalist, straightforward, no-brainer, zero-friction, idiot-proof", WCAG 2.2 AA everywhere and AAA text contrast. Think Apple / Linear / Stripe calm, Word "Review" familiarity. +- The owner talks German; everything on GitHub (code, commits, docs, PR) is English (see `CLAUDE.md`). +- The owner reacts to screenshots. Show them early and often, ask before big directional changes, keep each round small. + +## 2. Hard constraints (enforced by `node scripts/check.mjs`) + +- Single self-contained HTML; works via `file://`. No external URLs/CDNs/fonts loaded at runtime. +- CSP `connect-src 'none'` etc. must stay byte-identical to what README/SECURITY quote. +- No browser storage APIs (`localStorage`, `sessionStorage`, IndexedDB, cookies, Cache API). +- i18n: every key exists in `de` and `en` (`src/js/01-i18n.js`); landing/FAQ text is rendered at build time and checked against JSON-LD. +- `src/js/11-session.js` wraps these globals by reassignment, keep them as global function declarations: `runComparison`, `pushUndoSnapshot`, `performUndo`, `performRedo`, `handleFileUpload`, `handleDropFiles`. + +## 3. Code map + +Build: `python3 scripts/build.py` writes `site/index.html` (de, pinned), `site/en/index.html` (en) and `DocDiff.html` (offline, follows browser language). Never edit those by hand; commit them after building (CI compares). + +| File | What | +| :-- | :-- | +| `src/template.html` | Page shell, landing markup, top bar, menus, dialogs | +| `src/styles.css` | All styles (~5k lines, grew in several passes; token block at the top) | +| `src/js/01-i18n.js` | DE/EN strings | +| `src/js/04-core.js`, `04a-align.js` | Diff core (pure, also runs in the worker). Don't touch for UI work. | +| `src/js/05-render.js` | Paragraph/row HTML, list card HTML | +| `src/js/06-compare.js`, `06a`–`06d` | Comparison orchestration, row layout, search, review (accept/reject) | +| `src/js/07-interactions.js` | Selection, stepping, editing, undo | +| `src/js/08-export*.js` | Exports (docx/html/md/print/txt) | +| `src/js/09-import.js` | File intake (PDF.js, Mammoth). Don't touch for UI work. | +| `src/js/10-controls.js` | Top bar, menus, keyboard, theme, language | +| `src/js/11-session.js` | Saved work (`.docdiff`), unsaved-changes guard | +| `docs/og-image.html` | Source of `site/og-image.png` | +| `docs/releases/v1.5.0.md` | Release notes draft (update when UI changes) | + +## 4. What exists in the comparison view today (functionally complete) + +- Top bar: logo · file-pair button (popover: replace old/new, swap, info) · stepper "‹ 3 von 20 ›" · help · search (find in document) · "Ansicht" menu (side by side / stacked, page breaks, only changes, hide headers & footers, change list, theme) · primary "Exportieren" menu · "⋯" menu. +- Panes: old left, new right, sticky captions, page cards when page breaks are on, collapsible "Kopf-/Fußzeile" groups for header/footer lines that differ. +- Overview ruler at the right edge of the document area (coloured markers, viewport window, click/drag). +- Review: compact icon group ✓ ✗ ✎ at the active paragraph (new pane), status line "✓ Angenommen · Rückgängig" after a decision, auto-advance to the next open change; keys `J/K`, `A/R`, `E`. +- Change list (right): title + progress, first-visit hint, segmented "Offen / Geprüft", type tabs "Alle · Geändert · Hinzugefügt · Entfernt", cards with context snippet. +- Phone: compact top bar, bottom bar "‹ 3 von 20 › Exportieren", list as bottom sheet, stacked view only. + +Current screenshots: `docs/screenshot-light.png`, `docs/screenshot-dark.png`. Regenerate your own with headless Chromium (see §7). + +## 5. Why it reads as unpolished (coordinator's critique, verify yourself) + +1. **No coherent visual system.** Styles were layered by several passes: borders, radii, shadows, paddings and grey tones vary between top bar, panes, page cards, list cards, menus and dialogs. Start by defining a small token set (spacing scale, 2–3 radii, one border colour, one elevation, type scale) and apply it everywhere; delete the leftovers. +2. **Box-in-box nesting.** Pane → caption band → page card with border → header/footer group → paragraph with side bar → black active ring. Too many frames. Aim for one surface per pane, page breaks as a subtle divider/label, not cards. +3. **Heavy selection and colour.** The active change gets a thick black ring in both panes plus a black ring on the list card. Nearly every paragraph in the sample has an amber side bar, and the ruler is a wall of amber. Selection should be quiet (e.g. a soft tint or a thin accent line), and "changed paragraph" needs a much lighter treatment so actual word-level red/green marks carry the meaning. +4. **The change list header is a control stack:** title, progress bar, hint box, segmented control, tabs, all before the first card. Collapse into one compact header (e.g. title + "1 von 20 geprüft" + a single filter control). +5. **List cards are dense and noisy:** truncated fragments with "…", reference "§ 1 · S. 1" in small type, multiple lines of context. They should scan like an inbox: one line reference, one or two lines of snippet, clear reviewed state. +6. **Top bar elements look like form fields:** the file-pair button looks like a text input; the stepper is a bordered box in the centre. Make them read as titles/controls, not inputs. +7. **Review feedback shifts layout:** the "Angenommen · Rückgängig" line is inserted above the paragraph and pushes content; the old pane shows a stray check mark. Show status without moving text (inline badge in the reserved margin, or a subtle tint). +8. **Header/footer groups** ("Kopf-/Fußzeile ⌄") repeat on every page card and still dominate the top of page 1. +9. **Phone:** file names truncate badly ("…1_Vor….docx → …2_Nac….docx"); the icon group narrows the text column; check spacing rhythm and hit areas visually, not only by numbers. +10. **Typography:** check weights and sizes for document text vs. UI text vs. headings (the sample's uppercase title, bold headings, small grey metadata). Fewer sizes, fewer weights. +11. **Dark mode:** verify the same calm in dark (borders and amber look harsher there). +12. **Motion/micro-interactions:** stepping, accepting and filtering should feel smooth but subtle; respect `prefers-reduced-motion` (already wired). + +Two independent design reviews from this session are summarised in the git log (`git log --grep "Review follow-up\|Redesign"`). Their scores for the previous state: first run 8.5, compare/review 6.5, phone 7, visual design 7.5. + +## 6. Decisions already made with the owner (keep unless the owner changes them) + +- Landing page with the headline "Dokumente ~~mühsam~~ einfach vergleichen." stays (owner liked it). Keep the two numbered file slots and the privacy explanation. +- Word terms: "Annehmen / Ablehnen". Accept keeps the new wording, reject restores the old paragraph; Word export applies accepted, reverts rejected, tracks open changes. +- Actions only in one place (at the active paragraph), not duplicated on list cards. +- Two views (side by side / stacked) plus a "page breaks" toggle; stacked only on phones. +- Overview ruler instead of the old centre gutter. +- No browser storage: saved work only as a user-downloaded `.docdiff` file. +- Hosted `/` is German, `/en/` English; the offline file follows the browser language. + +## 7. How to verify + +- `python3 scripts/build.py && node scripts/check.mjs` must pass (privacy invariants, i18n parity, core/export/worker/app-state tests). +- Headless Chromium is usually at `/opt/pw-browsers/chromium-*/chrome-linux/chrome` in cloud sessions; install Playwright and axe-core in a scratch directory (`npm i playwright axe-core`), launch with `executablePath`, and don't run `playwright install`. +- The longer browser suites written in this session (session round-trip, exports, randomised UI fuzzer, race tests) lived in a scratch directory and are **not in the repo**. If you change interaction flows, re-create at least: load sample → step → accept/reject/undo → export Word (open with LibreOffice `soffice --convert-to` if available) → save and reopen work → no console errors, axe clean. +- Screenshots at 1440×900, 1280×720, 1024×768, 820×1180 (touch), 390×844 (touch, isMobile), light and dark, DE and EN. Look at them; judge them like a design lead. + +## 8. Definition of done + +- The owner looks at the screenshots and says it feels calm and mature. +- No functional regressions; `check.mjs` green; axe zero violations in empty, compare (both views), menus, search, dialogs, phone sheets; AAA text contrast in light and dark. +- `README.md` screenshots and `docs/releases/v1.5.0.md` updated to match the final UI. + +## 9. After the polish: release steps + +1. Open a PR from `claude/focused-mayer-3stkgw` to `main` (English title/body, no PR template exists). +2. After the owner approves and merges, push tag `v1.5.0` on the merge commit. `.github/workflows/release.yml` builds, verifies, and publishes the release with `DocDiff.html`, using `docs/releases/v1.5.0.md` (first line = title). +3. Pages deploys from `main` automatically (`.github/workflows/pages.yml`). + +## 10. Backlog (not for this round) + +- CLI (`npx docdiff old.pdf new.docx --json`) and an MCP server reusing `04-core.js`, so AI agents can use Doc Diff. +- Paste text directly into the landing slots; options "ignore case / whitespace / punctuation". +- Header/footer heuristics for boilerplate-heavy documents; banded alignment for very large shifted inserts. +- Repository settings for the owner: description, website, topics (suggested in the session); Cloudflare analytics would require a privacy policy update. diff --git a/docs/og-image.html b/docs/og-image.html new file mode 100644 index 0000000..cfaf10b --- /dev/null +++ b/docs/og-image.html @@ -0,0 +1,131 @@ + + + + + + + + +
+
Doc Diffdocdiff.app
+

Dokumente
mühsam einfach
vergleichen.

+

PDF, Word oder Text: Jede Änderung wird Wort für Wort markiert. Alles läuft lokal im Browser.

+
Kostenlos & Open SourceKeine UploadsKein Tracking
+
+
+
+
+
4 Änderungen
+
SeitenNebeneinanderUntereinander
+
+
+
Alte FassungVertrag_v1.pdf
+
Neue FassungVertrag_v2.docx
+
+
+
+

§ 1 Leistungsumfang

(1) Bereitstellung und Dokumentation gemäß Lastenheft vom 12.03.2026. Änderungen sind schriftlich einzureichen.
+

§ 2 Vergütung

(1) Festpreis 145.000,00 € netto, zahlbar innerhalb von 14 Tagen.
+

§ 4 (3) Haftung

Eine Haftung für Folgeschäden ist ausgeschlossen.
+
.
+
+
+

§ 1 Leistungsumfang

(1) Bereitstellung, Schulung und Dokumentation gemäß Lastenheft vom 18.04.2026. Änderungen sind in Textform einzureichen.
+

§ 2 Vergütung

(1) Festpreis 168.500,00 € netto, zahlbar innerhalb von 30 Tagen.
+
.
+

§ 2 (4) Preisanpassung

Anpassung nach VPI ab dem 13. Monat.
+
+
+
+ + diff --git a/docs/releases/v1.5.0.md b/docs/releases/v1.5.0.md new file mode 100644 index 0000000..04740b7 --- /dev/null +++ b/docs/releases/v1.5.0.md @@ -0,0 +1,81 @@ +# v1.5.0 — Review workflow, correctness fixes, Word tracked changes + +This release is mostly about getting the comparison right and making the review itself simple. Independent reviews and two rounds of adversarial testing (about 32,000 random comparisons and 2,900 randomised UI steps) found cases where Doc Diff could hide or misstate a change or lose an edit; those are fixed and covered by tests. + +## Review changes + +- **Accept or reject each change**, as in Word: accepting keeps the new wording, rejecting restores the old wording of that paragraph. Both mark the change as reviewed, move on to the next open change, and can be undone. The change list shows how many changes are still open. +- **Calmer comparison view:** one top bar (files, previous/next change, search, view, export, more) instead of five bars and a floating dock. Each version sits on one plain surface; page breaks are a thin labelled line instead of framed cards. The current change is a soft tint with a dark edge instead of a heavy black ring, and changed paragraphs get a thin, light edge so the red and green words carry the meaning. +- **Reviewing never moves the text.** A reviewed paragraph shows a ✓ or ✗ in its right margin, with an undo arrow below it. +- **Compact change list:** a title, the review progress and one filter (all, open, reviewed, or a kind of change). Each entry shows the reference and the first change in at most two lines; further changes in the same paragraph are counted as "+1", "+2". +- **Overview bar** between the two versions shows where the changes are and where you are; click or drag to jump. In the one-column view it sits next to the text. +- **View menu** with clear choices for the layout (side by side / stacked) and the colour scheme (light / dark / system). +- **Phones and tablets:** short file names that keep the distinguishing part ("v1_Vorher → v2_Nachher"), a bottom bar with previous/next, the change list and export, and the list as a bottom sheet. +- **Find in document** with highlighted matches and previous/next. +- **One small design system** (spacing, three corner radii, one border colour, two font weights), larger, clearer text, consistent colours (red removed, green added, a thin edge for changed paragraphs, blue only for actions) and AAA text contrast in light and dark mode. + +## Correctness + +- **Changes near the page margin were hidden.** The header/footer filter treated any line close to the top or bottom of a PDF page as a running header. It now hides a line only if it repeats on at least half of the pages, and only where both versions contain exactly the same line. Any header or footer line without an identical counterpart is shown and compared. Pure page numbers ("Seite 3 von 9") stay hidden. +- **The word diff could alter the text.** For example "Frist 14 Tage" → "Frist 7 14 Tage" showed the old version as "Frist Tage". Every row is now checked: unchanged + removed text reproduces the old version exactly, unchanged + added text the new one. +- **Bare page-number shapes were always hidden.** A table cell changing from "2/3" to "1/3" was invisible. +- **DOCX soft line breaks glued words together** ("Hauptstraße 1" + break + "12345 Berlin" became "Hauptstraße 112345 Berlin"). +- **Invisible differences showed up as changes:** decomposed umlauts (NFD), soft hyphens, zero-width spaces, ligatures such as "fi", narrow spaces, CR line ends. Text is normalised (NFC) before comparing; visible characters such as "m²" are never folded. +- **Renumbering no longer marks every following clause as changed.** Marker-only changes are shown in a muted style and not counted. Paragraphs that moved unchanged are labelled "Moved". +- **PDF intake:** hyphenated line breaks are joined, word spaces are derived from glyph positions, and wrapped lines starting with an amount or date stay in their paragraph. +- **DOCX numbering** is read from the document's own numbering definitions instead of being invented. Where it cannot be determined reliably, no number is shown. +- **Paragraph similarity** no longer counts repeated words several times. +- **Undo** no longer brings back text from a previously loaded document. +- **Editing one paragraph** could rebuild the new version incorrectly (header/footer lines moved, extra changes reported) and, on long documents, a quick second edit could drop the first. Both fixed. +- **Editing a heading** no longer re-pairs neighbouring paragraphs or turns the heading into a deletion plus an insertion. Ordinary PDF sentences starting with "§ 2" are no longer shown as headings. +- **Very long paragraphs** no longer hang the comparison. +- **Race conditions:** a slow file read can no longer overwrite a newer file chosen for the same side, and clearing a side cancels pending reads. +- **Ctrl/Cmd+C copies again.** Single-key shortcuts no longer fire with modifier keys, in text fields, or while a dialog is open. +- **Exporting before both files were loaded replaced your document with the sample.** The export menu is now disabled until a comparison exists. + +## File handling + +- Scanned PDFs, images, legacy `.doc`, `.rtf` and other unsupported files are rejected with a clear message instead of being compared as garbage. Partly scanned PDFs show which pages have no text. +- Password-protected and damaged files get a clear message. +- `.html` is read as text (scripts never run). Windows-1252 text and UTF-16 without BOM are read. +- A DOCX that contains tracked changes shows a warning, because it is compared as if all changes were accepted. + +## Export + +- **Word (`.docx`) with tracked changes.** Every open difference is a real Word revision (author "Doc Diff"), so you can accept or reject it in Word or LibreOffice; changes you already accepted in Doc Diff are applied, rejected ones are reverted. This replaces the previous HTML file with a `.doc` extension. +- **HTML report:** self-contained and read-only, with summary counts, a linked change list, the full redline, and a strict Content Security Policy. +- **Markdown:** document text is escaped, and changes render correctly in CommonMark. +- **PDF / print:** a dedicated print layout for all three views, with file names, date, page numbers and marks that survive greyscale printing. +- File names combine both document names and are safe on Windows, macOS and Linux. + +## Not losing work + +- The browser asks before you leave the page with unsaved edits; the app asks before actions that would discard them. +- **Saved work:** save both documents, your edits, the review status and the view as a `.docdiff` file and open it again later. Nothing is stored in the browser; the file is wherever you save it. + +## Speed + +- Comparisons run in a Web Worker, so the page stays responsive. 2,000 paragraphs: about 0.5 s instead of 5 s with a frozen page. PDF.js now runs in a real worker too. +- Navigation, search and view switching only update what changed. +- The start page appears about 3× sooner on slow connections (scripts load after the visible content). + +## Interface and accessibility + +- The start page explains the tool: what it does, what it cannot do, how the privacy protection works, and an FAQ. +- Plain labels throughout: "Alte / Neue Fassung", "Nebeneinander / Untereinander", "Annehmen / Ablehnen". +- WCAG 2.2 AA with no axe violations in the tested states, AAA text contrast, keyboard operation of every task, screen-reader announcements for each step and review decision, and `prefers-reduced-motion` support. +- English page at [docdiff.app/en/](https://docdiff.app/en/); the offline file follows the browser language. + +## Security + +- PDF.js runs with `isEvalSupported: false` (CVE-2024-4367 hardening in addition to the CSP). +- The documentation quotes the shipped Content Security Policy verbatim, and the build fails if they drift. + +## For contributors + +- The source is split into `src/template.html`, `src/styles.css` and `src/js/*.js`. The build still produces one self-contained HTML file. +- `node scripts/check.mjs` runs unit tests for the diff core, the export builders and the comparison worker, plus the privacy and build invariants. + +## Offline version + +Download `DocDiff.html` below and open it from disk. It works without an internet connection. diff --git a/docs/screenshot-dark.png b/docs/screenshot-dark.png index 0febcd4..7d27779 100644 Binary files a/docs/screenshot-dark.png and b/docs/screenshot-dark.png differ diff --git a/docs/screenshot-light.png b/docs/screenshot-light.png index d3447c5..9b3a227 100644 Binary files a/docs/screenshot-light.png and b/docs/screenshot-light.png differ diff --git a/scripts/build.py b/scripts/build.py index 615d67c..2930119 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,16 +1,76 @@ #!/usr/bin/env python3 """ -Builds the self-contained single-file HTML bundle (`site/index.html` and `DocDiff.html`) -from `src/template.html` and `vendor/`. +Builds the self-contained single-file HTML bundle from `src/template.html`, `src/styles.css`, +`src/js/*.js` and `vendor/`: + +- `site/index.html` and `DocDiff.html`: German page (default), identical files +- `site/en/index.html`: the same app as an English page for https://docdiff.app/en/ + +The page language only decides the static HTML (head metadata, structured data and the landing +text crawlers and no-JS visitors see); every page contains both languages and the DE / EN toggle. +Static text is rendered from `src/js/01-i18n.js`, the single source of truth (read with Node). """ import base64 +import html +import json import os +import re +import subprocess import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FONT_DIR = os.path.join(ROOT, "vendor", "fonts") LIB_DIR = os.path.join(ROOT, "vendor", "libs") TEMPLATE_PATH = os.path.join(ROOT, "src", "template.html") +CSS_PATH = os.path.join(ROOT, "src", "styles.css") +JS_DIR = os.path.join(ROOT, "src", "js") +WORKER_DIR = os.path.join(ROOT, "src", "worker") + +# Pure diff code shared verbatim between the page and the comparison Web Worker. +WORKER_SHARED_JS = ["04-core.js", "04a-align.js"] +WORKER_SOURCE_PLACEHOLDER = '"__DIFF_WORKER_SOURCE__"' + +SITE_URL = "https://docdiff.app/" +REPO_URL = "https://github.com/tschnorpfeil/docdiff" +OFFLINE_URL = REPO_URL + "/raw/main/DocDiff.html" + +PAGES = { + "de": { + "url": SITE_URL, + # The hosted root page stays German (search engines render with an English browser + # language and would otherwise see a copy of /en/). The offline file follows the browser. + "html_attrs": 'lang="de" data-page-lang="de"', + "output_html_attrs": {"DocDiff.html": 'lang="de"'}, + # Files that only exist on the website; the offline file keeps the inline SVG icon. + "output_strip_lines": { + "DocDiff.html": [ + ' \n', + ' \n', + ' \n', + ] + }, + "asset_base": "", + "og_locale": "de_DE", + "og_locale_alt": "en_US", + "outputs": [os.path.join("site", "index.html"), "DocDiff.html"], + }, + "en": { + "url": SITE_URL + "en/", + "html_attrs": 'lang="en" data-page-lang="en"', + "asset_base": "../", + "og_locale": "en_US", + "og_locale_alt": "de_DE", + "outputs": [os.path.join("site", "en", "index.html")], + }, +} + +# Elements whose content the app replaces at runtime (textContent or innerHTML) by an i18n key. +I18N_ELEMENT_RE = re.compile( + r'<(?P[a-z][a-z0-9]*)(?P[^>]*?\sdata-i18n(?P-html)?="(?P\w+)"[^>]*)>' + r"(?P.*?)", + re.S, +) +I18N_LABEL_RE = re.compile(r'<[a-z][a-z0-9]*[^>]*\sdata-i18n-label="(?P\w+)"[^>]*>') def b64_file(path: str) -> str: @@ -23,8 +83,160 @@ def read_text(path: str) -> str: return f.read() -def build() -> str: +def app_js_files() -> list: + """App scripts, concatenated in file-name order into one classic '); -const appScript = templateHtml.slice(lastScriptIdx); -const appJsCode = templateHtml.slice(lastScriptIdx + ' + + + + + + + + + + +
+
+ + + Doc Diff + + +
+ + +
+
+ +
+
+ + 0 von 0 + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ + +
+

Doc Diff

+
+
Old version
+
+
Drop the old version here
+
+
+
+ +
+
New version
+
+
Drop the new version here
+
+
+
+ +
+
Old and new version
+
+
+ + + + +
+
+ +
+

Compare documents easily.

+

Two versions as PDF, Word or text: every change is marked word by word. Your files never leave your device.

+
    +
  • Free & open source (MIT)
  • +
  • No uploads – everything runs locally in your browser
  • +
  • No ads, no tracking, no cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

You can also drop both files here at once – the first one becomes the old version.

+ +
+ + + +
+
+ +
+

How it works

+
    +
  1. Choose two versions As PDF, Word file or pasted text. The files are only read inside this browser tab.
  2. +
  3. Go through the changes Removed text is struck through in red, added text is marked green. You can accept or reject each change.
  4. +
  5. Save the result if you need it As a Word file with tracked changes, as PDF or as a report.
  6. +
+
+ +
+

What it can do – and what it can’t

+
+
+

Can

+
    +
  • Compare PDFs with a text layer, Word files (.docx) and plain text – also mixed, e.g. a PDF against a Word file
  • +
  • Show changes word by word – including changed numbers, dates and amounts
  • +
  • Show page breaks as in the original; repeating headers and footers are left out
  • +
  • Mark moved paragraphs as “moved”
  • +
  • Work without internet – as a single HTML file
  • +
+
+
+

Can’t

+
    +
  • Read scanned PDFs – there is no text recognition (OCR)
  • +
  • Open old Word files (.doc) – please save them as .docx first
  • +
  • Compare formatting – bold, italics, table layout and images are ignored; only the text is compared
  • +
+
+
+
+ +
+

Frequently asked questions

+
+ How can I check that nothing is uploaded? +
+

The page contains a security rule (Content Security Policy) that makes your browser block every network connection from the page. Your files are only read into this tab’s memory and discarded when you close it.

+

To check it yourself: open your browser’s developer tools (F12), switch to the “Network” tab and then load a document – no request appears. Or download the offline file, disconnect from the internet and open it: everything keeps working.

+
+
+
+ Can I use Doc Diff offline? +
+

Yes. Download DocDiff.html (a single file of just under 3 MB) and open it in your browser with a double-click – no installation and no internet needed.

+
+
+
+ Can I compare scanned PDFs? +
+

No. Doc Diff only reads PDFs with a text layer, that is, PDFs in which you can select and copy text. A scan is an image of the page, and there is no built-in text recognition (OCR). Such files are rejected with a message. Convert the scan into a searchable PDF with an OCR program first.

+
+
+
+ Can I keep working on the changes in Word? +
+

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.

+
+
+
+ Who is behind it? Where is the source code? +
+

Doc Diff is a private open-source project by Tobias Schnorpfeil – with no company, advertising or data collection behind it. The complete source code is available under the MIT licence on GitHub: you can review it, build it yourself or host it yourself.

+
+
+
+ + +
+
+ + +
+ + + + + + + +
+
+ +
+ + + +
+

Paste or edit text

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+

Help & shortcuts

+ +
+
+
+ +
+ +
+ + + +
+

Legal Notice & Privacy

+ +
+
+ +
+ + + +
+

+
+
+

+
+ +
+ + + + + + + + + diff --git a/site/index.html b/site/index.html index bcee6a7..60e0641 100644 --- a/site/index.html +++ b/site/index.html @@ -16,37 +16,3892 @@ - Mammoth.js v1.6.0: Copyright (c) Michael Williamson (BSD-2-Clause License) - jsdiff v5.2.0: Copyright (c) Kevin Decker (BSD-3-Clause License) --> - + - + + Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload + + + + + + + + - Doc Diff — Dokumente einfach vergleichen - - - - + + + + + - + - - - + + + + + + + + + + + + + + + + + +
+
+ + + Doc Diff + + +
+ + +
+
+ +
+
+ + 0 von 0 + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ + +
+

Doc Diff

+
+
Alte Fassung
+
+
Alte Fassung hier ablegen
+
+
+
+ +
+
Neue Fassung
+
+
Neue Fassung hier ablegen
+
+
+
+ +
+
Alte und neue Fassung
+
+
+ + + + +
+
+ +
+

Dokumente einfach vergleichen.

+

Zwei Fassungen als PDF, Word oder Text: Jede Änderung wird Wort für Wort markiert. Ihre Dateien verlassen dabei nie Ihr Gerät.

+
    +
  • Kostenlos & Open Source (MIT)
  • +
  • Keine Uploads – alles läuft lokal im Browser
  • +
  • Keine Werbung, kein Tracking, keine Cookies
  • +
+ +
+
+ + +
+
+ + +
+
+

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

- +
+ + + +
+
+ +
+

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. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit Änderungsverfolgung, als PDF oder als Bericht.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen – auch gemischt, etwa PDF mit Word
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seitenumbrüche wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Verschobene Absätze als „verschoben“ kennzeichnen
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, knapp 3 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Kann ich gescannte PDFs vergleichen? +
+

Nein. Doc Diff liest nur PDFs mit Textebene, also PDFs, in denen Sie Text markieren und kopieren können. Ein Scan ist ein Bild der Seite, und eine Texterkennung (OCR) ist nicht eingebaut. Solche Dateien werden mit einem Hinweis abgelehnt. Wandeln Sie den Scan vorher mit einem OCR-Programm in ein durchsuchbares PDF um.

+
+
+
+ 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.

+
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ + +
+
+ + +
+ + + + + + + +
+
+ +
+ + + +
+

Text einfügen oder bearbeiten

+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+

Hilfe & Tastenkürzel

+ +
+
+
+ +
+ +
+ + + +
+

Impressum & Datenschutz

+ +
+
+ +
+ + + +
+

+
+
+

+
+ +
+ - + diff --git a/site/llms-full.txt b/site/llms-full.txt new file mode 100644 index 0000000..b66f54f --- /dev/null +++ b/site/llms-full.txt @@ -0,0 +1,88 @@ +# Doc Diff: facts + +Plain facts about Doc Diff for people and AI assistants. Source: https://github.com/tschnorpfeil/docdiff (README). + +## What it is + +- A web app that compares two versions of a document and marks every changed word. +- Free, open source under the MIT license, made by Tobias Schnorpfeil as a private project. No company, no accounts, no ads, no tracking, no cookies. +- Runs entirely in the browser. The documents are not uploaded anywhere. +- The whole app is one self-contained HTML file. It is hosted at https://docdiff.app/ (German page, default) and https://docdiff.app/en/ (English page), and can be downloaded as https://github.com/tschnorpfeil/docdiff/raw/main/DocDiff.html (just under 3 MB) to use offline. +- Interface languages: German and English, with a DE / EN toggle. It was written with German contracts in mind (clause numbering such as "§ 1", "(1)", "1.1"). + +## When to use it + +- You have an old and a new version of a contract, tender, offer, policy or other text and want to see exactly what changed. +- You want to compare two PDFs, two Word files, or a PDF against a Word file, without sending them to an online service. +- You want a Word file with the differences as tracked changes, to accept or reject them in Word or LibreOffice. + +## Inputs + +- PDF with a text layer (text that can be selected and copied) +- Word .docx +- .html (text only), .md and plain text (UTF-8 or UTF-16) +- Pasted text +- Any combination, e.g. PDF against DOCX + +## How it compares + +- Paragraphs of both versions are matched first, then the words inside each matched pair are compared. Inserted and deleted paragraphs are shown as such. +- Unchanged plus removed parts always reproduce the old text exactly; unchanged plus added parts reproduce the new text exactly. +- Running headers, footers and page numbers that repeat on at least half of the pages are hidden (can be switched off). A header or footer that differs between the versions is shown. +- Renumbering alone ("(1)" to "(2)", "§ 4" to "§ 5") is shown in a muted style and not counted as a change. +- Paragraphs that moved unchanged are labelled "moved" at both positions. +- Hyphenated line breaks in PDFs are joined. + +## Output and views + +- Views: side by side or in one column (default on phones), with or without the original page breaks. +- Removed text is struck through in red, added text is green. A list shows every change; J / K or the arrow buttons jump between changes, and a slim overview bar next to the text shows where the changes are. +- Each change can be accepted (keep the new wording) or rejected (restore the old wording of that paragraph); both mark it as reviewed and move on to the next open change. The same header or footer change on several pages is one decision. Paragraphs of the new version can also be edited in place. Everything can be undone and redone. +- Export: Word .docx in which open changes are tracked changes and accepted ones are applied; PDF via the browser's print dialog; self-contained HTML report and Markdown report, both with the review status of every change; the text of the new version as .txt or to the clipboard. +- Saved work: the current state (both documents, edits and review status) can be saved as a .docdiff file and opened again later. + +## Limits + +- No OCR. Scanned PDFs and images without a text layer are rejected with a message. +- No legacy .doc or .rtf; save them as .docx or PDF first. +- Formatting is not compared. Bold, fonts, tables, images and layout are ignored; only the text is compared. Table cells come through as text in reading order. +- Multi-column PDF layouts are read line by line and can come out in the wrong order. +- Moves are only detected when the moved paragraph is identical. A paragraph that was moved and edited shows as one deletion and one insertion. +- Large documents take time. +- It is not a legal review tool. It shows differences in the text; whether they matter is up to the reader. + +## Privacy model + +- Files are read only into the memory of the browser tab and discarded when the tab is closed or reloaded. +- The page sets a Content Security Policy that forbids all network connections from the page (connect-src 'none', form-action 'none'). This is enforced by the browser. +- All libraries (PDF.js, Mammoth.js, jsdiff) and fonts are inside the HTML file; nothing is loaded from other servers. +- Nothing is stored in the browser: no localStorage, sessionStorage, IndexedDB or cookies. +- Not covered: opening docdiff.app is a normal page load from GitHub Pages, which sees the visitor's IP address; the page also loads its own icon and manifest from the same site. Browser extensions can read any page. Session files and exports contain the document text. The offline file avoids the page load. + +## How to verify it + +- Open the browser's developer tools, switch to the Network tab and compare two files: no request is made. +- Or download DocDiff.html, disconnect from the internet and open it: everything keeps working. +- The source code is public. The published HTML is built from it with `python3 scripts/build.py` and checked with `node scripts/check.mjs`; CI verifies that the committed files match a fresh build. + +## Keyboard + +- J / K: next / previous open change +- A / R: accept / reject the current change and move on to the next open one +- E: edit the current paragraph +- Enter (in the list of changes): jump to the change +- / or Ctrl+F / Cmd+F: find in document +- Ctrl+Z / Cmd+Z: undo; Ctrl+Y / Shift+Cmd+Z: redo +- Esc: close menus, search and dialogs +- Single-key shortcuts only work while the document or the list of changes has focus, never while typing; they can be switched off in the help dialog. + +## License and links + +- License: MIT (https://github.com/tschnorpfeil/docdiff/blob/main/LICENSE) +- Source code: https://github.com/tschnorpfeil/docdiff +- App: https://docdiff.app/ (German), https://docdiff.app/en/ (English) +- Offline file: https://github.com/tschnorpfeil/docdiff/raw/main/DocDiff.html + +## Kurzfassung (Deutsch) + +Doc Diff vergleicht zwei Fassungen eines Dokuments (PDF mit Textebene, Word .docx oder Text) und markiert jede Änderung Wort für Wort: Entferntes rot durchgestrichen, Neues grün. Es ist kostenlos, Open Source (MIT), ohne Konto, Werbung und Tracking. Alles läuft lokal im Browser; die Dateien werden nicht hochgeladen, und die Content-Security-Policy der Seite sperrt jede Netzwerkverbindung. Export als Word-Datei mit nachverfolgten Änderungen, als PDF oder als Bericht. Gescannte PDFs (ohne Texterkennung/OCR), alte .doc-Dateien und Formatierungsunterschiede werden nicht unterstützt. Adresse: https://docdiff.app/ (Deutsch), https://docdiff.app/en/ (Englisch). diff --git a/site/llms.txt b/site/llms.txt new file mode 100644 index 0000000..661b704 --- /dev/null +++ b/site/llms.txt @@ -0,0 +1,29 @@ +# Doc Diff + +> Doc Diff is a free, open-source (MIT) web app that compares two versions of a document (PDF with a text layer, Word .docx, or plain text) and marks every changed word. It runs entirely in the browser: the files are not uploaded, and the page's Content Security Policy (`connect-src 'none'`) makes the browser block every network connection from the page. No account, no ads, no tracking, no cookies. The interface is in German and English. + +Typical use: you have an old and a new version of a contract, tender, offer or other text and want to see exactly what changed, without sending the documents to a third-party server. Removed text is shown struck through in red, added text in green. The result can be exported as a Word .docx file with tracked changes, as PDF (print), as an HTML or Markdown report, or as plain text. + +Limits: no OCR (scanned PDFs without a text layer are rejected), no legacy .doc or .rtf, formatting (bold, fonts, table layout, images) is not compared, multi-column PDF layouts can come out in the wrong reading order, and a paragraph that was both moved and edited shows as a deletion plus an insertion. It is not a legal review tool. + +## App + +- [Doc Diff, German page](https://docdiff.app/): the app; German is the default, the interface switches to English for English-language browsers and has a DE / EN toggle +- [Doc Diff, English page](https://docdiff.app/en/): the same app, starting in English +- [Offline version (DocDiff.html)](https://github.com/tschnorpfeil/docdiff/raw/main/DocDiff.html): the whole app as a single HTML file of just under 3 MB; open it from disk, no installation and no internet needed + +## Documentation + +- [README](https://github.com/tschnorpfeil/docdiff#readme): overview, usage, features and limits +- [What it does](https://github.com/tschnorpfeil/docdiff#what-it-does): inputs, alignment and word diff, views, editing, export formats, session files +- [What it does not do](https://github.com/tschnorpfeil/docdiff#what-it-does-not-do): known limitations +- [Privacy](https://github.com/tschnorpfeil/docdiff#privacy): how "no upload" is enforced and how to verify it, and what it does not cover +- [Keyboard shortcuts](https://github.com/tschnorpfeil/docdiff#keyboard) +- [All facts in one plain-text file](https://docdiff.app/llms-full.txt) + +## Optional + +- [Source code on GitHub](https://github.com/tschnorpfeil/docdiff) +- [Security policy](https://github.com/tschnorpfeil/docdiff/blob/main/SECURITY.md) +- [License (MIT)](https://github.com/tschnorpfeil/docdiff/blob/main/LICENSE) +- [Third-party notices](https://github.com/tschnorpfeil/docdiff/blob/main/THIRD_PARTY_NOTICES.md): PDF.js, Mammoth.js, jsdiff, Geist fonts diff --git a/site/manifest.webmanifest b/site/manifest.webmanifest index e60f35b..9042729 100644 --- a/site/manifest.webmanifest +++ b/site/manifest.webmanifest @@ -1,7 +1,9 @@ { "name": "Doc Diff", "short_name": "DocDiff", - "description": "100% client-side document comparison for PDF, Word, and text.", + "description": "Compare two versions of a document (PDF, Word .docx or text) word by word in the browser. Files are not uploaded.", + "lang": "de", + "id": "/", "start_url": "/", "display": "standalone", "background_color": "#090a0f", diff --git a/site/og-image.png b/site/og-image.png index 7de2f30..78d34dd 100644 Binary files a/site/og-image.png and b/site/og-image.png differ diff --git a/site/robots.txt b/site/robots.txt index 33fcb89..4ac0c46 100644 --- a/site/robots.txt +++ b/site/robots.txt @@ -1,3 +1,5 @@ +# All crawlers are welcome, including search engines and AI assistants +# (e.g. Googlebot, Bingbot, GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended). User-agent: * Allow: / diff --git a/site/sitemap.xml b/site/sitemap.xml index 0df09db..318b66f 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -1,6 +1,17 @@ - + https://docdiff.app/ + 2026-10-22 + + + + + + https://docdiff.app/en/ + 2026-10-22 + + + diff --git a/src/js/01-i18n.js b/src/js/01-i18n.js new file mode 100644 index 0000000..92428ce --- /dev/null +++ b/src/js/01-i18n.js @@ -0,0 +1,751 @@ +// PDF.js and the diff engine run in Blob Web Workers built from inlined sources (offline, file:// compatible) + +// Placeholders in curly braces ({n}, {name}, {mod}) are filled by formatMsg() at runtime. +const I18N = { + de: { + // ── Top bar & documents ────────────────────────────────────────────── + filesTitle: 'Dokumente', + filesBtnLabel: 'Dokumente: {left} → {right}. Ersetzen, tauschen, Dateiinfo', + replaceLeft: 'Alte Fassung ersetzen…', + replaceShort: 'Ersetzen…', + removeShort: 'Entfernen', + replaceRight: 'Neue Fassung ersetzen…', + swapBtn: 'Alte und neue Fassung tauschen', + colLeft: 'Alte Fassung', + colRight: 'Neue Fassung', + paneUnifiedLabel: 'Alte und neue Fassung', + noFileLeft: 'Keine alte Fassung', + noFileRight: 'Keine neue Fassung', + metaPages: '{n} Seiten', + metaPagesOne: '1 Seite', + metaParas: '{n} Absätze', + metaParasOne: '1 Absatz', + demoLabel: 'Beispiel', + dockLabel: 'Änderungen durchgehen', + prevLabel: 'Vorherige Änderung', + nextLabel: 'Nächste Änderung', + prevTitle: 'Vorherige Änderung (K)', + nextTitle: 'Nächste offene Änderung (J)', + stepPos: '{i} von {n}', + mobStepLabel: 'Änderung {i} von {n}. Änderungsliste öffnen', + searchLabel: 'Im Dokument suchen', + searchTitle: 'Im Dokument suchen (/ oder {mod}+F)', + searchPlaceholder: 'Im Dokument suchen', + searchPrev: 'Vorheriger Treffer (Umschalt+Enter)', + searchNext: 'Nächster Treffer (Enter)', + searchClose: 'Suche schließen (Esc)', + searchCount: '{i} von {n}', + searchNone: 'Keine Treffer', + viewMenu: 'Ansicht', + viewLayoutLabel: 'Darstellung', + modeSplit: 'Nebeneinander', + modeInline: 'Untereinander', + pageBreaks: 'Seitenumbrüche anzeigen', + onlyChanges: 'Nur Änderungen', + hfFilterBtn: 'Kopf-/Fußzeilen ausblenden', + hfHiddenCount: '{n} ausgeblendet', + sidebarToggle: 'Änderungsliste', + themeLabel: 'Farbschema', + themeLight: 'Hell', + themeDark: 'Dunkel', + themeSystem: 'Wie System', + themeTitle: 'Hell / Dunkel umschalten', + mobList: 'Liste', + 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)', + moreLabel: 'Weitere Aktionen', + clearBtn: 'Neuer Vergleich', + textBtn: 'Text einfügen/bearbeiten', + sessionSave: 'Arbeitsstand speichern…', + sessionOpen: 'Arbeitsstand öffnen…', + undoLabel: 'Rückgängig', + redoLabel: 'Wiederherstellen', + helpMenu: 'Hilfe & Tastenkürzel', + helpBtn: 'Hilfe', + langMenu: 'English', + langTarget: 'English', + moreFormats: 'Weitere Formate', + langTitle: 'Sprache wechseln (Deutsch / English)', + kbdCtrl: 'Strg', + kbdShift: 'Umschalt', + + // ── Toasts & announcements ─────────────────────────────────────────── + toastWord: 'Word-Datei gespeichert: {name}', + toastHtml: 'Bericht gespeichert: {name}', + toastMd: 'Markdown-Bericht gespeichert: {name}', + toastTxt: 'Textdatei gespeichert: {name}', + toastSession: 'Arbeitsstand gespeichert: {name}', + toastCopied: 'Text der neuen Fassung kopiert', + toastEdited: 'Absatz bearbeitet', + toastEditedReopened: 'Absatz bearbeitet – Änderung wieder offen', + 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“.', + 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.', + announceStep: 'Änderung {i} von {n}: {ref}, {type}{status}', + announceAcceptedNext: 'Angenommen – weiter mit Änderung {j} von {n}', + announceRejectedNext: 'Abgelehnt – weiter mit Änderung {j} von {n}', + announceAcceptedDone: 'Änderung {i} von {n} angenommen – keine offenen Änderungen mehr', + announceRejectedDone: 'Änderung {i} von {n} abgelehnt – keine offenen Änderungen mehr', + announceReopened: 'Änderung {i} von {n} wieder offen', + announceSearch: 'Treffer {i} von {n}', + compareHeading: 'Vergleich: {left} mit {right}', + compareBusy: 'Dokumente werden verglichen …', + + // ── Change list ────────────────────────────────────────────────────── + listTitle: '{n} Änderungen', + listTitleOne: '1 Änderung', + listProgress: '{k} von {n} geprüft', + filterAll: 'Alle', + filterOpen: 'Offen', + filterReviewed: 'Geprüft', + filterMod: 'Geändert', + filterIns: 'Hinzugefügt', + filterDel: 'Entfernt', + stepPosFiltered: '{i} von {n} {label}', + stepLabel_open: 'offenen', + stepLabel_reviewed: 'geprüften', + stepLabel_modified: 'geänderten', + stepLabel_inserted: 'hinzugefügten', + stepLabel_deleted: 'entfernten', + listTitleFiltered: '{k} von {n} Änderungen', + listFilterLabel: 'Änderungen filtern', + filterGroupStatus: 'Prüfstand', + filterGroupKind: 'Art der Änderung', + mobDone: 'Fertig', + noMatchesTitle: 'Nichts in diesem Filter', + noMatchesDesc: 'Wählen Sie „Alle“ und heben Sie „Offen“ / „Geprüft“ auf, um alle Änderungen zu sehen.', + identicalTitle: 'Dokumente sind identisch', + identicalDesc: 'Zwischen alter und neuer Fassung gibt es keine Textunterschiede.', + typeMod: 'Geändert', + typeIns: 'Hinzugefügt', + typeDel: 'Entfernt', + typeMoved: 'Verschoben', + typeHf: 'Kopf-/Fußzeile', + statMod: 'geändert', + statIns: 'hinzugefügt', + statDel: 'entfernt', + statusOpen: 'offen', + statusAccepted: 'angenommen', + statusRejected: 'abgelehnt', + movedFromHere: 'Verschoben – von hier', + movedToHere: 'Verschoben – hierher', + moreDeltas: 'weitere Änderungen', + pageLabel: 'Seite', + pageShort: 'S.', + pageOf: 'von', + hfGroup: 'Kopf-/Fußzeile', + + // ── Review actions ─────────────────────────────────────────────────── + acceptBtn: 'Annehmen', + rejectBtn: 'Ablehnen', + editBtn: 'Bearbeiten', + reopenBtn: 'Rückgängig', + acceptTitle: 'Annehmen (A)', + rejectTitle: 'Ablehnen (R)', + editTitle: 'Bearbeiten (E)', + reopenTitle: 'Prüfung zurücknehmen', + rejectedInsNote: 'Abgelehnt – hinzugefügter Absatz danach entfernt', + cardRemoved: 'entfernt:', + cardAdded: 'hinzugefügt:', + paragraphShort: 'Absatz', + hfMergedRef: 'Kopf-/Fußzeile – auf {n} Seiten gleich geändert', + reviewBarLabel: 'Änderung {i} von {n}', + paraActionsLabel: 'Absatz', + acceptedNote: 'Angenommen', + rejectedNote: 'Abgelehnt – alte Fassung wiederhergestellt', + editorHintText: 'Enter = Übernehmen · Esc = Abbrechen', + editorSave: 'Übernehmen', + editorCancel: 'Abbrechen', + + // ── Help & legal ───────────────────────────────────────────────────── + helpTitle: 'Hilfe & Tastenkürzel', + helpSampleBtn: 'Beispiel ansehen', + helpOkBtn: 'Verstanden', + shortcutsToggle: 'Tastenkürzel mit einzelnen Buchstaben verwenden (J, K, A, R, /)', + helpBodyHtml: ` +
+

Lesen

+

Links steht die alte, rechts die neue Fassung. Durchgestrichen = entfernt, grün = hinzugefügt, ein bernsteinfarbener Rand = Absatz geändert. Die schmale Leiste zwischen den beiden Fassungen (untereinander: rechts neben dem Text) zeigt, wo im Dokument Änderungen liegen; ein Klick springt dorthin.

+
+
+

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.

+
+
+

Tastenkürzel

+ + + + + + + + + + + + +
J / KNächste / vorherige offene Änderung
AÄnderung annehmen
RÄnderung ablehnen
EAbsatz bearbeiten
EnterIn der Liste: zur Stelle springen
/ oder {mod}+FIm Dokument suchen
{mod}+ZRückgängig
{redo}Wiederherstellen
EscMenü, Suche oder Dialog schließen
+

Einzelne Buchstaben wirken nur, wenn der Text oder die Änderungsliste den Fokus hat – nie beim Schreiben.

+
+
+

Ihre Daten

+

Die Seite verbietet Ihrem Browser jede Netzwerkverbindung: Ihre Dateien werden nur in diesem Tab gelesen und beim Schließen verworfen. Mit Arbeitsstand speichern… (im Menü ⋯) sichern Sie beide Fassungen samt Prüfstand als .docdiff-Datei – bewahren Sie sie so sorgfältig auf wie die Dokumente selbst.

+
+ `, + footerPrivacy: 'Impressum & Datenschutz', + privacyTitle: 'Impressum & Datenschutz', + privacyBodyHtml: ` +

Impressum (§ 5 DDG):
Tobias Schnorpfeil · Im Palert 5 · 56253 Treis-Karden · tschnorpfeil@gmail.com

+

Datenschutz:
Alle Dateien und Texte werden ausschließlich lokal im Arbeitsspeicher Ihres Browsers verarbeitet. Es werden keine Dokumente hochgeladen, keine Cookies gesetzt und keine Tracker genutzt. Beim Aufruf über docdiff.app werden technisch notwendige Verbindungsdaten durch GitHub Pages (GitHub, Inc., USA) und Cloudflare DNS verarbeitet (Art. 6 Abs. 1 lit. f DSGVO).

+

Impressum · Datenschutz · GitHub

+ `, + + // ── Dialogs ────────────────────────────────────────────────────────── + close: 'Schließen', + cancel: 'Abbrechen', + compare: 'Vergleichen', + modalPasteTitle: 'Text einfügen oder bearbeiten', + modalLeftLabel: 'Alte Fassung', + modalRightLabel: 'Neue Fassung', + modalLeftPh: 'Text der alten Fassung hier einfügen…', + modalRightPh: 'Text der neuen Fassung hier einfügen…', + manualLeft: 'Eingefügter Text (alte Fassung)', + manualRight: 'Eingefügter Text (neue Fassung)', + sessionDiscardTitle: 'Ungespeicherte Arbeit verwerfen?', + sessionDiscardBody: + 'Sie haben Änderungen geprüft oder Absätze bearbeitet, und dieser Stand ist noch nicht gespeichert oder exportiert. Wenn Sie ihn verwerfen, geht diese Arbeit verloren.', + sessionDiscardConfirm: 'Verwerfen', + sessionSaveContinue: 'Arbeitsstand speichern', + sessionErrorTitle: 'Arbeitsstand kann nicht geöffnet werden', + sessionErrorInvalid: + 'Die Datei ist kein gültiger Doc-Diff-Arbeitsstand (.docdiff) oder sie ist beschädigt. Es wurde nichts verändert.', + sessionErrorTooLarge: 'Die Datei ist zu groß (maximal 50 MB). Es wurde nichts verändert.', + sessionErrorVersion: + 'Dieser Arbeitsstand wurde mit einer neueren Version von Doc Diff gespeichert und kann hier nicht geöffnet werden. Es wurde nichts verändert.', + sessionNothingTitle: 'Nichts zu speichern', + sessionNothingBody: 'Laden Sie zuerst ein Dokument, um einen Arbeitsstand zu speichern.', + sessionOk: 'OK', + + // ── Import ─────────────────────────────────────────────────────────── + readingFile: 'Lese', + readError: 'Fehler beim Lesen der Datei', + noticeClose: 'Hinweis schließen', + errUnsupportedType: 'Dieses Dateiformat wird nicht unterstützt. Bitte PDF, Word (.docx), HTML oder reinen Text (.txt, .md) verwenden.', + errLegacyDoc: 'Alte Word-Dateien (.doc) werden nicht unterstützt. Bitte in Word als .docx speichern und erneut laden.', + errRtf: 'RTF-Dateien werden nicht unterstützt. Bitte als .docx oder .txt speichern und erneut laden.', + errNotText: 'Die Datei ist kein lesbarer Text (UTF-8). Bitte PDF, Word (.docx) oder eine UTF-8-Textdatei verwenden.', + errEmptyFile: 'Die Datei enthält keinen Text. Bitte eine Datei mit Text wählen – bei einem Scan zuerst Texterkennung (OCR) ausführen.', + errScannedPdf: 'Dieses PDF ist ein Scan ohne Text. Bitte zuerst per Texterkennung (OCR) in ein durchsuchbares PDF umwandeln.', + errScannedPdfFile: '„{name}“ ist ein Scan ohne Text. Bitte zuerst per Texterkennung (OCR) in ein durchsuchbares PDF umwandeln.', + warnPdfPagesWithoutText: 'Hinweis: Seiten ohne Textebene (Scan/Bild) können nicht verglichen werden:', + errDocxEmpty: 'Im Word-Dokument wurde kein Text gefunden.', + errPdfPassword: + 'Das PDF ist mit einem Kennwort geschützt. Bitte den Kennwortschutz entfernen (z. B. ungeschützt neu speichern oder drucken) und die Datei erneut laden.', + errPdfCorrupt: 'Das PDF ist beschädigt oder unvollständig und kann nicht gelesen werden.', + errDocxCorrupt: 'Das Word-Dokument ist beschädigt, unvollständig oder kein gültiges .docx und kann nicht gelesen werden.', + warnTextLegacyEncoding: + 'Hinweis: Die Textdatei ist nicht UTF-8-kodiert und wurde als Windows-1252 (Westeuropäisch) gelesen. Bitte Umlaute und Sonderzeichen prüfen.', + warnDocxTrackedChanges: + 'Hinweis: Das Word-Dokument enthält nachverfolgte Änderungen. Verglichen wird die Fassung mit allen Änderungen angenommen; entfernter Text ist nicht enthalten.', + errImage: 'Bilddateien werden nicht unterstützt, da keine Texterkennung (OCR) verfügbar ist. Bitte ein PDF mit Textebene, Word (.docx) oder Text verwenden.', + dropLeft: 'Alte Fassung hier ablegen', + dropRight: 'Neue Fassung hier ablegen', + + // ── Exports ────────────────────────────────────────────────────────── + exportFallbackLeft: 'Alte Fassung', + exportFallbackRight: 'Neue Fassung', + exportTitleFmt: 'Vergleich: {left} → {right}', + exportVs: 'vs.', + exportCountsFmt: '{total} Änderungen ({mod} geändert, {ins} hinzugefügt, {del} entfernt)', + exportReviewFmt: '{k} von {n} geprüft ({a} angenommen, {r} abgelehnt)', + exportLabelOriginal: 'Alte Fassung', + exportLabelRevised: 'Neue Fassung', + exportLabelCreated: 'Erstellt am', + exportLabelChanges: 'Änderungen', + exportLabelReview: 'Prüfstand', + exportLabelScope: 'Umfang', + exportLabelHf: 'Kopf-/Fußzeilen', + 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', + exportSummaryHeading: 'Zusammenfassung', + exportCountTotal: 'Änderungen gesamt', + exportChangesHeading: 'Änderungsübersicht', + exportChangeListCaption: 'Alle Änderungen mit Fundstelle und Prüfstand. Ein Klick auf die Fundstelle springt zur Stelle im Text.', + exportColRef: 'Fundstelle', + exportColType: 'Art', + exportColStatus: 'Status', + exportColChange: 'Änderung', + exportRedlineHeading: 'Text mit Änderungen (alte → neue Fassung)', + exportLegendLabel: 'Legende', + exportLegendIns: 'hinzugefügt', + exportLegendDel: 'entfernt', + exportRejectedNote: 'abgelehnt, alte Fassung beibehalten', + exportNoChanges: 'Keine Unterschiede gefunden: Beide Fassungen sind inhaltlich identisch.', + exportFooterNote: 'Erstellt mit Doc Diff. Alle Inhalte wurden lokal im Browser verarbeitet, es wurden keine Daten hochgeladen.', + a11yRemovedStart: '[entfernt:', + a11yRemovedEnd: ']', + 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', + exportFileRedline: 'Vergleich', + exportFileReport: 'Bericht', + sampleLeftName: 'Vertrag_v1_Vorher.docx', + sampleRightName: 'Vertrag_v2_Nachher.docx', + + // ── Landing page (static HTML, rendered by scripts/build.py) ───────── + lpLoaded: 'Geladen', + lpChangeFile: 'Andere Datei wählen', + pasteText: 'Text einfügen', + loadSample: 'Beispiel ansehen', + heroTitle: 'Dokumente einfach vergleichen.', + heroSub: 'Zwei Fassungen als PDF, Word oder Text: Jede Änderung wird Wort für Wort markiert. Ihre Dateien verlassen dabei nie Ihr Gerät.', + trustFree: 'Kostenlos & Open Source (MIT)', + trustLocal: 'Keine Uploads – alles läuft lokal im Browser', + trustNoTracking: 'Keine Werbung, kein Tracking, keine Cookies', + slotLeftTitle: 'Alte Fassung', + slotRightTitle: 'Neue Fassung', + slotHint: 'PDF (mit Text), Word (.docx), Text', + slotChoose: 'Datei wählen', + slotDrag: 'oder hierher ziehen', + slotClearLeft: 'Alte Fassung entfernen', + slotClearRight: 'Neue Fassung entfernen', + dropBothHint: 'Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.', + howTitle: 'So funktioniert’s', + how1Title: 'Zwei Fassungen wählen', + how1Text: 'Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.', + 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.', + 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', + scopeCan2: 'Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge', + scopeCan3: 'Seitenumbrüche wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet', + scopeCan4: 'Ohne Internet arbeiten – als einzelne HTML-Datei', + scopeCannotTitle: 'Kann nicht', + scopeCannot1: 'Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)', + scopeCannot2: 'Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern', + scopeCannot3: 'Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text', + scopeCan5: 'Verschobene Absätze als „verschoben“ kennzeichnen', + faqTitle: 'Häufige Fragen', + faqUploadQ: 'Wie kann ich prüfen, dass nichts hochgeladen wird?', + faqUploadA: ` +

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+ `, + faqOfflineQ: 'Kann ich Doc Diff offline nutzen?', + faqOfflineA: ` +

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, knapp 3 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+ `, + faqWhoQ: 'Wer steckt dahinter? Wo ist der Quellcode?', + faqWhoA: ` +

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+ `, + faqScanQ: 'Kann ich gescannte PDFs vergleichen?', + faqScanA: ` +

Nein. Doc Diff liest nur PDFs mit Textebene, also PDFs, in denen Sie Text markieren und kopieren können. Ein Scan ist ein Bild der Seite, und eine Texterkennung (OCR) ist nicht eingebaut. Solche Dateien werden mit einem Hinweis abgelehnt. Wandeln Sie den Scan vorher mit einem OCR-Programm in ein durchsuchbares PDF um.

+ `, + 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.

+ `, + metaTitle: 'Doc Diff – PDF- und Word-Dokumente vergleichen, ohne Upload', + otherPageHint: 'Diese Seite gibt es auch auf Deutsch →', + metaDescription: 'Zwei Fassungen eines Vertrags oder Dokuments vergleichen: PDF, Word oder Text, Wort für Wort markiert. Kostenlos, Open Source, ohne Upload.', + ogImageAlt: 'Doc Diff: Zwei Vertragsfassungen nebeneinander, entfernter Text rot durchgestrichen, neuer Text grün markiert.', + footerSource: 'Quellcode auf GitHub', + githubMenu: 'Quellcode auf GitHub', + footerLicense: 'MIT-Lizenz', + }, + en: { + // ── Top bar & documents ────────────────────────────────────────────── + filesTitle: 'Documents', + filesBtnLabel: 'Documents: {left} → {right}. Replace, swap, file info', + replaceLeft: 'Replace old version…', + replaceShort: 'Replace…', + removeShort: 'Remove', + replaceRight: 'Replace new version…', + swapBtn: 'Swap old and new version', + colLeft: 'Old version', + colRight: 'New version', + paneUnifiedLabel: 'Old and new version', + noFileLeft: 'No old version', + noFileRight: 'No new version', + metaPages: '{n} pages', + metaPagesOne: '1 page', + metaParas: '{n} paragraphs', + metaParasOne: '1 paragraph', + demoLabel: 'Example', + dockLabel: 'Go through the changes', + prevLabel: 'Previous change', + nextLabel: 'Next change', + prevTitle: 'Previous change (K)', + nextTitle: 'Next open change (J)', + stepPos: '{i} of {n}', + mobStepLabel: 'Change {i} of {n}. Open the list of changes', + searchLabel: 'Find in document', + searchTitle: 'Find in document (/ or {mod}+F)', + searchPlaceholder: 'Find in document', + searchPrev: 'Previous match (Shift+Enter)', + searchNext: 'Next match (Enter)', + searchClose: 'Close search (Esc)', + searchCount: '{i} of {n}', + searchNone: 'No matches', + viewMenu: 'View', + viewLayoutLabel: 'Layout', + modeSplit: 'Side by side', + modeInline: 'Inline', + pageBreaks: 'Show page breaks', + onlyChanges: 'Changes only', + hfFilterBtn: 'Hide headers & footers', + hfHiddenCount: '{n} hidden', + sidebarToggle: 'List of changes', + themeLabel: 'Colour scheme', + themeLight: 'Light', + themeDark: 'Dark', + themeSystem: 'Same as system', + themeTitle: 'Switch between light and dark', + mobList: 'List', + 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)', + moreLabel: 'More actions', + clearBtn: 'New comparison', + textBtn: 'Paste or edit text', + sessionSave: 'Save work…', + sessionOpen: 'Open saved work…', + undoLabel: 'Undo', + redoLabel: 'Redo', + helpMenu: 'Help & shortcuts', + helpBtn: 'Help', + langMenu: 'Deutsch', + langTarget: 'Deutsch', + moreFormats: 'More formats', + langTitle: 'Switch language (Deutsch / English)', + kbdCtrl: 'Ctrl', + kbdShift: 'Shift', + + // ── Toasts & announcements ─────────────────────────────────────────── + toastWord: 'Word file saved: {name}', + toastHtml: 'Report saved: {name}', + toastMd: 'Markdown report saved: {name}', + toastTxt: 'Text file saved: {name}', + toastSession: 'Work saved: {name}', + toastCopied: 'Text of the new version copied', + toastEdited: 'Paragraph edited', + toastEditedReopened: 'Paragraph edited – change reopened', + 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.', + announceCompared: 'Comparison ready: {n} changes. Press J and K to move between changes.', + announceComparedOne: 'Comparison ready: 1 change.', + announceIdentical: 'Comparison ready: no text differences.', + announceStep: 'Change {i} of {n}: {ref}, {type}{status}', + announceAcceptedNext: 'Accepted – on to change {j} of {n}', + announceRejectedNext: 'Rejected – on to change {j} of {n}', + announceAcceptedDone: 'Change {i} of {n} accepted – no open changes left', + announceRejectedDone: 'Change {i} of {n} rejected – no open changes left', + announceReopened: 'Change {i} of {n} reopened', + announceSearch: 'Match {i} of {n}', + compareHeading: 'Comparison: {left} with {right}', + compareBusy: 'Comparing documents …', + + // ── Change list ────────────────────────────────────────────────────── + listTitle: '{n} changes', + listTitleOne: '1 change', + listProgress: '{k} of {n} reviewed', + filterAll: 'All', + filterOpen: 'Open', + filterReviewed: 'Reviewed', + filterMod: 'Changed', + filterIns: 'Added', + filterDel: 'Removed', + stepPosFiltered: '{i} of {n} {label}', + stepLabel_open: 'open', + stepLabel_reviewed: 'reviewed', + stepLabel_modified: 'changed', + stepLabel_inserted: 'added', + stepLabel_deleted: 'removed', + listTitleFiltered: '{k} of {n} changes', + listFilterLabel: 'Filter changes', + filterGroupStatus: 'Review status', + filterGroupKind: 'Kind of change', + mobDone: 'Done', + noMatchesTitle: 'Nothing in this filter', + noMatchesDesc: 'Choose “All” and clear “Open” / “Reviewed” to see every change.', + identicalTitle: 'Documents are identical', + identicalDesc: 'There are no text differences between the old and the new version.', + typeMod: 'Changed', + typeIns: 'Added', + typeDel: 'Removed', + typeMoved: 'Moved', + typeHf: 'Header/footer', + statMod: 'changed', + statIns: 'added', + statDel: 'removed', + statusOpen: 'open', + statusAccepted: 'accepted', + statusRejected: 'rejected', + movedFromHere: 'Moved – from here', + movedToHere: 'Moved – to here', + moreDeltas: 'more changes', + pageLabel: 'Page', + pageShort: 'p.', + pageOf: 'of', + hfGroup: 'Header/footer', + + // ── Review actions ─────────────────────────────────────────────────── + acceptBtn: 'Accept', + rejectBtn: 'Reject', + editBtn: 'Edit', + reopenBtn: 'Undo', + acceptTitle: 'Accept (A)', + rejectTitle: 'Reject (R)', + editTitle: 'Edit (E)', + reopenTitle: 'Undo the review of this change', + rejectedInsNote: 'Rejected – added paragraph below removed', + cardRemoved: 'removed:', + cardAdded: 'added:', + paragraphShort: 'Paragraph', + hfMergedRef: 'Header/footer – same change on {n} pages', + reviewBarLabel: 'Change {i} of {n}', + paraActionsLabel: 'Paragraph', + acceptedNote: 'Accepted', + rejectedNote: 'Rejected – old wording restored', + editorHintText: 'Enter = Apply · Esc = Cancel', + editorSave: 'Apply', + editorCancel: 'Cancel', + + // ── Help & legal ───────────────────────────────────────────────────── + helpTitle: 'Help & shortcuts', + helpSampleBtn: 'View example', + helpOkBtn: 'Got it', + shortcutsToggle: 'Use single-letter keyboard shortcuts (J, K, A, R, /)', + helpBodyHtml: ` +
+

Reading

+

The old version is on the left, the new one on the right. Struck through = removed, green = added, an amber edge = paragraph changed. The slim bar between the two versions (stacked view: next to the text) shows where the changes are in the document; click it to jump there.

+
+
+

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.

+
+
+

Keyboard shortcuts

+ + + + + + + + + + + + +
J / KNext / previous open change
AAccept the change
RReject the change
EEdit the paragraph
EnterIn the list: jump to the change
/ or {mod}+FFind in document
{mod}+ZUndo
{redo}Redo
EscClose menu, search or dialog
+

Single letters only work while the document or the list of changes has focus – never while typing.

+
+
+

Your data

+

The page forbids your browser from making any network connection: your files are only read inside this tab and discarded when you close it. Save work… (in the ⋯ menu) stores both versions and your review as a .docdiff file – keep it as carefully as the documents themselves.

+
+ `, + footerPrivacy: 'Legal & Privacy', + privacyTitle: 'Legal Notice & Privacy', + privacyBodyHtml: ` +

Legal Notice (§ 5 DDG):
Tobias Schnorpfeil · Im Palert 5 · 56253 Treis-Karden, Germany · tschnorpfeil@gmail.com

+

Privacy:
All files and texts are processed strictly locally in your browser's memory. No documents are uploaded, no cookies are set, and no analytics are used. When accessed via docdiff.app, standard connection metadata (IP address, timestamp, user agent) is processed by GitHub Pages (GitHub, Inc., USA) and Cloudflare DNS (Art. 6(1)(f) GDPR).

+

Impressum · Privacy Policy · GitHub

+ `, + + // ── Dialogs ────────────────────────────────────────────────────────── + close: 'Close', + cancel: 'Cancel', + compare: 'Compare', + modalPasteTitle: 'Paste or edit text', + modalLeftLabel: 'Old version', + modalRightLabel: 'New version', + modalLeftPh: 'Paste the text of the old version here…', + modalRightPh: 'Paste the text of the new version here…', + manualLeft: 'Pasted text (old version)', + manualRight: 'Pasted text (new version)', + sessionDiscardTitle: 'Discard unsaved work?', + sessionDiscardBody: + 'You have reviewed changes or edited paragraphs, and this work has not been saved or exported yet. If you discard it, it will be lost.', + sessionDiscardConfirm: 'Discard', + sessionSaveContinue: 'Save work', + sessionErrorTitle: 'Cannot open saved work', + sessionErrorInvalid: + 'This file is not valid saved Doc Diff work (.docdiff) or it is damaged. Nothing was changed.', + sessionErrorTooLarge: 'This file is too large (50 MB maximum). Nothing was changed.', + sessionErrorVersion: + 'This work was saved by a newer version of Doc Diff and cannot be opened here. Nothing was changed.', + sessionNothingTitle: 'Nothing to save', + sessionNothingBody: 'Load a document first to save your work.', + sessionOk: 'OK', + + // ── Import ─────────────────────────────────────────────────────────── + readingFile: 'Reading', + readError: 'Failed to read file', + noticeClose: 'Dismiss message', + errUnsupportedType: 'This file format is not supported. Please use PDF, Word (.docx), HTML or plain text (.txt, .md).', + errLegacyDoc: 'Legacy Word files (.doc) are not supported. Please save the file as .docx in Word and load it again.', + errRtf: 'RTF files are not supported. Please save the file as .docx or .txt and load it again.', + errNotText: 'The file is not readable text (UTF-8). Please use PDF, Word (.docx) or a UTF-8 text file.', + errEmptyFile: 'The file does not contain any text. Choose a file with text – for a scan, run OCR first.', + errScannedPdf: 'This PDF is a scan without text. Run OCR first to make it searchable.', + errScannedPdfFile: '“{name}” is a scan without text. Run OCR first to make it searchable.', + warnPdfPagesWithoutText: 'Note: pages without a text layer (scan/image) cannot be compared:', + errDocxEmpty: 'No text was found in the Word document.', + errPdfPassword: + 'The PDF is password-protected. Please remove the password protection (e.g. save or print it without protection) and load the file again.', + errPdfCorrupt: 'The PDF is damaged or incomplete and cannot be read.', + errDocxCorrupt: 'The Word document is damaged, incomplete or not a valid .docx and cannot be read.', + warnTextLegacyEncoding: + 'Note: the text file is not UTF-8 encoded and was read as Windows-1252 (Western European). Please check umlauts and special characters.', + warnDocxTrackedChanges: + 'Note: the Word document contains tracked changes. It is compared as if all changes were accepted; removed text is not included.', + errImage: 'Image files are not supported because text recognition (OCR) is not available. Please use a PDF with a text layer, Word (.docx) or text.', + dropLeft: 'Drop the old version here', + dropRight: 'Drop the new version here', + + // ── Exports ────────────────────────────────────────────────────────── + exportFallbackLeft: 'Old version', + exportFallbackRight: 'New version', + exportTitleFmt: 'Comparison: {left} → {right}', + exportVs: 'vs.', + exportCountsFmt: '{total} changes ({mod} changed, {ins} added, {del} removed)', + exportReviewFmt: '{k} of {n} reviewed ({a} accepted, {r} rejected)', + exportLabelOriginal: 'Old version', + exportLabelRevised: 'New version', + exportLabelCreated: 'Created', + exportLabelChanges: 'Changes', + exportLabelReview: 'Review', + exportLabelScope: 'Scope', + exportLabelHf: 'Headers/footers', + 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', + exportSummaryHeading: 'Summary', + exportCountTotal: 'Total changes', + exportChangesHeading: 'List of changes', + exportChangeListCaption: 'All changes with their clause reference and review status. Select a reference to jump to it in the text.', + exportColRef: 'Reference', + exportColType: 'Type', + exportColStatus: 'Status', + exportColChange: 'Change', + exportRedlineHeading: 'Text with changes (old → new version)', + exportLegendLabel: 'Legend', + exportLegendIns: 'added', + exportLegendDel: 'removed', + exportRejectedNote: 'rejected, old wording kept', + exportNoChanges: 'No differences found: both versions have identical content.', + exportFooterNote: 'Generated with Doc Diff. All content was processed locally in the browser; no data was uploaded.', + a11yRemovedStart: '[removed:', + a11yRemovedEnd: ']', + 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', + exportFileRedline: 'Comparison', + exportFileReport: 'Report', + sampleLeftName: 'Contract_v1_Before.docx', + sampleRightName: 'Contract_v2_After.docx', + + // ── Landing page (static HTML, rendered by scripts/build.py) ───────── + lpLoaded: 'Loaded', + lpChangeFile: 'Choose another file', + pasteText: 'Paste text', + loadSample: 'View example', + heroTitle: 'Compare documents easily.', + heroSub: 'Two versions as PDF, Word or text: every change is marked word by word. Your files never leave your device.', + trustFree: 'Free & open source (MIT)', + trustLocal: 'No uploads – everything runs locally in your browser', + trustNoTracking: 'No ads, no tracking, no cookies', + slotLeftTitle: 'Old version', + slotRightTitle: 'New version', + slotHint: 'PDF (with text), Word (.docx), text', + slotChoose: 'Choose file', + slotDrag: 'or drop it here', + slotClearLeft: 'Remove old version', + slotClearRight: 'Remove new version', + dropBothHint: 'You can also drop both files here at once – the first one becomes the old version.', + howTitle: 'How it works', + how1Title: 'Choose two versions', + how1Text: 'As PDF, Word file or pasted text. The files are only read inside this browser tab.', + 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.', + 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', + scopeCan2: 'Show changes word by word – including changed numbers, dates and amounts', + scopeCan3: 'Show page breaks as in the original; repeating headers and footers are left out', + scopeCan4: 'Work without internet – as a single HTML file', + scopeCannotTitle: 'Can’t', + scopeCannot1: 'Read scanned PDFs – there is no text recognition (OCR)', + scopeCannot2: 'Open old Word files (.doc) – please save them as .docx first', + scopeCannot3: 'Compare formatting – bold, italics, table layout and images are ignored; only the text is compared', + scopeCan5: 'Mark moved paragraphs as “moved”', + faqTitle: 'Frequently asked questions', + faqUploadQ: 'How can I check that nothing is uploaded?', + faqUploadA: ` +

The page contains a security rule (Content Security Policy) that makes your browser block every network connection from the page. Your files are only read into this tab’s memory and discarded when you close it.

+

To check it yourself: open your browser’s developer tools (F12), switch to the “Network” tab and then load a document – no request appears. Or download the offline file, disconnect from the internet and open it: everything keeps working.

+ `, + faqOfflineQ: 'Can I use Doc Diff offline?', + faqOfflineA: ` +

Yes. Download DocDiff.html (a single file of just under 3 MB) and open it in your browser with a double-click – no installation and no internet needed.

+ `, + faqWhoQ: 'Who is behind it? Where is the source code?', + faqWhoA: ` +

Doc Diff is a private open-source project by Tobias Schnorpfeil – with no company, advertising or data collection behind it. The complete source code is available under the MIT licence on GitHub: you can review it, build it yourself or host it yourself.

+ `, + faqScanQ: 'Can I compare scanned PDFs?', + faqScanA: ` +

No. Doc Diff only reads PDFs with a text layer, that is, PDFs in which you can select and copy text. A scan is an image of the page, and there is no built-in text recognition (OCR). Such files are rejected with a message. Convert the scan into a searchable PDF with an OCR program first.

+ `, + 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.

+ `, + 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).', + footerSource: 'Source code on GitHub', + githubMenu: 'Source code on GitHub', + footerLicense: 'MIT licence', + }, +}; diff --git a/src/js/02-samples.js b/src/js/02-samples.js new file mode 100644 index 0000000..f3ba039 --- /dev/null +++ b/src/js/02-samples.js @@ -0,0 +1,295 @@ +const SAMPLES = { + de: { + v1: `=== [SEITE 1] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 1 von 3 + +RAHMENVERTRAG ÜBER IT- UND PLANUNGSLEISTUNGEN + +Zwischen der BauTech Projektentwicklung GmbH, Friedrichstraße 114, 10117 Berlin (nachfolgend „Auftraggeber“ genannt) und der Alpha Systems Engineering AG, Maximilianstraße 35, 80539 München (nachfolgend „Auftragnehmer“ genannt). + +§ 1 Vertragsgegenstand und Leistungsumfang + +(1) Der Auftragnehmer verpflichtet sich zur schlüsselfertigen Erstellung, Konfiguration und Dokumentation der Cloud-Kalkulationsplattform gemäß dem Lastenheft vom 12.03.2026 (Anlage 1). + +1.1 Kernmodule und Liefergegenstände + +• Modul A: Mengenermittlung und strukturierter LV-Import (GAEB DA XML 3.3) +• Modul B: Urkalkulation, EKT-Aufgliederung und Zuschlagsberechnung (AGK / WuG) +• Modul C: Standard-Berichtswesen und Preisspiegel-Export als PDF-Dokument + +(2) Nachträgliche Änderungswünsche des Auftraggebers (Change Requests) sind schriftlich einzureichen und werden auf Basis der vereinbarten Tagessätze gesondert vergütet, sofern sie den ursprünglichen Aufwand um mehr als 5,0 % überschreiten. + +(3) Der Auftragnehmer erbringt sämtliche Leistungen ausschließlich durch eigenes, festangestelltes Fachpersonal am Standort Deutschland. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 1 von 3 +=== [SEITE 2] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 2 von 3 + +§ 2 Vergütung, Urkalkulation und Zahlungsbedingungen + +(1) Für die in § 1 genannten Leistungen vereinbaren die Parteien einen Pauschalfestpreis in Höhe von 145.000,00 € netto zuzüglich der gesetzlichen Umsatzsteuer. + +2.1 Kalkulationsgrundlagen und Zuschläge + +(2) Der Auftragnehmer hinterlegt vor Vertragsbeginn eine verschlossene Urkalkulation beim Auftraggeber, in der sämtliche Einzelkosten der Teilleistungen (EKT) sowie der kalkulierte Zuschlag für Allgemeine Geschäftskosten (AGK) in Höhe von 12,0 % und Wagnis und Gewinn (WuG) in Höhe von 6,0 % offengelegt sind. + +(3) Abschlagsrechnungen sind innerhalb von 14 Kalendertagen nach Rechnungseingang ohne Abzug zur Zahlung fällig. Bei Zahlung innerhalb von 7 Kalendertagen gewährt der Auftragnehmer 2,0 % Skonto. + +§ 3 Termine und Vertragsstrafe bei Verzug + +(1) Die produktive Bereitstellung (Go-Live) der Gesamtlösung erfolgt spätestens am 30.09.2026. + +(2) Gerät der Auftragnehmer mit dem verbindlichen Fertigstellungstermin in Verzug, verwirkt er für jede vollendete Kalenderwoche des Verzugs eine Vertragsstrafe in Höhe von 0,5 % der Netto-Auftragssumme, maximal jedoch 5,0 % der Gesamtauftragssumme. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 2 von 3 +=== [SEITE 3] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Fassung v1.0 (Stand: 12.03.2026) · Seite 3 von 3 + +§ 4 Gewährleistung und Haftung + +(1) Die Verjährungsfrist für Mängelansprüche beträgt 24 Monate ab schriftlicher Endabnahme durch den Auftraggeber. + +(2) Die Haftung der Parteien für leichte Fahrlässigkeit ist bei Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) auf den vertragstypischen, vorhersehbaren Schaden, höchstens jedoch auf 500.000,00 € je Schadensfall begrenzt. + +(3) Eine Haftung für entgangenen Gewinn oder mittelbare Folgeschäden ist im Falle leichter Fahrlässigkeit ausgeschlossen. + +§ 5 Geheimhaltung und Datenschutz + +(1) Beide Vertragsparteien verpflichten sich, alle im Rahmen der Zusammenarbeit erlangten vertraulichen Informationen sowie Geschäfts- und Betriebsgeheimnisse zeitlich unbegrenzt vertraulich zu behandeln. + +(2) Die Verarbeitung personenbezogener Daten erfolgt ausschließlich auf Servern innerhalb der Europäischen Union gemäß dem beigefügten Auftragsverarbeitungsvertrag (Anlage 2). + +§ 6 Kündigung und Schlussbestimmungen + +(1) Dieser Vertrag kann von beiden Parteien mit einer Frist von 3 Monaten zum Quartalsende ordentlich gekündigt werden. + +(2) Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Ausschließlicher Gerichtsstand für alle Streitigkeiten aus diesem Vertrag ist Berlin. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 3 von 3`, + v2: `=== [SEITE 1] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 1 von 4 + +RAHMENVERTRAG ÜBER IT- UND PLANUNGSLEISTUNGEN + +Zwischen der BauTech Projektentwicklung GmbH, Friedrichstraße 114, 10117 Berlin (nachfolgend „Auftraggeber“ genannt) und der Alpha Systems Engineering AG, Maximilianstraße 35, 80539 München (nachfolgend „Auftragnehmer“ genannt). + +§ 1 Vertragsgegenstand und Leistungsumfang + +(1) Der Auftragnehmer verpflichtet sich zur schlüsselfertigen Erstellung, Konfiguration, Schulung und Dokumentation der Cloud-Kalkulationsplattform gemäß dem aktualisierten Lastenheft vom 18.04.2026 (Anlage 1). + +1.1 Kernmodule und Liefergegenstände + +• Modul A: Mengenermittlung und strukturierter LV-Import (GAEB DA XML 3.3 & BIM-IFC) +• Modul B: Urkalkulation, EKT-Aufgliederung und Zuschlagsberechnung (AGK / WuG) +• Modul C: Echtzeit-Nachtragsmanagement, Preisspiegel und automatisierter Soll-Ist-Abgleich + +(2) Nachträgliche Änderungswünsche des Auftraggebers (Change Requests) sind in Textform einzureichen und werden auf Basis der vereinbarten Tagessätze gesondert vergütet, sofern sie den ursprünglichen Aufwand um mehr als 10,0 % überschreiten. + +(3) Der Auftragnehmer ist berechtigt, nach vorheriger schriftlicher Zustimmung des Auftraggebers qualifizierte Nachunternehmer innerhalb der Europäischen Union einzusetzen. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 1 von 4 +=== [SEITE 2] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 2 von 4 + +§ 2 Vergütung, Urkalkulation und Zahlungsbedingungen + +(1) Für die in § 1 genannten Leistungen vereinbaren die Parteien einen Pauschalfestpreis in Höhe von 168.500,00 € netto zuzüglich der gesetzlichen Umsatzsteuer. + +2.1 Kalkulationsgrundlagen und Zuschläge + +(2) Der Auftragnehmer hinterlegt vor Vertragsbeginn eine verschlossene Urkalkulation beim Auftraggeber, in der sämtliche Einzelkosten der Teilleistungen (EKT) sowie der kalkulierte Zuschlag für Allgemeine Geschäftskosten (AGK) in Höhe von 14,5 % und Wagnis und Gewinn (WuG) in Höhe von 7,5 % offengelegt sind. + +(3) Abschlagsrechnungen sind innerhalb von 30 Kalendertagen nach Rechnungseingang ohne Abzug zur Zahlung fällig. Ein Skontoabzug wird nicht gewährt. + +(4) NEU: Preisanpassungsklausel — Bei einer Laufzeitverlängerung von mehr als 12 Monaten sind beide Parteien berechtigt, eine angemessene Anpassung der Vergütungssätze entsprechend dem Verbraucherpreisindex zu verlangen. + +§ 3 Termine und Vertragsstrafe bei Verzug + +(1) Die produktive Bereitstellung (Go-Live) der Gesamtlösung erfolgt spätestens am 15.11.2026. + +(2) Gerät der Auftragnehmer mit dem verbindlichen Fertigstellungstermin schuldhaft in Verzug, verwirkt er für jede vollendete Kalenderwoche des Verzugs eine Vertragsstrafe in Höhe von 0,3 % der Netto-Auftragssumme, maximal jedoch 3,0 % der Gesamtauftragssumme. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 2 von 4 +=== [SEITE 3] === +[[HF]] Rahmenvertrag IT- & Planungsleistungen · Gegenentwurf v2.0 (Stand: 18.04.2026) · Seite 3 von 4 + +§ 4 Gewährleistung und Haftung + +(1) Die Verjährungsfrist für Mängelansprüche beträgt 12 Monate ab schriftlicher Endabnahme durch den Auftraggeber. + +(2) Die Haftung der Parteien für leichte Fahrlässigkeit ist bei Verletzung wesentlicher Vertragspflichten (Kardinalpflichten) auf den vertragstypischen, vorhersehbaren Schaden, höchstens jedoch auf 250.000,00 € je Schadensfall begrenzt. + +§ 5 Geheimhaltung, KI-Nutzung und Datenschutz + +(1) Beide Vertragsparteien verpflichten sich, alle im Rahmen der Zusammenarbeit erlangten vertraulichen Informationen für einen Zeitraum von 3 Jahren nach Vertragsende vertraulich zu behandeln. + +(2) Die Verarbeitung personenbezogener Daten erfolgt ausschließlich auf Servern innerhalb der Europäischen Union gemäß dem beigefügten Auftragsverarbeitungsvertrag (Anlage 2). + +(3) NEU: Dem Auftragnehmer ist es gestattet, anonymisierte Projektdaten zur Weiterentwicklung und zum Training eigener KI-gestützter Kalkulationsmodelle zu verwenden. + +§ 6 Kündigung und Schlussbestimmungen + +(1) Dieser Vertrag kann von beiden Parteien mit einer Frist von 6 Monaten zum Jahresende ordentlich gekündigt werden. + +(2) Es gilt ausschließlich das Recht der Bundesrepublik Deutschland unter Ausschluss des UN-Kaufrechts. Ausschließlicher Gerichtsstand für alle Streitigkeiten aus diesem Vertrag ist München. + +[[HF]] Vertraulich · BauTech GmbH / Alpha Systems AG · Seite 3 von 4`, + }, + en: { + v1: `=== [SEITE 1] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 1 of 3 + +MASTER SOFTWARE & ENGINEERING SERVICES AGREEMENT + +Between Apex Construction Partners Ltd., 114 Fleet Street, London (hereinafter referred to as "Client") and Alpha Systems Engineering Inc., 35 Market Street, San Francisco (hereinafter referred to as "Contractor"). + +1. Scope of Services + +(1) The Contractor undertakes to deliver, configure, and document the turnkey cloud cost-estimation platform in accordance with the Statement of Work dated March 12, 2026 (Exhibit A). + +1.1 Core Platform Modules and Deliverables + +• Module A: Quantity takeoff and structured BOQ import (GAEB DA XML 3.3) +• Module B: Baseline cost calculation (Urkalkulation), unit cost breakdown, and overhead markup (Zuschlag) +• Module C: Standard reporting and PDF bid comparison export + +(2) Subsequent change requests by the Client must be submitted in writing and shall be billed separately at the agreed daily rates if they exceed the baseline effort by more than 5.0%. + +(3) The Contractor shall perform all services exclusively using its own permanent, full-time engineering staff. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 1 of 3 +=== [SEITE 2] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 2 of 3 + +2. Fees, Baseline Cost Calculation and Payment Terms + +(1) For the services specified in Section 1, the parties agree on a fixed lump-sum fee of EUR 145,000.00 net plus applicable statutory value-added tax. + +2.1 Cost Calculation Basis and Overhead Markups + +(2) Prior to commencement, the Contractor shall deposit a sealed baseline cost calculation (Urkalkulation) disclosing all direct unit costs as well as the overhead markup (Zuschlag) of 12.0% and profit margin of 6.0%. + +(3) Milestone invoices are due and payable without deduction within 14 calendar days of receipt. A 2.0% early-payment discount applies if paid within 7 calendar days. + +3. Delivery Schedule and Liquidated Damages + +(1) Production deployment (Go-Live) of the complete platform shall take place no later than September 30, 2026. + +(2) If the Contractor misses the binding completion date, liquidated damages of 0.5% of the net contract price shall apply per completed calendar week of delay, capped at 5.0% of the total contract value. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 2 of 3 +=== [SEITE 3] === +[[HF]] Master Software & Engineering Agreement · Draft v1.0 (March 12, 2026) · Page 3 of 3 + +4. Warranty and Limitation of Liability + +(1) The warranty period for defects is 24 months from written final acceptance by the Client. + +(2) Liability for slight negligence in the event of a breach of material contractual obligations is limited to foreseeable, typical damages up to a maximum of EUR 500,000.00 per incident. + +(3) Liability for lost profits or indirect consequential damages in cases of slight negligence is excluded. + +5. Confidentiality and Data Protection + +(1) Both parties undertake to keep all confidential information and trade secrets obtained during the collaboration strictly confidential without time limitation. + +(2) Personal data shall be processed exclusively on servers located within the European Union in accordance with the Data Processing Addendum (Exhibit B). + +6. Termination and Governing Law + +(1) Either party may terminate this Agreement for convenience with 3 months' prior written notice to the end of a calendar quarter. + +(2) This Agreement is governed by the laws of the Federal Republic of Germany. Exclusive venue for all disputes arising out of this Agreement is Berlin. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 3 of 3`, + v2: `=== [SEITE 1] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 1 of 4 + +MASTER SOFTWARE & ENGINEERING SERVICES AGREEMENT + +Between Apex Construction Partners Ltd., 114 Fleet Street, London (hereinafter referred to as "Client") and Alpha Systems Engineering Inc., 35 Market Street, San Francisco (hereinafter referred to as "Contractor"). + +1. Scope of Services + +(1) The Contractor undertakes to deliver, configure, train users on, and document the turnkey cloud cost-estimation platform in accordance with the updated Statement of Work dated April 18, 2026 (Exhibit A). + +1.1 Core Platform Modules and Deliverables + +• Module A: Quantity takeoff and structured BOQ import (GAEB DA XML 3.3 & BIM-IFC) +• Module B: Baseline cost calculation (Urkalkulation), unit cost breakdown, and overhead markup (Zuschlag) +• Module C: Real-time change order management, bid comparison, and automated target/actual variance analysis + +(2) Subsequent change requests by the Client may be submitted in text form and shall be billed separately at the agreed daily rates if they exceed the baseline effort by more than 10.0%. + +(3) The Contractor is entitled to engage qualified subcontractors within the European Union subject to the Client's prior written consent. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 1 of 4 +=== [SEITE 2] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 2 of 4 + +2. Fees, Baseline Cost Calculation and Payment Terms + +(1) For the services specified in Section 1, the parties agree on a fixed lump-sum fee of EUR 168,500.00 net plus applicable statutory value-added tax. + +2.1 Cost Calculation Basis and Overhead Markups + +(2) Prior to commencement, the Contractor shall deposit a sealed baseline cost calculation (Urkalkulation) disclosing all direct unit costs as well as the overhead markup (Zuschlag) of 14.5% and profit margin of 7.5%. + +(3) Milestone invoices are due and payable without deduction within 30 calendar days of receipt. Early-payment discounts are not granted. + +(4) NEW: Price Adjustment Clause — In the event of a term extension exceeding 12 months, either party may request an adjustment of daily rates aligned with the consumer price index. + +3. Delivery Schedule and Liquidated Damages + +(1) Production deployment (Go-Live) of the complete platform shall take place no later than November 15, 2026. + +(2) If the Contractor culpably misses the binding completion date, liquidated damages of 0.3% of the net contract price shall apply per completed calendar week of delay, capped at 3.0% of the total contract value. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 2 of 4 +=== [SEITE 3] === +[[HF]] Master Software & Engineering Agreement · Counter-Proposal v2.0 (April 18, 2026) · Page 3 of 4 + +4. Warranty and Limitation of Liability + +(1) The warranty period for defects is 12 months from written final acceptance by the Client. + +(2) Liability for slight negligence in the event of a breach of material contractual obligations is limited to foreseeable, typical damages up to a maximum of EUR 250,000.00 per incident. + +5. Confidentiality, AI Training and Data Protection + +(1) Both parties undertake to keep all confidential information obtained during the collaboration strictly confidential for a period of 3 years following termination. + +(2) Personal data shall be processed exclusively on servers located within the European Union in accordance with the Data Processing Addendum (Exhibit B). + +(3) NEW: The Contractor is permitted to use anonymized project telemetry and metadata to train and improve its proprietary AI estimation models. + +6. Termination and Governing Law + +(1) Either party may terminate this Agreement for convenience with 6 months' prior written notice to the end of a calendar year. + +(2) This Agreement is governed by the laws of the Federal Republic of Germany. Exclusive venue for all disputes arising out of this Agreement is Munich. + +[[HF]] Confidential · Apex Construction Ltd. / Alpha Systems Inc. · Page 3 of 4`, + }, +}; + +// The samples above are written in a readable notation ("=== [SEITE n] ===" page breaks, +// "[[HF]]" header/footer lines). Convert it to the internal document encoding +// (see DOC_PAGE_MARK / DOC_META_MARK in 04-core.js); user input never gets this treatment. +for (const lang of Object.keys(SAMPLES)) { + for (const version of ['v1', 'v2']) { + SAMPLES[lang][version] = SAMPLES[lang][version] + .replace(/^=== \[SEITE (\d+)\] ===$/gm, '\u001E$1') + .replace(/^\[\[HF\]\] /gm, '\u001Fhf\u001F'); + } +} + +// A page built for one language (/en/) says so with data-page-lang; otherwise follow the browser. +const pageLang = typeof document !== 'undefined' ? document.documentElement.getAttribute('data-page-lang') : null; +const initialLang = pageLang === 'en' || pageLang === 'de' + ? pageLang + : typeof navigator !== 'undefined' && + navigator.language && + navigator.language.toLowerCase().startsWith('de') + ? 'de' + : 'en'; diff --git a/src/js/03-state.js b/src/js/03-state.js new file mode 100644 index 0000000..5edc839 --- /dev/null +++ b/src/js/03-state.js @@ -0,0 +1,270 @@ +// Layout tiers: phone < 768 px ≤ tablet < 1200 px ≤ desktop. +const PHONE_MAX = 767; +const TABLET_MAX = 1199; +function isPhoneWidth() { + return typeof window !== 'undefined' && window.innerWidth <= PHONE_MAX; +} +function isTabletOrSmaller() { + return typeof window !== 'undefined' && window.innerWidth <= TABLET_MAX; +} +const isMacPlatform = + typeof navigator !== 'undefined' && + /Mac|iPhone|iPad|iPod/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''); + +const initialDark = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-color-scheme: dark)').matches; +if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', initialDark); +} + +// Phones and tablets in portrait start in one column. +const initialInline = + typeof window !== 'undefined' && (isPhoneWidth() || (isTabletOrSmaller() && window.innerHeight > window.innerWidth)); + +const state = { + lang: initialLang, + dark: initialDark, + themePref: 'system', // 'light' | 'dark' | 'system' + viewMode: initialInline ? 'inline' : 'split', // 'split' | 'inline' + viewModeChosen: false, + // null = automatic (on for documents with real pages: PDF / DOCX / the example) + pageBreaks: null, + leftText: '', + rightText: '', + leftName: '', + rightName: '', + leftFormat: '', + rightFormat: '', + alignedRows: [], + changeRows: [], + listItems: [], + hoveredRowId: null, + activeRowId: null, + onlyChanges: false, + ignoreHeadersFooters: true, + filteredHfCount: 0, + rightAllBlocks: [], + sidebarFilter: 'all', // kind: all | modified | inserted | deleted + statusFilter: 'all', // all | open | reviewed + sidebarOpen: true, + searchQuery: '', + isProgrammaticScroll: false, + // Review status per change (see 06d-review.js), kept in undo / redo snapshots. + reviews: {}, + undoStack: [], + redoStack: [], + singleKeyShortcuts: true, +}; + +function t(key) { + return I18N[state.lang][key] || I18N.de[key] || key; +} + +const paneLeft = document.getElementById('pane-left'); +const paneRight = document.getElementById('pane-right'); +const paneUnified = document.getElementById('pane-unified'); +const sheetLeft = document.getElementById('sheet-left'); +const sheetRight = document.getElementById('sheet-right'); +const sheetUnified = document.getElementById('sheet-unified'); +const sidebarList = document.getElementById('sidebar-list'); + +// Shows `text` in the custom tooltip (mouse hover and keyboard focus only). Icon-only buttons +// also get it as their accessible name; `name` overrides the accessible name. +function setTooltip(el, text, name) { + if (!el) return; + el.setAttribute('data-tooltip', text); + el.removeAttribute('title'); + if (name) { + el.setAttribute('aria-label', name); + } else if (el.matches('button, a') && !el.textContent.trim()) { + el.setAttribute('aria-label', text); + } +} + +// Ctrl / ⌘ according to the platform. +function modKeyLabel() { + return isMacPlatform ? '⌘' : t('kbdCtrl'); +} +function redoKeyLabel() { + return isMacPlatform ? '⇧⌘Z' : `${t('kbdCtrl')}+Y`; +} +function undoKeyLabel() { + return isMacPlatform ? '⌘Z' : `${t('kbdCtrl')}+Z`; +} + +function formatMsg(key, params = {}) { + return t(key).replace(/\{(\w+)\}/g, (_, k) => (params[k] !== undefined ? String(params[k]) : '')); +} + +// "1 Änderung" / "19 Änderungen" +function changesTitle(n) { + return n === 1 ? t('listTitleOne') : formatMsg('listTitle', { n }); +} + +// Polite announcements for screen readers (one shared live region, never chatty). +let srStatusTimer = null; +function announce(message) { + const el = document.getElementById('sr-status'); + if (!el) return; + clearTimeout(srStatusTimer); + el.textContent = ''; + srStatusTimer = setTimeout(() => { + el.textContent = message; + }, 60); +} + +function applyDataI18n() { + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.getAttribute('data-i18n')); + }); + document.querySelectorAll('[data-i18n-html]').forEach(el => { + el.innerHTML = t(el.getAttribute('data-i18n-html')); + }); + document.querySelectorAll('[data-i18n-label]').forEach(el => { + el.setAttribute('aria-label', t(el.getAttribute('data-i18n-label'))); + }); +} + +function applyTranslationsToStaticDom() { + document.documentElement.lang = state.lang; + document.title = t('metaTitle'); + applyDataI18n(); + const btnLang = document.getElementById('btn-lang'); + // Named by the language it switches to, in that language. + const target = state.lang === 'de' ? 'en' : 'de'; + btnLang.textContent = t('langTarget'); + btnLang.setAttribute('lang', target); + setTooltip(btnLang, t('langTitle')); + const menuLang = document.querySelector('#menu-lang [data-i18n="langMenu"]'); + if (menuLang) menuLang.setAttribute('lang', target); + setTooltip(document.getElementById('btn-theme'), t('themeTitle')); + setTooltip(document.getElementById('btn-prev'), t('prevTitle'), t('prevLabel')); + setTooltip(document.getElementById('btn-next'), t('nextTitle'), t('nextLabel')); + setTooltip(document.getElementById('btn-search'), formatMsg('searchTitle', { mod: modKeyLabel() }), t('searchLabel')); + setTooltip(document.getElementById('btn-search-prev'), t('searchPrev')); + setTooltip(document.getElementById('btn-search-next'), t('searchNext')); + setTooltip(document.getElementById('btn-search-close'), t('searchClose')); + setTooltip(document.getElementById('btn-more'), t('moreLabel')); + document.getElementById('search-input').placeholder = t('searchPlaceholder'); + document.getElementById('textarea-left').placeholder = t('modalLeftPh'); + document.getElementById('textarea-right').placeholder = t('modalRightPh'); + document.getElementById('i18n-help-body').innerHTML = formatMsg('helpBodyHtml', { + mod: modKeyLabel(), + redo: redoKeyLabel(), + }); + document.getElementById('i18n-privacy-body').innerHTML = t('privacyBodyHtml'); + updateFileLabels(); +} + +// One rule for every shortened file name: keep the part that tells the two files apart and +// the extension, cut in the middle. The second name of a pair drops the start both share: +// "werkvertrag_v…1.pdf → …2.pdf". +function truncateFileName(name, other, max, second = false) { + const chars = Array.from(String(name || '')); + if (chars.length <= max) return chars.join(''); + const o = Array.from(String(other || '')); + let d = 0; + while (d < chars.length && d < o.length && chars[d] === o[d]) d++; + const tail = chars.slice(d); + if (d > 4 && tail.length && tail.length + 1 <= max) { + if (second) return '…' + tail.join(''); + const room = max - 1 - tail.length; + return (room >= 4 ? chars.slice(0, room).join('') : '') + '…' + tail.join(''); + } + // Tight space: the start of the distinguishing part plus the extension ("…1_Vor….docx"). + const extTail = (/\.[A-Za-z0-9]{1,5}$/.exec(tail.join('')) || [''])[0]; + if (d > 4 && tail.length > extTail.length + 2 && max >= extTail.length + 5) { + const stem = tail.slice(0, tail.length - extTail.length); + return '…' + stem.slice(0, max - 2 - extTail.length).join('') + '…' + extTail; + } + // No shared start (or the distinguishing part is too long): keep the start and the end with + // the extension. + const ext = (/\.[A-Za-z0-9]{1,5}$/.exec(chars.join('')) || [''])[0]; + const keepEnd = Math.max(ext.length + 3, Math.ceil((max - 1) * 0.45)); + const keepStart = Math.max(1, max - 1 - keepEnd); + return chars.slice(0, keepStart).join('') + '…' + chars.slice(chars.length - keepEnd).join(''); +} + +// Narrow screens: only the parts that tell the two files apart, without the shared start (cut at +// a separator) and the shared extension: "Vertrag_v1_Vorher.docx" / "Vertrag_v2_Nachher.docx" +// become "v1_Vorher" / "v2_Nachher". Null when that does not give two short, different names. +function compactFilePair(left, right, max) { + const a = Array.from(String(left || '')); + const b = Array.from(String(right || '')); + const extA = (/\.[A-Za-z0-9]{1,5}$/.exec(left) || [''])[0]; + const extB = (/\.[A-Za-z0-9]{1,5}$/.exec(right) || [''])[0]; + const stemA = a.slice(0, a.length - (extA === extB ? extA.length : 0)); + const stemB = b.slice(0, b.length - (extA === extB ? extB.length : 0)); + let d = 0; + while (d < stemA.length && d < stemB.length && stemA[d] === stemB[d]) d++; + while (d > 0 && !/[\s_\-.]/.test(stemA[d - 1])) d--; + const outA = stemA.slice(d).join('').trim(); + const outB = stemB.slice(d).join('').trim(); + if (!outA || !outB || outA === outB) return null; + if (Array.from(outA).length > max || Array.from(outB).length > max) return null; + return [outA, outB]; +} + +// File names in the top bar button, its popover and the pane captions. +function updateFileLabels() { + const left = state.leftName || t('noFileLeft'); + const right = state.rightName || t('noFileRight'); + const max = window.innerWidth >= 1440 ? 34 : window.innerWidth >= 1200 ? 26 : 12; + const filesLeft = document.getElementById('files-left'); + const filesRight = document.getElementById('files-right'); + const compact = max <= 12 && state.leftName && state.rightName ? compactFilePair(left, right, 16) : null; + filesLeft.textContent = compact ? compact[0] : truncateFileName(left, right, max); + filesRight.textContent = compact ? compact[1] : truncateFileName(right, left, max, true); + // Pane names for screen readers: "Alte Fassung: Vertrag_v1.docx". + document.getElementById('pane-left').setAttribute('aria-label', `${t('colLeft')}: ${left}`); + document.getElementById('pane-right').setAttribute('aria-label', `${t('colRight')}: ${right}`); + document.getElementById('pane-unified').setAttribute('aria-label', `${t('paneUnifiedLabel')}: ${left} → ${right}`); + document.getElementById('files-label').textContent = formatMsg('filesBtnLabel', { left, right }); + document.getElementById('fp-name-left').textContent = left; + document.getElementById('fp-name-right').textContent = right; + const unified = document.getElementById('filename-unified'); + if (unified) unified.textContent = `${left} → ${right}`; + updateColumnMeta('left'); + updateColumnMeta('right'); +} + +function setViewMode(mode, opts = {}) { + if (mode !== 'split' && mode !== 'inline') mode = 'split'; + // Phones only show one column. + if (mode === 'split' && typeof isPhoneWidth === 'function' && isPhoneWidth()) mode = 'inline'; + const changed = state.viewMode !== mode; + // The change in view before switching stays in view afterwards. + const keepRowId = changed && document.body.classList.contains('is-comparing') ? state.activeRowId : null; + state.viewMode = mode; + if (opts.user) state.viewModeChosen = true; + document.body.classList.toggle('view-inline', mode === 'inline'); + document.body.classList.toggle('view-split', mode === 'split'); + setMenuChecked('btn-mode-split', mode === 'split'); + setMenuChecked('btn-mode-inline', mode === 'inline'); + if (typeof placeReviewBar === 'function') placeReviewBar(); + requestAnimationFrame(() => { + if (keepRowId && state.activeRowId === keepRowId) selectAndScrollToRow(keepRowId, false, true, { flash: false }); + if (typeof scheduleRulerDraw === 'function') scheduleRulerDraw(); + }); +} + +function setMenuChecked(id, on) { + const el = typeof id === 'string' ? document.getElementById(id) : id; + if (el) el.setAttribute('aria-checked', String(Boolean(on))); +} + +// Page breaks are shown by default for documents that have real pages (PDF, DOCX, the example). +function documentsHavePages() { + return Boolean(lastComparison && lastComparison.result && (lastComparison.result.leftPages > 1 || lastComparison.result.rightPages > 1)) && + (state.rightAllBlocks || []).some(b => b.hasExplicitPage); +} +function pageBreaksShown() { + return state.pageBreaks === null ? documentsHavePages() : state.pageBreaks; +} +function applyPageBreaks() { + const on = pageBreaksShown(); + document.body.classList.toggle('no-page-breaks', !on); + setMenuChecked('btn-page-breaks', on); +} diff --git a/src/js/04-core.js b/src/js/04-core.js new file mode 100644 index 0000000..dc52328 --- /dev/null +++ b/src/js/04-core.js @@ -0,0 +1,1119 @@ +/* ── Document text encoding ────────────────────────────────────────────── + * Loaded documents are kept as plain strings (state.leftText / state.rightText). + * Structural metadata that is NOT part of the document text — page breaks, + * header/footer lines, list nesting depth — is encoded with ASCII control + * characters that cannot occur in user-visible text: + * + * DOC_PAGE_MARK + n starts page n (own line) + * DOC_META_MARK + meta + DOC_META_MARK prefixes a line with metadata, + * meta = "hf" | "lvl=2" | "hf;lvl=1" + * + * Every external input (typed/pasted text, text files, PDF and DOCX text) is + * passed through sanitizeExternalText(), which strips these control characters, + * so document content can never be (mis)interpreted as metadata, and + * docTextToPlainText() removes the metadata before text is shown to the user. + * This file must stay free of DOM access (it also runs inside a Worker). + */ +const DOC_PAGE_MARK = '\u001E'; +const DOC_META_MARK = '\u001F'; + +function sanitizeExternalText(text) { + return String(text == null ? '' : text) + .replace(/\r\n?/g, '\n') + .replace(/\f/g, '\n') + .replace(/[\u0000-\u0008\u000B\u000E-\u001F\u007F]/g, ''); +} + +function pageMarkerLine(pageNum) { + return `${DOC_PAGE_MARK}${pageNum}`; +} + +function metaLine(meta, text) { + return meta ? `${DOC_META_MARK}${meta}${DOC_META_MARK}${text}` : text; +} + +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); +} + +function parseLineMeta(line) { + if (!line.startsWith(DOC_META_MARK)) return { meta: null, text: line }; + const end = line.indexOf(DOC_META_MARK, 1); + if (end < 0) return { meta: null, text: line.slice(1) }; + const meta = {}; + for (const part of line.slice(1, end).split(';')) { + const [key, value] = part.split('='); + if (key) meta[key.trim()] = value === undefined ? true : value.trim(); + } + return { meta, text: line.slice(end + 1) }; +} + +// Plain text for display/editing (paste dialog): page and header/footer metadata removed. +function docTextToPlainText(docText) { + if (!docText) return ''; + const out = []; + for (const rawLine of String(docText).split('\n')) { + if (rawLine.startsWith(DOC_PAGE_MARK)) continue; + out.push(parseLineMeta(rawLine).text); + } + return out + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/* ── Header / footer detection ─────────────────────────────────────────── */ + +// A line that is nothing but a page number label ("Seite 3 von 9", "- 3 -", "Page 2/7"). +function isPageNumberLabel(line) { + if (!line || line.length > 40) return false; + return /^(?:(?:Seite|Page|Blatt|S\.|p\.)\s*\d+(?:\s*(?:von|of|\/)\s*\d+)?|-\s*\d+\s*-|\d+\s*(?:\/|von|of)\s*\d+)$/i.test( + line.trim() + ); +} + +// A page number label without a word like "Seite"/"Page" ("3/9", "3 von 9", "- 3 -"): the same +// shapes occur as content (shares, table cells), so they need more evidence (see parseIntoBlocks). +function isBarePageNumberLabel(line) { + return isPageNumberLabel(line) && !/^\s*(?:Seite|Page|Blatt|S\.|p\.)/i.test(line); +} + +// Text-only header/footer candidate test (used for pasted text without page geometry). +// Pure page labels qualify on their own; longer running lines ("Vertraulich · Seite 2 von 9") +// are only treated as header/footer by parseIntoBlocks when they repeat within the document. +function isLikelyHeaderOrFooterLine(line) { + if (!line || line.length > 130) return false; + if (isPageNumberLabel(line)) return true; + if (/(?:\bSeite|\bPage)\s+\d+\s*(?:von|of|\/)\s*\d+\s*$/i.test(line)) return true; + if (/^(?:Vertraulich|Confidential|Stand:|Fassung|Entwurf|Draft)\b.*(?:\bSeite|\bPage)\s+\d+/i.test(line)) { + return true; + } + return false; +} + +// Key for "same running line on every page": page numbers and other digits normalised away. +function normalizeHfRepeatKey(text) { + return String(text).toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); +} + +// Key for comparing header/footer lines ACROSS versions: only page numbers are normalised, +// so a changed date or version label in a header is still detected as a difference. +function normalizeHfCompareKey(text) { + return String(text) + .toLowerCase() + .replace(/(?:\bseite|\bpage|\bblatt|\bs\.|\bp\.)\s*\d+(?:\s*(?:von|of|\/)\s*\d+)?/g, 'seite #') + .replace(/^\s*-\s*\d+\s*-\s*$/, '#') + .replace(/^\s*\d+\s*$/, '#') + .replace(/\b\d+\s*(?:\/|von|of)\s*\d+\s*$/, '#') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Decides which margin lines of a paginated document are running headers/footers. + * pages: array (one entry per page) of candidate lines { text, yPct } where yPct is the + * line's vertical position as a fraction of the page height (0 = bottom, 1 = top). + * A candidate is a header/footer only if its digit-normalised text occurs on at least + * 50% of the pages (and on at least 2 pages) at a similar vertical position. + * Returns an array of boolean arrays parallel to `pages`. + */ +function detectRepeatingMarginLines(pages, yTolerance = 0.03) { + const numPages = pages.length; + const result = pages.map(lines => lines.map(() => false)); + if (numPages < 2) return result; + const minPages = Math.max(2, Math.ceil(numPages * 0.5)); + + const groups = new Map(); + pages.forEach((lines, p) => { + lines.forEach((line, l) => { + if (!line || !line.text || !line.text.trim()) return; + const band = line.yPct >= 0.5 ? 'T' : 'B'; + const key = `${band}|${normalizeHfRepeatKey(line.text)}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push({ p, l, y: line.yPct }); + }); + }); + + for (const members of groups.values()) { + if (new Set(members.map(m => m.p)).size < minPages) continue; + // Cluster by vertical position: keep the members close to the median y. + const ys = members.map(m => m.y).sort((a, b) => a - b); + const median = ys[Math.floor(ys.length / 2)]; + const close = members.filter(m => Math.abs(m.y - median) <= yTolerance); + if (new Set(close.map(m => m.p)).size < minPages) continue; + for (const m of close) result[m.p][m.l] = true; + } + return result; +} + +// Position of a header/footer line on its page: 'T' before the page's first body line, +// 'B' after it; '' for documents without pages. Returns one entry per header/footer block. +function headerFooterSlots(blocks) { + const out = []; + let page = null; + let seenBody = false; + for (const b of blocks) { + if (b.pageNum !== page) { + page = b.pageNum; + seenBody = false; + } + if (!b.isHeaderFooter) { + seenBody = true; + continue; + } + out.push({ block: b, band: b.hasExplicitPage ? (seenBody ? 'B' : 'T') : '' }); + } + return out; +} + +// Longest common subsequence of two token arrays: returns [matchedA, matchedB] as boolean +// arrays. Uses jsdiff's diffArrays (Myers) when available; otherwise a multiset fallback that +// only keeps tokens hidden when both versions contain them equally often. +function matchTokenSequences(a, b) { + const matchedA = new Array(a.length).fill(false); + const matchedB = new Array(b.length).fill(false); + const diffLib = getDiffLibrary(); + if (diffLib && typeof diffLib.diffArrays === 'function') { + let i = 0; + let j = 0; + for (const part of diffLib.diffArrays(a, b)) { + const n = part.count !== undefined ? part.count : part.value.length; + if (part.added) j += n; + else if (part.removed) i += n; + else { + for (let k = 0; k < n; k++) { + matchedA[i++] = true; + matchedB[j++] = true; + } + } + } + return [matchedA, matchedB]; + } + const count = arr => arr.reduce((m, t) => m.set(t, (m.get(t) || 0) + 1), new Map()); + const ca = count(a); + const cb = count(b); + a.forEach((t, k) => { matchedA[k] = ca.get(t) === cb.get(t); }); + b.forEach((t, k) => { matchedB[k] = ca.get(t) === cb.get(t); }); + return [matchedA, matchedB]; +} + +/** + * Change-preserving header/footer filter. Hiding running headers/footers must never hide a + * change, so the hidden candidates of both versions are matched against each other as + * sequences of (band, text) tokens in document order, with page numbers normalised away + * (normalizeHfCompareKey). Matching in order pairs the occurrences page by page, also when + * pages were inserted or removed. Only matched occurrences stay hidden; every unmatched one + * (a header changed on one page, a footer changed on all pages, a repeated line deleted on + * one page, an extra occurrence) is made visible at that occurrence (flag `hfVisible`) and + * diffed like body text. + * Exceptions, which stay hidden: pure page-number labels ("Seite 3 von 9": they change + * whenever the page count changes, not with the content), and the running lines of a PDF + * compared with a version that has no pages and no header/footer lines at all (DOCX, pasted + * text): there is nothing to compare them with, they are layout, not a change. + * Mutates the blocks; returns the number of header/footer blocks that remain hidden. + */ +function reconcileHeaderFooterBlocks(leftBlocks, rightBlocks) { + const hfL = leftBlocks.filter(b => b.isHeaderFooter); + const hfR = rightBlocks.filter(b => b.isHeaderFooter); + for (const b of hfL.concat(hfR)) delete b.hfVisible; + if (leftBlocks.length && rightBlocks.length && (hfL.length || hfR.length)) { + const paginated = blocks => blocks.some(b => b.hasExplicitPage); + const comparable = paginated(leftBlocks) === paginated(rightBlocks) || (hfL.length > 0 && hfR.length > 0); + if (comparable) { + const candidates = blocks => + headerFooterSlots(blocks).filter(slot => !isPageNumberLabel(slot.block.text)); + const slotsL = candidates(leftBlocks); + const slotsR = candidates(rightBlocks); + const token = slot => `${slot.band}|${normalizeHfCompareKey(slot.block.text)}`; + const [matchedL, matchedR] = matchTokenSequences(slotsL.map(token), slotsR.map(token)); + slotsL.forEach((slot, k) => { if (!matchedL[k]) slot.block.hfVisible = true; }); + slotsR.forEach((slot, k) => { if (!matchedR[k]) slot.block.hfVisible = true; }); + } + } + return [...hfL, ...hfR].filter(b => !b.hfVisible).length; +} + +function isHiddenHeaderFooter(block) { + return Boolean(block && block.isHeaderFooter && !block.hfVisible); +} + +/* ── Block classification ──────────────────────────────────────────────── */ + +// An ordinary sentence is never a heading, whatever it starts with ("§ 2 Der Auftragnehmer +// erbringt die Leistung fristgerecht."): it ends with sentence punctuation and has several +// words, or it is simply too long for a heading. +function looksLikeSentence(line) { + const words = line.split(/\s+/).length; + return (/[.;:,!?]["“”)]?$/.test(line) && words >= 6) || words >= 14; +} + +function classifyBlockKind(line, isFirstContentBlock, isHeaderFooter) { + if (isHeaderFooter) return 'hf'; + if (/^#\s+/.test(line)) return 'h1'; + if (/^##\s+/.test(line)) return 'h2'; + if (/^###\s+/.test(line)) return 'h3'; + if (looksLikeSentence(line)) return classifyBodyKind(line); + + if ( + isFirstContentBlock && + line.length < 105 && + !/^(§|\(\d+\)|\d+\.|[•‣▪◦–*-]\s)/.test(line) && + (/^[A-ZÄÖÜ0-9\s,&/-]{10,}$/.test(line) || !/[.:;]$/.test(line)) + ) { + return 'h1'; + } + + if ( + /^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i.test(line) && + line.length < 115 + ) { + return 'h2'; + } + + // "1. Haftung" is a heading; "1. Der Auftragnehmer haftet …." is a numbered paragraph. + if (/^\d+\.\s+[A-ZÄÖÜ]/.test(line) && line.length < 115 && !/[.;:,]$/.test(line)) { + return 'h2'; + } + + if ( + /^(\d+\.\d+(?:\.\d+)*)\s+[A-ZÄÖÜa-zäöü]/.test(line) && + line.length < 105 && + !/[.;]$/.test(line) + ) { + return 'h3'; + } + + if (/^[A-ZÄÖÜ\s&-]{10,}$/.test(line) && line.length < 105) { + return isFirstContentBlock ? 'h1' : 'h2'; + } + + return classifyBodyKind(line); +} + +function classifyBodyKind(line) { + if (/^[•‣▪◦–*-]\s+\S/.test(line)) { + return 'bullet'; + } + + if (/^(\(\d+\)|\([a-z]\)|\([ivx]+\)|\d+\)|[a-z]\))\s+\S/i.test(line)) { + return 'clause'; + } + + return 'p'; +} + +function extractListMarker(text, kind) { + if (!text) return null; + if (kind === 'bullet') { + const m = text.match(/^([•‣▪◦–*-])\s+(.*)$/s); + if (m) return { marker: '•', body: m[2] }; + } + if (kind === 'clause') { + const m = text.match(/^(\(\d+\)|\([a-z]\)|\([ivx]+\)|\d+\)|[a-z]\))\s+(.*)$/is); + if (m) return { marker: m[1], body: m[2] }; + } + return null; +} + +// Numbering prefix that only reflects the position in the document (list marker or +// heading number). Used to align blocks independent of renumbering. Returns null when +// the block has no such marker (or the remaining body would be too short to identify it). +function splitNumberingMarker(text, kind) { + let m = null; + if (kind === 'clause' || kind === 'bullet') { + const split = extractListMarker(text, kind); + if (split) m = [null, kind === 'bullet' ? text.match(/^\S+/)[0] : split.marker, split.body]; + } else if (kind === 'h2' || kind === 'h3') { + m = text.match(/^(§\s*\d+[a-z]?|Artikel\s+\d+|\d+(?:\.\d+)+\.?|\d+\.)\s+([A-ZÄÖÜ].*)$/s); + } + if (!m) return null; + const body = m[2].trim(); + if (body.length < 3) return null; + return { marker: m[1], body }; +} + +function extractClauseRef(line, fallbackIndex, currentSectionRef = '', kind = 'p') { + if (kind === 'hf') return t('typeHf'); + const secMatch = line.match(/^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i); + if (secMatch) { + return secMatch[1].replace(/\s+/g, ' ').trim(); + } + const numHeadingMatch = line.match(/^(\d+\.)\s+[A-ZÄÖÜ]/); + if (numHeadingMatch && line.length < 115) { + return numHeadingMatch[1]; + } + const subHeadingMatch = line.match(/^(\d+\.\d+(?:\.\d+)*)\s+/); + if (subHeadingMatch && line.length < 115) { + return subHeadingMatch[1]; + } + const subMatch = line.match(/^(\(\d+\)|\([a-z]\)|\d+(?:\.\d+)+|\d+\)|[a-z]\))/i); + if (subMatch) { + const sub = subMatch[1]; + return currentSectionRef ? `${currentSectionRef} ${sub}` : sub; + } + if (kind === 'bullet' && currentSectionRef) { + return `${currentSectionRef} •`; + } + return currentSectionRef ? `${currentSectionRef} #${fallbackIndex}` : `#${fallbackIndex}`; +} + +// Words for the similarity measure: lower case, with leading/trailing punctuation removed, so +// "Services" and "Services." (or "(Zuschlag)" and "Zuschlag") count as the same word. +function tokenizeWords(str) { + const words = str + .toLowerCase() + .split(/\s+/) + .map(w => w.replace(/^[^\p{L}\p{N}§€$%]+|[^\p{L}\p{N}§€$%]+$/gu, '') || w) + .filter(Boolean); + const counts = new Map(); + for (const w of words) counts.set(w, (counts.get(w) || 0) + 1); + return { words, set: new Set(words), counts }; +} + +/* ── PDF line joining ──────────────────────────────────────────────────── */ + +// Words after which a trailing hyphen is a genuine elision ("Vertrags- und Zahlungs…"). +const HYPHEN_ELISION_NEXT = /^(?:und|oder|bzw\.?|sowie|bis|als|wie|and|or|nor|to)\b/; + +// Joins a wrapped PDF line onto the paragraph text, undoing typographic hyphenation: +// "Vertrags-" + "strafenregelung" -> "Vertragsstrafenregelung" (lowercase continuation), +// "Ost-" + "West-Verbindung" -> "Ost-West-Verbindung" (hyphen kept, no space), +// "Vertrags-" + "und Zahlungsbedingungen" -> unchanged with a space. +function joinWrappedLine(prev, next) { + if (!prev) return next; + if (!next) return prev; + if (/[A-Za-zÄÖÜäöüß]­$/.test(prev)) return prev.slice(0, -1) + next; + if (/[A-Za-zÄÖÜäöüß]-$/.test(prev) && !/\s-$/.test(prev)) { + if (/^[a-zäöüß]/.test(next) && !HYPHEN_ELISION_NEXT.test(next)) return prev.slice(0, -1) + next; + if (/^[A-ZÄÖÜ]/.test(next)) return prev + next; + } + return `${prev} ${next}`; +} + +/* ── PDF text layout (pure; fed with PDF.js getTextContent() items) ──────── */ + +// Fraction of the page height at the top and bottom that may hold running headers/footers. +const PDF_MARGIN_BAND = 0.12; + +// List bullets drawn with symbol fonts come out of PDF.js as private-use code points +// (Symbol U+F0B7, OpenSymbol U+F095, Wingdings ▪ ➢ ❖ ● ■). At the start of a line they are +// shown as "•" so the line is recognised as a bullet item, as in the DOCX version. Other +// symbol glyphs (e.g. checked / unchecked boxes) are left untouched. +const PDF_SYMBOL_BULLET = /^[\uF0B7\uF095\uF0A7\uF0D8\uF076\uF06C\uF06E]\s*(?=\S)/; + +/** + * Groups PDF.js text items of one page into lines. + * Spaces are inserted from geometry (gap between the end of one item and the start of the + * next, relative to the font size) and from `hasEOL`, not between every item, so a font + * change inside a word ("Ge|samt|preis") does not split the word. + * pageView: [xMin, yMin, xMax, yMax] (PDF.js page.view). + * Returns [{ text, y, yPct, fontSize, gapBefore }] in reading order. + */ +function buildPdfPageLines(items, pageView) { + const view = pageView && pageView.length === 4 ? pageView : [0, 0, 595, 842]; + const yMin = view[1]; + const height = view[3] - view[1] || 842; + const lines = []; + let cur = null; + + for (const item of items || []) { + if (!item || typeof item.str !== 'string') continue; + const tr = item.transform || [1, 0, 0, 1, 0, 0]; + const fontSize = Math.hypot(tr[2], tr[3]) || Math.hypot(tr[0], tr[1]) || 10; + const x = tr[4]; + const y = tr[5]; + const str = sanitizeExternalText(item.str).replace(/\n/g, ' '); + if (!str) { + if (item.hasEOL && cur) cur.eol = true; + continue; + } + const hasWidth = typeof item.width === 'number' && item.width > 0; + const isNewLine = !cur || Math.abs(y - cur.y) > Math.max(2, 0.5 * Math.min(fontSize, cur.fontSize)); + if (isNewLine) { + if (cur) lines.push(cur); + cur = { text: str, y, fontSize, endX: hasWidth ? x + item.width : null, eol: Boolean(item.hasEOL) }; + continue; + } + let needSpace; + if (cur.eol) { + needSpace = true; + } else if (cur.endX === null || !hasWidth) { + needSpace = true; // no geometry: keep the conservative behaviour + } else { + const gap = x - cur.endX; + needSpace = gap > fontSize * 0.18 || gap < -fontSize * 0.5; + } + if (/\s$/.test(cur.text) || /^\s/.test(str)) needSpace = false; + cur.text += (needSpace ? ' ' : '') + str; + cur.endX = hasWidth ? x + item.width : null; + cur.eol = Boolean(item.hasEOL); + } + if (cur) lines.push(cur); + + const out = lines + .map(l => ({ + text: l.text.replace(/\s+/g, ' ').trim().replace(PDF_SYMBOL_BULLET, '• '), + y: l.y, + yPct: (l.y - yMin) / height, + fontSize: l.fontSize, + gapBefore: false, + })) + .filter(l => l.text); + + // Paragraph breaks: a vertical step clearly larger than the line step. The line step is + // the small end of this page's step distribution, capped at 1.45 × font size so pages + // consisting only of one-line paragraphs still get their breaks. + const steps = []; + for (let i = 1; i < out.length; i++) { + const dy = out[i - 1].y - out[i].y; + if (dy > 0) steps.push(dy); + } + steps.sort((a, b) => a - b); + const sizes = out.map(l => l.fontSize).sort((a, b) => a - b); + const bodySize = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 10; + const lineStep = Math.min(steps.length ? steps[Math.floor(steps.length * 0.1)] : bodySize * 1.2, bodySize * 1.45); + for (let i = 1; i < out.length; i++) { + const dy = out[i - 1].y - out[i].y; + out[i].gapBefore = dy > lineStep * 1.25 || dy < -out[i].fontSize; // upward jump = new column/block + } + return out; +} + +// Numbering such as "4." or "3.2" must be followed by whitespace; amounts ("145.000,00", +// "1.000 Stück") and dates ("12.03.2026") at the start of a wrapped line are not numbering. +const PDF_STRUCTURAL_START = + /^(§\s*\d+|\(\d+\)|(?!\d{1,3}(?:\.\d{3})+(?:,\d+)?\s|\d{1,2}\.\d{1,2}\.\d{2,4}\b)\d+\.(?:\d+\.)*\d*\s|[•‣▪◦–*-]\s|\([a-z]\)|[a-z]\)|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i; + +// Does `next` (first body line of a page) continue the paragraph `prev` from the previous page? +// A capitalised first word (German nouns) also continues when the previous page ended in the +// middle of a long paragraph of the same font size; a short unpunctuated line (heading) does not. +function continuesAcrossPage(prev, next, prevSize, nextSize) { + if (/[A-Za-zÄÖÜäöüß][-­]$/.test(prev)) return true; + if (/[.:;!?)"“”]$/.test(prev)) return false; + if (/^[a-zäöüß]/.test(next)) return true; + return ( + /^[A-ZÄÖÜ]/.test(next) && + prev.length >= 60 && + typeof prevSize === 'number' && + typeof nextSize === 'number' && + Math.abs(prevSize - nextSize) < 0.5 + ); +} + +/** + * Builds the encoded document text from per-page lines (see buildPdfPageLines). + * Running headers/footers are detected by repetition across pages and emitted as + * header/footer lines; wrapped lines are joined (with de-hyphenation) into paragraphs, + * also across page breaks. Returns { text, emptyPages } (1-based page numbers without text). + */ +function assemblePdfText(pages) { + const candidates = pages.map(lines => + lines.map(l => (l.yPct >= 1 - PDF_MARGIN_BAND || l.yPct <= PDF_MARGIN_BAND ? l : null)) + ); + const hfFlags = detectRepeatingMarginLines(candidates); + + const headers = pages.map(() => []); + const footers = pages.map(() => []); + const paragraphs = pages.map(() => []); + let para = null; + const flush = () => { + if (para) paragraphs[para.page].push(para.text); + para = null; + }; + + pages.forEach((lines, p) => { + let firstBodyLine = true; + lines.forEach((line, i) => { + if (hfFlags[p][i]) { + (line.yPct >= 0.5 ? headers : footers)[p].push(line.text); + return; + } + const structural = PDF_STRUCTURAL_START.test(line.text); + let newPara; + if (!para) newPara = true; + else if (para.page !== p && firstBodyLine) { + newPara = structural || !continuesAcrossPage(para.text, line.text, para.fontSize, line.fontSize); + } else newPara = line.gapBefore || structural; + firstBodyLine = false; + if (newPara) { + flush(); + para = { page: p, text: line.text, fontSize: line.fontSize }; + } else { + para.text = joinWrappedLine(para.text, line.text); + para.fontSize = line.fontSize; + } + }); + }); + flush(); + + const out = []; + const emptyPages = []; + pages.forEach((lines, p) => { + if (!lines.length) emptyPages.push(p + 1); + out.push(pageMarkerLine(p + 1)); + for (const h of headers[p]) out.push(metaLine('hf', h)); + for (const text of paragraphs[p]) out.push(text); + for (const f of footers[p]) out.push(metaLine('hf', f)); + }); + return { text: out.join('\n\n'), emptyPages }; +} + +/* ── Parsing ───────────────────────────────────────────────────────────── */ + +const LATIN_LIGATURES = { '\uFB00': 'ff', '\uFB01': 'fi', '\uFB02': 'fl', '\uFB03': 'ffi', '\uFB04': 'ffl', '\uFB05': 'st', '\uFB06': 'st' }; + +/** + * Text normalisation applied to both documents before they are compared, so that differences + * a reader cannot see are not reported as changes: + * - Unicode NFC: "Größe" with a precomposed "ö" equals "Größe" (decomposed, common in PDFs + * and text from macOS); canonically equivalent strings are the same text by definition; + * - Latin ligatures from PDFs ("fi" -> "fi"); + * - no-break, narrow no-break and other fixed-width spaces -> plain space; + * - invisible characters removed: soft hyphen, zero-width space, word joiner, BOM; + * - CR / CRLF and Unicode line/paragraph separators -> "\n". + * Visible characters are never changed (no NFKC: "m²" stays "m²", "½" stays "½"). + * Line-end soft hyphens in PDFs are resolved earlier (joinWrappedLine), before this runs. + */ +function normalizeComparableText(text) { + return text + .replace(/\r\n?|[\u2028\u2029\u0085]/g, '\n') + .normalize('NFC') + .replace(/[\uFB00-\uFB06]/g, ch => LATIN_LIGATURES[ch]) + .replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ') + .replace(/[\u00AD\u200B\u2060\uFEFF]/g, ''); +} + +function parseIntoBlocks(rawText) { + const normalized = normalizeComparableText(String(rawText || '')).trim(); + if (!normalized) return []; + + const rawLines = normalized.split(/\n+/); + const entries = []; + let currentPage = 1; + let hasExplicitPageMarkers = false; + + for (let i = 0; i < rawLines.length; i++) { + const rawLine = rawLines[i].trim(); + if (!rawLine) continue; + + if (rawLine.startsWith(DOC_PAGE_MARK)) { + const n = parseInt(rawLine.slice(DOC_PAGE_MARK.length), 10); + currentPage = Math.max(1, Number.isFinite(n) ? n : currentPage + 1); + hasExplicitPageMarkers = true; + continue; + } + + const { meta, text } = parseLineMeta(rawLine); + const line = text.trim(); + if (!line) continue; + entries.push({ + line, + page: currentPage, + hfMarked: Boolean(meta && meta.hf), + indent: meta && meta.lvl ? Math.max(0, Math.min(8, parseInt(meta.lvl, 10) || 0)) : 0, + }); + } + + // Text-only header/footer candidates count only when they repeat within the document. + const repeatCounts = new Map(); + for (const e of entries) { + if (!e.hfMarked && isLikelyHeaderOrFooterLine(e.line)) { + const key = normalizeHfRepeatKey(e.line); + repeatCounts.set(key, (repeatCounts.get(key) || 0) + 1); + } + } + + // Bare number labels ("2/3", "3 von 9", "- 4 -") also occur as content (a table cell "2/3", + // a share). Unless the PDF reader already marked them as running footer lines (by position + // and repetition), they are only page numbers when they form a running sequence: 2+ labels + // of the same shape with increasing numbers. Otherwise they are compared like any other line. + const barePageLabelKeys = new Set(); + const firstNums = new Map(); + for (const e of entries) { + if (e.hfMarked || !isBarePageNumberLabel(e.line)) continue; + const key = normalizeHfRepeatKey(e.line); + if (!firstNums.has(key)) firstNums.set(key, []); + firstNums.get(key).push(parseInt(e.line.match(/\d+/)[0], 10)); + } + for (const [key, nums] of firstNums) { + if (nums.length >= 2 && nums.every((n, i) => i === 0 || n > nums[i - 1])) barePageLabelKeys.add(key); + } + const isPageLabel = line => + isPageNumberLabel(line) && (!isBarePageNumberLabel(line) || barePageLabelKeys.has(normalizeHfRepeatKey(line))); + + const blocks = []; + let currentSectionRef = ''; + let seenContentBlock = false; + + for (const entry of entries) { + let line = entry.line; + const isHeaderFooter = + entry.hfMarked || + isPageLabel(line) || + (isLikelyHeaderOrFooterLine(line) && + (!isBarePageNumberLabel(line) || isPageLabel(line)) && + (repeatCounts.get(normalizeHfRepeatKey(line)) || 0) >= 2); + + const kind = classifyBlockKind(line, !seenContentBlock && !isHeaderFooter, isHeaderFooter); + if (!isHeaderFooter) { + seenContentBlock = true; + line = line.replace(/^#{1,3}\s+/, '').trim(); + if (!line) continue; + } + + const isHeading = kind === 'h1' || kind === 'h2' || kind === 'h3'; + + // The section reference ("1." / "§ 5") follows the numbering in the text, not the derived + // block kind: an edit that turns "1. Scope of Services" into a paragraph must not change + // the references (and thus the alignment) of every clause below it. + if (kind === 'h2' || kind === 'p' || kind === 'h1') { + const secMatch = line.match(/^(§\s*\d+[a-z]?|Artikel\s+\d+|Anlage\s+\d+|Section\s+\d+|Exhibit\s+[A-Z0-9]+)/i); + const numMatch = line.match(/^(\d+\.)\s+[A-ZÄÖÜ]/); + if (secMatch) { + currentSectionRef = secMatch[1].replace(/\s+/g, ' ').trim(); + } else if (numMatch) { + currentSectionRef = numMatch[1]; + } + } + + const idx = blocks.length + 1; + const numbering = isHeaderFooter ? null : splitNumberingMarker(line, kind); + const alignText = numbering ? numbering.body : line; + const { words, set, counts } = tokenizeWords(alignText); + blocks.push({ + index: idx, + pageNum: entry.page, + hasExplicitPage: hasExplicitPageMarkers, + refCode: extractClauseRef(line, idx, currentSectionRef, kind), + sectionRef: currentSectionRef, + text: line, + kind, + indent: entry.indent, + isHeading, + isHeaderFooter, + numberingMarker: numbering ? numbering.marker : '', + // Whitespace and the table-cell separator " · " (DOCX/HTML intake; a PDF has spaces there) do not + // make two paragraphs different: such rows are shown as unchanged, like renumbering. + alignKey: alignText.replace(/\s+/g, ' ').replace(/ \u00B7 /g, ' '), + words, + wordSet: set, + wordCounts: counts, + }); + } + + // If document had no explicit page markers (e.g. pasted text or flat DOCX), paginate cleanly by section/length + if (!hasExplicitPageMarkers && blocks.length > 0) { + let autoPage = 1; + let blocksOnCurrentPage = 0; + let charsOnCurrentPage = 0; + for (let i = 0; i < blocks.length; i++) { + const b = blocks[i]; + const startsNewMajorSection = + b.kind === 'h2' && blocksOnCurrentPage >= 5 && charsOnCurrentPage >= 650; + const exceedsPageCapacity = blocksOnCurrentPage >= 10 || charsOnCurrentPage >= 1850; + if (i > 0 && (startsNewMajorSection || exceedsPageCapacity)) { + autoPage++; + blocksOnCurrentPage = 0; + charsOnCurrentPage = 0; + } + b.pageNum = autoPage; + blocksOnCurrentPage++; + charsOnCurrentPage += b.text.length; + } + } + + return blocks; +} + +/** + * Parses both documents and applies the header/footer filter. + * Returns all blocks, the blocks to compare, and the number of hidden header/footer lines. + */ +function prepareComparisonBlocks(leftText, rightText, ignoreHeadersFooters) { + const allLeftBlocks = parseIntoBlocks(leftText); + const allRightBlocks = parseIntoBlocks(rightText); + const hfCount = reconcileHeaderFooterBlocks(allLeftBlocks, allRightBlocks); + const keep = b => !(ignoreHeadersFooters && isHiddenHeaderFooter(b)); + return { + allLeftBlocks, + allRightBlocks, + leftBlocks: allLeftBlocks.filter(keep), + rightBlocks: allRightBlocks.filter(keep), + hfCount, + }; +} + +/* ── Alignment ─────────────────────────────────────────────────────────── */ + +function blockAlignKey(block) { + return block.alignKey !== undefined ? block.alignKey : block.text; +} + +function blockWordCounts(block) { + if (!block.wordCounts) block.wordCounts = tokenizeWords(blockAlignKey(block)).counts; + return block.wordCounts; +} + +function computeBlockSimilarity(blockA, blockB) { + if (blockA.isHeaderFooter !== blockB.isHeaderFooter) return 0; + if (blockA.text === blockB.text || blockAlignKey(blockA) === blockAlignKey(blockB)) return 1.0; + // The block kind is re-derived from the text, so it may change with an edit ("1. Scope of + // Services" + a sentence is no longer a heading). Kind alone never prevents pairing; a short + // heading and a long paragraph are kept apart by the length bound below. + const lenA = blockA.words.length; + const lenB = blockB.words.length; + if (!lenA || !lenB) return 0; + + const sameSpecificRef = + blockA.refCode && + blockA.refCode === blockB.refCode && + !blockA.refCode.startsWith('#') && + !blockA.refCode.includes('#'); + + const maxPossible = (2.0 * Math.min(lenA, lenB)) / (lenA + lenB); + if (maxPossible < 0.28 && !sameSpecificRef) return 0; + + // Multiset intersection: every occurrence of a word can be matched at most once. + const countsA = blockWordCounts(blockA); + const countsB = blockWordCounts(blockB); + const [small, large] = countsA.size <= countsB.size ? [countsA, countsB] : [countsB, countsA]; + let intersection = 0; + for (const [word, n] of small) { + const m = large.get(word); + if (m) intersection += Math.min(n, m); + } + const wordSim = (2.0 * intersection) / (lenA + lenB); + return sameSpecificRef ? Math.max(wordSim, 0.42) : wordSim; +} + +// Minimum length for a deleted/inserted pair to be reported as a move. +const MIN_MOVE_TEXT_LENGTH = 12; + +// Pairs unmatched deleted and inserted blocks with identical text (ignoring whitespace +// and renumbering) and flags them as moved. Rows keep type 'deleted' / 'inserted', so every +// consumer that switches on row.type keeps working; `moved` and `movedToRowId` / +// `movedFromRowId` describe the relocation. +function markMovedRows(rows) { + const deletedByKey = new Map(); + for (const row of rows) { + if (row.type !== 'deleted' || row.leftBlock.isHeaderFooter) continue; + const key = blockAlignKey(row.leftBlock).trim(); + if (key.length < MIN_MOVE_TEXT_LENGTH) continue; + if (!deletedByKey.has(key)) deletedByKey.set(key, []); + deletedByKey.get(key).push(row); + } + for (const row of rows) { + if (row.type !== 'inserted' || row.rightBlock.isHeaderFooter) continue; + const candidates = deletedByKey.get(blockAlignKey(row.rightBlock).trim()); + if (!candidates || !candidates.length) continue; + const from = candidates.shift(); + from.moved = true; + from.movedToRowId = row.id; + row.moved = true; + row.movedFromRowId = from.id; + } + return rows; +} + +function alignDocumentBlocks(leftBlocks, rightBlocks, onProgress) { + const totalN = leftBlocks.length; + const totalM = rightBlocks.length; + if (totalN === 0 && totalM === 0) return []; + + const sameKey = (a, b) => a.text === b.text || blockAlignKey(a) === blockAlignKey(b); + + let prefixLen = 0; + while ( + prefixLen < totalN && + prefixLen < totalM && + sameKey(leftBlocks[prefixLen], rightBlocks[prefixLen]) + ) { + prefixLen++; + } + + let suffixLen = 0; + while ( + suffixLen < totalN - prefixLen && + suffixLen < totalM - prefixLen && + sameKey(leftBlocks[totalN - 1 - suffixLen], rightBlocks[totalM - 1 - suffixLen]) + ) { + suffixLen++; + } + + const midLeft = leftBlocks.slice(prefixLen, totalN - suffixLen); + const midRight = rightBlocks.slice(prefixLen, totalM - suffixLen); + const n = midLeft.length; + const m = midRight.length; + + const rawPairs = []; + for (let p = 0; p < prefixLen; p++) { + rawPairs.push({ left: leftBlocks[p], right: rightBlocks[p] }); + } + + if (n > 0 || m > 0) { + for (const pair of alignMiddleBlocks(midLeft, midRight, onProgress)) rawPairs.push(pair); + } + + for (let s = 0; s < suffixLen; s++) { + rawPairs.push({ + left: leftBlocks[totalN - suffixLen + s], + right: rightBlocks[totalM - suffixLen + s], + }); + } + + let monotonicPage = 1; + const rows = rawPairs.map((pair, idx) => { + const rawPage = + (pair.right && pair.right.pageNum) || + (pair.left && pair.left.pageNum) || + monotonicPage; + monotonicPage = Math.max(monotonicPage, rawPage); + return buildAlignedRow(pair, idx, monotonicPage); + }); + + return markMovedRows(rows); +} + +function buildAlignedRow(pair, idx, pageNum) { + const rowId = `row-${idx + 1}`; + if (pair.left && pair.right) { + if (pair.left.text === pair.right.text) { + return { + id: rowId, + rowNumber: idx + 1, + pageNum, + type: 'equal', + leftBlock: pair.left, + rightBlock: pair.right, + wordDiffs: [{ value: pair.left.text }], + }; + } + if (blockAlignKey(pair.left) === blockAlignKey(pair.right) && pair.left.kind === pair.right.kind) { + // Only the list marker / heading number differs (renumbering). Such rows are not + // counted as changes; the renderer shows the old and new marker in a muted style. + return { + id: rowId, + rowNumber: idx + 1, + pageNum, + type: 'equal', + markerOnly: true, + oldMarker: pair.left.numberingMarker, + newMarker: pair.right.numberingMarker, + leftBlock: pair.left, + rightBlock: pair.right, + wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text), + }; + } + return { + id: rowId, + rowNumber: idx + 1, + pageNum, + type: 'modified', + leftBlock: pair.left, + rightBlock: pair.right, + wordDiffs: computeCleanWordDiff(pair.left.text, pair.right.text), + }; + } + if (pair.left && !pair.right) { + return { + id: rowId, + rowNumber: idx + 1, + pageNum, + type: 'deleted', + leftBlock: pair.left, + rightBlock: null, + wordDiffs: [{ value: pair.left.text, removed: true }], + }; + } + return { + id: rowId, + rowNumber: idx + 1, + pageNum, + type: 'inserted', + leftBlock: null, + rightBlock: pair.right, + wordDiffs: [{ value: pair.right.text, added: true }], + }; +} + +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/* ── Word diff ─────────────────────────────────────────────────────────── */ + +// Invariant of every word diff: the unchanged + removed parts spell the old text exactly, +// and the unchanged + added parts spell the new text exactly. +function wordDiffPreservesTexts(parts, oldText, newText) { + let oldOut = ''; + let newOut = ''; + for (const part of parts) { + if (!part.added) oldOut += part.value; + if (!part.removed) newOut += part.value; + } + return oldOut === oldText && newOut === newText; +} + +function getDiffLibrary() { + if (typeof Diff !== 'undefined' && Diff && typeof Diff.diffWordsWithSpace === 'function') return Diff; + if (typeof window !== 'undefined' && window.Diff) return window.Diff; + return null; +} + +// Upper bound for the work of one word diff (Myers: tokens × edit length). Without it two long, +// largely rewritten paragraphs take quadratic time (two 10,000-word paragraphs: ~45 s; a 1 MB +// paragraph: hours). Normal paragraphs are far below the bound and always get the exact diff. +const WORD_DIFF_MAX_WORK = 1.5e7; + +// Coarse but exact fallback: common leading and trailing words unchanged, the rest replaced. +function coarseWordDiff(oldText, newText) { + const a = oldText.split(/(\s+)/); + const b = newText.split(/(\s+)/); + let pre = 0; + while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++; + let suf = 0; + while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++; + const parts = [ + { value: a.slice(0, pre).join('') }, + { value: a.slice(pre, a.length - suf).join(''), removed: true }, + { value: b.slice(pre, b.length - suf).join(''), added: true }, + { value: a.slice(a.length - suf).join('') }, + ]; + return parts.filter(p => p.value.length > 0); +} + +function computeCleanWordDiff(oldText, newText) { + const diffLib = getDiffLibrary(); + if (!diffLib) { + return [ + { value: oldText, removed: true }, + { value: newText, added: true }, + ]; + } + + // diffWordsWithSpace yields about two tokens (word + whitespace) per word. + const tokens = 2 * ((oldText.match(/\S+/g) || []).length + (newText.match(/\S+/g) || []).length) + 2; + const maxEditLength = Math.max(30, Math.floor(WORD_DIFF_MAX_WORK / tokens)); + const raw = diffLib.diffWordsWithSpace(oldText, newText, { maxEditLength }); + if (!raw) return coarseWordDiff(oldText, newText); + + // An unchanged number sandwiched between two changes is shown as replaced as well, so + // "1.000 EUR" -> "2.000 USD" reads as one change instead of three fragments. + const mergedNums = []; + for (let i = 0; i < raw.length; i++) { + const cur = raw[i]; + if ( + !cur.added && + !cur.removed && + /^[.,\d]+$/.test(cur.value) && + mergedNums.length > 0 && + i + 1 < raw.length + ) { + const prev = mergedNums[mergedNums.length - 1]; + const next = raw[i + 1]; + if ((prev.removed || prev.added) && (next.removed || next.added)) { + mergedNums.push({ value: cur.value, removed: true }); + mergedNums.push({ value: cur.value, added: true }); + continue; + } + } + mergedNums.push({ value: cur.value, added: Boolean(cur.added), removed: Boolean(cur.removed) }); + } + + // Expand partial numeric/date replacements to the whole number: + // "0,[5]" -> "[0,5]", "[145.000],00" -> "[145.000,00]", "[12.03].2026" -> "[12.03.2026]". + // Only for groups that both remove and add text: the unchanged digits are then moved into + // BOTH sides, which keeps the invariant. Pure insertions/deletions are left untouched + // ("Frist 14 Tage" -> "Frist 7 14 Tage" must show "7 " inserted and "14" unchanged). + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + let idx = 0; + while (idx < mergedNums.length) { + if (!isChange(mergedNums[idx])) { + idx++; + continue; + } + const start = idx; + while (idx < mergedNums.length && isChange(mergedNums[idx])) { + idx++; + } + const end = idx - 1; + const group = mergedNums.slice(start, idx); + const firstRem = group.find(tok => tok.removed); + const firstAdd = group.find(tok => tok.added); + if (!firstRem || !firstAdd) continue; + if (!group.some(tok => /\d/.test(tok.value))) continue; + // Only expand when both sides of the change start/end with a digit at the boundary. + const remText = group.filter(tok => tok.removed).map(tok => tok.value).join(''); + const addText = group.filter(tok => tok.added).map(tok => tok.value).join(''); + + if (start > 0 && !isChange(mergedNums[start - 1]) && /^[\d.,]/.test(remText) && /^[\d.,]/.test(addText)) { + const prevTok = mergedNums[start - 1]; + const prefixMatch = prevTok.value.match(/((?:\d+[.,])+\d*|[.,]\d+)$/); + if (prefixMatch) { + const numPrefix = prefixMatch[1]; + prevTok.value = prevTok.value.slice(0, prevTok.value.length - numPrefix.length); + firstRem.value = numPrefix + firstRem.value; + firstAdd.value = numPrefix + firstAdd.value; + } + } + + if (idx < mergedNums.length && !isChange(mergedNums[idx]) && /[\d.,]$/.test(remText) && /[\d.,]$/.test(addText)) { + const nextTok = mergedNums[idx]; + const suffixMatch = nextTok.value.match(/^([.,]\d+(?:[.,]\d+)*|\d+(?:[.,]\d+)*)/); + if (suffixMatch) { + const numSuffix = suffixMatch[1]; + nextTok.value = nextTok.value.slice(numSuffix.length); + let lastRem = null; + let lastAdd = null; + for (let k = start; k <= end; k++) { + if (mergedNums[k].removed) lastRem = mergedNums[k]; + if (mergedNums[k].added) lastAdd = mergedNums[k]; + } + lastRem.value = lastRem.value + numSuffix; + lastAdd.value = lastAdd.value + numSuffix; + } + } + } + + 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 })); + + if (wordDiffPreservesTexts(cleaned, oldText, newText)) return cleaned; + // Never show a diff that does not reproduce the source texts: fall back to the raw diff. + return raw.map(tok => + tok.added ? { value: tok.value, added: true } : tok.removed ? { value: tok.value, removed: true } : { value: tok.value } + ); +} + +function coalesceSideTokens(sideParts, prop) { + const out = []; + for (let i = 0; i < sideParts.length; i++) { + const cur = sideParts[i]; + if ( + !cur[prop] && + /^\s+$/.test(cur.value) && + out.length > 0 && + out[out.length - 1][prop] && + i + 1 < sideParts.length && + sideParts[i + 1][prop] + ) { + out[out.length - 1].value += cur.value; + continue; + } + if (cur[prop] && out.length > 0 && out[out.length - 1][prop]) { + out[out.length - 1].value += cur.value; + } else { + out.push({ value: cur.value, [prop]: Boolean(cur[prop]) }); + } + } + return out; +} diff --git a/src/js/04a-align.js b/src/js/04a-align.js new file mode 100644 index 0000000..dcdff0f --- /dev/null +++ b/src/js/04a-align.js @@ -0,0 +1,457 @@ +// Alignment engine and comparison pipeline. +// +// Everything in this file is pure (no DOM access) so that `scripts/build.py` can bundle it, +// together with jsdiff and `04-core.js`, into the Blob Web Worker that runs comparisons off +// the main thread (see `06a-compare-async.js` and `src/worker/compare-worker.js`). The very +// same functions also run on the main thread as the synchronous fallback. + +const ALIGN_GAP_PENALTY = -0.22; +// Middles up to this many DP cells are aligned with one global Needleman-Wunsch pass, +// exactly as before (~500 x 500 paragraphs, ≈ 20 pages against 20 pages). +const ALIGN_FULL_DP_MAX_CELLS = 250000; +// Gaps between anchors up to this size still get the exact DP; larger ones use a banded DP +// so memory stays bounded (the exact DP needs 5 bytes per cell). +const ALIGN_EXACT_DP_MAX_CELLS = 4000000; + +// Global Needleman-Wunsch alignment of two block lists (O(n·m) time and memory). +function alignBlocksExact(midLeft, midRight, onCells) { + const n = midLeft.length; + const m = midRight.length; + if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight); + + const dp = Array.from({ length: n + 1 }, () => new Float32Array(m + 1)); + const ptr = Array.from({ length: n + 1 }, () => new Uint8Array(m + 1)); + const GAP_PENALTY = ALIGN_GAP_PENALTY; + + for (let i = 1; i <= n; i++) { + dp[i][0] = i * GAP_PENALTY; + ptr[i][0] = 2; + } + for (let j = 1; j <= m; j++) { + dp[0][j] = j * GAP_PENALTY; + ptr[0][j] = 3; + } + + const reportEvery = Math.max(1, Math.floor(250000 / m)); + for (let i = 1; i <= n; i++) { + const rowPrev = dp[i - 1]; + const row = dp[i]; + const ptrRow = ptr[i]; + const leftBlock = midLeft[i - 1]; + for (let j = 1; j <= m; j++) { + const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]); + const matchScore = sim >= 0.28 ? sim : -0.5; + const scoreDiag = rowPrev[j - 1] + matchScore; + const scoreUp = rowPrev[j] + GAP_PENALTY; + const scoreLeft = row[j - 1] + GAP_PENALTY; + + if (scoreDiag >= scoreUp && scoreDiag >= scoreLeft) { + row[j] = scoreDiag; + ptrRow[j] = 1; + } else if (scoreUp >= scoreLeft) { + row[j] = scoreUp; + ptrRow[j] = 2; + } else { + row[j] = scoreLeft; + ptrRow[j] = 3; + } + } + if (onCells && i % reportEvery === 0) onCells(reportEvery * m); + } + if (onCells) onCells((n % reportEvery) * m); + + const pairs = []; + let i = n; + let j = m; + while (i > 0 || j > 0) { + const move = ptr[i][j]; + if (i > 0 && j > 0 && move === 1) { + pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] }); + i--; + j--; + } else if (i > 0 && (j === 0 || move === 2)) { + pairs.push({ left: midLeft[i - 1], right: null }); + i--; + } else { + pairs.push({ left: null, right: midRight[j - 1] }); + j--; + } + } + pairs.reverse(); + return pairs; +} + +function alignBlocksTrivial(midLeft, midRight) { + const pairs = []; + for (const b of midLeft) pairs.push({ left: b, right: null }); + for (const b of midRight) pairs.push({ left: null, right: b }); + return pairs; +} + +// Same scoring as alignBlocksExact, restricted to a diagonal band so that memory and time are +// O((n + m) · band). Only used for gaps that are too large for the exact DP and have no anchors. +function alignBlocksBanded(midLeft, midRight, maxCells, onCells) { + const n = midLeft.length; + const m = midRight.length; + if (n === 0 || m === 0) return alignBlocksTrivial(midLeft, midRight); + + const slope = m / n; + const minHalf = Math.ceil(Math.max(slope, 1)) + 2; + const half = Math.max(minHalf, Math.floor(maxCells / (2 * (n + 1)))); + const width = 2 * half + 1; + const lo = new Int32Array(n + 1); + const hi = new Int32Array(n + 1); + for (let i = 0; i <= n; i++) { + const center = Math.round(i * slope); + lo[i] = Math.max(0, center - half); + hi[i] = Math.min(m, center + half); + } + // Make consecutive bands overlap so a monotone path always exists. + for (let i = 1; i <= n; i++) { + if (lo[i] > hi[i - 1]) lo[i] = hi[i - 1]; + } + + const NEG = -Infinity; + const score = new Float32Array((n + 1) * width).fill(NEG); + const ptr = new Uint8Array((n + 1) * width); + const at = (i, j) => (j < lo[i] || j > hi[i] ? -1 : i * width + (j - lo[i])); + + for (let j = lo[0]; j <= hi[0]; j++) { + score[at(0, j)] = j * ALIGN_GAP_PENALTY; + ptr[at(0, j)] = 3; + } + + for (let i = 1; i <= n; i++) { + const leftBlock = midLeft[i - 1]; + for (let j = lo[i]; j <= hi[i]; j++) { + const k = at(i, j); + if (j === 0) { + score[k] = i * ALIGN_GAP_PENALTY; + ptr[k] = 2; + continue; + } + const kDiag = at(i - 1, j - 1); + const kUp = at(i - 1, j); + const kLeft = at(i, j - 1); + let scoreDiag = NEG; + if (kDiag >= 0 && score[kDiag] !== NEG) { + const sim = computeBlockSimilarity(leftBlock, midRight[j - 1]); + scoreDiag = score[kDiag] + (sim >= 0.28 ? sim : -0.5); + } + const scoreUp = kUp >= 0 ? score[kUp] + ALIGN_GAP_PENALTY : NEG; + const scoreLeft = kLeft >= 0 ? score[kLeft] + ALIGN_GAP_PENALTY : NEG; + if (scoreDiag >= scoreUp && scoreDiag >= scoreLeft) { + score[k] = scoreDiag; + ptr[k] = 1; + } else if (scoreUp >= scoreLeft) { + score[k] = scoreUp; + ptr[k] = 2; + } else { + score[k] = scoreLeft; + ptr[k] = 3; + } + } + if (onCells) onCells(hi[i] - lo[i] + 1); + } + + const pairs = []; + let i = n; + let j = m; + while (i > 0 || j > 0) { + const move = ptr[at(i, j)]; + if (i > 0 && j > 0 && move === 1) { + pairs.push({ left: midLeft[i - 1], right: midRight[j - 1] }); + i--; + j--; + } else if (i > 0 && (j === 0 || move === 2)) { + pairs.push({ left: midLeft[i - 1], right: null }); + i--; + } else { + pairs.push({ left: null, right: midRight[j - 1] }); + j--; + } + } + pairs.reverse(); + return pairs; +} + +// Patience-style anchors: blocks whose text (ignoring renumbering) occurs exactly once on each side, reduced to the +// longest chain that is increasing on both sides. Returns [[leftIdx, rightIdx], ...]. +function findUniqueBlockAnchors(midLeft, midRight) { + const leftCount = new Map(); + for (let i = 0; i < midLeft.length; i++) { + const key = blockAlignKey(midLeft[i]); + leftCount.set(key, leftCount.has(key) ? -1 : i); + } + const rightIndex = new Map(); + for (let j = 0; j < midRight.length; j++) { + const key = blockAlignKey(midRight[j]); + rightIndex.set(key, rightIndex.has(key) ? -1 : j); + } + + const candidates = []; + for (let i = 0; i < midLeft.length; i++) { + const key = blockAlignKey(midLeft[i]); + if (leftCount.get(key) !== i) continue; + const j = rightIndex.get(key); + if (j === undefined || j < 0) continue; + if (midLeft[i].isHeaderFooter !== midRight[j].isHeaderFooter) continue; + candidates.push([i, j]); + } + if (candidates.length === 0) return []; + + // Longest increasing subsequence on the right index (left indices are already increasing). + const tails = []; + const prev = new Int32Array(candidates.length).fill(-1); + for (let c = 0; c < candidates.length; c++) { + const j = candidates[c][1]; + let loIdx = 0; + let hiIdx = tails.length; + while (loIdx < hiIdx) { + const mid = (loIdx + hiIdx) >> 1; + if (candidates[tails[mid]][1] < j) loIdx = mid + 1; + else hiIdx = mid; + } + if (loIdx > 0) prev[c] = tails[loIdx - 1]; + tails[loIdx] = c; + } + const chain = []; + for (let c = tails[tails.length - 1]; c >= 0; c = prev[c]) chain.push(candidates[c]); + chain.reverse(); + return chain; +} + +// Aligns the differing middle of two documents. Small and medium middles use one global DP +// (identical to the historical behaviour); very long ones are first split at unique identical +// blocks so the DP only runs inside the gaps between them. +function alignMiddleBlocks(midLeft, midRight, onProgress) { + const n = midLeft.length; + const m = midRight.length; + const totalCells = n * m; + let doneCells = 0; + let lastReported = -1; + const onCells = onProgress + ? cells => { + doneCells += cells; + const frac = totalCells ? Math.min(1, doneCells / totalCells) : 1; + if (frac - lastReported >= 0.02) { + lastReported = frac; + onProgress(frac); + } + } + : null; + + if (totalCells <= ALIGN_FULL_DP_MAX_CELLS) { + return alignBlocksExact(midLeft, midRight, onCells); + } + + const anchors = findUniqueBlockAnchors(midLeft, midRight); + // Progress is measured against the cells that will actually be computed. + let plannedCells = 0; + let pi = 0; + let pj = 0; + for (const [ai, aj] of anchors.concat([[n, m]])) { + const cells = (ai - pi) * (aj - pj); + plannedCells += cells <= ALIGN_EXACT_DP_MAX_CELLS ? cells : Math.min(cells, ALIGN_EXACT_DP_MAX_CELLS * 2); + pi = ai + 1; + pj = aj + 1; + } + const scale = plannedCells > 0 ? totalCells / plannedCells : 1; + const scaledOnCells = onCells ? cells => onCells(cells * scale) : null; + const alignGapScaled = (gapLeft, gapRight) => { + const cells = gapLeft.length * gapRight.length; + if (cells <= ALIGN_EXACT_DP_MAX_CELLS) return alignBlocksExact(gapLeft, gapRight, scaledOnCells); + return alignBlocksBanded(gapLeft, gapRight, ALIGN_EXACT_DP_MAX_CELLS, scaledOnCells); + }; + + const pairs = []; + let prevI = 0; + let prevJ = 0; + for (const [ai, aj] of anchors) { + for (const p of alignGapScaled(midLeft.slice(prevI, ai), midRight.slice(prevJ, aj))) pairs.push(p); + pairs.push({ left: midLeft[ai], right: midRight[aj] }); + prevI = ai + 1; + prevJ = aj + 1; + } + for (const p of alignGapScaled(midLeft.slice(prevI), midRight.slice(prevJ))) pairs.push(p); + if (onProgress) onProgress(1); + return pairs; +} + +// Full comparison pipeline: parse both texts, reconcile and filter headers/footers, align. +// Pure and structured-clone friendly; this is what the worker runs and what the synchronous +// fallback runs on the main thread. +function computeComparison(request, onProgress) { + const report = typeof onProgress === 'function' ? onProgress : null; + const prepared = prepareComparisonBlocks( + request.leftText || '', + request.rightText || '', + request.ignoreHeadersFooters + ); + if (report) report(0.05); + + const { allLeftBlocks, allRightBlocks, leftBlocks, rightBlocks } = prepared; + let alignedRows = []; + if (leftBlocks.length > 0 && rightBlocks.length > 0) { + alignedRows = alignDocumentBlocks( + leftBlocks, + rightBlocks, + report ? frac => report(0.05 + frac * 0.9) : undefined + ); + } + if (report) report(1); + + for (const b of allLeftBlocks) stripAlignmentFeatures(b); + for (const b of allRightBlocks) stripAlignmentFeatures(b); + + return { + allLeftBlocks, + allRightBlocks, + leftBlocks, + rightBlocks, + filteredHfCount: prepared.hfCount, + leftPages: countPages(leftBlocks), + rightPages: countPages(rightBlocks), + alignedRows, + }; +} + +function countPages(blocks) { + return blocks.reduce((mx, b) => Math.max(mx, b.pageNum || 1), 0); +} + +// Tokenized word lists are only needed for alignment; dropping them keeps the result small +// when it is posted from the worker to the page. +const ALIGNMENT_ONLY_BLOCK_KEYS = ['words', 'wordSet', 'wordCounts']; + +function stripAlignmentFeatures(block) { + for (const key of ALIGNMENT_ONLY_BLOCK_KEYS) delete block[key]; +} + +function blocksEquivalent(a, b, ignoreText) { + for (const key of Object.keys(a)) { + if (ALIGNMENT_ONLY_BLOCK_KEYS.includes(key)) continue; + if (ignoreText && (key === 'text' || key === 'alignKey' || key === 'refCode' || key === 'kind' || + key === 'isHeading' || key === 'numberingMarker' || key === 'indent')) { + continue; + } + if (a[key] !== b[key]) return false; + } + return true; +} + +const MOVE_KEYS = ['moved', 'movedToRowId', 'movedFromRowId']; + +// Fast path for edits, reverts, undo and redo of long documents: when the left text and the +// options are unchanged +// and the new right text differs from the previous one in exactly one block (same block count; +// every other block identical, including page, section and header/footer metadata), the previous +// alignment is kept and only that row is re-diffed. Returns { result, changedRowIds } or null +// when a full re-alignment is required. This is an approximation of a full comparison, so the +// page re-checks it with a full comparison in the worker (see runComparison). +function computeIncrementalComparison(prev, request) { + if (!prev || !prev.request || !prev.result) return null; + const pr = prev.request; + if ( + pr.leftText !== request.leftText || + pr.ignoreHeadersFooters !== request.ignoreHeadersFooters || + (pr.labels && pr.labels.typeHf) !== (request.labels && request.labels.typeHf) + ) { + return null; + } + const old = prev.result; + if (!old.alignedRows.length || !old.allLeftBlocks) return null; + + const prepared = prepareComparisonBlocks( + request.leftText || '', + request.rightText || '', + request.ignoreHeadersFooters + ); + const { allLeftBlocks, allRightBlocks } = prepared; + if ( + allLeftBlocks.length !== old.allLeftBlocks.length || + allRightBlocks.length !== old.allRightBlocks.length || + prepared.hfCount !== old.filteredHfCount + ) { + return null; + } + // Header/footer reconciliation looks at both documents, so the left side must be re-checked. + for (let i = 0; i < allLeftBlocks.length; i++) { + if (!blocksEquivalent(old.allLeftBlocks[i], allLeftBlocks[i], false)) return null; + } + let changedIdx = -1; + for (let i = 0; i < allRightBlocks.length; i++) { + const a = old.allRightBlocks[i]; + const b = allRightBlocks[i]; + if (a.text === b.text) { + if (!blocksEquivalent(a, b, false)) return null; + continue; + } + if (changedIdx !== -1 || !blocksEquivalent(a, b, true)) return null; + changedIdx = i; + } + + const blockMap = new Map(); + for (let i = 0; i < allLeftBlocks.length; i++) blockMap.set(old.allLeftBlocks[i], allLeftBlocks[i]); + for (let i = 0; i < allRightBlocks.length; i++) blockMap.set(old.allRightBlocks[i], allRightBlocks[i]); + const leftBlocks = old.leftBlocks.map(b => blockMap.get(b)); + const rightBlocks = old.rightBlocks.map(b => blockMap.get(b)); + if (leftBlocks.some(b => !b) || rightBlocks.some(b => !b)) return null; + + const changedBlock = changedIdx === -1 ? null : allRightBlocks[changedIdx]; + let changedRowId = null; + let unmatchedEdit = false; + const alignedRows = old.alignedRows.map((row, idx) => { + const left = row.leftBlock ? blockMap.get(row.leftBlock) : null; + const right = row.rightBlock ? blockMap.get(row.rightBlock) : null; + if (changedBlock && right === changedBlock) { + // Only a matched pair keeps its row; an edited insertion may now match a deleted block. + if (!left) unmatchedEdit = true; + changedRowId = row.id; + return buildAlignedRow({ left, right }, idx, row.pageNum); + } + const copy = Object.assign({}, row, { leftBlock: left, rightBlock: right }); + for (const key of MOVE_KEYS) delete copy[key]; + return copy; + }); + + if (changedBlock) { + const changedRow = alignedRows.find(r => r.id === changedRowId); + if (!changedRow || unmatchedEdit) return null; // hidden header/footer, or unmatched block + // Keep the old pairing only if the alignment would still consider the blocks a match. + if ( + changedRow.leftBlock && + changedRow.type === 'modified' && + computeBlockSimilarity(changedRow.leftBlock, changedRow.rightBlock) < 0.28 + ) { + return null; + } + } + + markMovedRows(alignedRows); + const changedRowIds = []; + for (let i = 0; i < alignedRows.length; i++) { + const row = alignedRows[i]; + const before = old.alignedRows[i]; + if (row.id === changedRowId || MOVE_KEYS.some(key => row[key] !== before[key])) { + changedRowIds.push(row.id); + } + } + + for (const b of allLeftBlocks) stripAlignmentFeatures(b); + for (const b of allRightBlocks) stripAlignmentFeatures(b); + + return { + changedRowIds, + result: { + allLeftBlocks, + allRightBlocks, + leftBlocks, + rightBlocks, + filteredHfCount: prepared.hfCount, + leftPages: countPages(leftBlocks), + rightPages: countPages(rightBlocks), + alignedRows, + }, + }; +} diff --git a/src/js/05-render.js b/src/js/05-render.js new file mode 100644 index 0000000..bcd15e1 --- /dev/null +++ b/src/js/05-render.js @@ -0,0 +1,352 @@ +// Entity-safe search highlighting: splits raw text BEFORE escaping so HTML entities are never corrupted +function escapeAndHighlightText(rawText, query) { + if (!rawText) return ''; + if (!query || !query.trim()) return escapeHtml(rawText); + const q = query.trim(); + const safeQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${safeQ})`, 'gi'); + const parts = rawText.split(regex); + if (parts.length === 1) return escapeHtml(rawText); + + const qLower = q.toLowerCase(); + return parts + .map(part => + part.toLowerCase() === qLower + ? `${escapeHtml(part)}` + : escapeHtml(part) + ) + .join(''); +} + +// A removed / added run with its surrounding spaces outside the mark, so the tint and the +// underline cover the words only. +function tokHtml(tag, value, query) { + const cls = tag === 'del' ? 'tok-del' : 'tok-ins'; + const m = /^(\s*)([\s\S]*?)(\s*)$/.exec(value); + if (!m[2]) return `<${tag} class="${cls}">${escapeAndHighlightText(value, query)}`; + return `${escapeHtml(m[1])}<${tag} class="${cls}">${escapeAndHighlightText(m[2], query)}${escapeHtml(m[3])}`; +} + +function setSafeContainerHtml(container, trustedHtmlString) { + container.innerHTML = trustedHtmlString; +} + +function wrapMarkerAndBody(markerHtml, bodyHtml) { + return `${markerHtml}
${bodyHtml}
`; +} + +function renderLeftBlockHtml(row, query) { + const b = row.leftBlock; + if (!b) return ''; + const split = extractListMarker(b.text, b.kind); + + if (row.type === 'equal') { + if (row.markerOnly) return renderMarkerOnlyHtml(row, 'left', query); + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(b.text, query); + } + if (row.type === 'deleted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(b.text, query)}`; + } + if (row.type === 'modified') { + const rSplit = row.rightBlock ? extractListMarker(row.rightBlock.text, row.rightBlock.kind) : null; + if (split && rSplit) { + const bodyDiffs = computeCleanWordDiff(split.body, rSplit.body); + const sideParts = coalesceSideTokens( + bodyDiffs.filter(part => !part.added), + 'removed' + ); + const bodyHtml = sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.removed ? tokHtml('del', part.value, query) : content; + }) + .join(''); + const markerHtml = + split.marker === rSplit.marker + ? escapeAndHighlightText(split.marker, query) + : `${escapeAndHighlightText(split.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + const sideParts = coalesceSideTokens( + row.wordDiffs.filter(part => !part.added), + 'removed' + ); + return sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.removed ? tokHtml('del', part.value, query) : content; + }) + .join(''); + } + return ''; +} + +function renderRightBlockHtml(row, query) { + const b = row.rightBlock; + if (!b) return ''; + const split = extractListMarker(b.text, b.kind); + + if (row.type === 'equal') { + if (row.markerOnly) return renderMarkerOnlyHtml(row, 'right', query); + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(b.text, query); + } + if (row.type === 'inserted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(b.text, query)}`; + } + if (row.type === 'modified') { + const lSplit = row.leftBlock ? extractListMarker(row.leftBlock.text, row.leftBlock.kind) : null; + if (split && lSplit) { + const bodyDiffs = computeCleanWordDiff(lSplit.body, split.body); + const sideParts = coalesceSideTokens( + bodyDiffs.filter(part => !part.removed), + 'added' + ); + const bodyHtml = sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.added ? tokHtml('ins', part.value, query) : content; + }) + .join(''); + const markerHtml = + split.marker === lSplit.marker + ? escapeAndHighlightText(split.marker, query) + : `${escapeAndHighlightText(split.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + const sideParts = coalesceSideTokens( + row.wordDiffs.filter(part => !part.removed), + 'added' + ); + return sideParts + .map(part => { + const content = escapeAndHighlightText(part.value, query); + return part.added ? tokHtml('ins', part.value, query) : content; + }) + .join(''); + } + return ''; +} + +function coalesceUnifiedTokens(wordDiffs) { + const out = []; + let i = 0; + const isChange = t => Boolean(t && (t.removed || t.added)); + const isBridgeSpace = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s+$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]); + + while (i < wordDiffs.length) { + if (!isChange(wordDiffs[i])) { + out.push({ value: wordDiffs[i].value }); + i++; + continue; + } + const run = []; + while (i < wordDiffs.length && (isChange(wordDiffs[i]) || isBridgeSpace(i))) { + run.push(wordDiffs[i]); + i++; + } + const hasRem = run.some(t => t.removed); + const hasAdd = run.some(t => t.added); + if (hasRem && hasAdd) { + const delStr = run + .filter(t => !t.added) + .map(t => t.value) + .join(''); + const insStr = run + .filter(t => !t.removed) + .map(t => t.value) + .join(''); + out.push({ value: delStr, removed: true }); + out.push({ value: ' ' }); + out.push({ value: insStr, added: true }); + } else if (hasRem) { + out.push({ value: run.map(t => t.value).join(''), removed: true }); + } else if (hasAdd) { + out.push({ value: run.map(t => t.value).join(''), added: true }); + } + } + return out; +} + +function renderUnifiedBlockHtml(row, query, allowMarkerGrid = false) { + const primary = row.rightBlock || row.leftBlock; + const split = allowMarkerGrid && primary ? extractListMarker(primary.text, primary.kind) : null; + + if (row.type === 'equal') { + if (row.markerOnly) return renderMarkerOnlyHtml(row, 'unified', query, allowMarkerGrid); + if (split) { + return wrapMarkerAndBody( + escapeAndHighlightText(split.marker, query), + escapeAndHighlightText(split.body, query) + ); + } + return escapeAndHighlightText(row.rightBlock.text, query); + } + if (row.type === 'deleted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(row.leftBlock.text, query)}`; + } + if (row.type === 'inserted') { + if (split) { + return wrapMarkerAndBody( + `${escapeAndHighlightText(split.marker, query)}`, + `${escapeAndHighlightText(split.body, query)}` + ); + } + return `${escapeAndHighlightText(row.rightBlock.text, query)}`; + } + if (row.type === 'modified') { + const lSplit = row.leftBlock ? extractListMarker(row.leftBlock.text, row.leftBlock.kind) : null; + const rSplit = row.rightBlock ? extractListMarker(row.rightBlock.text, row.rightBlock.kind) : null; + if (allowMarkerGrid && lSplit && rSplit) { + const bodyDiffs = computeCleanWordDiff(lSplit.body, rSplit.body); + const bodyHtml = coalesceUnifiedTokens(bodyDiffs) + .map(part => { + const content = escapeAndHighlightText(part.value, query); + if (part.removed) return tokHtml('del', part.value, query); + if (part.added) return tokHtml('ins', part.value, query); + return content; + }) + .join(''); + const markerHtml = + lSplit.marker === rSplit.marker + ? escapeAndHighlightText(rSplit.marker, query) + : `${escapeAndHighlightText(lSplit.marker, query)} ${escapeAndHighlightText(rSplit.marker, query)}`; + return wrapMarkerAndBody(markerHtml, bodyHtml); + } + + return coalesceUnifiedTokens(row.wordDiffs) + .map(part => { + const content = escapeAndHighlightText(part.value, query); + if (part.removed) return tokHtml('del', part.value, query); + if (part.added) return tokHtml('ins', part.value, query); + return content; + }) + .join(''); + } + return ''; +} + +// Renumbered block (only the list marker / heading number changed): the body is unchanged, +// the old and new marker are shown in a muted style. Not counted as a change. +function renderMarkerOnlyHtml(row, side, query, allowMarkerGrid = true) { + const oldMarker = row.oldMarker || ''; + const newMarker = row.newMarker || ''; + const block = side === 'left' ? row.leftBlock : row.rightBlock; + const body = block.text.slice((side === 'left' ? oldMarker : newMarker).length).trim(); + const delHtml = `${escapeAndHighlightText(oldMarker, query)}`; + const insHtml = `${escapeAndHighlightText(newMarker, query)}`; + const markerHtml = side === 'left' ? delHtml : side === 'right' ? insHtml : `${delHtml} ${insHtml}`; + const split = extractListMarker(block.text, block.kind); + if (split && allowMarkerGrid) { + return wrapMarkerAndBody(markerHtml, escapeAndHighlightText(split.body, query)); + } + return `${markerHtml} ${escapeAndHighlightText(body, query)}`; +} + +// The landing page markup is static (src/template.html); this only reflects which +// versions are already loaded in its two slots. +function updateLandingSlots(left, right) { + [ + { side: 'left', info: left, name: state.leftName, fmt: state.leftFormat, titleKey: 'slotLeftTitle' }, + { side: 'right', info: right, name: state.rightName, fmt: state.rightFormat, titleKey: 'slotRightTitle' }, + ].forEach(({ side, info, name, fmt, titleKey }) => { + const slot = document.getElementById(`lp-slot-${side}`); + if (!slot) return; + const loaded = info.blocks > 0; + const otherLoaded = side === 'left' ? right.blocks > 0 : left.blocks > 0; + slot.classList.toggle('is-loaded', loaded); + slot.classList.toggle('is-next-step', !loaded && (side === 'left' || otherLoaded)); + + const titleEl = document.getElementById(`lp-${side}-title`); + const hintEl = document.getElementById(`lp-${side}-hint`); + const ctaEl = document.getElementById(`lp-${side}-cta`); + const clearBtn = document.getElementById(`lp-clear-${side}`); + if (loaded) { + const parts = [t(titleKey)]; + if (fmt && fmt !== 'DEMO') parts.push(fmt); + if (info.pages > 1) parts.push(formatMsg('metaPages', { n: info.pages })); + parts.push(info.blocks === 1 ? t('metaParasOne') : formatMsg('metaParas', { n: info.blocks })); + titleEl.textContent = name || t(titleKey); + titleEl.removeAttribute('data-i18n'); + hintEl.textContent = `${t('lpLoaded')} · ${parts.join(' · ')}`; + hintEl.removeAttribute('data-i18n'); + ctaEl.removeAttribute('data-i18n'); + ctaEl.textContent = t('lpChangeFile'); + } else { + titleEl.setAttribute('data-i18n', titleKey); + titleEl.textContent = t(titleKey); + hintEl.setAttribute('data-i18n', 'slotHint'); + hintEl.textContent = t('slotHint'); + ctaEl.setAttribute('data-i18n', 'slotChoose'); + ctaEl.textContent = t('slotChoose'); + } + if (clearBtn) clearBtn.hidden = !loaded; + }); +} + +// File facts for the documents popover: "PDF · 3 Seiten · 31 Absätze". +function updateColumnMeta(side) { + const metaEl = document.getElementById(side === 'left' ? 'meta-left' : 'meta-right'); + if (!metaEl) return; + const res = lastComparison && lastComparison.result; + const blocks = res ? (side === 'left' ? res.leftBlocks.length : res.rightBlocks.length) : 0; + const pages = res ? (side === 'left' ? res.leftPages : res.rightPages) : 0; + const rawFmt = side === 'left' ? state.leftFormat : state.rightFormat; + const fmt = rawFmt === 'DEMO' ? t('demoLabel') : rawFmt; + if (!blocks) { + metaEl.textContent = ''; + return; + } + const parts = []; + if (fmt) parts.push(fmt); + if (pages > 0) parts.push(pages === 1 ? t('metaPagesOne') : formatMsg('metaPages', { n: pages })); + parts.push(blocks === 1 ? t('metaParasOne') : formatMsg('metaParas', { n: blocks })); + metaEl.textContent = parts.join(' · '); +} + +function getClauseTypeClasses(block) { + if (!block) return ''; + const classes = []; + if (block.isHeading) classes.push('is-heading'); + if (block.kind) classes.push(`kind-${block.kind}`); + if (extractListMarker(block.text, block.kind)) classes.push('has-marker'); + if (block.indent > 0) classes.push(`indent-${Math.min(block.indent, 4)}`); + return classes.join(' '); +} diff --git a/src/js/06-compare.js b/src/js/06-compare.js new file mode 100644 index 0000000..fc245ce --- /dev/null +++ b/src/js/06-compare.js @@ -0,0 +1,788 @@ +// Applies a comparison result (from the worker, the sync fallback or the incremental fast path) +// to the state and the DOM. `opts.changedRowIds` marks an incremental update of a few rows. +// runComparison() itself lives in 06a-compare-async.js. +function applyComparisonResult(request, result, opts = {}) { + lastComparison = { request, result }; + state.rightAllBlocks = result.allRightBlocks; + state.filteredHfCount = result.filteredHfCount; + + const { leftBlocks, rightBlocks, leftPages, rightPages } = result; + + // Captions: the same shortening rule as the top bar, with more room. + const capLeft = state.leftName || t('noFileLeft'); + const capRight = state.rightName || t('noFileRight'); + document.getElementById('filename-left').textContent = truncateFileName(capLeft, capRight, 44); + document.getElementById('filename-right').textContent = truncateFileName(capRight, capLeft, 44); + updateFileLabels(); + + const bothLoaded = leftBlocks.length > 0 && rightBlocks.length > 0; + const wasComparing = document.body.classList.contains('is-comparing'); + const emptyLaunchpad = document.getElementById('empty-launchpad'); + // Focus sat on something that is about to disappear (landing page or comparison chrome)? + const active = document.activeElement; + // (A control in a dialog that just closed, or in a closed menu, counts as lost focus too.) + const focusWasLost = !active || active === document.body || !active.getClientRects().length; + const focusInLanding = Boolean(emptyLaunchpad && active && emptyLaunchpad.contains(active)); + const focusInCompareChrome = Boolean(active && active.closest && active.closest('.compare-only')); + // Focus inside a document pane or the change list is lost when they are re-rendered. + const focusInDocument = Boolean( + active && active.closest && active.closest('#sheet-left, #sheet-right, #sheet-unified, #sidebar-list') + ); + + document.body.classList.toggle('has-docs', leftBlocks.length > 0 || rightBlocks.length > 0); + document.body.classList.toggle('is-comparing', bothLoaded); + + if (!bothLoaded) { + pendingRowFocus = null; + pendingSelectAfterReject = null; + state.activeRowId = null; + state.hoveredRowId = null; + cancelPaneRender(); + resetRowLayoutRegistry(); + state.alignedRows = []; + state.changeRows = []; + state.listItems = []; + removeReviewBar(); + updateStepper(); + updateUndoRedoButtons(); + closeSearch(false); + + // Panes stay hidden until both versions exist; the landing slots show what is loaded. + setSafeContainerHtml(sheetLeft, ''); + setSafeContainerHtml(sheetRight, ''); + setSafeContainerHtml(sheetUnified, ''); + updateLandingSlots( + { blocks: leftBlocks.length, pages: leftPages }, + { blocks: rightBlocks.length, pages: rightPages } + ); + + bindEmptyStateButtons(); + renderSidebarList(false); + clearRuler(); + + if (wasComparing && (focusWasLost || focusInCompareChrome || focusInDocument)) { + const heading = document.getElementById('landing-title'); + if (emptyLaunchpad) emptyLaunchpad.scrollTop = 0; + if (heading) heading.focus({ preventScroll: true }); + } + return; + } + + state.alignedRows = result.alignedRows; + state.changeRows = state.alignedRows.filter(r => r.type !== 'equal'); + rebuildReviewIndex(); + // The selected / hovered row may be gone after an edit that removed paragraphs. + if (state.activeRowId && !state.alignedRows.some(r => r.id === state.activeRowId)) state.activeRowId = null; + if (state.hoveredRowId && !state.alignedRows.some(r => r.id === state.hoveredRowId)) state.hoveredRowId = null; + // After "Ablehnen": on to the next open change, or the rejected one when none is left. + const scrollAfterReject = pendingSelectAfterReject !== null && consumePendingReject(); + // Change 1 is selected as soon as a comparison is shown. + const preselect = !state.activeRowId && state.listItems.length > 0; + if (preselect) state.activeRowId = state.listItems[0].rowId; + + const compareHeading = document.getElementById('compare-heading'); + if (compareHeading) { + compareHeading.textContent = formatMsg('compareHeading', { + left: state.leftName || t('colLeft'), + right: state.rightName || t('colRight'), + }); + } + + applyPageBreaks(); + updateHfMenuItem(); + + // Edits, rejections, undo and redo usually change a single row: patch it in place instead of + // re-rendering all three panes. Clauses of the active row get `is-active` while rendering. + if (!(opts.changedRowIds && patchRenderedRows(opts.changedRowIds))) { + renderDocumentPanes(); + } + renderSidebarList(true); + updateStepper(); + updateUndoRedoButtons(); + placeReviewBar(); + if (state.searchQuery) scheduleSearchRecount(); + + scheduleRulerDraw(); + if (scrollAfterReject) { + const id = state.activeRowId; + requestAnimationFrame(() => { + if (state.activeRowId === id) selectAndScrollToRow(id, true, true, { flash: false, keepIfVisible: true }); + }); + } + if (preselect || !wasComparing) { + const id = state.activeRowId; + if (id) requestAnimationFrame(() => { + if (state.activeRowId === id) selectAndScrollToRow(id, false, !wasComparing || preselect, { flash: false, keepIfVisible: wasComparing }); + }); + } + applyPendingRowFocus(); + const focusNow = document.activeElement; + if (focusInDocument && wasComparing && (!focusNow || focusNow === document.body || !focusNow.isConnected)) { + activeDocPane().focus({ preventScroll: true }); + } + + const freshDocuments = !wasComparing || newDocumentsPending; + newDocumentsPending = false; + if (wasComparing && freshDocuments) { + // A file was replaced from the file menu: focus must not stay on . + const now = document.activeElement; + if (!now || now === document.body || !now.getClientRects().length) { + requestAnimationFrame(() => { + const a = document.activeElement; + if (!a || a === document.body || !a.getClientRects().length) activeDocPane().focus({ preventScroll: true }); + }); + } + } + if (freshDocuments) { + const total = state.listItems.length; + announce( + total === 0 + ? t('announceIdentical') + : total === 1 + ? t('announceComparedOne') + : formatMsg('announceCompared', { n: total }) + ); + // The landing page (and whatever had focus in it) just disappeared: hand focus to the + // document so keyboard users continue with Tab or J / K from a sensible place. + if (!wasComparing && (focusWasLost || focusInLanding)) { + requestAnimationFrame(() => activeDocPane().focus({ preventScroll: true })); + } + } +} + +function activeDocPane() { + return state.viewMode === 'inline' ? paneUnified : paneRight; +} + +// Counts per change type over the whole list (rejected changes keep their type). +function getChangeCounts() { + const counts = { mod: 0, ins: 0, del: 0 }; + for (const item of state.listItems) { + if (item.type === 'modified') counts.mod++; + else if (item.type === 'inserted') counts.ins++; + else if (item.type === 'deleted') counts.del++; + } + return counts; +} + +function updateHfMenuItem() { + setMenuChecked('btn-filter-hf', state.ignoreHeadersFooters); + const badge = document.getElementById('hf-count-badge'); + if (badge) { + badge.textContent = + state.ignoreHeadersFooters && state.filteredHfCount > 0 ? formatMsg('hfHiddenCount', { n: state.filteredHfCount }) : ''; + } +} + +function loadSampleComparison() { + const sample = SAMPLES[state.lang] || SAMPLES.de; + state.leftName = t('sampleLeftName'); + state.leftFormat = 'DEMO'; + state.leftText = sample.v1; + state.rightName = t('sampleRightName'); + state.rightFormat = 'DEMO'; + state.rightText = sample.v2; + resetEditHistory(); + runComparison(); +} + +function clearLandingSide(side) { + cancelPendingUploads(side); + if (side === 'left') { + state.leftText = ''; + state.leftName = ''; + state.leftFormat = ''; + document.getElementById('file-input-left').value = ''; + } else { + state.rightText = ''; + state.rightName = ''; + state.rightFormat = ''; + resetEditHistory(); + document.getElementById('file-input-right').value = ''; + } + runComparison(); + const btn = document.getElementById(`btn-empty-upload-${side}`); + if (btn) btn.focus(); +} + +let landingBound = false; +function bindEmptyStateButtons() { + if (landingBound) return; + landingBound = true; + + const slotLeft = document.getElementById('lp-slot-left'); + const slotRight = document.getElementById('lp-slot-right'); + [ + { el: slotLeft, side: 'left', inputId: 'file-input-left' }, + { el: slotRight, side: 'right', inputId: 'file-input-right' }, + ].forEach(({ el, side, inputId }) => { + if (!el) return; + document + .getElementById(`btn-empty-upload-${side}`) + .addEventListener('click', () => document.getElementById(inputId).click()); + const clearBtn = document.getElementById(`lp-clear-${side}`); + if (clearBtn) clearBtn.addEventListener('click', () => clearLandingSide(side)); + el.addEventListener('dragover', e => { + e.preventDefault(); + e.stopPropagation(); + el.classList.add('drag-over'); + }); + el.addEventListener('dragleave', () => { + el.classList.remove('drag-over'); + }); + el.addEventListener('drop', async e => { + e.preventDefault(); + e.stopPropagation(); + el.classList.remove('drag-over'); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); + await handleDropFiles(e.dataTransfer.files, side); + }); + }); + + const lpOverlay = document.getElementById('empty-launchpad'); + if (lpOverlay && !lpOverlay.dataset.dropBound) { + lpOverlay.dataset.dropBound = '1'; + lpOverlay.addEventListener('dragover', e => e.preventDefault()); + lpOverlay.addEventListener('drop', async e => { + e.preventDefault(); + dragCounter = 0; + document.body.classList.remove('is-dragging-file'); + const defaultSide = state.leftText && !state.rightText ? 'right' : 'left'; + await handleDropFiles(e.dataTransfer.files, defaultSide); + }); + } + + document.querySelectorAll('.btn-open-paste').forEach(btn => { + btn.addEventListener('click', () => openPasteModal()); + }); + + document.querySelectorAll('.btn-open-privacy').forEach(btn => { + btn.addEventListener('click', () => document.getElementById('privacy-modal').showModal()); + }); + + document.querySelectorAll('.btn-load-sample').forEach(btn => { + btn.addEventListener('click', loadSampleComparison); + }); +} + +// Moved blocks keep type 'deleted' (old position) / 'inserted' (new position); this note +// labels them so they are not read as a removal plus an unrelated addition. +function renderMovedNoteHtml(row) { + if (!row.moved) return ''; + const label = row.type === 'deleted' ? t('movedFromHere') : t('movedToHere'); + return `${escapeHtml(label)}`; +} + +function renderPageCardHeaderHtml(pageNum, totalPages) { + return `
${escapeHtml(t('pageLabel'))} ${pageNum} ${escapeHtml(t('pageOf'))} ${totalPages}
`; +} + +function getRowStateClass(row, side) { + if (row.type === 'equal') return 'state-equal'; + if (row.type === 'modified') return 'state-modified'; + if (side === 'left') return 'state-deleted'; + if (side === 'right') return 'state-inserted'; + return row.type === 'inserted' ? 'state-inserted' : 'state-deleted'; +} + +function isHeaderFooterRow(row) { + const b = row.rightBlock || row.leftBlock; + return Boolean(b && (b.isHeaderFooter || b.kind === 'hf')); +} + +// HTML of one aligned row in the three panes (left, right, unified). Kept free of +// indentation whitespace: every whitespace text node is a DOM node to parse and lay out. +function renderRowAnchorsHtml(row, idx, q) { + const deferClass = idx > 18 ? ' deferred-block' : ''; + const activeClass = row.id === state.activeRowId ? ' is-active' : ''; + const reviewClass = row.reviewStatus === 'accepted' ? ' is-accepted' : ''; + const extra = deferClass + activeClass + reviewClass; + const movedClass = row.moved ? ' is-moved' : ''; + const movedNote = renderMovedNoteHtml(row); + const ghostClass = row.ghostNote ? ' has-ghost' : ''; + // "✓ Angenommen · Rückgängig" / "✗ Abgelehnt … · Rückgängig" on top of a reviewed paragraph. + const ghostNote = reviewStatusHtml(row); + const anchorOpen = `
`; + + const left = row.leftBlock + ? `${anchorOpen}
${movedNote}${renderLeftBlockHtml(row, q)}
` + : `${anchorOpen}
`; + + const right = row.rightBlock + ? `${anchorOpen}
${ghostNote}${movedNote}${renderRightBlockHtml(row, q)}
` + : `${anchorOpen}
${ghostNote}
`; + + const primaryBlock = row.rightBlock || row.leftBlock; + const unified = `${anchorOpen}
${ghostNote}${movedNote}${renderUnifiedBlockHtml(row, q, true)}
`; + + return { left, right, unified }; +} + +// Pane rendering is chunked: the first pages are rendered synchronously, the rest in short +// background tasks so that no single task blocks the main thread for long (an 80-page contract +// produces ~50k DOM nodes). flushPaneRender() completes a pending render on demand. +let paneRenderJob = null; +const PANE_RENDER_FIRST_PAGES = 6; +const PANE_RENDER_CHUNK_MS = 14; + +function renderDocumentPanes() { + cancelPaneRender(); + cancelSearchHighlightJob(); + resetRowLayoutRegistry(); + const panes = [paneLeft, paneRight, paneUnified]; + const prevScroll = panes.map(p => p.scrollTop); + + const pageGroups = new Map(); + state.alignedRows.forEach((row, idx) => { + const p = row.pageNum || 1; + if (!pageGroups.has(p)) pageGroups.set(p, []); + pageGroups.get(p).push({ row, idx }); + }); + + setSafeContainerHtml(sheetLeft, ''); + setSafeContainerHtml(sheetRight, ''); + setSafeContainerHtml(sheetUnified, ''); + + const job = { + pages: Array.from(pageGroups.entries()), + totalPages: pageGroups.size || 1, + next: 0, + timer: null, + }; + paneRenderJob = job; + renderPaneChunk(job, PANE_RENDER_FIRST_PAGES, 0); + + // Re-render of a document the user had scrolled into (header/footer toggle, undo of a large + // change): render up to the previous position right away so the view does not jump. + const activePane = activeDocPane(); + const target = prevScroll[panes.indexOf(activePane)]; + if (target > 0) { + while (paneRenderJob === job && activePane.scrollHeight < target + 2 * activePane.clientHeight) { + renderPaneChunk(job, 1, PANE_RENDER_CHUNK_MS); + } + panes.forEach((p, i) => { + if (prevScroll[i]) p.scrollTop = prevScroll[i]; + }); + } + schedulePaneRenderChunk(job); +} + +// Consecutive header/footer lines of a page form one muted, collapsible group (in all three +// panes alike, so the rows stay aligned). +function wrapHeaderFooterGroups(entries, pageNum, rowsHtml) { + const out = []; + let i = 0; + let groupNo = 0; + while (i < entries.length) { + if (!isHeaderFooterRow(entries[i].row)) { + out.push(rowsHtml[i]); + i++; + continue; + } + const start = i; + while (i < entries.length && isHeaderFooterRow(entries[i].row)) i++; + const id = `hf-${pageNum}-${groupNo++}`; + out.push( + `
${rowsHtml.slice(start, i).join('')}
` + ); + } + return out.join(''); +} + +function renderPageCardsHtml(pageNum, entries, totalPages, q) { + const pageHeaderHtml = renderPageCardHeaderHtml(pageNum, totalPages); + const leftRows = []; + const rightRows = []; + const unifiedRows = []; + for (const { row, idx } of entries) { + const html = renderRowAnchorsHtml(row, idx, q); + leftRows.push(html.left); + rightRows.push(html.right); + unifiedRows.push(html.unified); + } + const card = rowsHtml => + `
${pageHeaderHtml}
${wrapHeaderFooterGroups(entries, pageNum, rowsHtml)}
`; + return { left: card(leftRows), right: card(rightRows), unified: card(unifiedRows) }; +} + +function renderPaneChunk(job, minPages, budgetMs) { + if (job !== paneRenderJob || job.next >= job.pages.length) return; + const t0 = performance.now(); + const q = state.searchQuery; + const left = []; + const right = []; + const unified = []; + let count = 0; + while ( + job.next < job.pages.length && + (count < minPages || performance.now() - t0 < budgetMs) + ) { + const [pageNum, entries] = job.pages[job.next++]; + const html = renderPageCardsHtml(pageNum, entries, job.totalPages, q); + left.push(html.left); + right.push(html.right); + unified.push(html.unified); + count++; + } + const firstNewCard = sheetLeft.children.length; + sheetLeft.insertAdjacentHTML('beforeend', left.join('')); + sheetRight.insertAdjacentHTML('beforeend', right.join('')); + sheetUnified.insertAdjacentHTML('beforeend', unified.join('')); + registerRenderedCards(firstNewCard); + if (job.next >= job.pages.length) paneRenderJob = null; +} + +function schedulePaneRenderChunk(job) { + if (job !== paneRenderJob) return; + job.timer = setTimeout(() => { + job.timer = null; + renderPaneChunk(job, 1, PANE_RENDER_CHUNK_MS); + if (paneRenderJob === job) schedulePaneRenderChunk(job); + else { + scheduleRulerDraw(); + if (state.searchQuery) scheduleSearchRecount(); + } + }, 0); +} + +function flushPaneRender() { + const job = paneRenderJob; + if (!job) return; + clearTimeout(job.timer); + renderPaneChunk(job, Infinity, Infinity); +} + +function cancelPaneRender() { + if (paneRenderJob) clearTimeout(paneRenderJob.timer); + paneRenderJob = null; +} + +// Re-renders one row in all three panes (search highlights, edits). Returns false if the row +// is not in the DOM yet. +function rerenderRowInPlace(row) { + const els = getRowElements(row.id); + if (!els.l || !els.r || !els.u) return false; + const html = renderRowAnchorsHtml(row, row.rowNumber - 1, state.searchQuery); + const replaceAnchor = (clauseEl, anchorHtml) => { + const tpl = document.createElement('template'); + tpl.innerHTML = anchorHtml; + const anchor = tpl.content.firstElementChild; + clauseEl.parentElement.replaceWith(anchor); + return anchor.firstElementChild; + }; + const l = replaceAnchor(els.l, html.left); + const r = replaceAnchor(els.r, html.right); + const u = replaceAnchor(els.u, html.unified); + registerRowElements(row.id, l, r, u); + if (row.id === state.activeRowId) placeReviewBar(); + return true; +} + +// Incremental update after an edit / rejection / undo (same row ids, a few rows changed). +// Returns false if the DOM does not hold the previous rendering, so a full render is needed. +function patchRenderedRows(changedRowIds) { + flushPaneRender(); + const rows = changedRowIds.map(id => state.alignedRows.find(r => r.id === id)); + if (rows.some(row => !row || !getRowElements(row.id).l)) return false; + // A header/footer row that joins or leaves a group needs the group markup: render fully. + if (rows.some(row => isHeaderFooterRow(row) !== Boolean(getRowElements(row.id).l.closest('.hf-group')))) return false; + for (const row of rows) { + if (!rerenderRowInPlace(row)) return false; + } + // Ghost notes and review classes of other rows may have changed (undo of a rejection). + for (const row of state.alignedRows) { + const els = getRowElements(row.id); + if (!els.r) continue; + const anchor = els.r.parentElement; + const hasGhost = anchor.classList.contains('has-ghost'); + if (hasGhost !== Boolean(row.ghostNote) && !changedRowIds.includes(row.id)) rerenderRowInPlace(row); + } + decorateReviewedRows(); + return true; +} + +// Rows are kept height-aligned between the left and right panes by a ResizeObserver that also +// notices width changes (see 06b-row-layout.js), so there is nothing left to do here. Kept for +// existing callers (resize, list toggle, view mode switch). +function equalizeRowHeights() {} + +function truncateClean(str, maxLen = 40) { + const clean = str.replace(/\s+/g, ' ').trim(); + if (clean.length <= maxLen) return clean; + const cut = clean.slice(0, maxLen); + const lastSpace = cut.lastIndexOf(' '); + return (lastSpace > 16 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.]+$/, '') + '…'; +} + +function extractDeltaPairs(wordDiffs) { + const pairs = []; + let i = 0; + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const isBridgeSpace = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s+$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]); + const isSingleWordBridge = idx => + idx > 0 && + idx + 1 < wordDiffs.length && + !isChange(wordDiffs[idx]) && + /^\s*\S{1,12}\s*$/.test(wordDiffs[idx].value) && + isChange(wordDiffs[idx - 1]) && + isChange(wordDiffs[idx + 1]) && + (wordDiffs[idx - 1].value.trim().includes(' ') || wordDiffs[idx + 1].value.trim().includes(' ')); + + while (i < wordDiffs.length) { + if (!isChange(wordDiffs[i])) { + i++; + continue; + } + const run = []; + while ( + i < wordDiffs.length && + (isChange(wordDiffs[i]) || isBridgeSpace(i) || isSingleWordBridge(i)) + ) { + run.push(wordDiffs[i]); + i++; + } + const rawDel = run + .filter(tok => !tok.added) + .map(tok => tok.value) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[,;\s]+|[,;\s]+$/g, '') + .trim(); + const rawIns = run + .filter(tok => !tok.removed) + .map(tok => tok.value) + .join('') + .replace(/\s+/g, ' ') + .replace(/^[,;\s]+|[,;\s]+$/g, '') + .trim(); + + if (!rawDel && !rawIns) continue; + pairs.push({ del: rawDel, ins: rawIns }); + } + return pairs; +} + +// 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) { + if (!item.ghost) return item.row.wordDiffs || []; + if (!ghostDiffCache.has(item.ghost)) { + ghostDiffCache.set(item.ghost, computeCleanWordDiff(item.ghost.leftText || '', item.ghost.rightText || '')); + } + return ghostDiffCache.get(item.ghost); +} + +function getItemTexts(item) { + if (item.ghost) return { left: item.ghost.leftText || '', right: item.ghost.rightText || '' }; + return { left: item.row.leftBlock ? item.row.leftBlock.text : '', right: item.row.rightBlock ? item.row.rightBlock.text : '' }; +} + +// A card scans like an inbox line: the first change in its sentence (up to four grey words before +// and after, removed words struck through, added ones underlined), at most two lines. Further +// changes in the same paragraph are counted in the card head. A paragraph that is removed or +// added as a whole reads "entfernt: …" / "hinzugefügt: …". +function renderSidebarDeltaHtml(item) { + const { left, right } = getItemTexts(item); + if (item.type === 'deleted') { + return { html: `${escapeHtml(t('cardRemoved'))} ${escapeHtml(truncateClean(left, 140))}`, more: 0 }; + } + if (item.type === 'inserted') { + return { html: `${escapeHtml(t('cardAdded'))} ${escapeHtml(truncateClean(right, 140))}`, more: 0 }; + } + const snippets = buildContextSnippets(getItemWordDiffs(item)); + if (snippets.length === 0) { + return { html: `${item.ghost ? escapeHtml(truncateClean(right, 140)) : renderUnifiedBlockHtml(item.row, '')}`, more: 0 }; + } + return { html: `${snippets[0]}`, more: snippets.length - 1 }; +} + +// The changed runs of a word diff (as in extractDeltaPairs; runs separated by at most two +// unchanged words are joined), each with up to four unchanged words of the new wording on +// either side. +function buildContextSnippets(wordDiffs, ctxWords = 4) { + const isChange = tok => Boolean(tok && (tok.removed || tok.added)); + const wordCount = s => (s.trim() ? s.trim().split(/\s+/).length : 0); + const runs = []; + let i = 0; + while (i < wordDiffs.length) { + if (!isChange(wordDiffs[i])) { + i++; + continue; + } + const start = i; + let end = i; + let j = i + 1; + while (j < wordDiffs.length) { + if (isChange(wordDiffs[j])) { + end = j; + j++; + continue; + } + // Unchanged text up to the next change: join when it is two words or fewer. + let k = j; + let gap = ''; + while (k < wordDiffs.length && !isChange(wordDiffs[k])) gap += wordDiffs[k++].value; + if (k < wordDiffs.length && wordCount(gap) <= 2) { + end = k; + j = k + 1; + continue; + } + break; + } + runs.push([start, end]); + i = end + 1; + } + const clean = s => s.replace(/\s+/g, ' '); + const newText = (from, to) => wordDiffs.slice(from, to).filter(tok => !tok.removed).map(tok => tok.value).join(''); + return runs.map(([start, end]) => { + const run = wordDiffs.slice(start, end + 1); + const del = clean(run.filter(tok => !tok.added).map(tok => tok.value).join('')).trim(); + const ins = clean(run.filter(tok => !tok.removed).map(tok => tok.value).join('')).trim(); + const beforeWords = clean(newText(0, start)).trimEnd().split(' ').filter(Boolean); + const afterWords = clean(newText(end + 1, wordDiffs.length)).trimStart().split(' ').filter(Boolean); + const before = beforeWords.length + ? (beforeWords.length > ctxWords ? '…' : '') + beforeWords.slice(-ctxWords).join(' ') + ' ' + : ''; + const after = afterWords.length + ? ' ' + afterWords.slice(0, ctxWords).join(' ') + (afterWords.length > ctxWords ? '…' : '') + : ''; + const ctx = s => (s.trim() ? `${escapeHtml(s)}` : ''); + const parts = []; + if (del) parts.push(`${escapeHtml(truncateClean(del, 44))}`); + if (ins) parts.push(`${escapeHtml(truncateClean(ins, 44))}`); + return ctx(before) + parts.join(' ') + ctx(after); + }); +} + +// "geändert" / "hinzugefügt" / "entfernt" / "Verschoben" +function getChangeTypeLabel(rowOrItem) { + if (rowOrItem.moved || (rowOrItem.row && !rowOrItem.ghost && rowOrItem.row.moved)) return t('typeMoved'); + const type = rowOrItem.type; + return type === 'modified' ? t('statMod') : type === 'inserted' ? t('statIns') : t('statDel'); +} + +function getStatusLabel(status) { + return status === 'accepted' ? t('statusAccepted') : status === 'rejected' ? t('statusRejected') : t('statusOpen'); +} + +// Clause reference for display, without the bullet glyph used internally for list items. +function getDisplayRef(row) { + const block = row.rightBlock || row.leftBlock; + const ref = block && block.refCode ? String(block.refCode) : ''; + // Unnumbered paragraphs carry an internal "#n" reference: show "Absatz" instead. + return ref.replace(/\s*[•‣▪◦]\s*$/, '').replace(/\s*#\d+$/, '').trim() || t('paragraphShort'); +} + +function getItemRef(item) { + if (item.members) return formatMsg('hfMergedRef', { n: item.pages || item.members.length }); + if (item.ghost) return String(item.ghost.refCode || '').replace(/\s*#\d+$/, '').trim() || t('paragraphShort'); + return getDisplayRef(item.row); +} + +function getItemPage(item) { + return item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; +} + +// List items under the active list filter (search never filters the list). +function getFilteredListItems() { + const f = state.sidebarFilter; + const s = state.statusFilter; + return state.listItems.filter(item => { + if (s === 'open' && item.status !== 'open') return false; + if (s === 'reviewed' && item.status === 'open') return false; + return f === 'all' || item.type === f; + }); +} +// Kept for callers and QA scripts written against the earlier name. +const getFilteredChangeRows = getFilteredListItems; + +function updateSidebarHeaderState() { + const total = state.listItems.length; + const counts = getChangeCounts(); + const review = getReviewCounts(); + const filtered = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; + document.getElementById('list-title').textContent = filtered + ? formatMsg('listTitleFiltered', { k: getFilteredListItems().length, n: total }) + : changesTitle(total); + document.getElementById('list-progress').textContent = total ? formatMsg('listProgress', { k: review.reviewed, n: total }) : ''; + document.getElementById('progress-fill').style.width = total ? `${(review.reviewed / total) * 100}%` : '0'; + const btnListCount = document.getElementById('btn-list-count'); + if (btnListCount) btnListCount.textContent = String(total); + const btnList = document.getElementById('btn-list'); + if (btnList) btnList.setAttribute('aria-label', formatMsg('listBtnLabel', { n: total })); + + // One filter: "Alle", a review status or a kind of change, each with its count. + const select = document.getElementById('list-filter'); + const value = state.statusFilter !== 'all' ? state.statusFilter : state.sidebarFilter; + const options = [ + ['all', 'filterAll', total], + ['open', 'filterOpen', review.open], + ['reviewed', 'filterReviewed', review.reviewed], + ['modified', 'filterMod', counts.mod], + ['inserted', 'filterIns', counts.ins], + ['deleted', 'filterDel', counts.del], + ]; + for (const [v, key, n] of options) { + const opt = select.querySelector(`option[value="${v}"]`); + opt.textContent = `${t(key)} (${n})`; + opt.disabled = n === 0 && v !== value; + } + document.getElementById('lf-group-status').label = t('filterGroupStatus'); + document.getElementById('lf-group-kind').label = t('filterGroupKind'); + if (select.value !== value) select.value = value; + select.classList.toggle('is-filtered', value !== 'all'); +} + +function renderSidebarList(bothLoaded) { + updateSidebarHeaderState(); + if (!bothLoaded) { + setSafeContainerHtml(sidebarList, ''); + return; + } + + const filtered = getFilteredListItems(); + if (filtered.length === 0) { + const isFilter = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; + setSafeContainerHtml( + sidebarList, + `

${escapeHtml( + isFilter ? t('noMatchesTitle') : t('identicalTitle') + )}

${escapeHtml(isFilter ? t('noMatchesDesc') : t('identicalDesc'))}
` + ); + return; + } + + const activeItem = getActiveItem(); + const activeVisible = activeItem && filtered.includes(activeItem); + const focusId = activeVisible ? activeItem.id : filtered[0].id; + setSafeContainerHtml( + sidebarList, + filtered + .map(item => { + const cls = (item.type === 'modified' ? 'mod' : item.type === 'inserted' ? 'ins' : 'del') + (item.row && item.row.moved && !item.ghost ? ' moved' : ''); + const isActive = activeItem === item; + const ref = getItemRef(item); + const page = getItemPage(item); + const statusIcon = + item.status === 'accepted' + ? `` + : item.status === 'rejected' + ? `` + : ''; + const pages = item.members ? itemMembers(item).map(m => getItemPage(m)) : [page]; + 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'))}` : ''; + return ``; + }) + .join('') + ); +} diff --git a/src/js/06a-compare-async.js b/src/js/06a-compare-async.js new file mode 100644 index 0000000..58b3293 --- /dev/null +++ b/src/js/06a-compare-async.js @@ -0,0 +1,360 @@ +// Comparison orchestration: runs parsing, alignment and word diffs in a Web Worker (started from +// a Blob URL, allowed by the CSP's `worker-src blob:`), shows progress, and makes sure only the +// latest comparison is applied. Falls back to the synchronous main-thread path when workers are +// unavailable, so the app never breaks. + +// 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 = "__DIFF_WORKER_SOURCE__"; + +// 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 +// as the sample loader in the export menu keep working). +const SYNC_COMPARE_MAX_CHARS = 40000; +const WORKER_READY_TIMEOUT_MS = 4000; +const COMPARE_STATUS_DELAY_MS = 150; + +let compareSeq = 0; +let lastComparison = null; + +const diffWorker = { + url: null, + worker: null, + ready: false, + broken: false, + job: null, // { id, resolve, reject, onProgress } + readyTimer: null, +}; + +function buildComparisonRequest() { + return { + leftText: state.leftText || '', + rightText: state.rightText || '', + ignoreHeadersFooters: state.ignoreHeadersFooters, + labels: { typeHf: t('typeHf') }, + }; +} + +function canUseDiffWorker() { + return ( + !diffWorker.broken && + typeof Worker !== 'undefined' && + typeof Blob !== 'undefined' && + typeof URL !== 'undefined' && + typeof URL.createObjectURL === 'function' && + !DIFF_WORKER_SOURCE.startsWith('__') + ); +} + +function getBlobWorkerUrl(source) { + return URL.createObjectURL(new Blob([source], { type: 'text/javascript' })); +} + +function markDiffWorkerBroken(reason) { + diffWorker.broken = true; + stopDiffWorker(); + const job = diffWorker.job; + diffWorker.job = null; + if (job) job.reject({ workerUnavailable: true, reason }); +} + +function stopDiffWorker() { + clearTimeout(diffWorker.readyTimer); + if (diffWorker.worker) diffWorker.worker.terminate(); + diffWorker.worker = null; + diffWorker.ready = false; +} + +function startDiffWorker() { + if (diffWorker.worker) return diffWorker.worker; + try { + if (!diffWorker.url) diffWorker.url = getBlobWorkerUrl(DIFF_WORKER_SOURCE); + const worker = new Worker(diffWorker.url); + diffWorker.worker = worker; + diffWorker.ready = false; + worker.onmessage = event => onDiffWorkerMessage(worker, event.data || {}); + worker.onerror = event => { + if (event && event.preventDefault) event.preventDefault(); + if (worker !== diffWorker.worker) return; + if (!diffWorker.ready) { + markDiffWorkerBroken('load'); + } else { + // A crash while computing: drop this worker, fail the job (it falls back to sync). + stopDiffWorker(); + const job = diffWorker.job; + diffWorker.job = null; + if (job) job.reject({ workerFailed: true }); + } + }; + worker.onmessageerror = () => { + const job = diffWorker.job; + diffWorker.job = null; + stopDiffWorker(); + if (job) job.reject({ workerFailed: true }); + }; + // Some environments block blob workers without reporting an error event. + diffWorker.readyTimer = setTimeout(() => { + if (worker === diffWorker.worker && !diffWorker.ready) markDiffWorkerBroken('timeout'); + }, WORKER_READY_TIMEOUT_MS); + return worker; + } catch (err) { + markDiffWorkerBroken('construct'); + return null; + } +} + +function onDiffWorkerMessage(worker, msg) { + if (worker !== diffWorker.worker) return; + if (msg.type === 'ready') { + diffWorker.ready = true; + clearTimeout(diffWorker.readyTimer); + return; + } + const job = diffWorker.job; + if (!job || msg.id !== job.id) return; + if (msg.type === 'progress') { + if (job.onProgress) job.onProgress(msg.value); + } else if (msg.type === 'result') { + diffWorker.job = null; + job.resolve(msg.result); + } else if (msg.type === 'error') { + diffWorker.job = null; + job.reject({ workerFailed: true, message: msg.message }); + } +} + +// Cancels the running worker job (if any). A busy worker cannot be interrupted, so it is +// terminated and a fresh one is started for the next request. +function cancelDiffWorkerJob() { + const job = diffWorker.job; + if (!job) return; + diffWorker.job = null; + stopDiffWorker(); + job.reject({ cancelled: true }); +} + +// Runs computeComparison(request) in the worker. Resolves with the result; rejects with +// { cancelled } when superseded, or { workerUnavailable | workerFailed } (caller falls back). +function runComparisonInWorker(request, onProgress) { + cancelDiffWorkerJob(); + return new Promise((resolve, reject) => { + if (!canUseDiffWorker()) { + reject({ workerUnavailable: true }); + return; + } + const worker = startDiffWorker(); + if (!worker) { + reject({ workerUnavailable: true }); + return; + } + const id = (runComparisonInWorker.nextId = (runComparisonInWorker.nextId || 0) + 1); + diffWorker.job = { id, resolve, reject, onProgress }; + try { + worker.postMessage({ type: 'compare', id, request }); + } catch (err) { + diffWorker.job = null; + reject({ workerFailed: true }); + } + }); +} + +// Entry point for every (re-)comparison. Returns a promise that settles once the result for +// this call has been applied, or once it was superseded by a newer call. +function runComparison(options = {}) { + const seq = ++compareSeq; + const request = buildComparisonRequest(); + const size = request.leftText.length + request.rightText.length; + const useWorker = !options.sync && size > SYNC_COMPARE_MAX_CHARS && canUseDiffWorker(); + + // Long documents: an edit / revert / undo that touches one row is shown immediately by + // re-diffing only that row; a full comparison then runs in the worker and replaces the + // result in the rare case that the complete alignment differs. + const incremental = useWorker ? computeIncrementalComparison(lastComparison, request) : null; + if (incremental) { + cancelDiffWorkerJob(); + hideCompareStatus(); + applyComparisonResult(request, incremental.result, { changedRowIds: incremental.changedRowIds }); + return runComparisonInWorker(request) + .then(result => { + if (seq !== compareSeq) return; + if (alignmentSignature(result.alignedRows) !== alignmentSignature(state.alignedRows)) { + applyComparisonResult(request, result); + } else { + lastComparison = { request, result }; + } + }) + .catch(() => {}); + } + + if (!useWorker) { + cancelDiffWorkerJob(); + hideCompareStatus(); + applyComparisonResult(request, computeComparison(request)); + return Promise.resolve(); + } + + showCompareStatus(seq, 0); + return runComparisonInWorker(request, value => showCompareStatus(seq, value)) + .catch(err => { + if (err && err.cancelled) return null; + if (seq !== compareSeq) return null; + // Worker unavailable or failed: compute on the main thread so the app keeps working. + return computeComparison(request); + }) + .then(result => { + if (!result || seq !== compareSeq) return; // superseded by a newer comparison + // The first comparison is announced by applyComparisonResult(); re-comparisons + // (header/footer filter, undo of a large change, …) are announced here. + const wasComparing = document.body.classList.contains('is-comparing'); + applyComparisonResult(request, result); + showCompareDone(seq, wasComparing); + }); +} + +// Everything that is visible about an alignment: pairing, type, word diffs and move links. +function alignmentSignature(rows) { + return JSON.stringify( + rows.map(r => [ + r.id, + r.type, + r.leftBlock ? r.leftBlock.index : 0, + r.rightBlock ? r.rightBlock.index : 0, + r.rightBlock ? r.rightBlock.text : '', + r.pageNum, + r.markerOnly || false, + r.movedToRowId || r.movedFromRowId || '', + r.wordDiffs, + ]) + ); +} + +/* ── Accessible progress status ─────────────────────────────────────────── */ + +const compareStatus = { el: null, showTimer: null, hideTimer: null, seq: 0 }; + +function getCompareStatusEl() { + if (compareStatus.el) return compareStatus.el; + const el = document.createElement('div'); + el.id = 'compare-status'; + el.className = 'compare-status'; + el.setAttribute('role', 'status'); + el.setAttribute('aria-live', 'polite'); + el.setAttribute('aria-atomic', 'true'); + // Only the text is announced; the percentage changes often and is visual only. + el.innerHTML = + ''; + document.body.appendChild(el); + compareStatus.el = el; + return el; +} + +function showCompareStatus(seq, value) { + if (seq !== compareSeq) return; + const el = getCompareStatusEl(); + clearTimeout(compareStatus.hideTimer); + const pct = Math.max(0, Math.min(100, Math.round((value || 0) * 100))); + const textEl = el.querySelector('.compare-status-text'); + if (textEl.textContent !== t('compareBusy')) textEl.textContent = t('compareBusy'); + el.querySelector('.compare-status-pct').textContent = pct > 0 ? `${pct} %` : ''; + el.classList.remove('is-done'); + document.body.classList.add('is-diff-busy'); + if (compareStatus.seq !== seq) { + compareStatus.seq = seq; + clearTimeout(compareStatus.showTimer); + // Only show the pill if the comparison takes noticeable time. + compareStatus.showTimer = setTimeout(() => { + if (document.body.classList.contains('is-diff-busy')) el.classList.add('is-visible'); + }, COMPARE_STATUS_DELAY_MS); + } +} + +function showCompareDone(seq, announceResult) { + if (seq !== compareSeq || !compareStatus.el || !announceResult) { + hideCompareStatus(); + return; + } + const el = compareStatus.el; + clearTimeout(compareStatus.showTimer); + document.body.classList.remove('is-diff-busy'); + el.querySelector('.compare-status-pct').textContent = ''; + const n = state.changeRows.length; + el.querySelector('.compare-status-text').textContent = + n === 0 ? t('announceIdentical') : n === 1 ? t('announceComparedOne') : formatMsg('announceCompared', { n }); + el.classList.add('is-done'); + compareStatus.hideTimer = setTimeout(() => { + el.classList.remove('is-visible', 'is-done'); + }, 1600); +} + +function hideCompareStatus() { + document.body.classList.remove('is-diff-busy'); + compareStatus.seq = 0; + if (!compareStatus.el) return; + clearTimeout(compareStatus.showTimer); + clearTimeout(compareStatus.hideTimer); + compareStatus.el.classList.remove('is-visible', 'is-done'); +} + +/* ── PDF.js in a real worker ────────────────────────────────────────────── */ + +// The PDF.js worker source ships in a non-executed - - - + - -
-
+ +
+
+ - Doc Diff -
-
- 0 Änderungen - · - +0 - −0 + Doc Diff + + +
+ + +
+
+ +
+
+ + 0 von 0 +
-
-
-
- + + + + + + -
- - - - - + +
- + - + +
- +
- -
-
-
- 1 · Vorher - Kein Ausgangsdokument - + +
+

Doc Diff

+
+
Alte Fassung
+
+
Alte Fassung hier ablegen
-
- - +
+
+ +
+
Neue Fassung
+
+
Neue Fassung hier ablegen
-
+
+ -
- -
+
+
Alte und neue Fassung
+
+
-
-
- 2 · Nachher - Kein neues Dokument - - -
-
- - + - - +

Sie können auch beide Dateien auf einmal hierher ziehen – die erste gilt als alte Fassung.

+ +
+ - +
-
+ + +
+

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. +
  3. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Die Liste rechts führt von Änderung zu Änderung.
  4. +
  5. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit markierten Änderungen, als PDF oder als Bericht speichern.
  6. +
+
+ +
+

Was es kann – und was nicht

+
+
+

Kann

+
    +
  • PDFs mit Textebene, Word-Dateien (.docx) und reinen Text vergleichen
  • +
  • Änderungen Wort für Wort zeigen – auch geänderte Zahlen, Daten und Beträge
  • +
  • Seiten wie im Original anzeigen; wiederkehrende Kopf- und Fußzeilen werden ausgeblendet
  • +
  • Verschobene Absätze als „verschoben“ kennzeichnen
  • +
  • Ohne Internet arbeiten – als einzelne HTML-Datei
  • +
+
+
+

Kann nicht

+
    +
  • Gescannte PDFs lesen – es gibt keine Texterkennung (OCR)
  • +
  • Alte Word-Dateien (.doc) öffnen – bitte vorher als .docx speichern
  • +
  • Formatierung vergleichen – Fett, Kursiv, Tabellenlayout und Bilder werden ignoriert, verglichen wird nur der Text
  • +
+
+
+
+ +
+

Häufige Fragen

+
+ Wie kann ich prüfen, dass nichts hochgeladen wird? +
+

Die Seite enthält eine Sicherheitsregel (Content-Security-Policy), mit der Ihr Browser jede Netzwerkverbindung aus der Seite heraus sperrt. Ihre Dateien werden nur im Arbeitsspeicher dieses Tabs gelesen und beim Schließen verworfen.

+

So prüfen Sie es selbst: Öffnen Sie die Entwicklerwerkzeuge Ihres Browsers (F12), wählen Sie den Reiter „Netzwerk“ und laden Sie dann ein Dokument – es erscheint keine Anfrage. Oder laden Sie die Offline-Datei herunter, trennen Sie die Internetverbindung und öffnen Sie sie: Alles funktioniert weiter.

+
+
+
+ Kann ich Doc Diff offline nutzen? +
+

Ja. Laden Sie DocDiff.html herunter (eine einzelne Datei, etwa 2,5 MB) und öffnen Sie sie per Doppelklick im Browser – ohne Installation und ohne Internet.

+
+
+
+ Kann ich gescannte PDFs vergleichen? +
+
+
+ Kann ich die Änderungen in Word weiterbearbeiten? +
+
+
+ Wer steckt dahinter? Wo ist der Quellcode? +
+

Doc Diff ist ein privates Open-Source-Projekt von Tobias Schnorpfeil – ohne Firma, Werbung oder Datensammlung dahinter. Der vollständige Quellcode steht unter MIT-Lizenz auf GitHub: Sie können ihn prüfen, selbst bauen oder selbst betreiben.

+
+
+
+ +
-
- - - -
-
-
-
1 · Vorher (Alter Stand) hier ablegen (PDF / DOCX / TXT)
-
-
-
- - - -
-
-
2 · Nachher (Neuer Stand) hier ablegen (PDF / DOCX / TXT)
-
-
-
- - -
-
-
- - -
- -
- - - - - - - - + + +
+
+ +
- +
- Texte direkt eingeben oder einfügen -
- - + +
- +
- +
- Kurzanleitung — So funktioniert Doc Diff -
-
+
+
+ +
- +
- Impressum & Datenschutz -
@@ -3436,3657 +559,37 @@
+ + +
+

+
+
+

+
+ +
+ + + + + + diff --git a/src/worker/compare-worker.js b/src/worker/compare-worker.js new file mode 100644 index 0000000..6f1111c --- /dev/null +++ b/src/worker/compare-worker.js @@ -0,0 +1,39 @@ +// Runtime of the comparison Web Worker. +// +// `scripts/build.py` concatenates jsdiff, `src/js/04-core.js`, `src/js/04a-align.js` and this file +// into one classic worker script and embeds it in the page as a string; the page starts it from a +// Blob URL (see `src/js/06a-compare-async.js`). No network access, no storage. +// +// Protocol (page -> worker): { type: 'compare', id, request } +// (worker -> page): { type: 'ready' } +// { type: 'progress', id, value } value in [0, 1] +// { type: 'result', id, result } +// { type: 'error', id, message } + +let workerLabels = {}; + +// 04-core.js looks up a few localized labels (e.g. the header/footer reference code) via t(). +function t(key) { + return workerLabels[key] || key; +} + +self.onmessage = event => { + const msg = event.data || {}; + if (msg.type !== 'compare') return; + const { id, request } = msg; + try { + workerLabels = request.labels || {}; + let lastSent = -1; + const result = computeComparison(request, value => { + if (value - lastSent >= 0.02 || value === 1) { + lastSent = value; + self.postMessage({ type: 'progress', id, value }); + } + }); + self.postMessage({ type: 'result', id, result }); + } catch (err) { + self.postMessage({ type: 'error', id, message: String((err && err.message) || err) }); + } +}; + +self.postMessage({ type: 'ready' });