From e21e443a593d42ee43e00ff6e52e3769f743e997 Mon Sep 17 00:00:00 2001 From: drelich Date: Mon, 6 Apr 2026 09:46:26 +0200 Subject: [PATCH] Fix PDF export font embedding and improve sync reliability - Replace data URL loading with temporary HTML file to avoid URL length limits - Embed font files as data URLs in print document CSS for offline rendering - Add font asset registry for Merriweather, Crimson Pro, Roboto Serif, and Average - Implement font file caching and blob-to-data-URL conversion - Clean up temporary HTML file after PDF generation - Fix sync to refresh notes after favorite status sync completes --- electron/main.cjs | 11 +++- src/components/NoteEditor.tsx | 2 + src/printExport.ts | 114 ++++++++++++++++++++++++++++++++++ src/services/syncManager.ts | 4 +- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index eaa9f5b..bc52552 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -121,6 +121,11 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => { return { canceled: true }; } + const tempHtmlPath = path.join( + app.getPath('temp'), + `nextcloud-notes-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.html` + ); + const pdfWindow = new BrowserWindow({ width: 960, height: 1100, @@ -135,9 +140,8 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => { }); try { - await pdfWindow.loadURL( - `data:text/html;charset=utf-8,${encodeURIComponent(payload.documentHtml)}` - ); + await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8'); + await pdfWindow.loadFile(tempHtmlPath); await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true); const pdfData = await pdfWindow.webContents.printToPDF({ @@ -152,6 +156,7 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => { filePath: saveResult.filePath, }; } finally { + await fs.unlink(tempHtmlPath).catch(() => undefined); if (!pdfWindow.isDestroyed()) { pdfWindow.destroy(); } diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index e75d406..3f182f1 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -11,6 +11,7 @@ import { } from '../services/desktop'; import { getNoteTitleFromContent, + loadPrintFontFaceCss, PrintExportPayload, sanitizeFileName, } from '../printExport'; @@ -268,6 +269,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan html: noteHtml, previewFont, previewFontSize, + previewFontFaceCss: await loadPrintFontFaceCss(previewFont), }; await exportPdfDocument({ diff --git a/src/printExport.ts b/src/printExport.ts index 2d94c55..e203bc1 100644 --- a/src/printExport.ts +++ b/src/printExport.ts @@ -4,6 +4,7 @@ export interface PrintExportPayload { html: string; previewFont: string; previewFontSize: number; + previewFontFaceCss?: string; } export const PRINT_EXPORT_QUERY_PARAM = 'printJob'; @@ -43,6 +44,117 @@ const escapeHtml = (value: string) => const escapeFontFamily = (value: string) => value.replace(/["\\]/g, '\\$&'); +interface PrintFontAsset { + fileName: string; + fontStyle: 'normal' | 'italic'; + fontWeight: string; +} + +const PRINT_FONT_ASSETS: Record = { + Merriweather: [ + { + fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf', + fontStyle: 'normal', + fontWeight: '300 900', + }, + { + fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf', + fontStyle: 'italic', + fontWeight: '300 900', + }, + ], + 'Crimson Pro': [ + { + fileName: 'CrimsonPro-VariableFont_wght.ttf', + fontStyle: 'normal', + fontWeight: '200 900', + }, + { + fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf', + fontStyle: 'italic', + fontWeight: '200 900', + }, + ], + 'Roboto Serif': [ + { + fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf', + fontStyle: 'normal', + fontWeight: '100 900', + }, + { + fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf', + fontStyle: 'italic', + fontWeight: '100 900', + }, + ], + Average: [ + { + fileName: 'Average-Regular.ttf', + fontStyle: 'normal', + fontWeight: '400', + }, + ], +}; + +const fontDataUrlCache = new Map>(); + +const blobToDataUrl = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.')); + reader.readAsDataURL(blob); + }); + +const getBundledFontUrl = (fileName: string) => + new URL(`./fonts/${fileName}`, window.location.href).toString(); + +const loadBundledFontDataUrl = async (fileName: string) => { + const cached = fontDataUrlCache.get(fileName); + if (cached) { + return cached; + } + + const pending = (async () => { + const response = await fetch(getBundledFontUrl(fileName)); + if (!response.ok) { + throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`); + } + + return blobToDataUrl(await response.blob()); + })(); + + fontDataUrlCache.set(fileName, pending); + return pending; +}; + +export const loadPrintFontFaceCss = async (fontFamily: string) => { + const fontAssets = PRINT_FONT_ASSETS[fontFamily]; + if (!fontAssets) { + return ''; + } + + const rules = await Promise.all( + fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => { + try { + const dataUrl = await loadBundledFontDataUrl(fileName); + return `@font-face { + font-family: "${escapeFontFamily(fontFamily)}"; + font-style: ${fontStyle}; + font-weight: ${fontWeight}; + font-display: swap; + src: url("${dataUrl}") format("truetype"); +}`; + } catch (error) { + console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error); + return ''; + } + }) + ); + + return rules.filter(Boolean).join('\n'); +}; + export const buildPrintDocument = (payload: PrintExportPayload) => { const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`; @@ -54,6 +166,8 @@ export const buildPrintDocument = (payload: PrintExportPayload) => { ${escapeHtml(payload.fileName)}