feat: add pdf-export
This commit is contained in:
1 parent
b8ed2aa14e
commit
2526144ab9
4 files changed
+420
-1
No files matched your search
Generated
+210
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user