From 2526144ab9dd9c27faa69ce8cf05f9a115f9ae4a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 17 Jun 2026 21:07:20 +0200 Subject: [PATCH] feat: add pdf-export --- package-lock.json | 211 +++++++++++++++++++++++++++++++++++++++++- package.json | 2 + src/App.svelte | 37 ++++++++ src/lib/export/pdf.ts | 171 ++++++++++++++++++++++++++++++++++ 4 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/lib/export/pdf.ts diff --git a/package-lock.json b/package-lock.json index c0c9cb1..4e9e693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "@tiptap/extension-text-style": "^3.23.6", "@tiptap/extension-underline": "^3.23.6", "@tiptap/pm": "^3.23.6", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "odf-kit": "^0.13.5" }, "devDependencies": { @@ -42,6 +44,15 @@ "vite": "^8.0.14" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -817,11 +828,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/acorn": { @@ -857,6 +881,35 @@ "node": ">= 0.4" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -867,6 +920,27 @@ "node": ">=6" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -894,6 +968,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -919,6 +1003,17 @@ } } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -958,6 +1053,25 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -968,6 +1082,23 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1304,6 +1435,19 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1482,6 +1626,33 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1532,6 +1703,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/svelte": { "version": "5.55.9", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", @@ -1560,6 +1741,25 @@ "node": ">=18" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1599,6 +1799,15 @@ "node": ">=14.17" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", diff --git a/package.json b/package.json index 9c809b6..3b013a3 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "@tiptap/extension-text-style": "^3.23.6", "@tiptap/extension-underline": "^3.23.6", "@tiptap/pm": "^3.23.6", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "odf-kit": "^0.13.5" } } diff --git a/src/App.svelte b/src/App.svelte index 9b83c3c..94d9561 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,6 +5,7 @@ import Toolbar from './lib/editor/Toolbar.svelte'; import ToolbarExpanded from './lib/editor/ToolbarExpanded.svelte'; import { buildOdt, deriveFilename } from './lib/export/odt'; + import { exportPdf } from './lib/export/pdf'; import { supportsFsAccess, saveOdt, saveAsOdt, openOdt } from './lib/export/saveFile'; import { importOdt } from './lib/import/odt'; import { getPageBreakDebug } from './lib/editor/pageBreaks'; @@ -94,6 +95,7 @@ const fsSupported = supportsFsAccess(); let saveMenuOpen = $state(false); let fileInput: HTMLInputElement | null = $state(null); + let pdfBusy = $state(false); // odf-kit export options for the current header/footer + page geometry. function hfOpts() { @@ -216,6 +218,27 @@ } } + // Lay the document out into A4 pages (Paged.js) and open the print dialog so the + // user can "Save as PDF" — vector text matching the editor, with header/footer. + async function handleExportPdf() { + if (!editor || pdfBusy) return; + saveMenuOpen = false; + pdfBusy = true; + try { + await exportPdf({ + source: editor.view.dom as HTMLElement, + json: editor.getJSON(), + orientation: pageOrientation, + numPages, + }); + } catch (err) { + console.error('[pdf] Export failed:', err); + alert('Could not export to PDF.'); + } finally { + pdfBusy = false; + } + } + function saveMenuClickOutside(node: HTMLElement) { function handler(e: MouseEvent) { if (!node.contains(e.target as Node)) saveMenuOpen = false; @@ -272,6 +295,14 @@ {/snippet} + {#snippet pdfIcon()} + + + {/snippet}
+
{/if} @@ -314,6 +348,9 @@ + {/if}
diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts new file mode 100644 index 0000000..d6886b6 --- /dev/null +++ b/src/lib/export/pdf.ts @@ -0,0 +1,171 @@ +// PDF export that matches the editor exactly AND keeps selectable/searchable text. +// +// Why this shape: letting the browser re-render at print time drifts the text (true +// vector metrics ≠ on-screen hinted metrics), which broke the page-break band masks. +// Instead we RASTER the editor's own on-screen render (where content, big text, tables, +// page-break bands and headers/footers are already correct) and overlay an INVISIBLE +// text layer positioned from the same on-screen measurements — so the PDF looks like a +// screenshot of the editor but its text can still be selected and searched. + +import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation'; +import { deriveFilename } from './odt'; + +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; // for the suggested filename + orientation?: Orientation; + numPages?: number; +} + +type Run = { str: string; x: number; y: number; h: number }; // doc px, relative to .paper top-left + +function pageDims(orientation: Orientation): { pageW: number; pageH: number } { + const landscape = orientation === 'landscape'; + return { + pageW: landscape ? PAGE_H_PORTRAIT : PAGE_W_PORTRAIT, + pageH: landscape ? PAGE_W_PORTRAIT : PAGE_H_PORTRAIT, + }; +} + +// 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): { 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'); + + 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 .tiptap { background:#fff !important; } +[data-pdf-export] .column-resize-handle, [data-pdf-export] .row-resize-handle { display:none !important; } +[data-pdf-export] .selectedCell::after { display: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; +} + +// Render the document to A4 pages (raster + invisible text) and download the PDF. +export async function exportPdf(opts: PdfOptions): Promise { + const paper = opts.source.closest('.paper') as HTMLElement | null; + if (!paper) throw new Error('paper element not found'); + const orientation: Orientation = opts.orientation ?? 'portrait'; + const { pageW, pageH } = pageDims(orientation); + const cycle = pageH + PAGE_GAP; + + const { holder, clone, style } = buildClone(paper, pageW); + document.head.appendChild(style); + document.body.appendChild(holder); + + try { + await document.fonts.ready; + const pages = Math.max(1, opts.numPages ?? Math.round((clone.offsetHeight + PAGE_GAP) / cycle)); + const runs = collectRuns(clone); + + const SCALE = 2; + const html2canvas = (await import('html2canvas')).default; + const canvas = await html2canvas(clone, { + scale: SCALE, + backgroundColor: '#ffffff', + width: pageW, + height: pages * cycle, + windowWidth: pageW, + logging: false, + }); + + const { jsPDF } = await import('jspdf'); + const fmt: [number, number] = [pageW * PT, pageH * PT]; + const doc = new jsPDF({ unit: 'pt', format: fmt, orientation: orientation === 'landscape' ? 'l' : 'p' }); + + const tmp = document.createElement('canvas'); + tmp.width = Math.round(pageW * SCALE); + tmp.height = Math.round(pageH * SCALE); + const ctx = tmp.getContext('2d')!; + + for (let i = 0; i < pages; i++) { + if (i > 0) doc.addPage(fmt, orientation === 'landscape' ? 'l' : 'p'); + + // Crop this page's surface [i*cycle, i*cycle+pageH] out of the full capture. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, tmp.width, tmp.height); + ctx.drawImage(canvas, 0, i * cycle * SCALE, pageW * SCALE, pageH * SCALE, 0, 0, pageW * SCALE, pageH * SCALE); + doc.addImage(tmp.toDataURL('image/png'), 'PNG', 0, 0, pageW * PT, pageH * PT); + + // Invisible selectable text for this page. + const top = i * cycle, bottom = i * cycle + pageH; + for (const r of runs) { + if (r.y < top || r.y >= bottom - 1) continue; + const fs = Math.max(1, r.h * PT); + doc.setFontSize(fs); + doc.text(r.str, r.x * PT, (r.y - top) * PT + fs * 0.8, { renderingMode: 'invisible', baseline: 'alphabetic' }); + } + } + + doc.save(deriveFilename(opts.json).replace(/\.odt$/i, '') + '.pdf'); + } finally { + holder.remove(); + style.remove(); + } +}