fix: a printed comment says which page it is anchored on

The list quoted its text but named no place, so on a five-page printout the
entry was a needle. It now leads with the page, the format LibreOffice prints
its own end-of-document comments in. Only the raster paths can say it — they
print the editor's own pagination, the vector one re-paginates in the browser.

commentsHeading gives way to commentLabels, since the page needs a word too.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:02 +02:00
1 parent 29433e1b1d
commit f51e3377e6
6 files changed
+57 -20

No files matched your search

+3 -3
View File
@@ -942,7 +942,7 @@
orientation: pageOrientation,
pageFormat,
numPages,
commentsHeading: t().comments.title,
commentLabels: { heading: t().comments.title, onPage: t().comments.onPage },
printMarkup: printMarkup(),
});
} catch (err) {
@@ -969,7 +969,7 @@
orientation: pageOrientation,
pageFormat,
numPages,
commentsHeading: t().comments.title,
commentLabels: { heading: t().comments.title, onPage: t().comments.onPage },
printMarkup: printMarkup(),
});
} catch (err) {
@@ -1001,7 +1001,7 @@
headerEvenDoc,
footerEvenDoc,
differentOddEven,
commentsHeading: t().comments.title,
commentLabels: { heading: t().comments.title, onPage: t().comments.onPage },
printMarkup: printMarkup(),
});
} catch (err) {
+6
View File
@@ -80,6 +80,12 @@ before (no list, no CSS, no margin shift). The Review tab's **Print markup** tog
`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.
- **An entry names its page**, which with the quoted text is what makes it findable in a
printout nobody can click — the format LibreOffice prints its own end-of-document
comments in. Only the raster paths can say it (`withAnchorPages`): they print the
editor's own pagination, while the vector path re-paginates in the browser and its entry
simply has no page. The page comes off one `cycle`, so mixed section paper is out — the
same limitation `Editor.svelte`'s own current-page readout has.
- **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
+27 -10
View File
@@ -12,7 +12,9 @@ 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 { BAR_STRIP_CM, commentListHtml, markReviewBlocks, printedComments, reviewPrintCss, type CommentLabels, type PrintedComment } from './reviewPrint';
const DEFAULT_COMMENT_LABELS: CommentLabels = { heading: 'Comments', onPage: (n) => `Page ${n}` };
import globalCss from '../../styles/global.css?inline';
import editorCss from '../../styles/editor.css?inline';
@@ -29,8 +31,8 @@ export interface PdfOptions {
orientation?: Orientation;
pageFormat?: PageFormat;
numPages?: number;
/** Heading of the printed comment list; no comments, no extra pages. */
commentsHeading?: string;
/** Labels of the printed comment list; this module has no i18n of its own. */
commentLabels?: CommentLabels;
/** Whether the review markup prints at all (`storage/printMarkup`); default on. */
printMarkup?: boolean;
}
@@ -269,6 +271,19 @@ function cropPageDataUrl(
return tmp.toDataURL(mime, quality);
}
// The page each comment sits on. Only the raster paths can say: they print the editor's
// own pagination, while the vector path re-paginates in the browser.
function withAnchorPages(list: PrintedComment[], source: HTMLElement, cycle: number): PrintedComment[] {
const base = source.getBoundingClientRect();
const scale = source.offsetWidth ? base.width / source.offsetWidth : 1;
return list.map((c) => {
const el = source.querySelector(`[data-comment="${CSS.escape(c.id)}"]`);
if (!el) return c;
const top = (el.getBoundingClientRect().top - base.top) / scale;
return { ...c, page: Math.max(1, Math.floor(top / cycle) + 1) };
});
}
// 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.
@@ -285,7 +300,7 @@ async function renderCommentPages(
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');
+ commentListHtml(list, opts.commentLabels ?? DEFAULT_COMMENT_LABELS);
document.body.appendChild(holder);
try {
await document.fonts.ready;
@@ -327,7 +342,8 @@ export async function exportPdf(opts: PdfOptions): Promise<void> {
const landscape = (opts.orientation ?? 'portrait') === 'landscape';
try {
const runs = collectRuns(clone);
const comments = opts.printMarkup === false ? [] : printedComments(opts.source);
const comments = opts.printMarkup === false
? [] : withAnchorPages(printedComments(opts.source), opts.source, cycle);
const extra = comments.length
? await renderCommentPages(opts, comments, pageW, pageH, scale)
: { images: [], runs: [] };
@@ -383,7 +399,8 @@ export async function printRaster(opts: PdfOptions): Promise<void> {
} finally {
cleanup();
}
const comments = opts.printMarkup === false ? [] : printedComments(opts.source);
const comments = opts.printMarkup === false
? [] : withAnchorPages(printedComments(opts.source), opts.source, cycle);
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, '');
@@ -446,8 +463,8 @@ export interface PrintPdfOptions {
headerEvenDoc?: HfDoc;
footerEvenDoc?: HfDoc;
differentOddEven?: boolean;
/** Heading of the printed comment list; no comments, no list. */
commentsHeading?: string;
/** Labels of the printed comment list; this module has no i18n of its own. */
commentLabels?: CommentLabels;
/** Whether the review markup prints at all (`storage/printMarkup`); default on. */
printMarkup?: boolean;
}
@@ -622,7 +639,7 @@ export function printPdf(opts: PrintPdfOptions): void {
headerEvenDoc: opts.headerEvenDoc ?? null,
footerEvenDoc: opts.footerEvenDoc ?? null,
differentOddEven: opts.differentOddEven ?? false,
commentsHeading: opts.commentsHeading,
commentLabels: opts.commentLabels,
printMarkup: opts.printMarkup,
};
const title = (o.fileName ?? deriveFilename(o.json)).replace(/\.(odt|pdf)$/i, '');
@@ -633,7 +650,7 @@ export function printPdf(opts: PrintPdfOptions): void {
const markup = o.printMarkup !== false;
const comments = markup ? printedComments(host) : [];
const review = markup && markReviewBlocks(host);
const list = comments.length ? commentListHtml(comments, o.commentsHeading ?? 'Comments') : '';
const list = comments.length ? commentListHtml(comments, o.commentLabels ?? DEFAULT_COMMENT_LABELS) : '';
const iframe = document.createElement('iframe');
iframe.setAttribute('aria-hidden', 'true');
+19 -7
View File
@@ -11,7 +11,14 @@ 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 };
export type PrintedComment = {
id: string; n: number; author: string; date: string; text: string; quote: string;
/** The page the anchor sits on, where the caller knows it (the raster paths do). */
page?: number;
};
/** The labels the list is printed with; this module has no i18n of its own. */
export type CommentLabels = { heading: string; onPage: (n: number) => string };
const OPEN_COMMENT = '[data-comment]:not([data-comment-resolved="true"])';
const MARKED = `${OPEN_COMMENT}, [data-insertion], [data-deletion]`;
@@ -81,12 +88,17 @@ function when(iso: string): string {
}
/** 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>`;
export function commentListHtml(list: PrintedComment[], labels: CommentLabels): string {
const rows = list.map((c) => {
// Page first, as LibreOffice prints its own end-of-document comments: with the
// quote it is what makes an entry findable in a printout nobody can click.
const meta = [c.page ? labels.onPage(c.page) : '', c.author, c.date ? when(c.date) : '']
.filter(Boolean).map(esc).join(' · ');
return `<li><span class="cl-meta">${meta}</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(labels.heading)}</h2><ol>${rows}</ol></section>`;
}
/** The rules both print paths draw the bar and the list with. */
+1
View File
@@ -81,6 +81,7 @@ const de: Messages = {
},
comments: {
title: 'Kommentare',
onPage: (n: number) => `Seite ${n}`,
empty: 'Noch keine Kommentare. Text markieren und einen hinzufügen.',
edit: 'Bearbeiten',
resolve: 'Erledigt',
+1
View File
@@ -84,6 +84,7 @@ const en = {
},
comments: {
title: 'Comments',
onPage: (n: number) => `Page ${n}`,
empty: 'No comments yet. Select some text and add one.',
edit: 'Edit',
resolve: 'Resolve',