feat: raster pdf and vector pdf(beta)

This commit is contained in:
Steffen Becker committed 2026-06-18 22:24:07 +02:00
1 parent f3f6b19574
commit fb2ce271fa
2 files changed
+225 -79

No files matched your search

+61 -77
View File
@@ -5,7 +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 { exportPdf, printPdf } 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';
@@ -93,9 +93,9 @@
// reload restores the doc from localStorage but the first Save re-prompts.
let fileHandle: FileSystemFileHandle | null = $state(null);
const fsSupported = supportsFsAccess();
let saveMenuOpen = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
let pdfBusy = $state(false);
let exportMenuOpen = $state(false);
// odf-kit export options for the current header/footer + page geometry.
function hfOpts() {
@@ -190,7 +190,7 @@
async function handleSave() {
if (!editor) return;
saveMenuOpen = false;
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
@@ -206,7 +206,7 @@
async function handleSaveAs() {
if (!editor) return;
saveMenuOpen = false;
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
@@ -222,7 +222,7 @@
// user can "Save as PDF" — vector text matching the editor, with header/footer.
async function handleExportPdf() {
if (!editor || pdfBusy) return;
saveMenuOpen = false;
exportMenuOpen = false;
pdfBusy = true;
try {
await exportPdf({
@@ -239,9 +239,28 @@
}
}
function saveMenuClickOutside(node: HTMLElement) {
// Vector PDF via the browser's print dialog: crisp, tiny, fonts embedded. Tables
// break natively; header/footer become CSS @page boxes (basic text + page numbers).
function handlePrintPdf() {
if (!editor) return;
exportMenuOpen = false;
try {
printPdf({
json: editor.getJSON(),
margins: pageMargins,
orientation: pageOrientation,
headerDoc,
footerDoc,
});
} catch (err) {
console.error('[pdf] Print failed:', err);
alert('Could not print to PDF.');
}
}
function exportMenuClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) saveMenuOpen = false;
if (!node.contains(e.target as Node)) exportMenuOpen = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
@@ -295,14 +314,6 @@
<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 -->
@@ -318,40 +329,41 @@
<path d="M1.75 12.5V4a1 1 0 0 1 1-1h3.2a1 1 0 0 1 .8.4l.7.95a1 1 0 0 0 .8.4h4.2a1 1 0 0 1 1 1v6.75a1 1 0 0 1-1 1H2.75a1 1 0 0 1-1-1z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
</svg>
</button>
{#if fsSupported}
<div class="save-split" use:saveMenuClickOutside>
<button class="file-action-btn save-main" onclick={handleSave} disabled={!editor} title="Save (Ctrl+S)">
{@render saveIcon()}
</button>
<button
class="save-chevron"
onclick={() => (saveMenuOpen = !saveMenuOpen)}
disabled={!editor}
title="Save options"
aria-haspopup="menu"
aria-expanded={saveMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{#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>
{:else}
<button class="file-action-btn" onclick={handleSave} disabled={!editor} title="Save .odt">
<!-- Single Save / Export button → ODT, Raster PDF, or Vector PDF (beta). -->
<div class="save-split" use:exportMenuClickOutside>
<button class="file-action-btn save-main" onclick={handleSave} disabled={!editor || pdfBusy} title="Save .odt (Ctrl+S)">
{@render saveIcon()}
</button>
<button class="file-action-btn" onclick={handleExportPdf} disabled={!editor || pdfBusy} title="Als PDF exportieren">
{@render pdfIcon()}
<button
class="save-chevron"
onclick={() => (exportMenuOpen = !exportMenuOpen)}
disabled={!editor || pdfBusy}
title="Save / Export"
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
{/if}
{#if exportMenuOpen}
<div class="theme-dropdown" role="menu">
<div class="theme-heading">Save / Export</div>
<button class="theme-option" onclick={handleSave} role="menuitem">
<span>ODT</span>
<span class="theme-option-hint">OpenDocument · fully editable</span>
</button>
<button class="theme-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
<span>{pdfBusy ? 'Exporting…' : 'Raster PDF'}</span>
<span class="theme-option-hint">Exact copy of the editor</span>
</button>
<button class="theme-option" onclick={handlePrintPdf} role="menuitem">
<span>Vector PDF (beta)</span>
<span class="theme-option-hint">Sharp &amp; small, but only basic headers/footers · opens print dialog</span>
</button>
</div>
{/if}
</div>
</div>
<div class="action-separator"></div>
<button
@@ -579,37 +591,6 @@
cursor: not-allowed;
}
.save-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 120px;
z-index: 100;
overflow: hidden;
}
.save-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
font-family: var(--font-sans);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.save-option:hover {
background: var(--color-btn-hover);
}
.toolbar-toggle-btn {
display: inline-flex;
align-items: center;
@@ -664,6 +645,7 @@
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 100px;
max-width: 240px;
z-index: 100;
overflow: hidden;
}
@@ -711,6 +693,8 @@
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
white-space: normal;
line-height: 1.3;
}
.file-input {
+164 -2
View File
@@ -7,8 +7,15 @@
// 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 { generateHTML } from '@tiptap/core';
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { extensions } from '../editor/extensions';
import { columnPercents } from '../editor/tableView';
import { deriveFilename } from './odt';
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
@@ -41,6 +48,16 @@ function buildClone(paper: HTMLElement, pageW: number): { holder: HTMLElement; c
clone.style.width = `${pageW}px`; // pin width so capture geometry can't drift on var inheritance
clone.classList.remove('show-formatting-marks', 'hf-editing');
// 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;';
@@ -137,7 +154,8 @@ export async function exportPdf(opts: PdfOptions): Promise<void> {
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' });
// compress: Flate the streams (e.g. the text layer); page images are JPEG below.
const doc = new jsPDF({ unit: 'pt', format: fmt, orientation: orientation === 'landscape' ? 'l' : 'p', compress: true });
const tmp = document.createElement('canvas');
tmp.width = Math.round(pageW * SCALE);
@@ -151,7 +169,8 @@ export async function exportPdf(opts: PdfOptions): Promise<void> {
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);
// JPEG (DCTDecode) keeps the file small; q0.92 @2× scale stays visually crisp.
doc.addImage(tmp.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, pageW * PT, pageH * PT);
// Invisible selectable text for this page.
const top = i * cycle, bottom = i * cycle + pageH;
@@ -169,3 +188,146 @@ export async function exportPdf(opts: PdfOptions): Promise<void> {
style.remove();
}
}
// ---------------------------------------------------------------------------
// Vector path: native browser print. Crisp, tiny, fonts embedded by the browser
// (incl. system fonts). The browser paginates natively (breaks tables cleanly),
// and headers/footers are emitted as CSS @page margin boxes (basic text + page
// numbers; rich inline formatting isn't expressible there).
// ---------------------------------------------------------------------------
export interface PrintPdfOptions {
json: Json;
margins: PageMargins;
orientation: Orientation;
headerDoc: HfDoc;
footerDoc: HfDoc;
}
// First-row column weights from table JSON (honours colspan); mirrors tableView.
function weightsFromRow(row: Json): (number | null)[] {
const w: (number | null)[] = [];
for (const cell of row?.content ?? []) {
const colspan: number = cell.attrs?.colspan ?? 1;
const cw: number[] | null = cell.attrs?.colwidth ?? null;
for (let j = 0; j < colspan; j++) w.push(cw && cw[j] ? cw[j] : null);
}
return w;
}
// JSON → clean HTML, re-inserting the percentage <colgroup>s the table node view
// builds at runtime but generateHTML omits (matches the editor's column widths).
function buildBodyHtml(json: Json): string {
const host = document.createElement('div');
host.innerHTML = generateHTML(json, extensions);
const percents: number[][] = [];
(function walk(n: Json) {
if (!n) return;
if (n.type === 'table') percents.push(columnPercents(weightsFromRow(n.content?.[0])));
for (const c of n.content ?? []) walk(c);
})(json);
host.querySelectorAll('table').forEach((table, i) => {
const cols = percents[i];
if (!cols?.length) return;
table.querySelector(':scope > colgroup')?.remove();
const cg = document.createElement('colgroup');
for (const p of cols) {
const col = document.createElement('col');
col.style.width = `${p}%`;
cg.appendChild(col);
}
table.insertBefore(cg, table.firstChild);
});
return host.innerHTML;
}
function cssStr(t: string): string {
return '"' + t.replace(/\\/g, '\\5c ').replace(/"/g, '\\22 ') + '"';
}
// HfDoc (single paragraph) → a CSS `content` value + alignment, mapping page-field
// atoms to counter(page)/counter(pages). Returns null for an empty zone.
function hfContent(doc: HfDoc): { content: string; align: 'left' | 'center' | 'right' } | null {
if (hfIsEmpty(doc)) return null;
const para = (doc as Json).content?.[0] as Json;
const ta = para?.attrs?.textAlign;
const align: 'left' | 'center' | 'right' = ta === 'center' || ta === 'right' ? ta : 'left';
const parts: string[] = [];
for (const n of para?.content ?? []) {
if (n.type === 'text' && n.text) parts.push(cssStr(n.text));
else if (n.type === 'pageNumber') parts.push('counter(page)');
else if (n.type === 'pageCount') parts.push('counter(pages)');
else if (n.type === 'hardBreak') parts.push('"\\A0 "');
}
if (!parts.length) return null;
return { content: parts.join(' '), align };
}
function marginBoxes(headerDoc: HfDoc, footerDoc: HfDoc): string {
const box = (edge: 'top' | 'bottom', doc: HfDoc): string => {
const hf = hfContent(doc);
if (!hf) return '';
const name = `@${edge}-${hf.align === 'left' ? 'left' : hf.align === 'right' ? 'right' : 'center'}`;
return `${name} { content: ${hf.content}; font-family: var(--font-serif); font-size: 11pt; color: #000; }`;
};
return box('top', headerDoc) + '\n' + box('bottom', footerDoc);
}
function printCss(o: PrintPdfOptions): string {
const m = o.margins;
const size = o.orientation === 'landscape' ? 'A4 landscape' : 'A4 portrait';
return `
@page {
size: ${size};
margin: ${m.top}cm ${m.right}cm ${m.bottom}cm ${m.left}cm;
${marginBoxes(o.headerDoc, o.footerDoc)}
}
html, body { margin: 0; padding: 0; background: #fff; }
.paper { width: auto !important; transform: none !important; box-shadow: none !important; background: none !important; }
.paper .tiptap { padding: 0 !important; min-height: 0 !important; background: #fff !important; box-shadow: none !important; color: #000 !important; }
/* generateHTML drops TipTap's .tableWrapper, whose margin gives a table its bottom gap. */
.paper .tiptap table { margin: 0 0 0.212cm; }
.paper .tiptap tr { break-inside: avoid; }
.paper .tiptap [data-color] { color: var(--font-color, currentColor) !important; }
.column-resize-handle, .row-resize-handle, .is-editor-empty::before { display: none !important; }
`;
}
// Build the document in a hidden iframe and invoke the browser's print → "Save as
// PDF", which produces crisp vector text with the fonts embedded.
export function printPdf(opts: PrintPdfOptions): void {
const o: PrintPdfOptions = {
json: opts.json,
margins: opts.margins ?? DEFAULT_MARGINS,
orientation: opts.orientation ?? 'portrait',
headerDoc: opts.headerDoc ?? null,
footerDoc: opts.footerDoc ?? null,
};
const title = deriveFilename(o.json).replace(/\.odt$/i, '');
const body = buildBodyHtml(o.json);
const iframe = document.createElement('iframe');
iframe.setAttribute('aria-hidden', 'true');
iframe.style.cssText = 'position:fixed; right:0; bottom:0; width:0; height:0; border:0; visibility:hidden;';
document.body.appendChild(iframe);
const idoc = iframe.contentDocument!;
idoc.open();
idoc.write(
`<!doctype html><html data-theme="light"><head><meta charset="utf-8"><title>${title}</title>` +
`<style>${globalCss}\n${editorCss}\n${printCss(o)}</style></head>` +
`<body><div class="paper"><div class="tiptap">${body}</div></div></body></html>`,
);
idoc.close();
const cleanup = () => iframe.remove();
const run = async () => {
try { await idoc.fonts.ready; } catch { /* ignore */ }
await new Promise((r) => requestAnimationFrame(() => r(null)));
iframe.contentWindow!.addEventListener('afterprint', cleanup, { once: true });
iframe.contentWindow!.focus();
iframe.contentWindow!.print();
setTimeout(cleanup, 120000); // fallback if afterprint never fires
};
void run();
}