feat: raster pdf and vector pdf(beta)
This commit is contained in:
1 parent
f3f6b19574
commit
fb2ce271fa
2 files changed
+225
-79
No files matched your search
+61
-77
@@ -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 & 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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user