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
This commit is contained in:
drelich
2026-04-06 09:46:26 +02:00
parent 6e970f37ea
commit e21e443a59
4 changed files with 127 additions and 4 deletions

View File

@@ -121,6 +121,11 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => {
return { canceled: true }; 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({ const pdfWindow = new BrowserWindow({
width: 960, width: 960,
height: 1100, height: 1100,
@@ -135,9 +140,8 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => {
}); });
try { try {
await pdfWindow.loadURL( await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8');
`data:text/html;charset=utf-8,${encodeURIComponent(payload.documentHtml)}` await pdfWindow.loadFile(tempHtmlPath);
);
await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true); await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true);
const pdfData = await pdfWindow.webContents.printToPDF({ const pdfData = await pdfWindow.webContents.printToPDF({
@@ -152,6 +156,7 @@ ipcMain.handle('desktop:export-pdf', async (event, payload) => {
filePath: saveResult.filePath, filePath: saveResult.filePath,
}; };
} finally { } finally {
await fs.unlink(tempHtmlPath).catch(() => undefined);
if (!pdfWindow.isDestroyed()) { if (!pdfWindow.isDestroyed()) {
pdfWindow.destroy(); pdfWindow.destroy();
} }

View File

@@ -11,6 +11,7 @@ import {
} from '../services/desktop'; } from '../services/desktop';
import { import {
getNoteTitleFromContent, getNoteTitleFromContent,
loadPrintFontFaceCss,
PrintExportPayload, PrintExportPayload,
sanitizeFileName, sanitizeFileName,
} from '../printExport'; } from '../printExport';
@@ -268,6 +269,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
html: noteHtml, html: noteHtml,
previewFont, previewFont,
previewFontSize, previewFontSize,
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
}; };
await exportPdfDocument({ await exportPdfDocument({

View File

@@ -4,6 +4,7 @@ export interface PrintExportPayload {
html: string; html: string;
previewFont: string; previewFont: string;
previewFontSize: number; previewFontSize: number;
previewFontFaceCss?: string;
} }
export const PRINT_EXPORT_QUERY_PARAM = 'printJob'; export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
@@ -43,6 +44,117 @@ const escapeHtml = (value: string) =>
const escapeFontFamily = (value: string) => const escapeFontFamily = (value: string) =>
value.replace(/["\\]/g, '\\$&'); value.replace(/["\\]/g, '\\$&');
interface PrintFontAsset {
fileName: string;
fontStyle: 'normal' | 'italic';
fontWeight: string;
}
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
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<string, Promise<string>>();
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((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) => { export const buildPrintDocument = (payload: PrintExportPayload) => {
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`; const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
@@ -54,6 +166,8 @@ export const buildPrintDocument = (payload: PrintExportPayload) => {
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" /> <meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
<title>${escapeHtml(payload.fileName)}</title> <title>${escapeHtml(payload.fileName)}</title>
<style> <style>
${payload.previewFontFaceCss ?? ''}
:root { :root {
color-scheme: light; color-scheme: light;
} }

View File

@@ -68,7 +68,9 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const notes = await this.fetchAndCacheNotes(); await this.fetchAndCacheNotes();
await this.syncFavoriteStatus();
const notes = await localDB.getAllNotes();
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
return notes; return notes;
} catch (error) { } catch (error) {