From 3e3d9ca7f17c5489fcba5670cf7304876dd52a3c Mon Sep 17 00:00:00 2001 From: drelich Date: Sat, 21 Mar 2026 21:49:50 +0100 Subject: [PATCH 1/2] feat: embed custom fonts in PDF exports using jsPDF addFont/setFont - Load TTF font files as base64 from local fonts directory - Use pdf.addFileToVFS() and pdf.addFont() to register custom fonts - Use pdf.setFont() to explicitly set preview font before rendering - Support all preview fonts: Merriweather, Crimson Pro, Roboto Serif, Average - Include italic variants for proper markdown italic rendering - Embed Source Code Pro for code blocks - Maintains efficient file size (~120KB increase vs 18MB with html2canvas) - Keeps proper margins, pagination, and page breaks --- src/components/NoteEditor.tsx | 104 ++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 18 deletions(-) diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index da9f983..498c5e7 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -226,15 +226,88 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i setTitleManuallyEdited(!titleMatchesFirstLine); }; + const loadFontAsBase64 = async (fontPath: string): Promise => { + const response = await fetch(fontPath); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = reader.result as string; + // Remove data URL prefix to get just the base64 string + resolve(base64.split(',')[1]); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + }; + const handleExportPDF = async () => { if (!note) return; setIsExportingPDF(true); try { + // Create PDF + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + // Load and add custom fonts based on preview font selection + const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = { + 'Merriweather': { + regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf', + italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf', + name: 'Merriweather' + }, + 'Crimson Pro': { + regular: '/fonts/CrimsonPro-VariableFont_wght.ttf', + italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf', + name: 'CrimsonPro' + }, + 'Roboto Serif': { + regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf', + italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf', + name: 'RobotoSerif' + }, + 'Average': { + regular: '/fonts/Average-Regular.ttf', + italic: '/fonts/Average-Regular.ttf', // No italic variant + name: 'Average' + } + }; + + const selectedFont = fontMap[previewFont]; + if (selectedFont) { + try { + const regularBase64 = await loadFontAsBase64(selectedFont.regular); + pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64); + pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal'); + + const italicBase64 = await loadFontAsBase64(selectedFont.italic); + pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64); + pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic'); + + // Set the custom font as default + pdf.setFont(selectedFont.name, 'normal'); + } catch (fontError) { + console.error('Failed to load custom font, using default:', fontError); + } + } + + // Add Source Code Pro for code blocks + try { + const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf'); + pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont); + pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal'); + } catch (codeFontError) { + console.error('Failed to load code font:', codeFontError); + } + const container = document.createElement('div'); container.style.fontFamily = `"${previewFont}", Georgia, serif`; - container.style.fontSize = '12px'; + container.style.fontSize = `${previewFontSize}px`; container.style.lineHeight = '1.6'; container.style.color = '#000000'; @@ -252,35 +325,30 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i const contentElement = document.createElement('div'); const html = marked.parse(localContent || '', { async: false }) as string; contentElement.innerHTML = html; - contentElement.style.fontSize = '12px'; + contentElement.style.fontSize = `${previewFontSize}px`; contentElement.style.lineHeight = '1.6'; contentElement.style.color = '#000000'; container.appendChild(contentElement); - // Apply monospace font to code elements const style = document.createElement('style'); style.textContent = ` - code, pre { font-family: "Source Code Pro", ui-monospace, monospace !important; } + * { font-family: "${previewFont}", Georgia, serif !important; } + code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; } pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } code { background: #f0f0f0; padding: 2px 4px; border-radius: 2px; } + h1, h2, h3 { margin-top: 1em; margin-bottom: 0.5em; } + p { margin: 0.5em 0; } + em { font-style: italic; } + strong { font-weight: bold; } `; container.appendChild(style); - // Create PDF using jsPDF's html() method (like dompdf) - const pdf = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); - - // Use jsPDF's html() method which handles pagination automatically + // Use jsPDF's html() method with custom font set await pdf.html(container, { callback: async (doc) => { - // Save the PDF const fileName = `${localTitle || 'note'}.pdf`; doc.save(fileName); - // Show success message using Tauri dialog setTimeout(async () => { try { await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, { @@ -293,10 +361,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i setIsExportingPDF(false); }, 500); }, - margin: [20, 20, 20, 20], // top, right, bottom, left margins in mm - autoPaging: 'text', // Enable automatic page breaks - width: 170, // Content width in mm (A4 width 210mm - 40mm margins) - windowWidth: 650, // Rendering width in pixels (matches content width ratio) + margin: [20, 20, 20, 20], + autoPaging: 'text', + width: 170, + windowWidth: 650, }); } catch (error) { console.error('PDF export failed:', error); From 3e93cf2408371b6f3c41ce8d8e4cc3f07df456ac Mon Sep 17 00:00:00 2001 From: drelich Date: Sat, 21 Mar 2026 22:14:16 +0100 Subject: [PATCH 2/2] feat: improve PDF export styling and functionality - Fix inline code padding to prevent overlap with line above - Add proper heading styles (h1, h2, h3) with correct font sizes - Add list styling (ul/ol) with proper bullets and numbering - Embed images as data URLs in PDF export - Fix list layout issues when images are present - Add image styling to prevent layout interference - Remove grey background from inline code for cleaner appearance --- src/components/NoteEditor.tsx | 53 ++++++++++++++++++++++++++++++----- src/index.css | 17 ++++++++++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 498c5e7..a01a589 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -305,6 +305,38 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i console.error('Failed to load code font:', codeFontError); } + // Process images to embed them as data URLs + let contentForPDF = localContent; + if (api) { + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + const matches = [...localContent.matchAll(imageRegex)]; + + for (const match of matches) { + const [fullMatch, alt, imagePath] = match; + + // Skip external URLs + if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { + continue; + } + + // Check cache first + const cacheKey = `${note.id}:${imagePath}`; + if (imageCache.has(cacheKey)) { + const dataUrl = imageCache.get(cacheKey)!; + contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); + continue; + } + + try { + const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); + imageCache.set(cacheKey, dataUrl); + contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); + } catch (error) { + console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error); + } + } + } + const container = document.createElement('div'); container.style.fontFamily = `"${previewFont}", Georgia, serif`; container.style.fontSize = `${previewFontSize}px`; @@ -323,7 +355,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i container.appendChild(titleElement); const contentElement = document.createElement('div'); - const html = marked.parse(localContent || '', { async: false }) as string; + const html = marked.parse(contentForPDF || '', { async: false }) as string; contentElement.innerHTML = html; contentElement.style.fontSize = `${previewFontSize}px`; contentElement.style.lineHeight = '1.6'; @@ -332,14 +364,21 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i const style = document.createElement('style'); style.textContent = ` - * { font-family: "${previewFont}", Georgia, serif !important; } + body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; } code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; } pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } - code { background: #f0f0f0; padding: 2px 4px; border-radius: 2px; } - h1, h2, h3 { margin-top: 1em; margin-bottom: 0.5em; } + code { padding: 0; } + h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; } + h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; } + h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; } p { margin: 0.5em 0; } - em { font-style: italic; } - strong { font-weight: bold; } + ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; } + ul { list-style-type: disc; } + ol { list-style-type: decimal; } + li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; } + em { font-style: italic; vertical-align: baseline; } + strong { font-weight: bold; vertical-align: baseline; line-height: inherit; } + img { max-width: 100%; height: auto; display: block; margin: 1em 0; } `; container.appendChild(style); @@ -820,7 +859,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i )}