feat: a printed document carries its comments and changes

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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent eb7f3c731d
commit 2aecb1a1b3
5 files changed
+231 -7

No files matched your search

+3
View File
@@ -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);
+3 -1
View File
@@ -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`)
+25
View File
@@ -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 `<draw:frame>`, 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 `<text:line-break/>`/`<text:page-count>`, 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.
+92 -6
View File
@@ -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 = `<style>${reviewPrintCss()}\n.comment-list { break-before: auto; }</style>`
+ 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<void> {
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<void> {
}
}
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<void> {
} 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(
`<!doctype html><html data-theme="light"><head><meta charset="utf-8"><title>${title}</title>` +
`<style>${globalCss}\n${editorCss}\n${printCss(o)}</style></head>` +
`<body><div class="paper"><div class="tiptap">${body}</div></div></body></html>`,
`<style>${globalCss}\n${editorCss}\n${printCss(o, review)}</style></head>` +
`<body><div class="paper"><div class="tiptap">${host.innerHTML}</div>${list}</div></body></html>`,
);
idoc.close();
+108
View File
@@ -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<string, PrintedComment>();
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<string, string> {
const order = new Map<string, string>();
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[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) => `<li>`
+ `<span class="cl-meta">${esc(c.author)}${c.date ? ` · ${esc(when(c.date))}` : ''}</span>`
+ (c.quote.trim() ? `<span class="cl-quote">${esc(c.quote)}</span>` : '')
+ `<span class="cl-text">${esc(c.text)}</span></li>`).join('');
return `<section class="comment-list"><h2>${esc(heading)}</h2><ol>${rows}</ol></section>`;
}
/** 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; }
`;
}