From 2aecb1a1b32813249e2263c38e311fca8a40f253 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 30 Aug 2026 21:37:58 +0000 Subject: [PATCH] feat: a printed document carries its comments and changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar in the margin beside every changed or commented block, and the comment bodies as a list after the document — a comment's text is nowhere on the page, so a bar alone could only say that one exists. Resolved comments print nowhere, and a document with no markup prints exactly as before. The print engine clips to the page area (measured), so the vector path buys the bar's strip out of the left @page margin and gives it back as padding. The raster paths clone the editor's own layer and append the list as whole pages. --- src/App.svelte | 3 + src/lib/components/CLAUDE.md | 4 +- src/lib/export/CLAUDE.md | 25 ++++++++ src/lib/export/pdf.ts | 98 ++++++++++++++++++++++++++++-- src/lib/export/reviewPrint.ts | 108 ++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 src/lib/export/reviewPrint.ts diff --git a/src/App.svelte b/src/App.svelte index 44fd3bb..c56efdc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -941,6 +941,7 @@ orientation: pageOrientation, pageFormat, numPages, + commentsHeading: t().comments.title, }); } catch (err) { console.error('[pdf] Export failed:', err); @@ -966,6 +967,7 @@ orientation: pageOrientation, pageFormat, numPages, + commentsHeading: t().comments.title, }); } catch (err) { console.error('[pdf] Print failed:', err); @@ -996,6 +998,7 @@ headerEvenDoc, footerEvenDoc, differentOddEven, + commentsHeading: t().comments.title, }); } catch (err) { console.error('[pdf] Print failed:', err); diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index cf4f753..7728f38 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -248,7 +248,9 @@ comment amber, so a pane entry can be found in the text. Each range's ends give once per page instead of through the gap. Inside the scaled `.paper` like the other layers, and inside the line numbers, which sit further out. The change or comment at the caret is drawn heavier and last, or a neighbour on the same line would cover it. Always -mounted: with nothing marked it renders nothing. +mounted: with nothing marked it renders nothing. It sits inside `.paper`, so the raster +export and print paths clone it and the bar prints — see `export/reviewPrint.ts` for what +the vector path has to rebuild instead. ## Reviewing leader (`ConnectorLayer.svelte`) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index a692fd9..167ddff 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -60,6 +60,31 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`. +## Printing the review markup (`reviewPrint.ts`) + +A printed document says what the screen says: a bar in the margin beside every changed or +commented block, and the comment bodies as a list after the document. The list is not a +nicety — a comment's text is nowhere on the page, so a bar alone could only say that one +exists. A resolved comment prints nowhere, and a document with no markup prints exactly as +before (no list, no CSS, no margin shift). There is no on/off switch; both products have one. + +- **The margin strip is paid for.** The print engine clips to the page area, so a bar at a + negative offset is silently dropped — measured, not assumed. `printPdf` takes + `BAR_STRIP_CM` out of the left `@page` margin and gives it back as `.paper` padding, + which leaves the text column exactly where it was. +- **The two paths differ in resolution.** The raster paths (`exportPdf`, `printRaster`) + clone the live `.paper`, so they get `ChangeBarLayer`'s own per-range bars for free — + `buildClone` only drops the `.active` weight, an editing state. `printPdf` rebuilds from + `generateHTML`, where no layout exists yet: `markReviewBlocks` marks whole top-level + blocks (CSS has no line box to hang a bar on, and only a top-level block's left edge is + the text column's), so a long paragraph with a small change is marked whole. +- **The list is a page, not a flow, in the raster paths.** The live `.paper` is an already + paginated layout nothing can flow into, so `renderCommentPages` lays the list out and + rasters it on its own; a page starts at an entry boundary, since a raster sliced mid-line + cuts the text in half. One entry taller than a page is clipped. The vector path just + appends the section next to `.tiptap` — a sibling, or the editor's own heading and list + rules would restyle it — and lets the browser paginate. + ## Header/footer export - **Export:** a synthetic `__cust_hf__` node routes to `unknownNodeHandler`, which calls odf-kit's `setHeader`/`setFooter` (`applyHfRuns`); `hardBreak`→`LBR`, `pageNumber`→`addPageNumber`, `pageCount`→`PGC` sentinel. A zone image is an as-char ``, or — floating — a page-anchored one plus a minted `HfBg*` graphic style (`run-through` behind the text, positioned from the page corner). `applyHfPostProcess` (on styles.xml) rewrites the sentinels to ``/``, applies the paragraph alignment to the `Header`/`Footer` styles, and converts geometry to Word's model: page margin = the zone's edge distance (`headerDistanceCm`/`footerDistanceCm`), header/footer `min-height` = body margin − distance, so the **body** still starts at the editor's margin. diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index 742fffd..440d808 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -12,6 +12,7 @@ import { columnPercents } from '../editor/extensions/tableView'; import { effectiveOrderedDef, formatOrdinal } from '../utils/orderedListTypes'; import { defaultBulletChar } from '../utils/bulletListTypes'; import { deriveFilename } from './odt'; +import { BAR_STRIP_CM, commentListHtml, markReviewBlocks, printedComments, reviewPrintCss, type PrintedComment } from './reviewPrint'; import globalCss from '../../styles/global.css?inline'; import editorCss from '../../styles/editor.css?inline'; @@ -28,6 +29,8 @@ export interface PdfOptions { orientation?: Orientation; pageFormat?: PageFormat; numPages?: number; + /** Heading of the printed comment list; no comments, no extra pages. */ + commentsHeading?: string; } type Run = { str: string; x: number; y: number; h: number }; // doc px, relative to .paper top-left @@ -44,6 +47,8 @@ function buildClone(paper: HTMLElement, pageW: number): { holder: HTMLElement; c clone.style.transform = 'none'; clone.style.width = `${pageW}px`; // pin width so capture geometry can't drift on var inheritance clone.classList.remove('show-formatting-marks', 'hf-editing'); + // The margin bar prints at one weight: which one the caret is on is an editing state. + clone.querySelectorAll('.change-bar.active').forEach((el) => el.classList.remove('active')); // html2canvas paints an inline background from one bounding rect, so a wrapped // highlight covers the bold term preceding it on the first line. Lift bold-and- @@ -261,12 +266,68 @@ function cropPageDataUrl( return tmp.toDataURL(mime, quality); } +// The comment list as whole extra pages. The live .paper is an already paginated +// layout the list cannot flow into, so it is laid out and rastered on its own; a page +// starts at an entry boundary, because a raster sliced mid-line cuts the text in half. +async function renderCommentPages( + opts: PdfOptions, list: PrintedComment[], pageW: number, pageH: number, scale: number, +): Promise<{ images: string[]; runs: Run[][] }> { + const cs = getComputedStyle(opts.source); + // .tiptap's padding is the page margins; PdfOptions carries the live DOM, not them. + const [mT, mR, mB, mL] = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(parseFloat); + const contentW = Math.max(50, pageW - mL - mR); + const usableH = Math.max(50, pageH - mT - mB); + + const holder = document.createElement('div'); + holder.setAttribute('data-pdf-export', ''); + holder.style.cssText = `position:fixed; left:-100000px; top:0; width:${contentW}px; background:#fff; color:#000;`; + holder.innerHTML = `` + + commentListHtml(list, opts.commentsHeading ?? 'Comments'); + document.body.appendChild(holder); + try { + await document.fonts.ready; + const starts = [0]; + for (const li of Array.from(holder.querySelectorAll('li')) as HTMLElement[]) { + if (li.offsetTop + li.offsetHeight - starts[starts.length - 1] > usableH) starts.push(li.offsetTop); + } + const total = holder.scrollHeight; + const html2canvas = (await import('html2canvas')).default; + const canvas = await html2canvas(holder, { + scale, backgroundColor: '#ffffff', width: contentW, height: total, windowWidth: contentW, logging: false, + }); + const all = collectRuns(holder); + const images: string[] = []; + const runs: Run[][] = []; + for (const start of starts) { + const tmp = document.createElement('canvas'); + tmp.width = Math.round(pageW * scale); + tmp.height = Math.round(pageH * scale); + const ctx = tmp.getContext('2d')!; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, tmp.width, tmp.height); + const h = Math.min(usableH, total - start); + ctx.drawImage(canvas, 0, start * scale, contentW * scale, h * scale, + mL * scale, mT * scale, contentW * scale, h * scale); + images.push(tmp.toDataURL('image/jpeg', 0.92)); + runs.push(all.filter((r) => r.y >= start && r.y < start + usableH) + .map((r) => ({ ...r, x: r.x + mL, y: r.y - start + mT }))); + } + return { images, runs }; + } finally { + holder.remove(); + } +} + // Render the document to A4 pages (raster + invisible text) and download the PDF. export async function exportPdf(opts: PdfOptions): Promise { const { canvas, clone, pages, pageW, pageH, cycle, scale, cleanup } = await renderPaperToCanvas(opts); const landscape = (opts.orientation ?? 'portrait') === 'landscape'; try { const runs = collectRuns(clone); + const comments = printedComments(opts.source); + const extra = comments.length + ? await renderCommentPages(opts, comments, pageW, pageH, scale) + : { images: [], runs: [] }; const { jsPDF } = await import('jspdf'); const fmt: [number, number] = [pageW * PT, pageH * PT]; // compress: Flate the streams (e.g. the text layer); page images are JPEG. @@ -286,6 +347,16 @@ export async function exportPdf(opts: PdfOptions): Promise { } } + for (let i = 0; i < extra.images.length; i++) { + doc.addPage(fmt, landscape ? 'l' : 'p'); + doc.addImage(extra.images[i], 'JPEG', 0, 0, pageW * PT, pageH * PT); + for (const r of extra.runs[i]) { + const fs = Math.max(1, r.h * PT); + doc.setFontSize(fs); + doc.text(r.str, r.x * PT, r.y * PT + fs * 0.8, { renderingMode: 'invisible', baseline: 'alphabetic' }); + } + } + // Test seam: the smoke test takes the bytes here — capturing the real doc.save() // download hangs in headless Chromium (docs/headless-testing.md). const sink = (window as { __edentextPdfSink?: (bytes: ArrayBuffer) => void }).__edentextPdfSink; @@ -309,6 +380,8 @@ export async function printRaster(opts: PdfOptions): Promise { } finally { cleanup(); } + const comments = printedComments(opts.source); + if (comments.length) imgs = imgs.concat((await renderCommentPages(opts, comments, pageW, pageH, scale)).images); const title = (opts.fileName ?? deriveFilename(opts.json)).replace(/\.(odt|pdf)$/i, ''); // Explicit cm size (orientation baked into w/h) works for any format, unlike a CSS @@ -370,6 +443,8 @@ export interface PrintPdfOptions { headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; + /** Heading of the printed comment list; no comments, no list. */ + commentsHeading?: string; } // First-row column weights from table JSON (honours colspan); mirrors tableView. @@ -492,23 +567,28 @@ function marginBoxesFirst(headerDoc: HfDoc, footerDoc: HfDoc): string { return edge('top', headerDoc) + '\n' + edge('bottom', footerDoc); } -function printCss(o: PrintPdfOptions): string { +function printCss(o: PrintPdfOptions, review: boolean): string { const m = o.margins; // Explicit cm size (orientation baked into w/h) works for any format, unlike a CSS // size keyword such as A4/letter. const d = pageDimsCm(o.pageFormat ?? 'A4', o.orientation); const size = `${d.w}cm ${d.h}cm`; + // The margin bar has to be paid for: the print engine clips to the page area, so a + // strip comes out of the left page margin and the text gets it back as padding — + // which leaves the text column exactly where it was. Measured, not assumed. + const strip = review ? Math.min(BAR_STRIP_CM, m.left) : 0; return ` @page { size: ${size}; - margin: ${m.top}cm ${m.right}cm ${m.bottom}cm ${m.left}cm; + margin: ${m.top}cm ${m.right}cm ${m.bottom}cm ${m.left - strip}cm; ${marginBoxes(o.headerDoc, o.footerDoc)} } ${o.differentOddEven ? `@page :left {\n ${marginBoxesFirst(o.headerEvenDoc ?? null, o.footerEvenDoc ?? null)}\n}` : ''} ${o.differentFirstPage ? `@page :first {\n ${marginBoxesFirst(o.headerFirstDoc ?? null, o.footerFirstDoc ?? null)}\n}` : ''} html, body { margin: 0; padding: 0; background: #fff; } -.paper { width: auto !important; transform: none !important; box-shadow: none !important; background: #fff !important; } +.paper { width: auto !important; padding-left: ${strip}cm !important; transform: none !important; box-shadow: none !important; background: #fff !important; } .paper .tiptap { padding: 0 !important; min-height: 0 !important; background: none !important; box-shadow: none !important; color: #000 !important; } +${review ? reviewPrintCss() : ''} /* generateHTML drops TipTap's .tableWrapper; mirror its (zero) margin. */ .paper .tiptap table { margin: 0; } .paper .tiptap tr { break-inside: avoid; } @@ -539,7 +619,13 @@ export function printPdf(opts: PrintPdfOptions): void { differentOddEven: opts.differentOddEven ?? false, }; const title = (o.fileName ?? deriveFilename(o.json)).replace(/\.(odt|pdf)$/i, ''); - const body = buildBodyHtml(o.json); + // The layers are Svelte DOM and generateHTML knows nothing of them, so this path + // rebuilds the review markup from the marks the document itself carries. + const host = document.createElement('div'); + host.innerHTML = buildBodyHtml(o.json); + const comments = printedComments(host); + const review = markReviewBlocks(host); + const list = comments.length ? commentListHtml(comments, o.commentsHeading ?? 'Comments') : ''; const iframe = document.createElement('iframe'); iframe.setAttribute('aria-hidden', 'true'); @@ -550,8 +636,8 @@ export function printPdf(opts: PrintPdfOptions): void { idoc.open(); idoc.write( `${title}` + - `` + - `
${body}
`, + `` + + `
${host.innerHTML}
${list}
`, ); idoc.close(); diff --git a/src/lib/export/reviewPrint.ts b/src/lib/export/reviewPrint.ts new file mode 100644 index 0000000..08be410 --- /dev/null +++ b/src/lib/export/reviewPrint.ts @@ -0,0 +1,108 @@ +// What a printed document says about its review markup, the way both word processors +// print it: a bar in the margin beside every changed or commented block, and the comment +// bodies as a list after the document. A comment's text is nowhere on the page, so a bar +// alone could only say that one exists — never what it says. + +import { REVISION_AUTHOR_COLORS } from '../editor/extensions/trackChanges'; + +/** Gap from the text edge to the bar, and the strip the left page margin gives up for it. */ +export const BAR_GAP_CM = 0.2; +export const BAR_STRIP_CM = 0.32; + +const COMMENT_COLOR = '#c88c00'; + +export type PrintedComment = { id: string; n: number; author: string; date: string; text: string; quote: string }; + +const OPEN_COMMENT = '[data-comment]:not([data-comment-resolved="true"])'; +const MARKED = `${OPEN_COMMENT}, [data-insertion], [data-deletion]`; + +/** Every unresolved comment in document order, numbered. A handled one prints nowhere. */ +export function printedComments(root: ParentNode): PrintedComment[] { + const out: PrintedComment[] = []; + const byId = new Map(); + for (const el of Array.from(root.querySelectorAll(OPEN_COMMENT))) { + const id = el.getAttribute('data-comment') ?? ''; + if (!id) continue; + const seen = byId.get(id); + // Runs of one comment split by a paragraph boundary: one entry, quoting them all. + if (seen) { seen.quote += el.textContent ?? ''; continue; } + const entry: PrintedComment = { + id, n: out.length + 1, + author: el.getAttribute('data-comment-author') ?? '', + date: el.getAttribute('data-comment-date') ?? '', + text: el.getAttribute('data-comment-text') ?? '', + quote: el.textContent ?? '', + }; + byId.set(id, entry); + out.push(entry); + } + return out; +} + +// The bar takes the revision author's colour, handed out as the editor's decoration +// does it: in order of first appearance. +function authorColors(root: ParentNode): Map { + const order = new Map(); + for (const el of Array.from(root.querySelectorAll('[data-rev-author]'))) { + const a = el.getAttribute('data-rev-author') ?? ''; + if (!order.has(a)) order.set(a, REVISION_AUTHOR_COLORS[order.size % REVISION_AUTHOR_COLORS.length]); + } + return order; +} + +/** + * Marks the blocks the margin bar hangs on, for the vector print path — the raster one + * clones the editor's own layer instead. Per top-level block, not per line: CSS has no + * line box to hang a bar on, and only a top-level block's left edge is the text column's. + * Returns whether anything was marked, which is what pays for the margin strip. + */ +export function markReviewBlocks(root: HTMLElement): boolean { + const colors = authorColors(root); + let marked = false; + for (const el of Array.from(root.querySelectorAll(MARKED))) { + let block: HTMLElement | null = el as HTMLElement; + while (block?.parentElement && block.parentElement !== root) block = block.parentElement; + if (!block || block.parentElement !== root || block.hasAttribute('data-review-bar')) continue; + const author = el.getAttribute('data-rev-author'); + block.setAttribute('data-review-bar', ''); + block.style.setProperty('--review-bar', + author === null ? COMMENT_COLOR : colors.get(author) ?? REVISION_AUTHOR_COLORS[0]); + marked = true; + } + return marked; +} + +const esc = (s: string) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c] ?? c)); + +// The stored ISO date, in the reader's locale — an imported file may carry any format. +function when(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); +} + +/** The comment bodies, after the document: what the margin bar cannot carry. */ +export function commentListHtml(list: PrintedComment[], heading: string): string { + const rows = list.map((c) => `
  • ` + + `${esc(c.author)}${c.date ? ` · ${esc(when(c.date))}` : ''}` + + (c.quote.trim() ? `${esc(c.quote)}` : '') + + `${esc(c.text)}
  • `).join(''); + return `

    ${esc(heading)}

      ${rows}
    `; +} + +/** The rules both print paths draw the bar and the list with. */ +export function reviewPrintCss(): string { + return ` +[data-review-bar] { position: relative; } +[data-review-bar]::before { + content: ''; position: absolute; left: -${BAR_GAP_CM}cm; top: 0; bottom: 0; + width: 2px; border-radius: 1px; background: var(--review-bar, ${COMMENT_COLOR}); +} +.comment-list { break-before: page; font-family: var(--font-serif); color: #000; } +.comment-list h2 { font-family: var(--font-sans); font-size: 14pt; margin: 0 0 0.4cm; } +.comment-list ol { margin: 0; padding-left: 1.1cm; } +.comment-list li { margin: 0 0 0.35cm; break-inside: avoid; } +.cl-meta { display: block; font-size: 9pt; color: #555; } +.cl-quote { display: block; margin: 0.05cm 0; padding-left: 0.2cm; border-left: 2px solid ${COMMENT_COLOR}; color: #555; } +.cl-text { display: block; white-space: pre-wrap; } +`; +}