diff --git a/src/App.svelte b/src/App.svelte index 723d1e9..fcc96b0 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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) { diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index c41cee9..9014f76 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts index fd347ce..fe47675 100644 --- a/src/lib/export/pdf.ts +++ b/src/lib/export/pdf.ts @@ -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 = `` - + 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 { 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 { } 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'); diff --git a/src/lib/export/reviewPrint.ts b/src/lib/export/reviewPrint.ts index 08be410..b71370a 100644 --- a/src/lib/export/reviewPrint.ts +++ b/src/lib/export/reviewPrint.ts @@ -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) => `
  • ` - + `${esc(c.author)}${c.date ? ` · ${esc(when(c.date))}` : ''}` - + (c.quote.trim() ? `${esc(c.quote)}` : '') - + `${esc(c.text)}
  • `).join(''); - return `

    ${esc(heading)}

      ${rows}
    `; +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 `
  • ${meta}` + + (c.quote.trim() ? `${esc(c.quote)}` : '') + + `${esc(c.text)}
  • `; + }).join(''); + return `

    ${esc(labels.heading)}

      ${rows}
    `; } /** The rules both print paths draw the bar and the list with. */ diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 3020a60..4d5833a 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 6388636..689edb6 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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',