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:
1 parent
eb7f3c731d
commit
2aecb1a1b3
5 files changed
+231
-7
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>' }[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; }
|
||||
`;
|
||||
}
|
||||
Reference in new issue
Block a user