feat: add pdf-export

This commit is contained in:
Steffen Becker committed 2026-06-17 21:07:20 +02:00
1 parent b8ed2aa14e
commit 2526144ab9
4 files changed
+420 -1

No files matched your search

+210 -1
View File
@@ -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",
+2
View File
@@ -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"
}
}
+37
View File
@@ -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 @@
<rect x="4.75" y="8.75" width="6.5" height="4.75" rx="0.5" stroke="currentColor" stroke-width="1.3"/>
</svg>
{/snippet}
{#snippet pdfIcon()}
<!-- Page with a down arrow -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M9 1.75H4.5A1.25 1.25 0 0 0 3.25 3v10A1.25 1.25 0 0 0 4.5 14.25h7A1.25 1.25 0 0 0 12.75 13V5.5L9 1.75z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M9 1.75V5.5h3.75" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
<path d="M8 8v3.4M6.4 9.9 8 11.5l1.6-1.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{/snippet}
<div class="file-actions">
<button class="file-action-btn" onclick={handleNew} disabled={!editor} title="New document">
<!-- Page with folded corner + plus -->
@@ -307,6 +338,9 @@
{#if saveMenuOpen}
<div class="save-dropdown" role="menu">
<button class="save-option" onclick={handleSaveAs} role="menuitem">Save As…</button>
<button class="save-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
{pdfBusy ? 'Exportiere…' : 'Als PDF exportieren…'}
</button>
</div>
{/if}
</div>
@@ -314,6 +348,9 @@
<button class="file-action-btn" onclick={handleSave} disabled={!editor} title="Save .odt">
{@render saveIcon()}
</button>
<button class="file-action-btn" onclick={handleExportPdf} disabled={!editor || pdfBusy} title="Als PDF exportieren">
{@render pdfIcon()}
</button>
{/if}
</div>
<div class="action-separator"></div>
+171
View File
@@ -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<void> {
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();
}
}