diff --git a/DocDiff.html b/DocDiff.html index ec4c443..e77f564 100644 --- a/DocDiff.html +++ b/DocDiff.html @@ -109,7 +109,7 @@ "sameAs": [ "https://github.com/tschnorpfeil/docdiff" ], - "softwareVersion": "1.6.5" + "softwareVersion": "1.7.0" }, { "@type": "SoftwareSourceCode", @@ -252,6 +252,7 @@ --topbar-h: 52px; --list-w: 336px; --ruler-w: 14px; + --pane-pad-x: var(--sp-6); /* outer padding of a document pane; the inner side is --sp-4 */ --background: 0 0% 97.5%; --foreground: 215 25% 13%; @@ -362,6 +363,14 @@ padding: 0; } +/* The hidden attribute must win over component display values. Without this, an author + `display` (`.btn { display: inline-flex }`, `.menu-item { display: flex }`) beats the + user-agent `[hidden]` rule and keeps elements visible that JS has marked as hidden, + for example the "accept all" action when a comparison has no changes. */ +[hidden] { + display: none !important; +} + html { font-size: 100%; } @@ -623,6 +632,14 @@ cursor: pointer; flex-shrink: 0; } +/* Direct swap next to the file pair: quieter than the stepper, same 44 px target */ +.tb-swap { + color: hsl(var(--muted-foreground)); +} +.tb-swap:hover, +.tb-swap:focus-visible { + color: hsl(var(--foreground)); +} .tb-icon-btn.sm { width: 30px; height: 30px; @@ -1146,7 +1163,7 @@ display: inline-flex; align-items: center; min-height: 24px; - color: hsl(var(--primary)); + color: hsl(var(--primary-ink)); text-decoration: underline; text-underline-offset: 3px; } @@ -1477,10 +1494,12 @@ } .scope-can li::before { content: "✓"; + content: "✓" / ""; color: hsl(var(--success-ink)); } .scope-cannot li::before { content: "–"; + content: "–" / ""; color: hsl(var(--destructive-ink)); } @@ -1595,7 +1614,7 @@ /* The hidden stacked pane spans the ruler too; the extra padding keeps its text width */ body:not(.view-inline) #pane-unified { grid-column: 1 / 4; - padding-right: calc(var(--sp-6) + var(--ruler-w)); + padding-right: calc(var(--pane-pad-x) + var(--ruler-w)); } body.view-inline #pane-left, @@ -1613,7 +1632,7 @@ overflow-y: auto; overflow-x: hidden; position: relative; - padding: 0 var(--sp-6) var(--sp-7); + padding: 0 var(--pane-pad-x) var(--sp-7); background: hsl(var(--card)); scrollbar-width: thin; scrollbar-color: hsl(var(--input)) transparent; @@ -1654,6 +1673,15 @@ } } +/* Narrow desktops (1200–1439 px): the list and the outer padding give the text some room back. + Both panes shrink by the same amount, so unchanged paragraphs still wrap identically. */ +@media (min-width: 1200px) and (max-width: 1439px) { + :root { + --list-w: 304px; + --pane-pad-x: var(--sp-5); + } +} + /* Sticky, neutral caption per pane: "Alte Fassung · Vertrag_v1.docx" */ .pane-caption { position: sticky; @@ -3650,8 +3678,8 @@ - - + + @@ -3736,6 +3764,10 @@

Dokumente

+ +
@@ -3955,7 +3987,7 @@

So funktioniert’s

-
    +
    1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
    2. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
    3. Ergebnis sichern, wenn Sie es brauchen Als saubere Endfassung (Word im Originalformat oder PDF) oder als Vergleich mit Änderungsverfolgung.
    4. @@ -3980,7 +4012,7 @@

      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
        • @@ -3991,7 +4023,7 @@

          Kann

      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
        • @@ -4290,6 +4322,10 @@

          metaParas: '{n} Absätze', metaParasOne: '1 Absatz', demoLabel: 'Beispiel', + filterNumeric: 'Zahlen, Währungen & Prozent', + stepLabel_numeric: 'mit Zahlenänderungen', + reviewAcceptAll: 'Alle offenen annehmen ({n})', + reviewCompleteExport: 'Alles geprüft · Word-Endfassung exportieren', dockLabel: 'Änderungen durchgehen', prevLabel: 'Vorherige Änderung', nextLabel: 'Nächste Änderung', @@ -4364,6 +4400,7 @@

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word-Datei gespeichert: {name}', toastWordOriginal: 'Word-Datei im Originalformat gespeichert: {name}', + confirmWordFallback: 'Das Originalformat konnte nicht sicher erhalten werden. Stattdessen ein neu gesetztes Word-Dokument herunterladen? Tabellen, Bilder und Seitenlayout können vom Original abweichen.', noticeWordFallback: 'Gespeichert: {name}. Das Originalformat ließ sich für diese Datei nicht sicher übernehmen (z. B. wegen Änderungen in Fußnoten oder Textfeldern). Die Word-Datei wurde deshalb neu gesetzt; der Text ist vollständig.', toastHtml: 'Bericht gespeichert: {name}', toastMd: 'Markdown-Bericht gespeichert: {name}', @@ -4438,9 +4475,9 @@

          acceptBtn: 'Annehmen', rejectBtn: 'Ablehnen', editBtn: 'Bearbeiten', - reopenBtn: 'Rückgängig', - acceptTitle: 'Annehmen (A)', - rejectTitle: 'Ablehnen (R)', + reopenBtn: 'Prüfung zurücknehmen', + acceptTitle: 'Änderung annehmen (A)', + rejectTitle: 'Ablehnen – alte Fassung wiederherstellen (R)', editTitle: 'Bearbeiten (E)', reopenTitle: 'Prüfung zurücknehmen', rejectedInsNote: 'Abgelehnt – hinzugefügter Absatz danach entfernt', @@ -4517,7 +4554,7 @@

          Ihre Daten

          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.', + 'Sie haben Änderungen geprüft oder Absätze bearbeitet, und dieser Arbeitsstand ist noch nicht als .docdiff-Datei gespeichert. Wenn Sie ihn verwerfen, geht diese Arbeit verloren.', sessionDiscardConfirm: 'Verwerfen', sessionSaveContinue: 'Arbeitsstand speichern', sessionErrorTitle: 'Arbeitsstand kann nicht geöffnet werden', @@ -4703,6 +4740,10 @@

          Ihre Daten

          metaParas: '{n} paragraphs', metaParasOne: '1 paragraph', demoLabel: 'Example', + filterNumeric: 'Numbers, currencies & percentages', + stepLabel_numeric: 'with number changes', + reviewAcceptAll: 'Accept all open ({n})', + reviewCompleteExport: 'Review complete · Export final Word document', dockLabel: 'Go through the changes', prevLabel: 'Previous change', nextLabel: 'Next change', @@ -4777,6 +4818,7 @@

          Ihre Daten

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word file saved: {name}', toastWordOriginal: 'Word file saved in the original format: {name}', + confirmWordFallback: 'The original formatting could not be preserved reliably. Download a newly typeset Word document instead? Tables, images and page layout may differ from the original.', noticeWordFallback: 'Saved: {name}. The original format could not be kept reliably for this file (for example because of changes in footnotes or text boxes), so the Word file was typeset anew; the text is complete.', toastHtml: 'Report saved: {name}', toastMd: 'Markdown report saved: {name}', @@ -4851,9 +4893,9 @@

          Ihre Daten

          acceptBtn: 'Accept', rejectBtn: 'Reject', editBtn: 'Edit', - reopenBtn: 'Undo', - acceptTitle: 'Accept (A)', - rejectTitle: 'Reject (R)', + reopenBtn: 'Undo review', + acceptTitle: 'Accept change (A)', + rejectTitle: 'Reject – restore the old version (R)', editTitle: 'Edit (E)', reopenTitle: 'Undo the review of this change', rejectedInsNote: 'Rejected – added paragraph below removed', @@ -4930,7 +4972,7 @@

          Your data

          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.', + 'You have reviewed changes or edited paragraphs, and this work has not been saved as a .docdiff session file yet. If you discard it, it will be lost.', sessionDiscardConfirm: 'Discard', sessionSaveContinue: 'Save work', sessionErrorTitle: 'Cannot open saved work', @@ -8405,6 +8447,18 @@

          Your data

          return item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; } +// Inspect only changed text: an unchanged clause number must not trigger this filter. +function itemHasNumericChange(item) { + if (item.ghost) return computeCleanWordDiff(item.ghost.leftText || '', item.ghost.rightText || '').some(part => + (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + const row = item.row; + if (row.wordDiffs && row.wordDiffs.length) { + return row.wordDiffs.some(part => (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + } + if (row.type === 'modified') return false; + return /[0-9\p{Sc}%]/u.test((row.rightBlock || row.leftBlock || {}).text || ''); +} + // List items under the active list filter (search never filters the list). function getFilteredListItems() { const f = state.sidebarFilter; @@ -8412,7 +8466,7 @@

          Your data

          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; + return f === 'all' || (f === 'numeric' ? itemHasNumericChange(item) : item.type === f); }); } // Kept for callers and QA scripts written against the earlier name. @@ -8422,6 +8476,21 @@

          Your data

          const total = state.listItems.length; const counts = getChangeCounts(); const review = getReviewCounts(); + let action = document.getElementById('btn-review-all'); + if (!action) { + action = document.createElement('button'); + action.id = 'btn-review-all'; + action.type = 'button'; + action.className = 'btn btn-ghost'; + action.addEventListener('click', () => { + if (getReviewCounts().open) acceptAllOpenChanges(); + else handleExportAction('word-final'); + }); + document.getElementById('sidebar-list').before(action); + } + action.hidden = !total; + action.disabled = !canReviewNow(); + action.textContent = review.open ? formatMsg('reviewAcceptAll', { n: review.open }) : t('reviewCompleteExport'); const filtered = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; document.getElementById('list-title').textContent = filtered ? formatMsg('listTitleFiltered', { k: getFilteredListItems().length, n: total }) @@ -8435,6 +8504,11 @@

          Your data

          // One filter: "Alle", a review status or a kind of change, each with its count. const select = document.getElementById('list-filter'); + if (!select.querySelector('option[value="numeric"]')) { + const option = document.createElement('option'); + option.value = 'numeric'; + document.getElementById('lf-group-kind').appendChild(option); + } const value = state.statusFilter !== 'all' ? state.statusFilter : state.sidebarFilter; const options = [ ['all', 'filterAll', total], @@ -8443,6 +8517,7 @@

          Your data

          ['modified', 'filterMod', counts.mod], ['inserted', 'filterIns', counts.ins], ['deleted', 'filterDel', counts.del], + ['numeric', 'filterNumeric', state.listItems.filter(itemHasNumericChange).length], ]; for (const [v, key, n] of options) { const opt = select.querySelector(`option[value="${v}"]`); @@ -9431,20 +9506,21 @@

          Your data

          } // Running headers and footers repeat on every page with the page number in them: the same -// change on several pages is one decision. Numbers are ignored when comparing the lines; the -// changed words themselves must be identical. +// change on several pages is one decision. Digits in the surrounding text are ignored, but the +// changed words themselves (numbers included) must be identical. Rejected entries recompute +// their changed words from the rejected text so a rejected group stays one entry. function headerFooterGroupKey(item) { if (item.type !== 'modified') return ''; - const norm = s => String(s || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); + const diffs = getItemWordDiffs(item); + const changedParts = Array.isArray(diffs) ? diffs.filter(d => d.added || d.removed) : []; + const hasTokens = changedParts.length > 0; + const norm = s => { + const text = String(s || ''); + return (hasTokens ? text.replace(/\d+/g, '#') : text).replace(/\s+/g, ' ').trim(); + }; const left = item.ghost ? item.ghost.leftText : item.row.leftBlock ? item.row.leftBlock.text : ''; const right = item.ghost ? item.ghost.rightText : item.row.rightBlock ? item.row.rightBlock.text : ''; - let changed = ''; - if (!item.ghost && Array.isArray(item.row.wordDiffs)) { - changed = item.row.wordDiffs - .filter(d => d.added || d.removed) - .map(d => (d.added ? '+' : '-') + d.value) - .join('\u0001'); - } + const changed = changedParts.map(d => (d.added ? '+' : '-') + d.value).join('\u0001'); return [item.status, item.ghost ? 'g' : 'l', norm(left), norm(right), changed].join('\u0000'); } @@ -9875,6 +9951,35 @@

          Your data

          return done; } +// All open list entries in this comparison, including hidden/filtered entries and every +// member of a grouped header/footer change. Revalidate after the confirmation dialog. +async function acceptAllOpenChanges() { + if (!canReviewNow()) return false; + const items = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (!items.length) return false; + const leftText = state.leftText, rightText = state.rightText; + const signature = JSON.stringify(items.map(item => itemMembers(item).map(m => m.row.reviewKey))); + const de = state.lang === 'de'; + const choice = await showSessionDialog({ + title: de ? 'Alle offenen Änderungen annehmen?' : 'Accept all open changes?', + body: de + ? `Alle ${items.length} offenen Einträge im gesamten Vergleich annehmen, einschließlich ausgeblendeter und gefilterter Einträge sowie aller Seiten gruppierter Kopf-/Fußzeilen? Dies ist mit einem Rückgängig-Schritt umkehrbar.` + : `Accept all ${items.length} open entries in the entire comparison, including hidden and filtered entries and every page of grouped headers/footers? This can be undone in one step.`, + confirmLabel: de ? 'Alle annehmen' : 'Accept all', + cancelLabel: de ? 'Abbrechen' : 'Cancel', + }); + if (choice !== 'confirm' || !canReviewNow() || state.leftText !== leftText || state.rightText !== rightText) return false; + const current = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (JSON.stringify(current.map(item => itemMembers(item).map(m => m.row.reviewKey))) !== signature) return false; + pushUndoSnapshot(); + for (const item of current) { + for (const m of itemMembers(item)) state.reviews[m.row.reviewKey] = { status: 'accepted', type: m.row.type }; + } + refreshReviewState(); + announce(de ? `${items.length} Einträge angenommen.` : `Accepted ${items.length} entries.`); + return true; +} + /* ── Overview ruler: where the changes are, and which part of the document is in view ─── */ const rulerEl = document.getElementById('ruler'); @@ -10657,6 +10762,24 @@

          Your data

          }, 60); }); +/* ── Direct swap in the top bar ───────────────────────────────────────────── + * A second entry point to the swap in the documents popover. It only forwards the click, so the + * swap itself (and the guard in front of it that protects unsaved review work) stays in one place. + * The aria-label comes from data-i18n-label; the visual tooltip reads the same text on demand, + * so it follows the language without a separate update step. */ +const btnSwapQuick = document.getElementById('btn-swap-quick'); +if (btnSwapQuick) { + const syncSwapTooltip = () => btnSwapQuick.setAttribute('data-tooltip', t('swapBtn')); + syncSwapTooltip(); + // Runs on the button itself, before the document-level tooltip handlers read the attribute. + btnSwapQuick.addEventListener('pointerover', syncSwapTooltip); + btnSwapQuick.addEventListener('focusin', syncSwapTooltip); + btnSwapQuick.addEventListener('click', () => { + hideTooltip(); + document.getElementById('btn-swap-docs').click(); + }); +} + /* ── Word export: a real .docx (OOXML) with tracked changes, built in the browser ── * Everything here is dependency-free: a minimal ZIP writer (STORE, CRC-32) plus hand-written * WordprocessingML. Changes are emitted as genuine revisions (/), so Word, @@ -12007,14 +12130,17 @@

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

          if (typeof console !== 'undefined') console.warn('Doc Diff: original-format Word export not possible:', err && err.message); } } + if (fellBack && !window.confirm(t('confirmWordFallback'))) return false; if (!bytes) bytes = buildGeneratedDocx(kind, date); - const filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + let filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + if (fellBack) filename = filename.replace(/\.docx$/, '_Generated.docx'); downloadBlob(bytes, DOCX_MIME, filename); if (fellBack && typeof showImportNotice === 'function') { showImportNotice(tf('noticeWordFallback', { name: filename }), 'warning'); } else if (typeof showToast === 'function') { showToast(tf(source ? 'toastWordOriginal' : 'toastWord', { name: filename })); } + return true; } function startPrint(kind) { @@ -12112,9 +12238,24 @@

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

          /* ── ZIP reading (all entries, as bytes) ─────────────────────────────────── */ -async function inflateRaw(bytes) { +async function inflateRaw(bytes, limit) { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - return new Uint8Array(await new Response(stream).arrayBuffer()); + const reader = stream.getReader(); + const chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) throw new Error('zip: decompression limit exceeded'); + chunks.push(value); + } + } finally { await reader.cancel().catch(() => {}); } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } + return result; } /** Reads every entry of a ZIP archive: [{ name, data: Uint8Array }] in archive order. */ @@ -12130,9 +12271,12 @@

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

          } if (eocd < 0) throw new Error('zip: no central directory'); const count = dv.getUint16(eocd + 10, true); + if (count > 4096) throw new Error('zip: entry count limit exceeded'); let p = dv.getUint32(eocd + 16, true); const utf8 = new TextDecoder('utf-8'); const out = []; + const entryLimit = 64 * 1024 * 1024, totalLimit = 256 * 1024 * 1024; + let total = 0; for (let n = 0; n < count; n++) { if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory'); const method = dv.getUint16(p + 10, true); @@ -12145,10 +12289,15 @@

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

          p += 46 + nameLen + extraLen + commentLen; const dataStart = localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); const raw = bytes.subarray(dataStart, dataStart + compSize); + const limit = Math.min(entryLimit, totalLimit - total); let data; - if (method === 0) data = raw.slice(); - else if (method === 8) data = await inflateRaw(raw); + if (method === 0) { + if (raw.length > limit) throw new Error('zip: decompression limit exceeded'); + data = raw.slice(); + } + else if (method === 8) data = await inflateRaw(raw, limit); else throw new Error(`zip: unsupported method ${method}`); + total += data.length; out.push({ name, data }); } return out; @@ -12926,11 +13075,15 @@

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

          step.open ? (oldText === null ? [{ value: newText, added: true }] : computeCleanWordDiff(oldText, newText)) : [{ value: newText }]; const writeCell = (cell, tokens) => { const paras = cell.paras.filter((p, i) => cell.texts[i]); + if (paras.length > 1) throw new Error('docx: cannot safely rewrite a multi-paragraph cell'); const first = paras[0] || cell.paras[0]; if (!first) throw new Error('docx: cell without paragraph'); edit(first.start, first.end, rewriteDocxParagraph(first, xml, ctx, tokens, 0, rev, '')); for (const other of paras.slice(1)) edit(other.start, other.end, rewriteDocxParagraph(other, xml, ctx, [], 0, rev, '')); }; + if (filled.some(c => c.text.includes(' · ')) || + (oldCells && oldCells.length !== filled.length) || + newCells.length !== filled.length) throw new Error('docx: ambiguous table row'); if (newCells.length === filled.length) { newCells.forEach((text, i) => { const cell = filled[i]; @@ -12940,12 +13093,7 @@

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

          }); return newCells.join(' · '); } - // The number of filled cells changed: the whole row text goes into the first cell. - const target = filled[0] || unit.cells[0]; - if (!target) throw new Error('docx: row without cells'); - writeCell(target, step.open && step.oldBlock ? computeCleanWordDiff(docxKey(step.oldBlock.text), docxKey(step.block.text)) : [{ value: docxKey(step.block.text) }]); - for (const cell of filled) if (cell !== target) writeCell(cell, []); - return docxKey(step.block.text); + throw new Error('docx: ambiguous table row'); } /* ── Entry point ────────────────────────────────────────────────────────── */ @@ -13088,17 +13236,20 @@

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

          // map them to clear, translated messages. A page that cannot be read fails the whole file // rather than silently dropping its text from the comparison. const pages = []; + const task = window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }); try { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; + const pdf = await task.promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); - const textContent = await page.getTextContent(); - pages.push(buildPdfPageLines(textContent.items, page.view)); + try { + const textContent = await page.getTextContent(); + pages.push(buildPdfPageLines(textContent.items, page.view)); + } finally { page.cleanup(); } } } catch (err) { if (err instanceof ImportError) throw err; throw new ImportError(err && err.name === 'PasswordException' ? 'errPdfPassword' : 'errPdfCorrupt'); - } + } finally { await task.destroy(); } const { text, emptyPages } = assemblePdfText(pages); if (pages.length === 0 || emptyPages.length === pages.length) { // No text layer at all: a scan or an image-only PDF. OCR is not available. @@ -13112,50 +13263,22 @@

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

          /* ── DOCX ──────────────────────────────────────────────────────────────── */ -// Minimal ZIP reader (central directory + stored/deflate entries) for the few XML parts -// Mammoth does not expose. Returns null when the archive cannot be read this way. +// Validate the entire archive with the bounded reader before Mammoth decompresses it. +// Returning null on a budget failure would let the unbounded fallback run anyway. async function readZipTextEntries(arrayBuffer, wantedNames) { + // Without DecompressionStream (older browsers) the bounded reader cannot inflate anything; + // let Mammoth import the text and skip only the extra parts, as before. + if (typeof DecompressionStream === 'undefined') return null; try { - const bytes = new Uint8Array(arrayBuffer); - const dv = new DataView(arrayBuffer); - let eocd = -1; - for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { - if (dv.getUint32(i, true) === 0x06054b50) { - eocd = i; - break; - } - } - if (eocd < 0) return null; - const count = dv.getUint16(eocd + 10, true); - let p = dv.getUint32(eocd + 16, true); + const entries = await readZipEntries(arrayBuffer); const utf8 = new TextDecoder('utf-8'); const out = {}; - for (let n = 0; n < count; n++) { - if (dv.getUint32(p, true) !== 0x02014b50) return null; - const method = dv.getUint16(p + 10, true); - const compSize = dv.getUint32(p + 20, true); - const nameLen = dv.getUint16(p + 28, true); - const extraLen = dv.getUint16(p + 30, true); - const commentLen = dv.getUint16(p + 32, true); - const localOffset = dv.getUint32(p + 42, true); - const name = utf8.decode(bytes.subarray(p + 46, p + 46 + nameLen)); - p += 46 + nameLen + extraLen + commentLen; - if (!wantedNames.includes(name)) continue; - const dataStart = - localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); - const data = bytes.subarray(dataStart, dataStart + compSize); - if (method === 0) { - out[name] = utf8.decode(data); - } else if (method === 8 && typeof DecompressionStream !== 'undefined') { - const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - out[name] = utf8.decode(await new Response(stream).arrayBuffer()); - } else { - return null; - } + for (const entry of entries) { + if (wantedNames.includes(entry.name)) out[entry.name] = utf8.decode(entry.data); } return out; } catch (err) { - return null; + throw new ImportError('errDocxCorrupt'); } } @@ -13787,6 +13910,8 @@

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

          async function handleDropFiles(files, targetSide) { if (!files || !files.length) return; + // FileList is live: the first upload clears its input, including the second file. + files = Array.from(files); if (files.length >= 2) { clearImportNotice(); await handleFileUpload(files[0], 'left', { keepNotice: true }); @@ -13827,8 +13952,8 @@

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

          const fileInputRight = document.getElementById('file-input-right'); document.getElementById('btn-upload-left').addEventListener('click', () => fileInputLeft.click()); document.getElementById('btn-upload-right').addEventListener('click', () => fileInputRight.click()); -fileInputLeft.addEventListener('change', e => handleFileUpload(e.target.files[0], 'left')); -fileInputRight.addEventListener('change', e => handleFileUpload(e.target.files[0], 'right')); +fileInputLeft.addEventListener('change', e => handleDropFiles(e.target.files, 'left')); +fileInputRight.addEventListener('change', e => handleDropFiles(e.target.files, 'right')); document.getElementById('btn-clear-left').addEventListener('click', () => { cancelPendingUploads('left'); @@ -14305,7 +14430,7 @@

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

          if (isTextEntryElement(el) || (el.closest && el.closest('[contenteditable], .clause-inline-editor, dialog'))) { return false; } - return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list')); + return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list, .review-bar, .stepper')); } window.addEventListener('keydown', e => { @@ -14417,8 +14542,12 @@

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

          function toggleLanguage() { const langHint = document.getElementById('lang-hint'); if (langHint) langHint.hidden = true; + const previousSample = SAMPLES[state.lang] || SAMPLES.de; + const pristineDemo = state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO' && + state.leftText === previousSample.v1 && state.rightText === previousSample.v2 && + Object.keys(state.reviews).length === 0 && state.undoStack.length === 0 && state.redoStack.length === 0; state.lang = state.lang === 'de' ? 'en' : 'de'; - if (state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO') { + if (pristineDemo) { const sample = SAMPLES[state.lang] || SAMPLES.de; state.leftName = t('sampleLeftName'); state.leftText = sample.v1; @@ -14766,8 +14895,7 @@

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

          /* ── Work-Loss Protection: Unsaved-Changes Guard & Saved Work (.docdiff) ── * * Nothing is ever written to browser storage. Work is protected in two ways: - * 1. A dirty flag (inline edits, accepted / rejected changes since the last load, save or - * export) drives the native `beforeunload` prompt and an in-app dialog in front of every + * 1. A dirty flag (inline edits, accepted / rejected changes since the last load or session save) drives the native `beforeunload` prompt and an in-app dialog in front of every * action that would throw this work away ("Abbrechen / Arbeitsstand speichern / Verwerfen"). * 2. "Arbeitsstand speichern…" downloads a self-describing JSON file (.docdiff) to the * user's own disk; "Arbeitsstand öffnen…" validates it strictly and restores it through @@ -14782,7 +14910,7 @@

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

          * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.6.5'; +const DOCDIFF_APP_VERSION = '1.7.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -14801,7 +14929,7 @@

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

          const sessionTracker = { // Right-hand text as it was last loaded (before any inline edits). originalRightText: state.rightText, - // Right-hand text and review status at the last "clean point" (load, save, export). + // Right-hand text and review status at the last "clean point" (load or session save). cleanRightText: state.rightText, cleanReviews: serializeReviews(), // Right-hand text seen by the previous runComparison call. @@ -14874,16 +15002,21 @@

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

          wrapGlobalFunction('resetEditHistory', original => function resetEditHistoryForLoad() { sessionTracker.editPending = false; - return original.apply(this, arguments); + const result = original.apply(this, arguments); + if (!sessionTracker.restoring) { + sessionTracker.originalRightText = state.rightText; + markSessionClean(); + } + return result; } ); wrapGlobalFunction('handleFileUpload', original => async function guardedFileUpload(file, side) { - if (file && side === 'right' && !sessionTracker.bypassGuard && isSessionDirty()) { + if (file && !sessionTracker.bypassGuard && isSessionDirty()) { const ok = await confirmDiscardEdits(); if (!ok) { - const input = document.getElementById('file-input-right'); + const input = document.getElementById(`file-input-${side}`); if (input) input.value = ''; return undefined; } @@ -14894,12 +15027,22 @@

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

          wrapGlobalFunction('handleDropFiles', original => async function guardedDropFiles(files, targetSide) { - const replacesRight = files && (files.length >= 2 || (files.length === 1 && targetSide === 'right')); - if (replacesRight && !sessionTracker.bypassGuard && isSessionDirty()) { - if (!(await confirmDiscardEdits())) return undefined; + const replacesDocument = files && files.length > 0; + if (replacesDocument && !sessionTracker.bypassGuard) { + // Keep the pair under one guard even when it starts clean: replacing the left + // side can reset reviews and otherwise cause a second prompt halfway through. + const pickedFiles = Array.from(files); + if (isSessionDirty() && !(await confirmDiscardEdits())) { + // Clear the pickers so choosing the same file again still fires a change event. + for (const side of ['left', 'right']) { + const input = document.getElementById(`file-input-${side}`); + if (input) input.value = ''; + } + return undefined; + } sessionTracker.bypassGuard++; try { - return await original.apply(this, arguments); + return await original.call(this, pickedFiles, targetSide); } finally { sessionTracker.bypassGuard--; } @@ -14998,7 +15141,7 @@

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

          discardLabel: t('sessionDiscardConfirm'), }); if (choice === 'confirm') { - saveSession(); + if (!saveSession()) return false; return true; } return choice === 'discard'; @@ -15016,24 +15159,21 @@

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

          const SESSION_GUARDED_CLICKS = [ { selector: '#btn-clear' }, { selector: '#btn-clear-right' }, + { selector: '#btn-clear-left' }, { selector: '#lp-clear-right' }, { selector: '#btn-swap-docs' }, { selector: '.btn-load-sample' }, { selector: '#btn-help-sample' }, { // The paste dialog is pre-filled with the edited text; only an actual - // change of the right-hand text discards edits. + // change of either document resets review decisions. selector: '#btn-apply-modal', when: () => { const ta = document.getElementById('textarea-right'); - return !!ta && ta.value !== pasteModalShown.right; + const left = document.getElementById('textarea-left'); + return (!!ta && ta.value !== pasteModalShown.right) || (!!left && left.value !== pasteModalShown.left); }, }, - { - // Toggling the language while the sample is shown reloads the sample. - selector: '#btn-lang, #menu-lang, #footer-lang-link', - when: () => state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO', - }, ]; document.addEventListener( @@ -15202,9 +15342,17 @@

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

          } const name = buildSessionFileName(); const json = JSON.stringify(buildSessionPayload(), null, 1); + if (new Blob([json]).size > SESSION_MAX_FILE_BYTES) { + showSessionDialog({ + title: state.lang === 'de' ? 'Arbeitsstand kann nicht gespeichert werden' : 'Cannot save work', + body: t('sessionErrorTooLarge'), confirmLabel: t('sessionOk'), + }); + return false; + } downloadSessionFile(json, name); markSessionClean(); if (typeof showToast === 'function') showToast(formatMsg('toastSession', { name })); + return true; } /* ── Opening: strict validation, then restore ───────────────────────────── */ @@ -15483,17 +15631,9 @@

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

          if (e.target.closest('[data-session-action="save"]')) saveSession(); }); -// Downloading the result counts as saving it. Copying and printing do not: the -// clipboard is volatile, and neither a failed copy nor a cancelled print dialog can be -// detected, so they must not silence the unsaved-changes warning. -const SESSION_CLEANING_EXPORTS = ['txt', 'word', 'html', 'md']; -document.addEventListener('click', e => { - if (!(e.target instanceof Element)) return; - const item = e.target.closest('#export-menu [data-export]'); - if (item && SESSION_CLEANING_EXPORTS.includes(item.getAttribute('data-export'))) { - markSessionClean(); - } -}); +// Result exports do not preserve review decisions, source documents or undo history. +// Only saving a .docdiff session establishes a clean point. +const SESSION_CLEANING_EXPORTS = []; // A dropped .docdiff file is saved work, never a document to compare. This // capture listener runs before the pane / landing drop handlers. diff --git a/README.md b/README.md index ddef35b..a13af8c 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ python3 scripts/build.py # writes site/index.html, site/en/index.html and DocD node scripts/check.mjs # invariants and tests ``` -`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. +`scripts/check.mjs` verifies the Content Security Policy, the absence of external URLs and storage APIs, matching German and English strings, the search metadata and web app manifests of both language pages (title, canonical, hreflang, JSON-LD that matches the visible FAQ, sitemap, `llms.txt`, `manifest.webmanifest`), and runs the unit tests for the diff core (`scripts/test-core.mjs`), the export builders (`scripts/test-export.mjs`), and app state guards (`scripts/test-app-state.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). diff --git a/docs/releases/v1.7.0.md b/docs/releases/v1.7.0.md new file mode 100644 index 0000000..3e277b8 --- /dev/null +++ b/docs/releases/v1.7.0.md @@ -0,0 +1,10 @@ +# v1.7.0 — Accept all, numbers filter, two-file import and safer DOCX handling + +- **Review workflow.** "Accept all open" accepts every open change in the comparison after a confirmation that names the count and scope (including filtered entries and every page of grouped headers and footers), and it is one undo step. A new "Numbers, currencies & percentages" filter isolates changes to digits, amounts and percentages, and once everything is reviewed the action offers the final Word export. Review tooltips explain accepting, restoring and undoing, and the stepper and floating review controls support the reading shortcuts. +- **Import.** Both file pickers accept two files at once: the first is the old version, the second the new one. Replacing a reviewed pair asks once instead of twice, an explicit load starts a clean baseline, and cancelling the prompt lets you pick the same file again. A swap button in the top bar exchanges the two versions. +- **Safer DOCX and PDF handling.** DOCX archives are read with fixed limits (4,096 parts, 64 MiB per part, 256 MiB in total) before import and source export. Original-format Word export refuses ambiguous table edits and offers a newly typeset document (`_Generated.docx`) instead. PDF pages and loading tasks are cleaned up after extraction. +- **Fixes.** Rejected header/footer changes keep their numeric differences and still stay grouped across pages; the `hidden` attribute always hides elements; dark-mode links use the readable ink colour; narrower laptop widths get tighter padding; the English pages ship an English web app manifest. + +## Offline version + +Download `DocDiff.html` below and open it from disk. It works without an internet connection. diff --git a/docs/review-improvements.md b/docs/review-improvements.md new file mode 100644 index 0000000..2a2d6f1 --- /dev/null +++ b/docs/review-improvements.md @@ -0,0 +1,57 @@ +# Review assessment and implementation + +The previous review mixed reproducible defects, product proposals and unverified claims. This assessment records what was changed and what remains open. No numeric quality score or search-ranking improvement has been established. + +## Implemented + +- **DOCX safety:** ambiguous table-cell boundaries and edits to cells containing several nonempty paragraphs stop original-format rewriting. The user chooses whether to download a generated document before downloading it; its filename ends in `_Generated.docx`. Unchanged content keeps the existing source-preservation path. +- **Bounded DOCX archives:** a shared reader limits archives to 4,096 entries, 64 MiB per entry and 256 MiB total decompressed bytes. Import validates the archive before handing it to Mammoth; source export uses the same limits. These bounds reduce decompression risk; they do not guarantee a fixed peak memory usage. +- **PDF cleanup:** each extracted page is cleaned up and the loading task is destroyed after success or failure. +- **Review correctness:** rejected header/footer entries retain substantive numeric differences. They recompute their changed words from the rejected text, so a rejected group whose pages differ only in the page number stays one entry. Live modified entries already included changed-token evidence, so the previous blanket grouping diagnosis was too broad. +- **Session continuity:** switching language preserves edited/reviewed demos. Result exports no longer mark the session as saved. Oversized session downloads are blocked without clearing dirty state or allowing a pending destructive action. +- **Review workflow:** stepper and floating review controls support reading shortcuts. Batch acceptance explicitly names the whole-comparison scope, confirms the count, revalidates the selection and creates one undo step. After all entries are reviewed, the action offers final Word export. +- **Numeric filter:** changed digits, currency symbols and percentages can be isolated. This is a text filter, not a legal-risk classifier; dates or amounts spelled without digits may not match. +- **Import workflow:** both document pickers allow two files and use the existing drop order: first file is the old version, second is the new version. No age or filename heuristic silently reorders them. Cancelling the unsaved-work prompt clears the pickers, so the same file can be chosen again. Browsers without `DecompressionStream` keep importing DOCX text through Mammoth and only skip list numbering, as before. +- **UI:** direct toolbar swap reuses the existing guarded action; dark-mode links use the readable ink token; smaller laptop widths receive narrower sidebar and pane padding; decorative list markers are hidden from supported screen readers. Review tooltips explain acceptance, restoration and undoing a review. +- **Localized manifest:** English pages use an English manifest with `/en/` as its start URL and scope. Both manifests, their descriptions and icon files are checked. This corrects installation metadata; no SEO or AEO benefit is claimed. + +## Rejected recommendations + +- **Mark final Word export as a saved session:** it loses source files, review state and history. Only a `.docdiff` save establishes the saved point. Session history remains subject to its existing serialization budget. +- **Automatically infer old/new from timestamps or filenames:** such evidence can be misleading. Keep explicit ordering and make swapping easy. +- **Update sitemap timestamps on every build:** rebuilds alone do not establish that page content changed. Keep content dates intentional. +- **Delete assets solely because the application does not reference them:** external consumers may use published paths. No deletion was necessary for this task. +- **Add another landing preview:** the hero already demonstrates deleted and inserted text. Extra content needs a demonstrated usability benefit. +- **Promise ranking gains from keywords or `llms.txt`:** no ranking evidence was provided. + +## Deferred proposals and limitations + +- Moved-and-edited matching, paragraph split/merge alignment and expanded numbering grammar need representative fixtures and reconstruction checks before changing matching decisions. +- Structured cell editing and table rendering need persistent cell identities. The current safety fix refuses ambiguous source edits rather than claiming full table fidelity. +- Batch rejection needs a replacement model covering deleted rows and grouped changes; only batch acceptance is implemented. +- Notes, scoped search and suppressed-content inspection remain product proposals, not verified defects. +- Lazy mounting, virtualization and separate hosted assets need render-time/memory measurements and accessibility checks. Existing comparison-time figures do not establish DOM rendering performance. +- OCR, formatting comparison and reliable multi-column PDF reading remain outside the current feature set. Identical moves are detected; moved-and-edited content can appear as deletion plus insertion. + +## Verification + +Run `python3 scripts/build.py` and `node scripts/check.mjs`. Checks cover security/privacy invariants, both languages and manifests, worker parity, core diff behavior, export builders and application-state guards. New regression cases cover table refusal, fallback cancellation/naming, numeric filtering, session continuity and batch acceptance. + +## Browser and office verification on 2026-11-03 + +Additional interactive checks used locally generated DOCX files with delivery and price changes, plus PDFs rendered from those files by LibreOffice. + +- Two DOCX files selected in one picker loaded both versions and produced the expected two changes. This exposed a live `FileList` bug: clearing the picker after the first upload removed the second file. The intake now snapshots the selection before awaiting either upload. +- PDF/PDF import produced the expected two changed passages. Mixed PDF/DOCX import succeeded, but the compact PDF fixture merged several paragraphs, increasing alignment noise. Cross-format paragraph reconstruction remains a limitation. +- Batch acceptance confirmed the count and whole-comparison scope. Twenty demo entries were accepted with one undo snapshot; one keyboard undo restored all twenty open entries. +- The numeric filter reduced the demo list to fifteen entries and updated navigation counts. Accepting via the keyboard after clicking the stepper worked. +- Language switching kept review decisions. Saving and reopening a `.docdiff` file restored review counts, undo history and the original DOCX source. +- Replacing a saved pair revealed a second bug: review resets could make the second upload prompt again. Intake now guards the pair as one operation. Explicit loads establish a clean baseline even when the right text is unchanged. Reloading a reviewed, saved pair was rechecked without an intermediate dialog or stale dirty state. +- Direct toolbar swap reversed the document names without creating a dirty state on a fresh comparison. Replacing either document and changing either paste field now guards pending review work. +- Ambiguous table redline export displayed the fallback confirmation. Cancellation produced no new download; confirmation downloaded a file ending in `_Generated.docx`. +- Original-format final and tracked DOCX exports, plus the generated fallback, opened and rendered successfully in LibreOffice. Rendered pages were visually inspected: text, table columns and revision markings were present without clipping or overlap in these fixtures. +- Layout was checked at 1280×800 and 390×844 CSS pixels. The interactive comparison had no horizontal overflow. Because the preview screenshot endpoint timed out, visual inspection used local Chrome renders of captured DOM with the built CSS and fonts; the mobile capture used a 390-pixel iframe. These captures verify layout, not touch-device behavior. + +The additional checks found and fixed the import and baseline issues above. Regression coverage now totals 15 app-state groups, alongside 426 export groups, 51 core tests and 18 worker comparisons. Build, checks and whitespace validation pass. + +Microsoft Word, Safari, Firefox, physical mobile devices and interactive assistive technologies were not tested. These fixture checks do not establish fidelity for every Word feature or every PDF layout. The deferred proposals above remain separate work; this verification does not turn them into completed features. diff --git a/scripts/build.py b/scripts/build.py index edd2b30..91f4b86 100755 --- a/scripts/build.py +++ b/scripts/build.py @@ -50,6 +50,7 @@ ] }, "asset_base": "", + "manifest_href": "manifest.webmanifest", "og_locale": "de_DE", "og_locale_alt": "en_US", "og_image": SITE_URL + "og-image.png?v=3", @@ -59,6 +60,7 @@ "url": SITE_URL + "en/", "html_attrs": 'lang="en" data-page-lang="en"', "asset_base": "../", + "manifest_href": "manifest.webmanifest", "og_locale": "en_US", "og_locale_alt": "de_DE", "og_image": SITE_URL + "og-image-en.png?v=1", @@ -222,8 +224,10 @@ def render_page(template: str, lang: str, i18n: dict, version: str) -> str: ) alt_lang = "en" if lang == "de" else "de" alt_href = (SITE_URL + "en/") if lang == "de" else SITE_URL + manifest_href = page.get("manifest_href", "manifest.webmanifest") out = ( - out.replace("__HTML_ATTRS__", page["html_attrs"]) + out.replace('href="__ASSET_BASE__manifest.webmanifest"', f'href="{manifest_href}"') + .replace("__HTML_ATTRS__", page["html_attrs"]) .replace("__PAGE_URL__", page["url"]) .replace("__ASSET_BASE__", page["asset_base"]) .replace("__OG_LOCALE__", page["og_locale"]) diff --git a/scripts/check.mjs b/scripts/check.mjs index cc646d2..0f726e2 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -147,7 +147,7 @@ for (const siteFile of ['CNAME', 'og-image.png', 'og-image-en.png', 'impressum.h assert.equal(fs.readFileSync(path.join(root, 'site', 'CNAME'), 'utf8').trim(), 'docdiff.app', 'CNAME must be docdiff.app'); // 7. Search engines and assistants: both language pages, head metadata, structured data, crawl files -for (const siteFile of ['en/index.html', 'llms.txt', 'llms-full.txt', 'manifest.webmanifest', 'favicon.ico', 'apple-touch-icon.png']) { +for (const siteFile of ['en/index.html', 'llms.txt', 'llms-full.txt', 'manifest.webmanifest', 'en/manifest.webmanifest', 'favicon.ico', 'apple-touch-icon.png']) { assert.ok(fs.existsSync(path.join(root, 'site', siteFile)), `Missing required site file: site/${siteFile}`); } const enHtmlPath = path.join(root, 'site', 'en', 'index.html'); @@ -207,12 +207,19 @@ for (const page of [ assert.equal(metaContent(head, 'name', 'application-name'), 'Doc Diff'); assert.ok(head.includes('= 3, `${relPath}: manifest icons`); + for (const icon of manifestData.icons) { + const iconRel = icon.src.startsWith('/') ? icon.src.slice(1) : icon.src; + assert.ok(fs.existsSync(path.join(root, 'site', iconRel)), `${relPath}: manifest icon ${icon.src} missing`); + } } console.log('✓ SEO checks passed (de + en pages, metadata, JSON-LD FAQ, robots, sitemap, llms.txt).'); diff --git a/scripts/test-app-state.mjs b/scripts/test-app-state.mjs index e9bd3ed..8cbf592 100644 --- a/scripts/test-app-state.mjs +++ b/scripts/test-app-state.mjs @@ -75,12 +75,12 @@ let groups = 0; groups++; } -// 3. Only real downloads mark the session as saved; a failed copy is never reported as copied. +// 3. Lossy result downloads never mark the full session saved; failed copy stays unreported. { const m = session.match(/const SESSION_CLEANING_EXPORTS = (\[[^\]]*\]);/); assert.ok(m, 'SESSION_CLEANING_EXPORTS present'); const list = vm.runInNewContext(m[1]); - assert.deepEqual([...list].sort(), ['html', 'md', 'txt', 'word']); + assert.deepEqual([...list], [], 'only a session save preserves the full work'); const copy = extractFunction(exportJs, 'copyFinalTextToClipboard'); assert.match(copy, /if \(copied\) flashCopied\(\);/, 'copied label only after a successful copy'); groups++; @@ -180,6 +180,8 @@ let groups = 0; assert.equal(ok(el('SECTION', ['#pane-right'])), true, 'document pane'); assert.equal(ok(el('BUTTON', ['#sidebar-list'])), true, 'change list item'); assert.equal(ok(el('BUTTON', [])), false, 'toolbar button'); + assert.equal(ok(el('BUTTON', ['.stepper'])), true, 'stepper'); + assert.equal(ok(el('BUTTON', ['.review-bar'])), true, 'floating review controls'); assert.equal(ok(el('TEXTAREA', ['#pane-right', '.clause-inline-editor'])), false, 'inline editor'); assert.equal(ok(el('INPUT', [], { type: 'search' })), false, 'search box'); assert.equal(ok(el('DIV', ['#pane-right', '[contenteditable]'], { ce: true })), false, 'contenteditable'); @@ -197,6 +199,7 @@ let groups = 0; vm.runInContext('var t = key => key; var formatMsg = k => k; function getDisplayRef(row) { const b = row.rightBlock || row.leftBlock; return (b && b.refCode) || ""; }', ctx); for (const f of ['02-samples.js', '04-core.js', '04a-align.js']) vm.runInContext(read(f), ctx, { filename: f }); vm.runInContext(review, ctx, { filename: '06d-review.js' }); + vm.runInContext(`const ghostDiffCache = new WeakMap();\n${extractFunction(read('06-compare.js'), 'getItemWordDiffs')}`, ctx); vm.runInContext( `${['rebuildRightTextFromRows', 'sameBlock', 'makeHistorySnapshot', 'restoreHistorySnapshot', 'pushUndoSnapshot', 'performUndo', 'performRedo'] .map(n => extractFunction(interactions, n)) @@ -332,4 +335,151 @@ let groups = 0; groups++; } +// Language changes preserve saved demo edits, reviews and undo history. +{ + const ctx = { state: {}, SAMPLES: { de: { v1: 'old', v2: 'new' }, en: { v1: 'old en', v2: 'new en' } }, + document: { getElementById: () => null }, t: k => k, + resetEditHistory() { throw new Error('history must survive'); }, + applyTranslationsToStaticDom() {}, runComparison() {} }; + vm.createContext(ctx); + vm.runInContext(extractFunction(read('10-controls.js'), 'toggleLanguage'), ctx); + for (const extra of [{ rightText: 'saved edit' }, { reviews: { k: { status: 'accepted' } } }, { undoStack: ['new'] }]) { + ctx.state = { lang: 'de', leftFormat: 'DEMO', rightFormat: 'DEMO', leftText: 'old', rightText: 'new', reviews: {}, undoStack: [], redoStack: [], ...extra }; + const before = JSON.stringify({ ...ctx.state, lang: 'en' }); + vm.runInContext('toggleLanguage()', ctx); + assert.equal(JSON.stringify(ctx.state), before); + } + groups++; +} + +// Exact changed tokens protect substantive digits; missing tokens require exact text. +{ + const ctx = {}; + vm.createContext(ctx); + vm.runInContext(extractFunction(read('06d-review.js'), 'headerFooterGroupKey'), ctx); + // Stand-in for getItemWordDiffs: a rejected entry recomputes its tokens from the texts. + ctx.ghostDiffs = null; + vm.runInContext('function getItemWordDiffs(item) { return item.ghost ? ghostDiffs : item.row.wordDiffs || []; }', ctx); + const key = (left, right, diffs, ghost = false) => { + ctx.ghostDiffs = ghost ? diffs || [] : null; + ctx.item = { type: 'modified', status: ghost ? 'rejected' : 'open', + ghost: ghost ? { leftText: left, rightText: right } : null, + row: { leftBlock: { text: left }, rightBlock: { text: right }, wordDiffs: diffs } }; + return vm.runInContext('headerFooterGroupKey(item)', ctx); + }; + const tokens = n => [{ removed: true, value: '100' }, { added: true, value: String(n) }]; + assert.equal(key('Page 1 total 100', 'Page 1 total 200', tokens(200)), key('Page 2 total 100', 'Page 2 total 200', tokens(200))); + assert.notEqual(key('Total 100', 'Total 200', tokens(200)), key('Total 100', 'Total 300', tokens(300))); + for (const ghost of [true, false]) assert.notEqual(key('Total 100', 'Total 200', undefined, ghost), key('Total 100', 'Total 300', undefined, ghost)); + // A rejected group whose pages differ only in the page number stays one entry. + const draft = [{ removed: true, value: 'Draft' }, { added: true, value: 'Final' }]; + assert.equal(key('Page 1 of 10 Draft', 'Page 1 of 10 Final', draft, true), key('Page 2 of 10 Draft', 'Page 2 of 10 Final', draft, true)); + assert.notEqual(key('Total 100', 'Total 200', tokens(200), true), key('Total 100', 'Total 300', tokens(300), true)); + groups++; +} + +// Batch acceptance is confirmed, revalidated and one undo step, including grouped members. +{ + const ctx = { state: {}, canReviewNow: () => true, itemMembers: item => item.members || [item], + showSessionDialog: async () => 'confirm', pushUndoSnapshot() { ctx.undos++; }, + refreshReviewState() {}, announce() {}, undos: 0 }; + vm.createContext(ctx); + vm.runInContext('async ' + extractFunction(read('06d-review.js'), 'acceptAllOpenChanges'), ctx); + const member = k => ({ row: { reviewKey: k, type: 'modified' } }); + const reset = () => { ctx.undos = 0; ctx.state = { lang: 'en', leftText: 'a', rightText: 'b', reviews: {}, listItems: [ + { status: 'open', members: [member('one'), member('two')] }, { status: 'accepted', ...member('prior') }] }; }; + reset(); + assert.equal(await vm.runInContext('acceptAllOpenChanges()', ctx), true); + assert.equal(ctx.undos, 1); + assert.deepEqual(Object.keys(ctx.state.reviews), ['one', 'two']); + reset(); ctx.showSessionDialog = async () => 'cancel'; + assert.equal(await vm.runInContext('acceptAllOpenChanges()', ctx), false); + assert.equal(ctx.undos, 0); + reset(); ctx.showSessionDialog = async () => { ctx.state.rightText = 'changed'; return 'confirm'; }; + assert.equal(await vm.runInContext('acceptAllOpenChanges()', ctx), false); + assert.equal(ctx.undos, 0); + groups++; +} + +// A file exceeding the loader limit is never downloaded or marked clean. +{ + let downloads = 0, clean = 0; + const ctx = { state: { leftText: 'A', rightText: 'B', lang: 'en' }, + SESSION_MAX_FILE_BYTES: 10, Blob: class { constructor() { this.size = 11; } }, + t: k => k, buildSessionFileName: () => 'work.docdiff', buildSessionPayload: () => ({}), + showSessionDialog() {}, downloadSessionFile() { downloads++; }, markSessionClean() { clean++; } }; + vm.createContext(ctx); + vm.runInContext(extractFunction(session, 'saveSession'), ctx); + assert.equal(vm.runInContext('saveSession()', ctx), false); + assert.equal(downloads, 0); + assert.equal(clean, 0); + groups++; +} + +// A picker FileList is live and can be cleared by the first asynchronous upload. +{ + const files = { 0: { name: 'old.docx' }, 1: { name: 'new.docx' }, length: 2 }; + const calls = []; + const ctx = { files, clearImportNotice() {}, async handleFileUpload(file, side) { + calls.push([file?.name, side]); + delete files[0]; delete files[1]; files.length = 0; + } }; + vm.createContext(ctx); + vm.runInContext('async ' + extractFunction(read('09-import.js'), 'handleDropFiles'), ctx); + await vm.runInContext("handleDropFiles(files, 'left')", ctx); + assert.deepEqual(calls, [['old.docx', 'left'], ['new.docx', 'right']]); + groups++; +} + +// Pair replacement has one guard, including when resetting the left side changes dirty state. +{ + const start = session.indexOf("wrapGlobalFunction('handleDropFiles'"); + const end = session.indexOf('/* ── Accessible', start); + for (const [dirty, allow] of [[false, true], [true, true], [true, false]]) { + let wrapped, calls = 0, prompts = 0; + const tracker = { bypassGuard: 0 }; + const inputs = { 'file-input-left': { value: 'C:\\fakepath\\a.docx' }, 'file-input-right': { value: 'C:\\fakepath\\b.docx' } }; + const ctx = { sessionTracker: tracker, isSessionDirty: () => dirty, + document: { getElementById: id => inputs[id] || null }, + confirmDiscardEdits: async () => { prompts++; return allow; }, + wrapGlobalFunction: (_, factory) => { wrapped = factory(async files => { + calls++; + assert.equal(tracker.bypassGuard, 1, 'the entire pair bypasses nested guards'); + assert.ok(Array.isArray(files), 'selection is snapshotted before waiting'); + }); } }; + vm.createContext(ctx); + vm.runInContext(session.slice(start, end), ctx); + await wrapped({ 0: {}, 1: {}, length: 2 }, 'left'); + assert.equal(prompts, dirty ? 1 : 0); + assert.equal(calls, !dirty || allow ? 1 : 0); + assert.equal(tracker.bypassGuard, 0, 'guard restored'); + // Cancelling clears both pickers so the same file can be chosen again. + const cleared = dirty && !allow; + assert.equal(inputs['file-input-left'].value === '', cleared); + assert.equal(inputs['file-input-right'].value === '', cleared); + } + groups++; +} + +// Explicitly loading a pair establishes a baseline even when its right text is unchanged. +{ + const tracker = { restoring: false, editPending: true }; + let wrapped, clean = 0; + const ctx = { sessionTracker: tracker, state: { rightText: 'same text' }, + markSessionClean: () => { clean++; }, + wrapGlobalFunction: (_, factory) => { wrapped = factory(() => 42); } }; + vm.createContext(ctx); + const start = session.indexOf("wrapGlobalFunction('resetEditHistory'"); + const end = session.indexOf("wrapGlobalFunction('handleFileUpload'", start); + vm.runInContext(session.slice(start, end), ctx); + assert.equal(wrapped(), 42); + assert.equal(clean, 1); + assert.equal(tracker.originalRightText, 'same text'); + assert.equal(tracker.editPending, false); + tracker.restoring = true; + wrapped(); + assert.equal(clean, 1, 'session restoration keeps its own baseline'); + groups++; +} + console.log(`✓ App state guard tests passed (${groups} groups).`); diff --git a/scripts/test-export.mjs b/scripts/test-export.mjs index 30b4e68..2ba9149 100644 --- a/scripts/test-export.mjs +++ b/scripts/test-export.mjs @@ -7,6 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import assert from 'node:assert/strict'; +import { deflateRawSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -456,4 +457,57 @@ await (async () => { checks += 6; })(); +// Streaming limits use actual output bytes, independent of archive size claims. +{ + const compressed = deflateRawSync(Buffer.alloc(100_000, 65)); + await assert.rejects(api.inflateRaw(compressed, 64), /decompression limit/); + assert.equal((await api.inflateRaw(compressed, 100_000)).length, 100_000); + const tooMany = Buffer.alloc(22); + tooMany.writeUInt32LE(0x06054b50, 0); + tooMany.writeUInt16LE(4097, 10); + await assert.rejects(api.readZipEntries(tooMany), /entry count limit/); + checks++; +} + +// Numeric filtering examines changed tokens, not unchanged clause numbers. +{ + assert.equal(api.itemHasNumericChange({ row: { type: 'modified', wordDiffs: [{ value: '§ 3 ' }, { value: 'may', removed: true }, { value: 'must', added: true }] } }), false); + assert.equal(api.itemHasNumericChange({ row: { type: 'modified', wordDiffs: [{ value: '14', removed: true }, { value: '30', added: true }] } }), true); + assert.equal(api.itemHasNumericChange({ row: { type: 'inserted', rightBlock: { text: 'Price CHF 50' } } }), true); + assert.equal(api.itemHasNumericChange({ ghost: { leftText: 'Term 14 days', rightText: 'Term 30 days' } }), true); + checks++; +} + +// Ambiguous cell boundaries and paragraph structure must never be flattened silently. +{ + const step = { block: { text: 'A · B · C' }, oldBlock: null, open: false }; + assert.throws(() => api.rewriteDocxRow({ cells: [{ text: 'A · B' }, { text: 'C' }] }, '', {}, step, () => '', () => {}), /ambiguous table/); + assert.throws(() => api.rewriteDocxRow({ cells: [{ text: 'A' }, { text: 'B' }] }, '', {}, { ...step, block: { text: 'AB' } }, () => '', () => {}), /ambiguous table/); + const cell = { text: 'A B', texts: ['A', 'B'], paras: [{}, {}] }; + assert.throws(() => api.rewriteDocxRow({ cells: [cell] }, '', {}, { ...step, block: { text: 'C' } }, () => '', () => {}), /multi-paragraph/); + checks += 3; +} + +// A source-format failure requires a decision before any generated download. +await (async () => { + const originals = {}; + for (const key of ['getRightDocxSource', 'buildDocxFromSource', 'buildGeneratedDocx', 'downloadBlob', 'confirm']) originals[key] = api[key]; + let downloads = 0; + let filename = ''; + api.getRightDocxSource = () => ({}); + api.buildDocxFromSource = async () => { throw new Error('unsupported table'); }; + api.buildGeneratedDocx = () => new Uint8Array(); + api.downloadBlob = (_, __, name) => { downloads++; filename = name; }; + api.confirm = () => false; + try { + assert.equal(await api.exportWord('final'), false); + assert.equal(downloads, 0, 'cancel leaves no downloaded fallback'); + api.confirm = () => true; + assert.equal(await api.exportWord('final'), true); + assert.equal(downloads, 1); + assert.match(filename, /_Generated\.docx$/); + checks += 2; + } finally { Object.assign(api, originals); } +})(); + console.log(`✓ Export builder tests passed (${checks} check groups).`); diff --git a/site/en/index.html b/site/en/index.html index 6caba5e..9c9f3fc 100644 --- a/site/en/index.html +++ b/site/en/index.html @@ -42,7 +42,7 @@ - + @@ -112,7 +112,7 @@ "sameAs": [ "https://github.com/tschnorpfeil/docdiff" ], - "softwareVersion": "1.6.5" + "softwareVersion": "1.7.0" }, { "@type": "SoftwareSourceCode", @@ -255,6 +255,7 @@ --topbar-h: 52px; --list-w: 336px; --ruler-w: 14px; + --pane-pad-x: var(--sp-6); /* outer padding of a document pane; the inner side is --sp-4 */ --background: 0 0% 97.5%; --foreground: 215 25% 13%; @@ -365,6 +366,14 @@ padding: 0; } +/* The hidden attribute must win over component display values. Without this, an author + `display` (`.btn { display: inline-flex }`, `.menu-item { display: flex }`) beats the + user-agent `[hidden]` rule and keeps elements visible that JS has marked as hidden, + for example the "accept all" action when a comparison has no changes. */ +[hidden] { + display: none !important; +} + html { font-size: 100%; } @@ -626,6 +635,14 @@ cursor: pointer; flex-shrink: 0; } +/* Direct swap next to the file pair: quieter than the stepper, same 44 px target */ +.tb-swap { + color: hsl(var(--muted-foreground)); +} +.tb-swap:hover, +.tb-swap:focus-visible { + color: hsl(var(--foreground)); +} .tb-icon-btn.sm { width: 30px; height: 30px; @@ -1149,7 +1166,7 @@ display: inline-flex; align-items: center; min-height: 24px; - color: hsl(var(--primary)); + color: hsl(var(--primary-ink)); text-decoration: underline; text-underline-offset: 3px; } @@ -1480,10 +1497,12 @@ } .scope-can li::before { content: "✓"; + content: "✓" / ""; color: hsl(var(--success-ink)); } .scope-cannot li::before { content: "–"; + content: "–" / ""; color: hsl(var(--destructive-ink)); } @@ -1598,7 +1617,7 @@ /* The hidden stacked pane spans the ruler too; the extra padding keeps its text width */ body:not(.view-inline) #pane-unified { grid-column: 1 / 4; - padding-right: calc(var(--sp-6) + var(--ruler-w)); + padding-right: calc(var(--pane-pad-x) + var(--ruler-w)); } body.view-inline #pane-left, @@ -1616,7 +1635,7 @@ overflow-y: auto; overflow-x: hidden; position: relative; - padding: 0 var(--sp-6) var(--sp-7); + padding: 0 var(--pane-pad-x) var(--sp-7); background: hsl(var(--card)); scrollbar-width: thin; scrollbar-color: hsl(var(--input)) transparent; @@ -1657,6 +1676,15 @@ } } +/* Narrow desktops (1200–1439 px): the list and the outer padding give the text some room back. + Both panes shrink by the same amount, so unchanged paragraphs still wrap identically. */ +@media (min-width: 1200px) and (max-width: 1439px) { + :root { + --list-w: 304px; + --pane-pad-x: var(--sp-5); + } +} + /* Sticky, neutral caption per pane: "Alte Fassung · Vertrag_v1.docx" */ .pane-caption { position: sticky; @@ -3653,8 +3681,8 @@ - - + + @@ -3739,6 +3767,10 @@

          Documents

+ +
@@ -3958,7 +3990,7 @@

How it works

-
    +
    1. Choose two versions As PDF, Word file or pasted text. The files are only read inside this browser tab.
    2. Go through the changes Removed text is struck through in red, added text is marked green. You can accept or reject each change.
    3. Save the result if you need it As a clean final version (Word in the original format, or PDF) or as a comparison with tracked changes.
    4. @@ -3983,7 +4015,7 @@

      What it can do – and what it can

      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
        • @@ -3994,7 +4026,7 @@

          Can

      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
        • @@ -4293,6 +4325,10 @@

          metaParas: '{n} Absätze', metaParasOne: '1 Absatz', demoLabel: 'Beispiel', + filterNumeric: 'Zahlen, Währungen & Prozent', + stepLabel_numeric: 'mit Zahlenänderungen', + reviewAcceptAll: 'Alle offenen annehmen ({n})', + reviewCompleteExport: 'Alles geprüft · Word-Endfassung exportieren', dockLabel: 'Änderungen durchgehen', prevLabel: 'Vorherige Änderung', nextLabel: 'Nächste Änderung', @@ -4367,6 +4403,7 @@

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word-Datei gespeichert: {name}', toastWordOriginal: 'Word-Datei im Originalformat gespeichert: {name}', + confirmWordFallback: 'Das Originalformat konnte nicht sicher erhalten werden. Stattdessen ein neu gesetztes Word-Dokument herunterladen? Tabellen, Bilder und Seitenlayout können vom Original abweichen.', noticeWordFallback: 'Gespeichert: {name}. Das Originalformat ließ sich für diese Datei nicht sicher übernehmen (z. B. wegen Änderungen in Fußnoten oder Textfeldern). Die Word-Datei wurde deshalb neu gesetzt; der Text ist vollständig.', toastHtml: 'Bericht gespeichert: {name}', toastMd: 'Markdown-Bericht gespeichert: {name}', @@ -4441,9 +4478,9 @@

          acceptBtn: 'Annehmen', rejectBtn: 'Ablehnen', editBtn: 'Bearbeiten', - reopenBtn: 'Rückgängig', - acceptTitle: 'Annehmen (A)', - rejectTitle: 'Ablehnen (R)', + reopenBtn: 'Prüfung zurücknehmen', + acceptTitle: 'Änderung annehmen (A)', + rejectTitle: 'Ablehnen – alte Fassung wiederherstellen (R)', editTitle: 'Bearbeiten (E)', reopenTitle: 'Prüfung zurücknehmen', rejectedInsNote: 'Abgelehnt – hinzugefügter Absatz danach entfernt', @@ -4520,7 +4557,7 @@

          Ihre Daten

          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.', + 'Sie haben Änderungen geprüft oder Absätze bearbeitet, und dieser Arbeitsstand ist noch nicht als .docdiff-Datei gespeichert. Wenn Sie ihn verwerfen, geht diese Arbeit verloren.', sessionDiscardConfirm: 'Verwerfen', sessionSaveContinue: 'Arbeitsstand speichern', sessionErrorTitle: 'Arbeitsstand kann nicht geöffnet werden', @@ -4706,6 +4743,10 @@

          Ihre Daten

          metaParas: '{n} paragraphs', metaParasOne: '1 paragraph', demoLabel: 'Example', + filterNumeric: 'Numbers, currencies & percentages', + stepLabel_numeric: 'with number changes', + reviewAcceptAll: 'Accept all open ({n})', + reviewCompleteExport: 'Review complete · Export final Word document', dockLabel: 'Go through the changes', prevLabel: 'Previous change', nextLabel: 'Next change', @@ -4780,6 +4821,7 @@

          Ihre Daten

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word file saved: {name}', toastWordOriginal: 'Word file saved in the original format: {name}', + confirmWordFallback: 'The original formatting could not be preserved reliably. Download a newly typeset Word document instead? Tables, images and page layout may differ from the original.', noticeWordFallback: 'Saved: {name}. The original format could not be kept reliably for this file (for example because of changes in footnotes or text boxes), so the Word file was typeset anew; the text is complete.', toastHtml: 'Report saved: {name}', toastMd: 'Markdown report saved: {name}', @@ -4854,9 +4896,9 @@

          Ihre Daten

          acceptBtn: 'Accept', rejectBtn: 'Reject', editBtn: 'Edit', - reopenBtn: 'Undo', - acceptTitle: 'Accept (A)', - rejectTitle: 'Reject (R)', + reopenBtn: 'Undo review', + acceptTitle: 'Accept change (A)', + rejectTitle: 'Reject – restore the old version (R)', editTitle: 'Edit (E)', reopenTitle: 'Undo the review of this change', rejectedInsNote: 'Rejected – added paragraph below removed', @@ -4933,7 +4975,7 @@

          Your data

          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.', + 'You have reviewed changes or edited paragraphs, and this work has not been saved as a .docdiff session file yet. If you discard it, it will be lost.', sessionDiscardConfirm: 'Discard', sessionSaveContinue: 'Save work', sessionErrorTitle: 'Cannot open saved work', @@ -8408,6 +8450,18 @@

          Your data

          return item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; } +// Inspect only changed text: an unchanged clause number must not trigger this filter. +function itemHasNumericChange(item) { + if (item.ghost) return computeCleanWordDiff(item.ghost.leftText || '', item.ghost.rightText || '').some(part => + (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + const row = item.row; + if (row.wordDiffs && row.wordDiffs.length) { + return row.wordDiffs.some(part => (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + } + if (row.type === 'modified') return false; + return /[0-9\p{Sc}%]/u.test((row.rightBlock || row.leftBlock || {}).text || ''); +} + // List items under the active list filter (search never filters the list). function getFilteredListItems() { const f = state.sidebarFilter; @@ -8415,7 +8469,7 @@

          Your data

          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; + return f === 'all' || (f === 'numeric' ? itemHasNumericChange(item) : item.type === f); }); } // Kept for callers and QA scripts written against the earlier name. @@ -8425,6 +8479,21 @@

          Your data

          const total = state.listItems.length; const counts = getChangeCounts(); const review = getReviewCounts(); + let action = document.getElementById('btn-review-all'); + if (!action) { + action = document.createElement('button'); + action.id = 'btn-review-all'; + action.type = 'button'; + action.className = 'btn btn-ghost'; + action.addEventListener('click', () => { + if (getReviewCounts().open) acceptAllOpenChanges(); + else handleExportAction('word-final'); + }); + document.getElementById('sidebar-list').before(action); + } + action.hidden = !total; + action.disabled = !canReviewNow(); + action.textContent = review.open ? formatMsg('reviewAcceptAll', { n: review.open }) : t('reviewCompleteExport'); const filtered = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; document.getElementById('list-title').textContent = filtered ? formatMsg('listTitleFiltered', { k: getFilteredListItems().length, n: total }) @@ -8438,6 +8507,11 @@

          Your data

          // One filter: "Alle", a review status or a kind of change, each with its count. const select = document.getElementById('list-filter'); + if (!select.querySelector('option[value="numeric"]')) { + const option = document.createElement('option'); + option.value = 'numeric'; + document.getElementById('lf-group-kind').appendChild(option); + } const value = state.statusFilter !== 'all' ? state.statusFilter : state.sidebarFilter; const options = [ ['all', 'filterAll', total], @@ -8446,6 +8520,7 @@

          Your data

          ['modified', 'filterMod', counts.mod], ['inserted', 'filterIns', counts.ins], ['deleted', 'filterDel', counts.del], + ['numeric', 'filterNumeric', state.listItems.filter(itemHasNumericChange).length], ]; for (const [v, key, n] of options) { const opt = select.querySelector(`option[value="${v}"]`); @@ -9434,20 +9509,21 @@

          Your data

          } // Running headers and footers repeat on every page with the page number in them: the same -// change on several pages is one decision. Numbers are ignored when comparing the lines; the -// changed words themselves must be identical. +// change on several pages is one decision. Digits in the surrounding text are ignored, but the +// changed words themselves (numbers included) must be identical. Rejected entries recompute +// their changed words from the rejected text so a rejected group stays one entry. function headerFooterGroupKey(item) { if (item.type !== 'modified') return ''; - const norm = s => String(s || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); + const diffs = getItemWordDiffs(item); + const changedParts = Array.isArray(diffs) ? diffs.filter(d => d.added || d.removed) : []; + const hasTokens = changedParts.length > 0; + const norm = s => { + const text = String(s || ''); + return (hasTokens ? text.replace(/\d+/g, '#') : text).replace(/\s+/g, ' ').trim(); + }; const left = item.ghost ? item.ghost.leftText : item.row.leftBlock ? item.row.leftBlock.text : ''; const right = item.ghost ? item.ghost.rightText : item.row.rightBlock ? item.row.rightBlock.text : ''; - let changed = ''; - if (!item.ghost && Array.isArray(item.row.wordDiffs)) { - changed = item.row.wordDiffs - .filter(d => d.added || d.removed) - .map(d => (d.added ? '+' : '-') + d.value) - .join('\u0001'); - } + const changed = changedParts.map(d => (d.added ? '+' : '-') + d.value).join('\u0001'); return [item.status, item.ghost ? 'g' : 'l', norm(left), norm(right), changed].join('\u0000'); } @@ -9878,6 +9954,35 @@

          Your data

          return done; } +// All open list entries in this comparison, including hidden/filtered entries and every +// member of a grouped header/footer change. Revalidate after the confirmation dialog. +async function acceptAllOpenChanges() { + if (!canReviewNow()) return false; + const items = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (!items.length) return false; + const leftText = state.leftText, rightText = state.rightText; + const signature = JSON.stringify(items.map(item => itemMembers(item).map(m => m.row.reviewKey))); + const de = state.lang === 'de'; + const choice = await showSessionDialog({ + title: de ? 'Alle offenen Änderungen annehmen?' : 'Accept all open changes?', + body: de + ? `Alle ${items.length} offenen Einträge im gesamten Vergleich annehmen, einschließlich ausgeblendeter und gefilterter Einträge sowie aller Seiten gruppierter Kopf-/Fußzeilen? Dies ist mit einem Rückgängig-Schritt umkehrbar.` + : `Accept all ${items.length} open entries in the entire comparison, including hidden and filtered entries and every page of grouped headers/footers? This can be undone in one step.`, + confirmLabel: de ? 'Alle annehmen' : 'Accept all', + cancelLabel: de ? 'Abbrechen' : 'Cancel', + }); + if (choice !== 'confirm' || !canReviewNow() || state.leftText !== leftText || state.rightText !== rightText) return false; + const current = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (JSON.stringify(current.map(item => itemMembers(item).map(m => m.row.reviewKey))) !== signature) return false; + pushUndoSnapshot(); + for (const item of current) { + for (const m of itemMembers(item)) state.reviews[m.row.reviewKey] = { status: 'accepted', type: m.row.type }; + } + refreshReviewState(); + announce(de ? `${items.length} Einträge angenommen.` : `Accepted ${items.length} entries.`); + return true; +} + /* ── Overview ruler: where the changes are, and which part of the document is in view ─── */ const rulerEl = document.getElementById('ruler'); @@ -10660,6 +10765,24 @@

          Your data

          }, 60); }); +/* ── Direct swap in the top bar ───────────────────────────────────────────── + * A second entry point to the swap in the documents popover. It only forwards the click, so the + * swap itself (and the guard in front of it that protects unsaved review work) stays in one place. + * The aria-label comes from data-i18n-label; the visual tooltip reads the same text on demand, + * so it follows the language without a separate update step. */ +const btnSwapQuick = document.getElementById('btn-swap-quick'); +if (btnSwapQuick) { + const syncSwapTooltip = () => btnSwapQuick.setAttribute('data-tooltip', t('swapBtn')); + syncSwapTooltip(); + // Runs on the button itself, before the document-level tooltip handlers read the attribute. + btnSwapQuick.addEventListener('pointerover', syncSwapTooltip); + btnSwapQuick.addEventListener('focusin', syncSwapTooltip); + btnSwapQuick.addEventListener('click', () => { + hideTooltip(); + document.getElementById('btn-swap-docs').click(); + }); +} + /* ── Word export: a real .docx (OOXML) with tracked changes, built in the browser ── * Everything here is dependency-free: a minimal ZIP writer (STORE, CRC-32) plus hand-written * WordprocessingML. Changes are emitted as genuine revisions (/), so Word, @@ -12010,14 +12133,17 @@

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

          if (typeof console !== 'undefined') console.warn('Doc Diff: original-format Word export not possible:', err && err.message); } } + if (fellBack && !window.confirm(t('confirmWordFallback'))) return false; if (!bytes) bytes = buildGeneratedDocx(kind, date); - const filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + let filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + if (fellBack) filename = filename.replace(/\.docx$/, '_Generated.docx'); downloadBlob(bytes, DOCX_MIME, filename); if (fellBack && typeof showImportNotice === 'function') { showImportNotice(tf('noticeWordFallback', { name: filename }), 'warning'); } else if (typeof showToast === 'function') { showToast(tf(source ? 'toastWordOriginal' : 'toastWord', { name: filename })); } + return true; } function startPrint(kind) { @@ -12115,9 +12241,24 @@

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

          /* ── ZIP reading (all entries, as bytes) ─────────────────────────────────── */ -async function inflateRaw(bytes) { +async function inflateRaw(bytes, limit) { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - return new Uint8Array(await new Response(stream).arrayBuffer()); + const reader = stream.getReader(); + const chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) throw new Error('zip: decompression limit exceeded'); + chunks.push(value); + } + } finally { await reader.cancel().catch(() => {}); } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } + return result; } /** Reads every entry of a ZIP archive: [{ name, data: Uint8Array }] in archive order. */ @@ -12133,9 +12274,12 @@

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

          } if (eocd < 0) throw new Error('zip: no central directory'); const count = dv.getUint16(eocd + 10, true); + if (count > 4096) throw new Error('zip: entry count limit exceeded'); let p = dv.getUint32(eocd + 16, true); const utf8 = new TextDecoder('utf-8'); const out = []; + const entryLimit = 64 * 1024 * 1024, totalLimit = 256 * 1024 * 1024; + let total = 0; for (let n = 0; n < count; n++) { if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory'); const method = dv.getUint16(p + 10, true); @@ -12148,10 +12292,15 @@

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

          p += 46 + nameLen + extraLen + commentLen; const dataStart = localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); const raw = bytes.subarray(dataStart, dataStart + compSize); + const limit = Math.min(entryLimit, totalLimit - total); let data; - if (method === 0) data = raw.slice(); - else if (method === 8) data = await inflateRaw(raw); + if (method === 0) { + if (raw.length > limit) throw new Error('zip: decompression limit exceeded'); + data = raw.slice(); + } + else if (method === 8) data = await inflateRaw(raw, limit); else throw new Error(`zip: unsupported method ${method}`); + total += data.length; out.push({ name, data }); } return out; @@ -12929,11 +13078,15 @@

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

          step.open ? (oldText === null ? [{ value: newText, added: true }] : computeCleanWordDiff(oldText, newText)) : [{ value: newText }]; const writeCell = (cell, tokens) => { const paras = cell.paras.filter((p, i) => cell.texts[i]); + if (paras.length > 1) throw new Error('docx: cannot safely rewrite a multi-paragraph cell'); const first = paras[0] || cell.paras[0]; if (!first) throw new Error('docx: cell without paragraph'); edit(first.start, first.end, rewriteDocxParagraph(first, xml, ctx, tokens, 0, rev, '')); for (const other of paras.slice(1)) edit(other.start, other.end, rewriteDocxParagraph(other, xml, ctx, [], 0, rev, '')); }; + if (filled.some(c => c.text.includes(' · ')) || + (oldCells && oldCells.length !== filled.length) || + newCells.length !== filled.length) throw new Error('docx: ambiguous table row'); if (newCells.length === filled.length) { newCells.forEach((text, i) => { const cell = filled[i]; @@ -12943,12 +13096,7 @@

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

          }); return newCells.join(' · '); } - // The number of filled cells changed: the whole row text goes into the first cell. - const target = filled[0] || unit.cells[0]; - if (!target) throw new Error('docx: row without cells'); - writeCell(target, step.open && step.oldBlock ? computeCleanWordDiff(docxKey(step.oldBlock.text), docxKey(step.block.text)) : [{ value: docxKey(step.block.text) }]); - for (const cell of filled) if (cell !== target) writeCell(cell, []); - return docxKey(step.block.text); + throw new Error('docx: ambiguous table row'); } /* ── Entry point ────────────────────────────────────────────────────────── */ @@ -13091,17 +13239,20 @@

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

          // map them to clear, translated messages. A page that cannot be read fails the whole file // rather than silently dropping its text from the comparison. const pages = []; + const task = window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }); try { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; + const pdf = await task.promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); - const textContent = await page.getTextContent(); - pages.push(buildPdfPageLines(textContent.items, page.view)); + try { + const textContent = await page.getTextContent(); + pages.push(buildPdfPageLines(textContent.items, page.view)); + } finally { page.cleanup(); } } } catch (err) { if (err instanceof ImportError) throw err; throw new ImportError(err && err.name === 'PasswordException' ? 'errPdfPassword' : 'errPdfCorrupt'); - } + } finally { await task.destroy(); } const { text, emptyPages } = assemblePdfText(pages); if (pages.length === 0 || emptyPages.length === pages.length) { // No text layer at all: a scan or an image-only PDF. OCR is not available. @@ -13115,50 +13266,22 @@

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

          /* ── DOCX ──────────────────────────────────────────────────────────────── */ -// Minimal ZIP reader (central directory + stored/deflate entries) for the few XML parts -// Mammoth does not expose. Returns null when the archive cannot be read this way. +// Validate the entire archive with the bounded reader before Mammoth decompresses it. +// Returning null on a budget failure would let the unbounded fallback run anyway. async function readZipTextEntries(arrayBuffer, wantedNames) { + // Without DecompressionStream (older browsers) the bounded reader cannot inflate anything; + // let Mammoth import the text and skip only the extra parts, as before. + if (typeof DecompressionStream === 'undefined') return null; try { - const bytes = new Uint8Array(arrayBuffer); - const dv = new DataView(arrayBuffer); - let eocd = -1; - for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { - if (dv.getUint32(i, true) === 0x06054b50) { - eocd = i; - break; - } - } - if (eocd < 0) return null; - const count = dv.getUint16(eocd + 10, true); - let p = dv.getUint32(eocd + 16, true); + const entries = await readZipEntries(arrayBuffer); const utf8 = new TextDecoder('utf-8'); const out = {}; - for (let n = 0; n < count; n++) { - if (dv.getUint32(p, true) !== 0x02014b50) return null; - const method = dv.getUint16(p + 10, true); - const compSize = dv.getUint32(p + 20, true); - const nameLen = dv.getUint16(p + 28, true); - const extraLen = dv.getUint16(p + 30, true); - const commentLen = dv.getUint16(p + 32, true); - const localOffset = dv.getUint32(p + 42, true); - const name = utf8.decode(bytes.subarray(p + 46, p + 46 + nameLen)); - p += 46 + nameLen + extraLen + commentLen; - if (!wantedNames.includes(name)) continue; - const dataStart = - localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); - const data = bytes.subarray(dataStart, dataStart + compSize); - if (method === 0) { - out[name] = utf8.decode(data); - } else if (method === 8 && typeof DecompressionStream !== 'undefined') { - const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - out[name] = utf8.decode(await new Response(stream).arrayBuffer()); - } else { - return null; - } + for (const entry of entries) { + if (wantedNames.includes(entry.name)) out[entry.name] = utf8.decode(entry.data); } return out; } catch (err) { - return null; + throw new ImportError('errDocxCorrupt'); } } @@ -13790,6 +13913,8 @@

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

          async function handleDropFiles(files, targetSide) { if (!files || !files.length) return; + // FileList is live: the first upload clears its input, including the second file. + files = Array.from(files); if (files.length >= 2) { clearImportNotice(); await handleFileUpload(files[0], 'left', { keepNotice: true }); @@ -13830,8 +13955,8 @@

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

          const fileInputRight = document.getElementById('file-input-right'); document.getElementById('btn-upload-left').addEventListener('click', () => fileInputLeft.click()); document.getElementById('btn-upload-right').addEventListener('click', () => fileInputRight.click()); -fileInputLeft.addEventListener('change', e => handleFileUpload(e.target.files[0], 'left')); -fileInputRight.addEventListener('change', e => handleFileUpload(e.target.files[0], 'right')); +fileInputLeft.addEventListener('change', e => handleDropFiles(e.target.files, 'left')); +fileInputRight.addEventListener('change', e => handleDropFiles(e.target.files, 'right')); document.getElementById('btn-clear-left').addEventListener('click', () => { cancelPendingUploads('left'); @@ -14308,7 +14433,7 @@

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

          if (isTextEntryElement(el) || (el.closest && el.closest('[contenteditable], .clause-inline-editor, dialog'))) { return false; } - return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list')); + return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list, .review-bar, .stepper')); } window.addEventListener('keydown', e => { @@ -14420,8 +14545,12 @@

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

          function toggleLanguage() { const langHint = document.getElementById('lang-hint'); if (langHint) langHint.hidden = true; + const previousSample = SAMPLES[state.lang] || SAMPLES.de; + const pristineDemo = state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO' && + state.leftText === previousSample.v1 && state.rightText === previousSample.v2 && + Object.keys(state.reviews).length === 0 && state.undoStack.length === 0 && state.redoStack.length === 0; state.lang = state.lang === 'de' ? 'en' : 'de'; - if (state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO') { + if (pristineDemo) { const sample = SAMPLES[state.lang] || SAMPLES.de; state.leftName = t('sampleLeftName'); state.leftText = sample.v1; @@ -14769,8 +14898,7 @@

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

          /* ── Work-Loss Protection: Unsaved-Changes Guard & Saved Work (.docdiff) ── * * Nothing is ever written to browser storage. Work is protected in two ways: - * 1. A dirty flag (inline edits, accepted / rejected changes since the last load, save or - * export) drives the native `beforeunload` prompt and an in-app dialog in front of every + * 1. A dirty flag (inline edits, accepted / rejected changes since the last load or session save) drives the native `beforeunload` prompt and an in-app dialog in front of every * action that would throw this work away ("Abbrechen / Arbeitsstand speichern / Verwerfen"). * 2. "Arbeitsstand speichern…" downloads a self-describing JSON file (.docdiff) to the * user's own disk; "Arbeitsstand öffnen…" validates it strictly and restores it through @@ -14785,7 +14913,7 @@

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

          * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.6.5'; +const DOCDIFF_APP_VERSION = '1.7.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -14804,7 +14932,7 @@

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

          const sessionTracker = { // Right-hand text as it was last loaded (before any inline edits). originalRightText: state.rightText, - // Right-hand text and review status at the last "clean point" (load, save, export). + // Right-hand text and review status at the last "clean point" (load or session save). cleanRightText: state.rightText, cleanReviews: serializeReviews(), // Right-hand text seen by the previous runComparison call. @@ -14877,16 +15005,21 @@

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

          wrapGlobalFunction('resetEditHistory', original => function resetEditHistoryForLoad() { sessionTracker.editPending = false; - return original.apply(this, arguments); + const result = original.apply(this, arguments); + if (!sessionTracker.restoring) { + sessionTracker.originalRightText = state.rightText; + markSessionClean(); + } + return result; } ); wrapGlobalFunction('handleFileUpload', original => async function guardedFileUpload(file, side) { - if (file && side === 'right' && !sessionTracker.bypassGuard && isSessionDirty()) { + if (file && !sessionTracker.bypassGuard && isSessionDirty()) { const ok = await confirmDiscardEdits(); if (!ok) { - const input = document.getElementById('file-input-right'); + const input = document.getElementById(`file-input-${side}`); if (input) input.value = ''; return undefined; } @@ -14897,12 +15030,22 @@

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

          wrapGlobalFunction('handleDropFiles', original => async function guardedDropFiles(files, targetSide) { - const replacesRight = files && (files.length >= 2 || (files.length === 1 && targetSide === 'right')); - if (replacesRight && !sessionTracker.bypassGuard && isSessionDirty()) { - if (!(await confirmDiscardEdits())) return undefined; + const replacesDocument = files && files.length > 0; + if (replacesDocument && !sessionTracker.bypassGuard) { + // Keep the pair under one guard even when it starts clean: replacing the left + // side can reset reviews and otherwise cause a second prompt halfway through. + const pickedFiles = Array.from(files); + if (isSessionDirty() && !(await confirmDiscardEdits())) { + // Clear the pickers so choosing the same file again still fires a change event. + for (const side of ['left', 'right']) { + const input = document.getElementById(`file-input-${side}`); + if (input) input.value = ''; + } + return undefined; + } sessionTracker.bypassGuard++; try { - return await original.apply(this, arguments); + return await original.call(this, pickedFiles, targetSide); } finally { sessionTracker.bypassGuard--; } @@ -15001,7 +15144,7 @@

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

          discardLabel: t('sessionDiscardConfirm'), }); if (choice === 'confirm') { - saveSession(); + if (!saveSession()) return false; return true; } return choice === 'discard'; @@ -15019,24 +15162,21 @@

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

          const SESSION_GUARDED_CLICKS = [ { selector: '#btn-clear' }, { selector: '#btn-clear-right' }, + { selector: '#btn-clear-left' }, { selector: '#lp-clear-right' }, { selector: '#btn-swap-docs' }, { selector: '.btn-load-sample' }, { selector: '#btn-help-sample' }, { // The paste dialog is pre-filled with the edited text; only an actual - // change of the right-hand text discards edits. + // change of either document resets review decisions. selector: '#btn-apply-modal', when: () => { const ta = document.getElementById('textarea-right'); - return !!ta && ta.value !== pasteModalShown.right; + const left = document.getElementById('textarea-left'); + return (!!ta && ta.value !== pasteModalShown.right) || (!!left && left.value !== pasteModalShown.left); }, }, - { - // Toggling the language while the sample is shown reloads the sample. - selector: '#btn-lang, #menu-lang, #footer-lang-link', - when: () => state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO', - }, ]; document.addEventListener( @@ -15205,9 +15345,17 @@

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

          } const name = buildSessionFileName(); const json = JSON.stringify(buildSessionPayload(), null, 1); + if (new Blob([json]).size > SESSION_MAX_FILE_BYTES) { + showSessionDialog({ + title: state.lang === 'de' ? 'Arbeitsstand kann nicht gespeichert werden' : 'Cannot save work', + body: t('sessionErrorTooLarge'), confirmLabel: t('sessionOk'), + }); + return false; + } downloadSessionFile(json, name); markSessionClean(); if (typeof showToast === 'function') showToast(formatMsg('toastSession', { name })); + return true; } /* ── Opening: strict validation, then restore ───────────────────────────── */ @@ -15486,17 +15634,9 @@

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

          if (e.target.closest('[data-session-action="save"]')) saveSession(); }); -// Downloading the result counts as saving it. Copying and printing do not: the -// clipboard is volatile, and neither a failed copy nor a cancelled print dialog can be -// detected, so they must not silence the unsaved-changes warning. -const SESSION_CLEANING_EXPORTS = ['txt', 'word', 'html', 'md']; -document.addEventListener('click', e => { - if (!(e.target instanceof Element)) return; - const item = e.target.closest('#export-menu [data-export]'); - if (item && SESSION_CLEANING_EXPORTS.includes(item.getAttribute('data-export'))) { - markSessionClean(); - } -}); +// Result exports do not preserve review decisions, source documents or undo history. +// Only saving a .docdiff session establishes a clean point. +const SESSION_CLEANING_EXPORTS = []; // A dropped .docdiff file is saved work, never a document to compare. This // capture listener runs before the pane / landing drop handlers. diff --git a/site/en/manifest.webmanifest b/site/en/manifest.webmanifest new file mode 100644 index 0000000..290a4b0 --- /dev/null +++ b/site/en/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "Doc Diff", + "short_name": "DocDiff", + "description": "Compare two versions of a contract, marked word by word: PDF, Word or text. Runs locally in your browser, no upload, works offline. Free and open source.", + "lang": "en", + "id": "/en/", + "start_url": "/en/", + "scope": "/en/", + "display": "standalone", + "background_color": "#060606", + "theme_color": "#060606", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/site/index.html b/site/index.html index bc869d4..087a771 100644 --- a/site/index.html +++ b/site/index.html @@ -112,7 +112,7 @@ "sameAs": [ "https://github.com/tschnorpfeil/docdiff" ], - "softwareVersion": "1.6.5" + "softwareVersion": "1.7.0" }, { "@type": "SoftwareSourceCode", @@ -255,6 +255,7 @@ --topbar-h: 52px; --list-w: 336px; --ruler-w: 14px; + --pane-pad-x: var(--sp-6); /* outer padding of a document pane; the inner side is --sp-4 */ --background: 0 0% 97.5%; --foreground: 215 25% 13%; @@ -365,6 +366,14 @@ padding: 0; } +/* The hidden attribute must win over component display values. Without this, an author + `display` (`.btn { display: inline-flex }`, `.menu-item { display: flex }`) beats the + user-agent `[hidden]` rule and keeps elements visible that JS has marked as hidden, + for example the "accept all" action when a comparison has no changes. */ +[hidden] { + display: none !important; +} + html { font-size: 100%; } @@ -626,6 +635,14 @@ cursor: pointer; flex-shrink: 0; } +/* Direct swap next to the file pair: quieter than the stepper, same 44 px target */ +.tb-swap { + color: hsl(var(--muted-foreground)); +} +.tb-swap:hover, +.tb-swap:focus-visible { + color: hsl(var(--foreground)); +} .tb-icon-btn.sm { width: 30px; height: 30px; @@ -1149,7 +1166,7 @@ display: inline-flex; align-items: center; min-height: 24px; - color: hsl(var(--primary)); + color: hsl(var(--primary-ink)); text-decoration: underline; text-underline-offset: 3px; } @@ -1480,10 +1497,12 @@ } .scope-can li::before { content: "✓"; + content: "✓" / ""; color: hsl(var(--success-ink)); } .scope-cannot li::before { content: "–"; + content: "–" / ""; color: hsl(var(--destructive-ink)); } @@ -1598,7 +1617,7 @@ /* The hidden stacked pane spans the ruler too; the extra padding keeps its text width */ body:not(.view-inline) #pane-unified { grid-column: 1 / 4; - padding-right: calc(var(--sp-6) + var(--ruler-w)); + padding-right: calc(var(--pane-pad-x) + var(--ruler-w)); } body.view-inline #pane-left, @@ -1616,7 +1635,7 @@ overflow-y: auto; overflow-x: hidden; position: relative; - padding: 0 var(--sp-6) var(--sp-7); + padding: 0 var(--pane-pad-x) var(--sp-7); background: hsl(var(--card)); scrollbar-width: thin; scrollbar-color: hsl(var(--input)) transparent; @@ -1657,6 +1676,15 @@ } } +/* Narrow desktops (1200–1439 px): the list and the outer padding give the text some room back. + Both panes shrink by the same amount, so unchanged paragraphs still wrap identically. */ +@media (min-width: 1200px) and (max-width: 1439px) { + :root { + --list-w: 304px; + --pane-pad-x: var(--sp-5); + } +} + /* Sticky, neutral caption per pane: "Alte Fassung · Vertrag_v1.docx" */ .pane-caption { position: sticky; @@ -3653,8 +3681,8 @@ - - + + @@ -3739,6 +3767,10 @@

          Dokumente

+ +
@@ -3958,7 +3990,7 @@

So funktioniert’s

-
    +
    1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
    2. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Jede Änderung können Sie annehmen oder ablehnen.
    3. Ergebnis sichern, wenn Sie es brauchen Als saubere Endfassung (Word im Originalformat oder PDF) oder als Vergleich mit Änderungsverfolgung.
    4. @@ -3983,7 +4015,7 @@

      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
        • @@ -3994,7 +4026,7 @@

          Kann

      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
        • @@ -4293,6 +4325,10 @@

          metaParas: '{n} Absätze', metaParasOne: '1 Absatz', demoLabel: 'Beispiel', + filterNumeric: 'Zahlen, Währungen & Prozent', + stepLabel_numeric: 'mit Zahlenänderungen', + reviewAcceptAll: 'Alle offenen annehmen ({n})', + reviewCompleteExport: 'Alles geprüft · Word-Endfassung exportieren', dockLabel: 'Änderungen durchgehen', prevLabel: 'Vorherige Änderung', nextLabel: 'Nächste Änderung', @@ -4367,6 +4403,7 @@

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word-Datei gespeichert: {name}', toastWordOriginal: 'Word-Datei im Originalformat gespeichert: {name}', + confirmWordFallback: 'Das Originalformat konnte nicht sicher erhalten werden. Stattdessen ein neu gesetztes Word-Dokument herunterladen? Tabellen, Bilder und Seitenlayout können vom Original abweichen.', noticeWordFallback: 'Gespeichert: {name}. Das Originalformat ließ sich für diese Datei nicht sicher übernehmen (z. B. wegen Änderungen in Fußnoten oder Textfeldern). Die Word-Datei wurde deshalb neu gesetzt; der Text ist vollständig.', toastHtml: 'Bericht gespeichert: {name}', toastMd: 'Markdown-Bericht gespeichert: {name}', @@ -4441,9 +4478,9 @@

          acceptBtn: 'Annehmen', rejectBtn: 'Ablehnen', editBtn: 'Bearbeiten', - reopenBtn: 'Rückgängig', - acceptTitle: 'Annehmen (A)', - rejectTitle: 'Ablehnen (R)', + reopenBtn: 'Prüfung zurücknehmen', + acceptTitle: 'Änderung annehmen (A)', + rejectTitle: 'Ablehnen – alte Fassung wiederherstellen (R)', editTitle: 'Bearbeiten (E)', reopenTitle: 'Prüfung zurücknehmen', rejectedInsNote: 'Abgelehnt – hinzugefügter Absatz danach entfernt', @@ -4520,7 +4557,7 @@

          Ihre Daten

          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.', + 'Sie haben Änderungen geprüft oder Absätze bearbeitet, und dieser Arbeitsstand ist noch nicht als .docdiff-Datei gespeichert. Wenn Sie ihn verwerfen, geht diese Arbeit verloren.', sessionDiscardConfirm: 'Verwerfen', sessionSaveContinue: 'Arbeitsstand speichern', sessionErrorTitle: 'Arbeitsstand kann nicht geöffnet werden', @@ -4706,6 +4743,10 @@

          Ihre Daten

          metaParas: '{n} paragraphs', metaParasOne: '1 paragraph', demoLabel: 'Example', + filterNumeric: 'Numbers, currencies & percentages', + stepLabel_numeric: 'with number changes', + reviewAcceptAll: 'Accept all open ({n})', + reviewCompleteExport: 'Review complete · Export final Word document', dockLabel: 'Go through the changes', prevLabel: 'Previous change', nextLabel: 'Next change', @@ -4780,6 +4821,7 @@

          Ihre Daten

          // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word file saved: {name}', toastWordOriginal: 'Word file saved in the original format: {name}', + confirmWordFallback: 'The original formatting could not be preserved reliably. Download a newly typeset Word document instead? Tables, images and page layout may differ from the original.', noticeWordFallback: 'Saved: {name}. The original format could not be kept reliably for this file (for example because of changes in footnotes or text boxes), so the Word file was typeset anew; the text is complete.', toastHtml: 'Report saved: {name}', toastMd: 'Markdown report saved: {name}', @@ -4854,9 +4896,9 @@

          Ihre Daten

          acceptBtn: 'Accept', rejectBtn: 'Reject', editBtn: 'Edit', - reopenBtn: 'Undo', - acceptTitle: 'Accept (A)', - rejectTitle: 'Reject (R)', + reopenBtn: 'Undo review', + acceptTitle: 'Accept change (A)', + rejectTitle: 'Reject – restore the old version (R)', editTitle: 'Edit (E)', reopenTitle: 'Undo the review of this change', rejectedInsNote: 'Rejected – added paragraph below removed', @@ -4933,7 +4975,7 @@

          Your data

          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.', + 'You have reviewed changes or edited paragraphs, and this work has not been saved as a .docdiff session file yet. If you discard it, it will be lost.', sessionDiscardConfirm: 'Discard', sessionSaveContinue: 'Save work', sessionErrorTitle: 'Cannot open saved work', @@ -8408,6 +8450,18 @@

          Your data

          return item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; } +// Inspect only changed text: an unchanged clause number must not trigger this filter. +function itemHasNumericChange(item) { + if (item.ghost) return computeCleanWordDiff(item.ghost.leftText || '', item.ghost.rightText || '').some(part => + (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + const row = item.row; + if (row.wordDiffs && row.wordDiffs.length) { + return row.wordDiffs.some(part => (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + } + if (row.type === 'modified') return false; + return /[0-9\p{Sc}%]/u.test((row.rightBlock || row.leftBlock || {}).text || ''); +} + // List items under the active list filter (search never filters the list). function getFilteredListItems() { const f = state.sidebarFilter; @@ -8415,7 +8469,7 @@

          Your data

          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; + return f === 'all' || (f === 'numeric' ? itemHasNumericChange(item) : item.type === f); }); } // Kept for callers and QA scripts written against the earlier name. @@ -8425,6 +8479,21 @@

          Your data

          const total = state.listItems.length; const counts = getChangeCounts(); const review = getReviewCounts(); + let action = document.getElementById('btn-review-all'); + if (!action) { + action = document.createElement('button'); + action.id = 'btn-review-all'; + action.type = 'button'; + action.className = 'btn btn-ghost'; + action.addEventListener('click', () => { + if (getReviewCounts().open) acceptAllOpenChanges(); + else handleExportAction('word-final'); + }); + document.getElementById('sidebar-list').before(action); + } + action.hidden = !total; + action.disabled = !canReviewNow(); + action.textContent = review.open ? formatMsg('reviewAcceptAll', { n: review.open }) : t('reviewCompleteExport'); const filtered = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; document.getElementById('list-title').textContent = filtered ? formatMsg('listTitleFiltered', { k: getFilteredListItems().length, n: total }) @@ -8438,6 +8507,11 @@

          Your data

          // One filter: "Alle", a review status or a kind of change, each with its count. const select = document.getElementById('list-filter'); + if (!select.querySelector('option[value="numeric"]')) { + const option = document.createElement('option'); + option.value = 'numeric'; + document.getElementById('lf-group-kind').appendChild(option); + } const value = state.statusFilter !== 'all' ? state.statusFilter : state.sidebarFilter; const options = [ ['all', 'filterAll', total], @@ -8446,6 +8520,7 @@

          Your data

          ['modified', 'filterMod', counts.mod], ['inserted', 'filterIns', counts.ins], ['deleted', 'filterDel', counts.del], + ['numeric', 'filterNumeric', state.listItems.filter(itemHasNumericChange).length], ]; for (const [v, key, n] of options) { const opt = select.querySelector(`option[value="${v}"]`); @@ -9434,20 +9509,21 @@

          Your data

          } // Running headers and footers repeat on every page with the page number in them: the same -// change on several pages is one decision. Numbers are ignored when comparing the lines; the -// changed words themselves must be identical. +// change on several pages is one decision. Digits in the surrounding text are ignored, but the +// changed words themselves (numbers included) must be identical. Rejected entries recompute +// their changed words from the rejected text so a rejected group stays one entry. function headerFooterGroupKey(item) { if (item.type !== 'modified') return ''; - const norm = s => String(s || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); + const diffs = getItemWordDiffs(item); + const changedParts = Array.isArray(diffs) ? diffs.filter(d => d.added || d.removed) : []; + const hasTokens = changedParts.length > 0; + const norm = s => { + const text = String(s || ''); + return (hasTokens ? text.replace(/\d+/g, '#') : text).replace(/\s+/g, ' ').trim(); + }; const left = item.ghost ? item.ghost.leftText : item.row.leftBlock ? item.row.leftBlock.text : ''; const right = item.ghost ? item.ghost.rightText : item.row.rightBlock ? item.row.rightBlock.text : ''; - let changed = ''; - if (!item.ghost && Array.isArray(item.row.wordDiffs)) { - changed = item.row.wordDiffs - .filter(d => d.added || d.removed) - .map(d => (d.added ? '+' : '-') + d.value) - .join('\u0001'); - } + const changed = changedParts.map(d => (d.added ? '+' : '-') + d.value).join('\u0001'); return [item.status, item.ghost ? 'g' : 'l', norm(left), norm(right), changed].join('\u0000'); } @@ -9878,6 +9954,35 @@

          Your data

          return done; } +// All open list entries in this comparison, including hidden/filtered entries and every +// member of a grouped header/footer change. Revalidate after the confirmation dialog. +async function acceptAllOpenChanges() { + if (!canReviewNow()) return false; + const items = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (!items.length) return false; + const leftText = state.leftText, rightText = state.rightText; + const signature = JSON.stringify(items.map(item => itemMembers(item).map(m => m.row.reviewKey))); + const de = state.lang === 'de'; + const choice = await showSessionDialog({ + title: de ? 'Alle offenen Änderungen annehmen?' : 'Accept all open changes?', + body: de + ? `Alle ${items.length} offenen Einträge im gesamten Vergleich annehmen, einschließlich ausgeblendeter und gefilterter Einträge sowie aller Seiten gruppierter Kopf-/Fußzeilen? Dies ist mit einem Rückgängig-Schritt umkehrbar.` + : `Accept all ${items.length} open entries in the entire comparison, including hidden and filtered entries and every page of grouped headers/footers? This can be undone in one step.`, + confirmLabel: de ? 'Alle annehmen' : 'Accept all', + cancelLabel: de ? 'Abbrechen' : 'Cancel', + }); + if (choice !== 'confirm' || !canReviewNow() || state.leftText !== leftText || state.rightText !== rightText) return false; + const current = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (JSON.stringify(current.map(item => itemMembers(item).map(m => m.row.reviewKey))) !== signature) return false; + pushUndoSnapshot(); + for (const item of current) { + for (const m of itemMembers(item)) state.reviews[m.row.reviewKey] = { status: 'accepted', type: m.row.type }; + } + refreshReviewState(); + announce(de ? `${items.length} Einträge angenommen.` : `Accepted ${items.length} entries.`); + return true; +} + /* ── Overview ruler: where the changes are, and which part of the document is in view ─── */ const rulerEl = document.getElementById('ruler'); @@ -10660,6 +10765,24 @@

          Your data

          }, 60); }); +/* ── Direct swap in the top bar ───────────────────────────────────────────── + * A second entry point to the swap in the documents popover. It only forwards the click, so the + * swap itself (and the guard in front of it that protects unsaved review work) stays in one place. + * The aria-label comes from data-i18n-label; the visual tooltip reads the same text on demand, + * so it follows the language without a separate update step. */ +const btnSwapQuick = document.getElementById('btn-swap-quick'); +if (btnSwapQuick) { + const syncSwapTooltip = () => btnSwapQuick.setAttribute('data-tooltip', t('swapBtn')); + syncSwapTooltip(); + // Runs on the button itself, before the document-level tooltip handlers read the attribute. + btnSwapQuick.addEventListener('pointerover', syncSwapTooltip); + btnSwapQuick.addEventListener('focusin', syncSwapTooltip); + btnSwapQuick.addEventListener('click', () => { + hideTooltip(); + document.getElementById('btn-swap-docs').click(); + }); +} + /* ── Word export: a real .docx (OOXML) with tracked changes, built in the browser ── * Everything here is dependency-free: a minimal ZIP writer (STORE, CRC-32) plus hand-written * WordprocessingML. Changes are emitted as genuine revisions (/), so Word, @@ -12010,14 +12133,17 @@

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

          if (typeof console !== 'undefined') console.warn('Doc Diff: original-format Word export not possible:', err && err.message); } } + if (fellBack && !window.confirm(t('confirmWordFallback'))) return false; if (!bytes) bytes = buildGeneratedDocx(kind, date); - const filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + let filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + if (fellBack) filename = filename.replace(/\.docx$/, '_Generated.docx'); downloadBlob(bytes, DOCX_MIME, filename); if (fellBack && typeof showImportNotice === 'function') { showImportNotice(tf('noticeWordFallback', { name: filename }), 'warning'); } else if (typeof showToast === 'function') { showToast(tf(source ? 'toastWordOriginal' : 'toastWord', { name: filename })); } + return true; } function startPrint(kind) { @@ -12115,9 +12241,24 @@

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

          /* ── ZIP reading (all entries, as bytes) ─────────────────────────────────── */ -async function inflateRaw(bytes) { +async function inflateRaw(bytes, limit) { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - return new Uint8Array(await new Response(stream).arrayBuffer()); + const reader = stream.getReader(); + const chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) throw new Error('zip: decompression limit exceeded'); + chunks.push(value); + } + } finally { await reader.cancel().catch(() => {}); } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } + return result; } /** Reads every entry of a ZIP archive: [{ name, data: Uint8Array }] in archive order. */ @@ -12133,9 +12274,12 @@

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

          } if (eocd < 0) throw new Error('zip: no central directory'); const count = dv.getUint16(eocd + 10, true); + if (count > 4096) throw new Error('zip: entry count limit exceeded'); let p = dv.getUint32(eocd + 16, true); const utf8 = new TextDecoder('utf-8'); const out = []; + const entryLimit = 64 * 1024 * 1024, totalLimit = 256 * 1024 * 1024; + let total = 0; for (let n = 0; n < count; n++) { if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory'); const method = dv.getUint16(p + 10, true); @@ -12148,10 +12292,15 @@

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

          p += 46 + nameLen + extraLen + commentLen; const dataStart = localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); const raw = bytes.subarray(dataStart, dataStart + compSize); + const limit = Math.min(entryLimit, totalLimit - total); let data; - if (method === 0) data = raw.slice(); - else if (method === 8) data = await inflateRaw(raw); + if (method === 0) { + if (raw.length > limit) throw new Error('zip: decompression limit exceeded'); + data = raw.slice(); + } + else if (method === 8) data = await inflateRaw(raw, limit); else throw new Error(`zip: unsupported method ${method}`); + total += data.length; out.push({ name, data }); } return out; @@ -12929,11 +13078,15 @@

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

          step.open ? (oldText === null ? [{ value: newText, added: true }] : computeCleanWordDiff(oldText, newText)) : [{ value: newText }]; const writeCell = (cell, tokens) => { const paras = cell.paras.filter((p, i) => cell.texts[i]); + if (paras.length > 1) throw new Error('docx: cannot safely rewrite a multi-paragraph cell'); const first = paras[0] || cell.paras[0]; if (!first) throw new Error('docx: cell without paragraph'); edit(first.start, first.end, rewriteDocxParagraph(first, xml, ctx, tokens, 0, rev, '')); for (const other of paras.slice(1)) edit(other.start, other.end, rewriteDocxParagraph(other, xml, ctx, [], 0, rev, '')); }; + if (filled.some(c => c.text.includes(' · ')) || + (oldCells && oldCells.length !== filled.length) || + newCells.length !== filled.length) throw new Error('docx: ambiguous table row'); if (newCells.length === filled.length) { newCells.forEach((text, i) => { const cell = filled[i]; @@ -12943,12 +13096,7 @@

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

          }); return newCells.join(' · '); } - // The number of filled cells changed: the whole row text goes into the first cell. - const target = filled[0] || unit.cells[0]; - if (!target) throw new Error('docx: row without cells'); - writeCell(target, step.open && step.oldBlock ? computeCleanWordDiff(docxKey(step.oldBlock.text), docxKey(step.block.text)) : [{ value: docxKey(step.block.text) }]); - for (const cell of filled) if (cell !== target) writeCell(cell, []); - return docxKey(step.block.text); + throw new Error('docx: ambiguous table row'); } /* ── Entry point ────────────────────────────────────────────────────────── */ @@ -13091,17 +13239,20 @@

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

          // map them to clear, translated messages. A page that cannot be read fails the whole file // rather than silently dropping its text from the comparison. const pages = []; + const task = window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }); try { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; + const pdf = await task.promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); - const textContent = await page.getTextContent(); - pages.push(buildPdfPageLines(textContent.items, page.view)); + try { + const textContent = await page.getTextContent(); + pages.push(buildPdfPageLines(textContent.items, page.view)); + } finally { page.cleanup(); } } } catch (err) { if (err instanceof ImportError) throw err; throw new ImportError(err && err.name === 'PasswordException' ? 'errPdfPassword' : 'errPdfCorrupt'); - } + } finally { await task.destroy(); } const { text, emptyPages } = assemblePdfText(pages); if (pages.length === 0 || emptyPages.length === pages.length) { // No text layer at all: a scan or an image-only PDF. OCR is not available. @@ -13115,50 +13266,22 @@

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

          /* ── DOCX ──────────────────────────────────────────────────────────────── */ -// Minimal ZIP reader (central directory + stored/deflate entries) for the few XML parts -// Mammoth does not expose. Returns null when the archive cannot be read this way. +// Validate the entire archive with the bounded reader before Mammoth decompresses it. +// Returning null on a budget failure would let the unbounded fallback run anyway. async function readZipTextEntries(arrayBuffer, wantedNames) { + // Without DecompressionStream (older browsers) the bounded reader cannot inflate anything; + // let Mammoth import the text and skip only the extra parts, as before. + if (typeof DecompressionStream === 'undefined') return null; try { - const bytes = new Uint8Array(arrayBuffer); - const dv = new DataView(arrayBuffer); - let eocd = -1; - for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { - if (dv.getUint32(i, true) === 0x06054b50) { - eocd = i; - break; - } - } - if (eocd < 0) return null; - const count = dv.getUint16(eocd + 10, true); - let p = dv.getUint32(eocd + 16, true); + const entries = await readZipEntries(arrayBuffer); const utf8 = new TextDecoder('utf-8'); const out = {}; - for (let n = 0; n < count; n++) { - if (dv.getUint32(p, true) !== 0x02014b50) return null; - const method = dv.getUint16(p + 10, true); - const compSize = dv.getUint32(p + 20, true); - const nameLen = dv.getUint16(p + 28, true); - const extraLen = dv.getUint16(p + 30, true); - const commentLen = dv.getUint16(p + 32, true); - const localOffset = dv.getUint32(p + 42, true); - const name = utf8.decode(bytes.subarray(p + 46, p + 46 + nameLen)); - p += 46 + nameLen + extraLen + commentLen; - if (!wantedNames.includes(name)) continue; - const dataStart = - localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); - const data = bytes.subarray(dataStart, dataStart + compSize); - if (method === 0) { - out[name] = utf8.decode(data); - } else if (method === 8 && typeof DecompressionStream !== 'undefined') { - const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - out[name] = utf8.decode(await new Response(stream).arrayBuffer()); - } else { - return null; - } + for (const entry of entries) { + if (wantedNames.includes(entry.name)) out[entry.name] = utf8.decode(entry.data); } return out; } catch (err) { - return null; + throw new ImportError('errDocxCorrupt'); } } @@ -13790,6 +13913,8 @@

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

          async function handleDropFiles(files, targetSide) { if (!files || !files.length) return; + // FileList is live: the first upload clears its input, including the second file. + files = Array.from(files); if (files.length >= 2) { clearImportNotice(); await handleFileUpload(files[0], 'left', { keepNotice: true }); @@ -13830,8 +13955,8 @@

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

          const fileInputRight = document.getElementById('file-input-right'); document.getElementById('btn-upload-left').addEventListener('click', () => fileInputLeft.click()); document.getElementById('btn-upload-right').addEventListener('click', () => fileInputRight.click()); -fileInputLeft.addEventListener('change', e => handleFileUpload(e.target.files[0], 'left')); -fileInputRight.addEventListener('change', e => handleFileUpload(e.target.files[0], 'right')); +fileInputLeft.addEventListener('change', e => handleDropFiles(e.target.files, 'left')); +fileInputRight.addEventListener('change', e => handleDropFiles(e.target.files, 'right')); document.getElementById('btn-clear-left').addEventListener('click', () => { cancelPendingUploads('left'); @@ -14308,7 +14433,7 @@

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

          if (isTextEntryElement(el) || (el.closest && el.closest('[contenteditable], .clause-inline-editor, dialog'))) { return false; } - return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list')); + return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list, .review-bar, .stepper')); } window.addEventListener('keydown', e => { @@ -14420,8 +14545,12 @@

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

          function toggleLanguage() { const langHint = document.getElementById('lang-hint'); if (langHint) langHint.hidden = true; + const previousSample = SAMPLES[state.lang] || SAMPLES.de; + const pristineDemo = state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO' && + state.leftText === previousSample.v1 && state.rightText === previousSample.v2 && + Object.keys(state.reviews).length === 0 && state.undoStack.length === 0 && state.redoStack.length === 0; state.lang = state.lang === 'de' ? 'en' : 'de'; - if (state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO') { + if (pristineDemo) { const sample = SAMPLES[state.lang] || SAMPLES.de; state.leftName = t('sampleLeftName'); state.leftText = sample.v1; @@ -14769,8 +14898,7 @@

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

          /* ── Work-Loss Protection: Unsaved-Changes Guard & Saved Work (.docdiff) ── * * Nothing is ever written to browser storage. Work is protected in two ways: - * 1. A dirty flag (inline edits, accepted / rejected changes since the last load, save or - * export) drives the native `beforeunload` prompt and an in-app dialog in front of every + * 1. A dirty flag (inline edits, accepted / rejected changes since the last load or session save) drives the native `beforeunload` prompt and an in-app dialog in front of every * action that would throw this work away ("Abbrechen / Arbeitsstand speichern / Verwerfen"). * 2. "Arbeitsstand speichern…" downloads a self-describing JSON file (.docdiff) to the * user's own disk; "Arbeitsstand öffnen…" validates it strictly and restores it through @@ -14785,7 +14913,7 @@

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

          * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.6.5'; +const DOCDIFF_APP_VERSION = '1.7.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -14804,7 +14932,7 @@

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

          const sessionTracker = { // Right-hand text as it was last loaded (before any inline edits). originalRightText: state.rightText, - // Right-hand text and review status at the last "clean point" (load, save, export). + // Right-hand text and review status at the last "clean point" (load or session save). cleanRightText: state.rightText, cleanReviews: serializeReviews(), // Right-hand text seen by the previous runComparison call. @@ -14877,16 +15005,21 @@

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

          wrapGlobalFunction('resetEditHistory', original => function resetEditHistoryForLoad() { sessionTracker.editPending = false; - return original.apply(this, arguments); + const result = original.apply(this, arguments); + if (!sessionTracker.restoring) { + sessionTracker.originalRightText = state.rightText; + markSessionClean(); + } + return result; } ); wrapGlobalFunction('handleFileUpload', original => async function guardedFileUpload(file, side) { - if (file && side === 'right' && !sessionTracker.bypassGuard && isSessionDirty()) { + if (file && !sessionTracker.bypassGuard && isSessionDirty()) { const ok = await confirmDiscardEdits(); if (!ok) { - const input = document.getElementById('file-input-right'); + const input = document.getElementById(`file-input-${side}`); if (input) input.value = ''; return undefined; } @@ -14897,12 +15030,22 @@

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

          wrapGlobalFunction('handleDropFiles', original => async function guardedDropFiles(files, targetSide) { - const replacesRight = files && (files.length >= 2 || (files.length === 1 && targetSide === 'right')); - if (replacesRight && !sessionTracker.bypassGuard && isSessionDirty()) { - if (!(await confirmDiscardEdits())) return undefined; + const replacesDocument = files && files.length > 0; + if (replacesDocument && !sessionTracker.bypassGuard) { + // Keep the pair under one guard even when it starts clean: replacing the left + // side can reset reviews and otherwise cause a second prompt halfway through. + const pickedFiles = Array.from(files); + if (isSessionDirty() && !(await confirmDiscardEdits())) { + // Clear the pickers so choosing the same file again still fires a change event. + for (const side of ['left', 'right']) { + const input = document.getElementById(`file-input-${side}`); + if (input) input.value = ''; + } + return undefined; + } sessionTracker.bypassGuard++; try { - return await original.apply(this, arguments); + return await original.call(this, pickedFiles, targetSide); } finally { sessionTracker.bypassGuard--; } @@ -15001,7 +15144,7 @@

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

          discardLabel: t('sessionDiscardConfirm'), }); if (choice === 'confirm') { - saveSession(); + if (!saveSession()) return false; return true; } return choice === 'discard'; @@ -15019,24 +15162,21 @@

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

          const SESSION_GUARDED_CLICKS = [ { selector: '#btn-clear' }, { selector: '#btn-clear-right' }, + { selector: '#btn-clear-left' }, { selector: '#lp-clear-right' }, { selector: '#btn-swap-docs' }, { selector: '.btn-load-sample' }, { selector: '#btn-help-sample' }, { // The paste dialog is pre-filled with the edited text; only an actual - // change of the right-hand text discards edits. + // change of either document resets review decisions. selector: '#btn-apply-modal', when: () => { const ta = document.getElementById('textarea-right'); - return !!ta && ta.value !== pasteModalShown.right; + const left = document.getElementById('textarea-left'); + return (!!ta && ta.value !== pasteModalShown.right) || (!!left && left.value !== pasteModalShown.left); }, }, - { - // Toggling the language while the sample is shown reloads the sample. - selector: '#btn-lang, #menu-lang, #footer-lang-link', - when: () => state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO', - }, ]; document.addEventListener( @@ -15205,9 +15345,17 @@

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

          } const name = buildSessionFileName(); const json = JSON.stringify(buildSessionPayload(), null, 1); + if (new Blob([json]).size > SESSION_MAX_FILE_BYTES) { + showSessionDialog({ + title: state.lang === 'de' ? 'Arbeitsstand kann nicht gespeichert werden' : 'Cannot save work', + body: t('sessionErrorTooLarge'), confirmLabel: t('sessionOk'), + }); + return false; + } downloadSessionFile(json, name); markSessionClean(); if (typeof showToast === 'function') showToast(formatMsg('toastSession', { name })); + return true; } /* ── Opening: strict validation, then restore ───────────────────────────── */ @@ -15486,17 +15634,9 @@

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

          if (e.target.closest('[data-session-action="save"]')) saveSession(); }); -// Downloading the result counts as saving it. Copying and printing do not: the -// clipboard is volatile, and neither a failed copy nor a cancelled print dialog can be -// detected, so they must not silence the unsaved-changes warning. -const SESSION_CLEANING_EXPORTS = ['txt', 'word', 'html', 'md']; -document.addEventListener('click', e => { - if (!(e.target instanceof Element)) return; - const item = e.target.closest('#export-menu [data-export]'); - if (item && SESSION_CLEANING_EXPORTS.includes(item.getAttribute('data-export'))) { - markSessionClean(); - } -}); +// Result exports do not preserve review decisions, source documents or undo history. +// Only saving a .docdiff session establishes a clean point. +const SESSION_CLEANING_EXPORTS = []; // A dropped .docdiff file is saved work, never a document to compare. This // capture listener runs before the pane / landing drop handlers. diff --git a/site/manifest.webmanifest b/site/manifest.webmanifest index 017fa1a..907d06e 100644 --- a/site/manifest.webmanifest +++ b/site/manifest.webmanifest @@ -5,6 +5,7 @@ "lang": "de", "id": "/", "start_url": "/", + "scope": "/", "display": "standalone", "background_color": "#060606", "theme_color": "#060606", diff --git a/src/js/01-i18n.js b/src/js/01-i18n.js index cc4cb45..cf97c1b 100644 --- a/src/js/01-i18n.js +++ b/src/js/01-i18n.js @@ -21,6 +21,10 @@ const I18N = { metaParas: '{n} Absätze', metaParasOne: '1 Absatz', demoLabel: 'Beispiel', + filterNumeric: 'Zahlen, Währungen & Prozent', + stepLabel_numeric: 'mit Zahlenänderungen', + reviewAcceptAll: 'Alle offenen annehmen ({n})', + reviewCompleteExport: 'Alles geprüft · Word-Endfassung exportieren', dockLabel: 'Änderungen durchgehen', prevLabel: 'Vorherige Änderung', nextLabel: 'Nächste Änderung', @@ -95,6 +99,7 @@ const I18N = { // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word-Datei gespeichert: {name}', toastWordOriginal: 'Word-Datei im Originalformat gespeichert: {name}', + confirmWordFallback: 'Das Originalformat konnte nicht sicher erhalten werden. Stattdessen ein neu gesetztes Word-Dokument herunterladen? Tabellen, Bilder und Seitenlayout können vom Original abweichen.', noticeWordFallback: 'Gespeichert: {name}. Das Originalformat ließ sich für diese Datei nicht sicher übernehmen (z. B. wegen Änderungen in Fußnoten oder Textfeldern). Die Word-Datei wurde deshalb neu gesetzt; der Text ist vollständig.', toastHtml: 'Bericht gespeichert: {name}', toastMd: 'Markdown-Bericht gespeichert: {name}', @@ -169,9 +174,9 @@ const I18N = { acceptBtn: 'Annehmen', rejectBtn: 'Ablehnen', editBtn: 'Bearbeiten', - reopenBtn: 'Rückgängig', - acceptTitle: 'Annehmen (A)', - rejectTitle: 'Ablehnen (R)', + reopenBtn: 'Prüfung zurücknehmen', + acceptTitle: 'Änderung annehmen (A)', + rejectTitle: 'Ablehnen – alte Fassung wiederherstellen (R)', editTitle: 'Bearbeiten (E)', reopenTitle: 'Prüfung zurücknehmen', rejectedInsNote: 'Abgelehnt – hinzugefügter Absatz danach entfernt', @@ -248,7 +253,7 @@ const I18N = { 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.', + 'Sie haben Änderungen geprüft oder Absätze bearbeitet, und dieser Arbeitsstand ist noch nicht als .docdiff-Datei gespeichert. Wenn Sie ihn verwerfen, geht diese Arbeit verloren.', sessionDiscardConfirm: 'Verwerfen', sessionSaveContinue: 'Arbeitsstand speichern', sessionErrorTitle: 'Arbeitsstand kann nicht geöffnet werden', @@ -434,6 +439,10 @@ const I18N = { metaParas: '{n} paragraphs', metaParasOne: '1 paragraph', demoLabel: 'Example', + filterNumeric: 'Numbers, currencies & percentages', + stepLabel_numeric: 'with number changes', + reviewAcceptAll: 'Accept all open ({n})', + reviewCompleteExport: 'Review complete · Export final Word document', dockLabel: 'Go through the changes', prevLabel: 'Previous change', nextLabel: 'Next change', @@ -508,6 +517,7 @@ const I18N = { // ── Toasts & announcements ─────────────────────────────────────────── toastWord: 'Word file saved: {name}', toastWordOriginal: 'Word file saved in the original format: {name}', + confirmWordFallback: 'The original formatting could not be preserved reliably. Download a newly typeset Word document instead? Tables, images and page layout may differ from the original.', noticeWordFallback: 'Saved: {name}. The original format could not be kept reliably for this file (for example because of changes in footnotes or text boxes), so the Word file was typeset anew; the text is complete.', toastHtml: 'Report saved: {name}', toastMd: 'Markdown report saved: {name}', @@ -582,9 +592,9 @@ const I18N = { acceptBtn: 'Accept', rejectBtn: 'Reject', editBtn: 'Edit', - reopenBtn: 'Undo', - acceptTitle: 'Accept (A)', - rejectTitle: 'Reject (R)', + reopenBtn: 'Undo review', + acceptTitle: 'Accept change (A)', + rejectTitle: 'Reject – restore the old version (R)', editTitle: 'Edit (E)', reopenTitle: 'Undo the review of this change', rejectedInsNote: 'Rejected – added paragraph below removed', @@ -661,7 +671,7 @@ const I18N = { 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.', + 'You have reviewed changes or edited paragraphs, and this work has not been saved as a .docdiff session file yet. If you discard it, it will be lost.', sessionDiscardConfirm: 'Discard', sessionSaveContinue: 'Save work', sessionErrorTitle: 'Cannot open saved work', diff --git a/src/js/06-compare.js b/src/js/06-compare.js index 1d8755a..1f8c14b 100644 --- a/src/js/06-compare.js +++ b/src/js/06-compare.js @@ -712,6 +712,18 @@ function getItemPage(item) { return item.ghost ? item.ghost.pageNum || 1 : item.row.pageNum || 1; } +// Inspect only changed text: an unchanged clause number must not trigger this filter. +function itemHasNumericChange(item) { + if (item.ghost) return computeCleanWordDiff(item.ghost.leftText || '', item.ghost.rightText || '').some(part => + (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + const row = item.row; + if (row.wordDiffs && row.wordDiffs.length) { + return row.wordDiffs.some(part => (part.added || part.removed) && /[0-9\p{Sc}%]/u.test(part.value)); + } + if (row.type === 'modified') return false; + return /[0-9\p{Sc}%]/u.test((row.rightBlock || row.leftBlock || {}).text || ''); +} + // List items under the active list filter (search never filters the list). function getFilteredListItems() { const f = state.sidebarFilter; @@ -719,7 +731,7 @@ function getFilteredListItems() { 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; + return f === 'all' || (f === 'numeric' ? itemHasNumericChange(item) : item.type === f); }); } // Kept for callers and QA scripts written against the earlier name. @@ -729,6 +741,21 @@ function updateSidebarHeaderState() { const total = state.listItems.length; const counts = getChangeCounts(); const review = getReviewCounts(); + let action = document.getElementById('btn-review-all'); + if (!action) { + action = document.createElement('button'); + action.id = 'btn-review-all'; + action.type = 'button'; + action.className = 'btn btn-ghost'; + action.addEventListener('click', () => { + if (getReviewCounts().open) acceptAllOpenChanges(); + else handleExportAction('word-final'); + }); + document.getElementById('sidebar-list').before(action); + } + action.hidden = !total; + action.disabled = !canReviewNow(); + action.textContent = review.open ? formatMsg('reviewAcceptAll', { n: review.open }) : t('reviewCompleteExport'); const filtered = state.sidebarFilter !== 'all' || state.statusFilter !== 'all'; document.getElementById('list-title').textContent = filtered ? formatMsg('listTitleFiltered', { k: getFilteredListItems().length, n: total }) @@ -742,6 +769,11 @@ function updateSidebarHeaderState() { // One filter: "Alle", a review status or a kind of change, each with its count. const select = document.getElementById('list-filter'); + if (!select.querySelector('option[value="numeric"]')) { + const option = document.createElement('option'); + option.value = 'numeric'; + document.getElementById('lf-group-kind').appendChild(option); + } const value = state.statusFilter !== 'all' ? state.statusFilter : state.sidebarFilter; const options = [ ['all', 'filterAll', total], @@ -750,6 +782,7 @@ function updateSidebarHeaderState() { ['modified', 'filterMod', counts.mod], ['inserted', 'filterIns', counts.ins], ['deleted', 'filterDel', counts.del], + ['numeric', 'filterNumeric', state.listItems.filter(itemHasNumericChange).length], ]; for (const [v, key, n] of options) { const opt = select.querySelector(`option[value="${v}"]`); diff --git a/src/js/06d-review.js b/src/js/06d-review.js index 7d9290e..14d5e18 100644 --- a/src/js/06d-review.js +++ b/src/js/06d-review.js @@ -134,20 +134,21 @@ function isHeaderFooterItem(item) { } // Running headers and footers repeat on every page with the page number in them: the same -// change on several pages is one decision. Numbers are ignored when comparing the lines; the -// changed words themselves must be identical. +// change on several pages is one decision. Digits in the surrounding text are ignored, but the +// changed words themselves (numbers included) must be identical. Rejected entries recompute +// their changed words from the rejected text so a rejected group stays one entry. function headerFooterGroupKey(item) { if (item.type !== 'modified') return ''; - const norm = s => String(s || '').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim(); + const diffs = getItemWordDiffs(item); + const changedParts = Array.isArray(diffs) ? diffs.filter(d => d.added || d.removed) : []; + const hasTokens = changedParts.length > 0; + const norm = s => { + const text = String(s || ''); + return (hasTokens ? text.replace(/\d+/g, '#') : text).replace(/\s+/g, ' ').trim(); + }; const left = item.ghost ? item.ghost.leftText : item.row.leftBlock ? item.row.leftBlock.text : ''; const right = item.ghost ? item.ghost.rightText : item.row.rightBlock ? item.row.rightBlock.text : ''; - let changed = ''; - if (!item.ghost && Array.isArray(item.row.wordDiffs)) { - changed = item.row.wordDiffs - .filter(d => d.added || d.removed) - .map(d => (d.added ? '+' : '-') + d.value) - .join('\u0001'); - } + const changed = changedParts.map(d => (d.added ? '+' : '-') + d.value).join('\u0001'); return [item.status, item.ghost ? 'g' : 'l', norm(left), norm(right), changed].join('\u0000'); } @@ -577,3 +578,32 @@ function runReviewAction(action, fromList) { applyPendingRowFocusIfIdle(); return done; } + +// All open list entries in this comparison, including hidden/filtered entries and every +// member of a grouped header/footer change. Revalidate after the confirmation dialog. +async function acceptAllOpenChanges() { + if (!canReviewNow()) return false; + const items = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (!items.length) return false; + const leftText = state.leftText, rightText = state.rightText; + const signature = JSON.stringify(items.map(item => itemMembers(item).map(m => m.row.reviewKey))); + const de = state.lang === 'de'; + const choice = await showSessionDialog({ + title: de ? 'Alle offenen Änderungen annehmen?' : 'Accept all open changes?', + body: de + ? `Alle ${items.length} offenen Einträge im gesamten Vergleich annehmen, einschließlich ausgeblendeter und gefilterter Einträge sowie aller Seiten gruppierter Kopf-/Fußzeilen? Dies ist mit einem Rückgängig-Schritt umkehrbar.` + : `Accept all ${items.length} open entries in the entire comparison, including hidden and filtered entries and every page of grouped headers/footers? This can be undone in one step.`, + confirmLabel: de ? 'Alle annehmen' : 'Accept all', + cancelLabel: de ? 'Abbrechen' : 'Cancel', + }); + if (choice !== 'confirm' || !canReviewNow() || state.leftText !== leftText || state.rightText !== rightText) return false; + const current = state.listItems.filter(item => item.status === 'open' && !item.ghost); + if (JSON.stringify(current.map(item => itemMembers(item).map(m => m.row.reviewKey))) !== signature) return false; + pushUndoSnapshot(); + for (const item of current) { + for (const m of itemMembers(item)) state.reviews[m.row.reviewKey] = { status: 'accepted', type: m.row.type }; + } + refreshReviewState(); + announce(de ? `${items.length} Einträge angenommen.` : `Accepted ${items.length} entries.`); + return true; +} diff --git a/src/js/07-interactions.js b/src/js/07-interactions.js index c301229..ff06aff 100644 --- a/src/js/07-interactions.js +++ b/src/js/07-interactions.js @@ -779,3 +779,21 @@ window.addEventListener('resize', () => { scheduleRulerDraw(); }, 60); }); + +/* ── Direct swap in the top bar ───────────────────────────────────────────── + * A second entry point to the swap in the documents popover. It only forwards the click, so the + * swap itself (and the guard in front of it that protects unsaved review work) stays in one place. + * The aria-label comes from data-i18n-label; the visual tooltip reads the same text on demand, + * so it follows the language without a separate update step. */ +const btnSwapQuick = document.getElementById('btn-swap-quick'); +if (btnSwapQuick) { + const syncSwapTooltip = () => btnSwapQuick.setAttribute('data-tooltip', t('swapBtn')); + syncSwapTooltip(); + // Runs on the button itself, before the document-level tooltip handlers read the attribute. + btnSwapQuick.addEventListener('pointerover', syncSwapTooltip); + btnSwapQuick.addEventListener('focusin', syncSwapTooltip); + btnSwapQuick.addEventListener('click', () => { + hideTooltip(); + document.getElementById('btn-swap-docs').click(); + }); +} diff --git a/src/js/08-export.js b/src/js/08-export.js index 1d9e282..1286aa3 100644 --- a/src/js/08-export.js +++ b/src/js/08-export.js @@ -905,14 +905,17 @@ async function exportWord(kind) { if (typeof console !== 'undefined') console.warn('Doc Diff: original-format Word export not possible:', err && err.message); } } + if (fellBack && !window.confirm(t('confirmWordFallback'))) return false; if (!bytes) bytes = buildGeneratedDocx(kind, date); - const filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + let filename = kind === 'final' ? buildFinalFileName('docx') : buildExportFileName('exportFileRedline', 'docx'); + if (fellBack) filename = filename.replace(/\.docx$/, '_Generated.docx'); downloadBlob(bytes, DOCX_MIME, filename); if (fellBack && typeof showImportNotice === 'function') { showImportNotice(tf('noticeWordFallback', { name: filename }), 'warning'); } else if (typeof showToast === 'function') { showToast(tf(source ? 'toastWordOriginal' : 'toastWord', { name: filename })); } + return true; } function startPrint(kind) { diff --git a/src/js/08b-export-docx-source.js b/src/js/08b-export-docx-source.js index b2f538e..8bebf50 100644 --- a/src/js/08b-export-docx-source.js +++ b/src/js/08b-export-docx-source.js @@ -17,9 +17,24 @@ /* ── ZIP reading (all entries, as bytes) ─────────────────────────────────── */ -async function inflateRaw(bytes) { +async function inflateRaw(bytes, limit) { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - return new Uint8Array(await new Response(stream).arrayBuffer()); + const reader = stream.getReader(); + const chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.length; + if (size > limit) throw new Error('zip: decompression limit exceeded'); + chunks.push(value); + } + } finally { await reader.cancel().catch(() => {}); } + const result = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } + return result; } /** Reads every entry of a ZIP archive: [{ name, data: Uint8Array }] in archive order. */ @@ -35,9 +50,12 @@ async function readZipEntries(input) { } if (eocd < 0) throw new Error('zip: no central directory'); const count = dv.getUint16(eocd + 10, true); + if (count > 4096) throw new Error('zip: entry count limit exceeded'); let p = dv.getUint32(eocd + 16, true); const utf8 = new TextDecoder('utf-8'); const out = []; + const entryLimit = 64 * 1024 * 1024, totalLimit = 256 * 1024 * 1024; + let total = 0; for (let n = 0; n < count; n++) { if (dv.getUint32(p, true) !== 0x02014b50) throw new Error('zip: bad central directory'); const method = dv.getUint16(p + 10, true); @@ -50,10 +68,15 @@ async function readZipEntries(input) { p += 46 + nameLen + extraLen + commentLen; const dataStart = localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); const raw = bytes.subarray(dataStart, dataStart + compSize); + const limit = Math.min(entryLimit, totalLimit - total); let data; - if (method === 0) data = raw.slice(); - else if (method === 8) data = await inflateRaw(raw); + if (method === 0) { + if (raw.length > limit) throw new Error('zip: decompression limit exceeded'); + data = raw.slice(); + } + else if (method === 8) data = await inflateRaw(raw, limit); else throw new Error(`zip: unsupported method ${method}`); + total += data.length; out.push({ name, data }); } return out; @@ -831,11 +854,15 @@ function rewriteDocxRow(unit, xml, ctx, step, rev, edit) { step.open ? (oldText === null ? [{ value: newText, added: true }] : computeCleanWordDiff(oldText, newText)) : [{ value: newText }]; const writeCell = (cell, tokens) => { const paras = cell.paras.filter((p, i) => cell.texts[i]); + if (paras.length > 1) throw new Error('docx: cannot safely rewrite a multi-paragraph cell'); const first = paras[0] || cell.paras[0]; if (!first) throw new Error('docx: cell without paragraph'); edit(first.start, first.end, rewriteDocxParagraph(first, xml, ctx, tokens, 0, rev, '')); for (const other of paras.slice(1)) edit(other.start, other.end, rewriteDocxParagraph(other, xml, ctx, [], 0, rev, '')); }; + if (filled.some(c => c.text.includes(' · ')) || + (oldCells && oldCells.length !== filled.length) || + newCells.length !== filled.length) throw new Error('docx: ambiguous table row'); if (newCells.length === filled.length) { newCells.forEach((text, i) => { const cell = filled[i]; @@ -845,12 +872,7 @@ function rewriteDocxRow(unit, xml, ctx, step, rev, edit) { }); return newCells.join(' · '); } - // The number of filled cells changed: the whole row text goes into the first cell. - const target = filled[0] || unit.cells[0]; - if (!target) throw new Error('docx: row without cells'); - writeCell(target, step.open && step.oldBlock ? computeCleanWordDiff(docxKey(step.oldBlock.text), docxKey(step.block.text)) : [{ value: docxKey(step.block.text) }]); - for (const cell of filled) if (cell !== target) writeCell(cell, []); - return docxKey(step.block.text); + throw new Error('docx: ambiguous table row'); } /* ── Entry point ────────────────────────────────────────────────────────── */ diff --git a/src/js/09-import.js b/src/js/09-import.js index 168cfc3..1224c59 100644 --- a/src/js/09-import.js +++ b/src/js/09-import.js @@ -77,17 +77,20 @@ async function extractPdfText(arrayBuffer) { // map them to clear, translated messages. A page that cannot be read fails the whole file // rather than silently dropping its text from the comparison. const pages = []; + const task = window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }); try { - const pdf = await window.pdfjsLib.getDocument({ data: arrayBuffer, isEvalSupported: false }).promise; + const pdf = await task.promise; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); - const textContent = await page.getTextContent(); - pages.push(buildPdfPageLines(textContent.items, page.view)); + try { + const textContent = await page.getTextContent(); + pages.push(buildPdfPageLines(textContent.items, page.view)); + } finally { page.cleanup(); } } } catch (err) { if (err instanceof ImportError) throw err; throw new ImportError(err && err.name === 'PasswordException' ? 'errPdfPassword' : 'errPdfCorrupt'); - } + } finally { await task.destroy(); } const { text, emptyPages } = assemblePdfText(pages); if (pages.length === 0 || emptyPages.length === pages.length) { // No text layer at all: a scan or an image-only PDF. OCR is not available. @@ -101,50 +104,22 @@ async function extractPdfText(arrayBuffer) { /* ── DOCX ──────────────────────────────────────────────────────────────── */ -// Minimal ZIP reader (central directory + stored/deflate entries) for the few XML parts -// Mammoth does not expose. Returns null when the archive cannot be read this way. +// Validate the entire archive with the bounded reader before Mammoth decompresses it. +// Returning null on a budget failure would let the unbounded fallback run anyway. async function readZipTextEntries(arrayBuffer, wantedNames) { + // Without DecompressionStream (older browsers) the bounded reader cannot inflate anything; + // let Mammoth import the text and skip only the extra parts, as before. + if (typeof DecompressionStream === 'undefined') return null; try { - const bytes = new Uint8Array(arrayBuffer); - const dv = new DataView(arrayBuffer); - let eocd = -1; - for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) { - if (dv.getUint32(i, true) === 0x06054b50) { - eocd = i; - break; - } - } - if (eocd < 0) return null; - const count = dv.getUint16(eocd + 10, true); - let p = dv.getUint32(eocd + 16, true); + const entries = await readZipEntries(arrayBuffer); const utf8 = new TextDecoder('utf-8'); const out = {}; - for (let n = 0; n < count; n++) { - if (dv.getUint32(p, true) !== 0x02014b50) return null; - const method = dv.getUint16(p + 10, true); - const compSize = dv.getUint32(p + 20, true); - const nameLen = dv.getUint16(p + 28, true); - const extraLen = dv.getUint16(p + 30, true); - const commentLen = dv.getUint16(p + 32, true); - const localOffset = dv.getUint32(p + 42, true); - const name = utf8.decode(bytes.subarray(p + 46, p + 46 + nameLen)); - p += 46 + nameLen + extraLen + commentLen; - if (!wantedNames.includes(name)) continue; - const dataStart = - localOffset + 30 + dv.getUint16(localOffset + 26, true) + dv.getUint16(localOffset + 28, true); - const data = bytes.subarray(dataStart, dataStart + compSize); - if (method === 0) { - out[name] = utf8.decode(data); - } else if (method === 8 && typeof DecompressionStream !== 'undefined') { - const stream = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate-raw')); - out[name] = utf8.decode(await new Response(stream).arrayBuffer()); - } else { - return null; - } + for (const entry of entries) { + if (wantedNames.includes(entry.name)) out[entry.name] = utf8.decode(entry.data); } return out; } catch (err) { - return null; + throw new ImportError('errDocxCorrupt'); } } @@ -776,6 +751,8 @@ window.addEventListener('drop', e => { async function handleDropFiles(files, targetSide) { if (!files || !files.length) return; + // FileList is live: the first upload clears its input, including the second file. + files = Array.from(files); if (files.length >= 2) { clearImportNotice(); await handleFileUpload(files[0], 'left', { keepNotice: true }); @@ -816,8 +793,8 @@ const fileInputLeft = document.getElementById('file-input-left'); const fileInputRight = document.getElementById('file-input-right'); document.getElementById('btn-upload-left').addEventListener('click', () => fileInputLeft.click()); document.getElementById('btn-upload-right').addEventListener('click', () => fileInputRight.click()); -fileInputLeft.addEventListener('change', e => handleFileUpload(e.target.files[0], 'left')); -fileInputRight.addEventListener('change', e => handleFileUpload(e.target.files[0], 'right')); +fileInputLeft.addEventListener('change', e => handleDropFiles(e.target.files, 'left')); +fileInputRight.addEventListener('change', e => handleDropFiles(e.target.files, 'right')); document.getElementById('btn-clear-left').addEventListener('click', () => { cancelPendingUploads('left'); diff --git a/src/js/10-controls.js b/src/js/10-controls.js index 734c5ca..ff7c4fa 100644 --- a/src/js/10-controls.js +++ b/src/js/10-controls.js @@ -453,7 +453,7 @@ function isShortcutFocusContext(el) { if (isTextEntryElement(el) || (el.closest && el.closest('[contenteditable], .clause-inline-editor, dialog'))) { return false; } - return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list')); + return Boolean(el.closest && el.closest('#pane-left, #pane-right, #pane-unified, #sidebar-list, .review-bar, .stepper')); } window.addEventListener('keydown', e => { @@ -565,8 +565,12 @@ function clearComparison() { function toggleLanguage() { const langHint = document.getElementById('lang-hint'); if (langHint) langHint.hidden = true; + const previousSample = SAMPLES[state.lang] || SAMPLES.de; + const pristineDemo = state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO' && + state.leftText === previousSample.v1 && state.rightText === previousSample.v2 && + Object.keys(state.reviews).length === 0 && state.undoStack.length === 0 && state.redoStack.length === 0; state.lang = state.lang === 'de' ? 'en' : 'de'; - if (state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO') { + if (pristineDemo) { const sample = SAMPLES[state.lang] || SAMPLES.de; state.leftName = t('sampleLeftName'); state.leftText = sample.v1; diff --git a/src/js/11-session.js b/src/js/11-session.js index 92d8856..e515d2f 100644 --- a/src/js/11-session.js +++ b/src/js/11-session.js @@ -1,8 +1,7 @@ /* ── Work-Loss Protection: Unsaved-Changes Guard & Saved Work (.docdiff) ── * * Nothing is ever written to browser storage. Work is protected in two ways: - * 1. A dirty flag (inline edits, accepted / rejected changes since the last load, save or - * export) drives the native `beforeunload` prompt and an in-app dialog in front of every + * 1. A dirty flag (inline edits, accepted / rejected changes since the last load or session save) drives the native `beforeunload` prompt and an in-app dialog in front of every * action that would throw this work away ("Abbrechen / Arbeitsstand speichern / Verwerfen"). * 2. "Arbeitsstand speichern…" downloads a self-describing JSON file (.docdiff) to the * user's own disk; "Arbeitsstand öffnen…" validates it strictly and restores it through @@ -17,7 +16,7 @@ * entry; version 1 files (no review status) still open. */ -const DOCDIFF_APP_VERSION = '1.6.5'; +const DOCDIFF_APP_VERSION = '1.7.0'; const SESSION_FORMAT_ID = 'docdiff-session'; const SESSION_FORMAT_VERSION = 2; const SESSION_FILE_EXT = '.docdiff'; @@ -36,7 +35,7 @@ const REVIEW_TYPES = ['modified', 'inserted', 'deleted']; const sessionTracker = { // Right-hand text as it was last loaded (before any inline edits). originalRightText: state.rightText, - // Right-hand text and review status at the last "clean point" (load, save, export). + // Right-hand text and review status at the last "clean point" (load or session save). cleanRightText: state.rightText, cleanReviews: serializeReviews(), // Right-hand text seen by the previous runComparison call. @@ -109,16 +108,21 @@ wrapGlobalFunction('runComparison', original => wrapGlobalFunction('resetEditHistory', original => function resetEditHistoryForLoad() { sessionTracker.editPending = false; - return original.apply(this, arguments); + const result = original.apply(this, arguments); + if (!sessionTracker.restoring) { + sessionTracker.originalRightText = state.rightText; + markSessionClean(); + } + return result; } ); wrapGlobalFunction('handleFileUpload', original => async function guardedFileUpload(file, side) { - if (file && side === 'right' && !sessionTracker.bypassGuard && isSessionDirty()) { + if (file && !sessionTracker.bypassGuard && isSessionDirty()) { const ok = await confirmDiscardEdits(); if (!ok) { - const input = document.getElementById('file-input-right'); + const input = document.getElementById(`file-input-${side}`); if (input) input.value = ''; return undefined; } @@ -129,12 +133,22 @@ wrapGlobalFunction('handleFileUpload', original => wrapGlobalFunction('handleDropFiles', original => async function guardedDropFiles(files, targetSide) { - const replacesRight = files && (files.length >= 2 || (files.length === 1 && targetSide === 'right')); - if (replacesRight && !sessionTracker.bypassGuard && isSessionDirty()) { - if (!(await confirmDiscardEdits())) return undefined; + const replacesDocument = files && files.length > 0; + if (replacesDocument && !sessionTracker.bypassGuard) { + // Keep the pair under one guard even when it starts clean: replacing the left + // side can reset reviews and otherwise cause a second prompt halfway through. + const pickedFiles = Array.from(files); + if (isSessionDirty() && !(await confirmDiscardEdits())) { + // Clear the pickers so choosing the same file again still fires a change event. + for (const side of ['left', 'right']) { + const input = document.getElementById(`file-input-${side}`); + if (input) input.value = ''; + } + return undefined; + } sessionTracker.bypassGuard++; try { - return await original.apply(this, arguments); + return await original.call(this, pickedFiles, targetSide); } finally { sessionTracker.bypassGuard--; } @@ -233,7 +247,7 @@ async function confirmDiscardEdits() { discardLabel: t('sessionDiscardConfirm'), }); if (choice === 'confirm') { - saveSession(); + if (!saveSession()) return false; return true; } return choice === 'discard'; @@ -251,24 +265,21 @@ function showSessionError(messageKey) { const SESSION_GUARDED_CLICKS = [ { selector: '#btn-clear' }, { selector: '#btn-clear-right' }, + { selector: '#btn-clear-left' }, { selector: '#lp-clear-right' }, { selector: '#btn-swap-docs' }, { selector: '.btn-load-sample' }, { selector: '#btn-help-sample' }, { // The paste dialog is pre-filled with the edited text; only an actual - // change of the right-hand text discards edits. + // change of either document resets review decisions. selector: '#btn-apply-modal', when: () => { const ta = document.getElementById('textarea-right'); - return !!ta && ta.value !== pasteModalShown.right; + const left = document.getElementById('textarea-left'); + return (!!ta && ta.value !== pasteModalShown.right) || (!!left && left.value !== pasteModalShown.left); }, }, - { - // Toggling the language while the sample is shown reloads the sample. - selector: '#btn-lang, #menu-lang, #footer-lang-link', - when: () => state.leftFormat === 'DEMO' && state.rightFormat === 'DEMO', - }, ]; document.addEventListener( @@ -437,9 +448,17 @@ function saveSession() { } const name = buildSessionFileName(); const json = JSON.stringify(buildSessionPayload(), null, 1); + if (new Blob([json]).size > SESSION_MAX_FILE_BYTES) { + showSessionDialog({ + title: state.lang === 'de' ? 'Arbeitsstand kann nicht gespeichert werden' : 'Cannot save work', + body: t('sessionErrorTooLarge'), confirmLabel: t('sessionOk'), + }); + return false; + } downloadSessionFile(json, name); markSessionClean(); if (typeof showToast === 'function') showToast(formatMsg('toastSession', { name })); + return true; } /* ── Opening: strict validation, then restore ───────────────────────────── */ @@ -718,17 +737,9 @@ document.addEventListener('click', e => { if (e.target.closest('[data-session-action="save"]')) saveSession(); }); -// Downloading the result counts as saving it. Copying and printing do not: the -// clipboard is volatile, and neither a failed copy nor a cancelled print dialog can be -// detected, so they must not silence the unsaved-changes warning. -const SESSION_CLEANING_EXPORTS = ['txt', 'word', 'html', 'md']; -document.addEventListener('click', e => { - if (!(e.target instanceof Element)) return; - const item = e.target.closest('#export-menu [data-export]'); - if (item && SESSION_CLEANING_EXPORTS.includes(item.getAttribute('data-export'))) { - markSessionClean(); - } -}); +// Result exports do not preserve review decisions, source documents or undo history. +// Only saving a .docdiff session establishes a clean point. +const SESSION_CLEANING_EXPORTS = []; // A dropped .docdiff file is saved work, never a document to compare. This // capture listener runs before the pane / landing drop handlers. diff --git a/src/styles.css b/src/styles.css index 83be513..ff9a2c3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -59,6 +59,7 @@ --topbar-h: 52px; --list-w: 336px; --ruler-w: 14px; + --pane-pad-x: var(--sp-6); /* outer padding of a document pane; the inner side is --sp-4 */ --background: 0 0% 97.5%; --foreground: 215 25% 13%; @@ -169,6 +170,14 @@ padding: 0; } +/* The hidden attribute must win over component display values. Without this, an author + `display` (`.btn { display: inline-flex }`, `.menu-item { display: flex }`) beats the + user-agent `[hidden]` rule and keeps elements visible that JS has marked as hidden, + for example the "accept all" action when a comparison has no changes. */ +[hidden] { + display: none !important; +} + html { font-size: 100%; } @@ -430,6 +439,14 @@ body:not(.is-comparing) .topbar { cursor: pointer; flex-shrink: 0; } +/* Direct swap next to the file pair: quieter than the stepper, same 44 px target */ +.tb-swap { + color: hsl(var(--muted-foreground)); +} +.tb-swap:hover, +.tb-swap:focus-visible { + color: hsl(var(--foreground)); +} .tb-icon-btn.sm { width: 30px; height: 30px; @@ -953,7 +970,7 @@ body.is-comparing .empty-launchpad { display: inline-flex; align-items: center; min-height: 24px; - color: hsl(var(--primary)); + color: hsl(var(--primary-ink)); text-decoration: underline; text-underline-offset: 3px; } @@ -1284,10 +1301,12 @@ body.is-dragging-file .drop-slot-btn { } .scope-can li::before { content: "✓"; + content: "✓" / ""; color: hsl(var(--success-ink)); } .scope-cannot li::before { content: "–"; + content: "–" / ""; color: hsl(var(--destructive-ink)); } @@ -1402,7 +1421,7 @@ body:not(.view-inline) #pane-right { grid-column: 3; } /* The hidden stacked pane spans the ruler too; the extra padding keeps its text width */ body:not(.view-inline) #pane-unified { grid-column: 1 / 4; - padding-right: calc(var(--sp-6) + var(--ruler-w)); + padding-right: calc(var(--pane-pad-x) + var(--ruler-w)); } body.view-inline #pane-left, @@ -1420,7 +1439,7 @@ body:not(.view-inline) #pane-unified { overflow-y: auto; overflow-x: hidden; position: relative; - padding: 0 var(--sp-6) var(--sp-7); + padding: 0 var(--pane-pad-x) var(--sp-7); background: hsl(var(--card)); scrollbar-width: thin; scrollbar-color: hsl(var(--input)) transparent; @@ -1461,6 +1480,15 @@ body:not(.view-inline) #pane-unified { } } +/* Narrow desktops (1200–1439 px): the list and the outer padding give the text some room back. + Both panes shrink by the same amount, so unchanged paragraphs still wrap identically. */ +@media (min-width: 1200px) and (max-width: 1439px) { + :root { + --list-w: 304px; + --pane-pad-x: var(--sp-5); + } +} + /* Sticky, neutral caption per pane: "Alte Fassung · Vertrag_v1.docx" */ .pane-caption { position: sticky; diff --git a/src/template.html b/src/template.html index 010d44b..70e575c 100644 --- a/src/template.html +++ b/src/template.html @@ -72,8 +72,8 @@ - - + + @@ -158,6 +158,10 @@

          Dokumente

+ +
@@ -377,7 +381,7 @@

So funktioniert’s

-
    +
    1. Zwei Fassungen wählen Als PDF, Word-Datei oder eingefügten Text. Die Dateien werden nur in diesem Browser-Tab gelesen.
    2. Änderungen durchgehen Entferntes ist rot durchgestrichen, Neues grün markiert. Die Liste rechts führt von Änderung zu Änderung.
    3. Ergebnis sichern, wenn Sie es brauchen Als Word-Datei mit markierten Änderungen, als PDF oder als Bericht speichern.
    4. @@ -402,7 +406,7 @@

      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
        • @@ -413,7 +417,7 @@

          Kann

      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