// PDF export that matches the editor exactly AND keeps selectable text. The browser's // print-time re-render drifts text (vector ≠ hinted metrics) and broke the band masks, // so we raster the on-screen render and overlay an invisible, positioned text layer. import { generateHTML } from '@tiptap/core'; import { type Orientation } from '../storage/pageOrientation'; import { DEFAULT_MARGINS, PX_PER_CM, type PageMargins } from '../storage/pageMargins'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { hfIsEmpty, type HfDoc } from '../storage/headerFooter'; import { extensions } from '../editor/extensions'; 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 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'; const PAGE_GAP = 20; // must match pageBreaks.ts / editor.css --page-gap const PT = 72 / 96; // CSS px @96dpi → PDF points // eslint-disable-next-line @typescript-eslint/no-explicit-any type Json = any; export interface PdfOptions { source: HTMLElement; // the live `.tiptap` element (editor.view.dom) json: Json; // fallback for the suggested filename fileName?: string; // document name (any extension); overrides the heading-derived name orientation?: Orientation; pageFormat?: PageFormat; numPages?: number; /** 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; } type Run = { str: string; x: number; y: number; h: number }; // doc px, relative to .paper top-left function pageDims(format: PageFormat, orientation: Orientation): { pageW: number; pageH: number } { const { w, h } = pageDimsCm(format, orientation); return { pageW: w * PX_PER_CM, pageH: h * PX_PER_CM }; } // Off-screen, scale-1, theme-neutral copy of the live .paper. Rendered identically to // the editor (no print re-layout), so the raster + measured text positions agree. function buildClone(paper: HTMLElement, pageW: number, markup: boolean): { holder: HTMLElement; clone: HTMLElement; style: HTMLStyleElement } { const clone = paper.cloneNode(true) as HTMLElement; 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. if (markup) clone.querySelectorAll('.change-bar.active').forEach((el) => el.classList.remove('active')); else clone.querySelectorAll('.change-bar-layer').forEach((el) => el.remove()); // 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- // highlighted runs into a later paint pass so the highlight can't cover them. clone.querySelectorAll('strong').forEach((el) => { if (el.closest('mark') || el.querySelector('mark')) { el.style.position = 'relative'; el.style.zIndex = '1'; } }); const holder = document.createElement('div'); holder.setAttribute('data-pdf-export', ''); holder.style.cssText = 'position:fixed; left:-100000px; top:0; background:#fff;'; // Theme-neutral: white page, black text, real explicit colors (ignore dark/allBlack). holder.style.setProperty('--color-page-bg', '#fff'); holder.style.setProperty('--color-page-text', '#000'); holder.style.setProperty('--color-page-border', '#000'); holder.appendChild(clone); const style = document.createElement('style'); style.textContent = ` [data-pdf-export] .paper, [data-pdf-export] .paper .tiptap { box-shadow:none !important; } [data-pdf-export] .paper { background:#fff !important; } [data-pdf-export] .paper .tiptap { background:none !important; } [data-pdf-export] .column-resize-handle, [data-pdf-export] .row-resize-handle { display:none !important; } [data-pdf-export] .selectedCell::after { display:none !important; } /* No selection highlight in the output — and its color-mix background computes to a color() value html2canvas cannot parse, which aborted the whole capture. */ [data-pdf-export] .ProseMirror-selectednode { background:none !important; outline:none !important; box-shadow:none !important; } [data-pdf-export] .tiptap .is-editor-empty::before { content:none !important; } [data-pdf-export] .hf-bar { display:none !important; } [data-pdf-export] .tiptap [data-color] { color:var(--font-color, currentColor) !important; } `; return { holder, clone, style }; } // Invisible-text runs: one per wrapped line of every text node, positioned in doc px // relative to the .paper top-left (the same frame the raster is cropped in). function collectRuns(root: HTMLElement): Run[] { const base = root.getBoundingClientRect(); const runs: Run[] = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const range = document.createRange(); let node: Node | null; while ((node = walker.nextNode())) { const tn = node as Text; const text = tn.data; if (!text.trim()) continue; range.selectNodeContents(tn); const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0); if (!rects.length) continue; let offset = 0; for (let li = 0; li < rects.length; li++) { let end: number; if (li === rects.length - 1) { end = text.length; } else { // Largest offset whose [offset..mid] still ends on this line (top < next line's top). const nextTop = rects[li + 1].top; let lo = offset + 1, hi = text.length, best = offset + 1; while (lo <= hi) { const mid = (lo + hi) >> 1; range.setStart(tn, offset); range.setEnd(tn, mid); const rs = range.getClientRects(); const t = rs.length ? rs[rs.length - 1].top : rects[li].top; if (t < nextTop - 0.5) { best = mid; lo = mid + 1; } else { hi = mid - 1; } } end = best; } const str = text.slice(offset, end); const r = rects[li]; if (str.trim()) runs.push({ str, x: r.left - base.left, y: r.top - base.top, h: r.height }); offset = end; } } return runs; } // 1-based ordinal of a li within its list (start attr respected). function liOrdinal(li: HTMLElement): number { const parent = li.parentElement!; const items = Array.from(parent.children).filter((c) => c.tagName === 'LI'); return parseInt(parent.getAttribute('start') ?? '1', 10) + items.indexOf(li); } // The outermost
    of the unbroken 'multilevel' run this list sits in — where the // chain `counters(list-item, '.')` prints starts, so the walk cannot stop at the nearest // one. Keyed on the *effective* numbering, the attr editor.css draws from: a list // numbered by a named style carries no `data-list-style` of its own. function multilevelRoot(ol: HTMLElement, root: HTMLElement): HTMLElement | null { let found: HTMLElement | null = null; for (let e: HTMLElement | null = ol; e && e !== root; e = e.parentElement) { if (e.tagName !== 'OL') continue; if (e.getAttribute('data-eff-list-style') !== 'multilevel') break; found = e; } return found; } // The marker text the browser renders for a list item: the depth bullet/bulletChar // for a