diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 6d5d478719..3a25669c42 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -6,7 +6,7 @@ import fileSaver from 'file-saver'; const { saveAs } = fileSaver; - import { downloadChatAsPDF } from '$lib/apis/utils'; + import { downloadChatPdf } from '$lib/utils/pdf'; import { copyToClipboard, createMessagesList } from '$lib/utils'; import { @@ -74,159 +74,19 @@ }; const downloadPdf = async () => { - const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ - import('jspdf'), - import('html2canvas-pro') - ]); - - if ($settings?.stylizedPdfExport ?? true) { - showFullMessages = true; - await tick(); - - const containerElement = document.getElementById('full-messages-container'); - if (containerElement) { - try { - const isDarkMode = document.documentElement.classList.contains('dark'); - const virtualWidth = 800; // px, fixed width for cloned element - - // Clone and style - const clonedElement = containerElement.cloneNode(true); - clonedElement.classList.add('text-black'); - clonedElement.classList.add('dark:text-white'); - clonedElement.style.width = `${virtualWidth}px`; - clonedElement.style.position = 'absolute'; - clonedElement.style.left = '-9999px'; - clonedElement.style.height = 'auto'; - document.body.appendChild(clonedElement); - - // Wait for DOM update/layout - await new Promise((r) => setTimeout(r, 100)); - - // Render entire content once - const canvas = await html2canvas(clonedElement, { - backgroundColor: isDarkMode ? '#000' : '#fff', - useCORS: true, - scale: 2, // increase resolution - width: virtualWidth - }); - - document.body.removeChild(clonedElement); - - const pdf = new jsPDF('p', 'mm', 'a4'); - const pageWidthMM = 210; - const pageHeightMM = 297; - - // Convert page height in mm to px on canvas scale for cropping - // Get canvas DPI scale: - const pxPerMM = canvas.width / virtualWidth; // width in px / width in px? - // Since 1 page width is 210 mm, but canvas width is 800 px at scale 2 - // Assume 1 mm = px / (pageWidthMM scaled) - // Actually better: Calculate scale factor from px/mm: - // virtualWidth px corresponds directly to 210mm in PDF, so pxPerMM: - const pxPerPDFMM = canvas.width / pageWidthMM; // canvas px per PDF mm - - // Height in px for one page slice: - const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM); - - let offsetY = 0; - let page = 0; - - while (offsetY < canvas.height) { - // Height of slice - const sliceHeight = Math.min(pagePixelHeight, canvas.height - offsetY); - - // Create temp canvas for slice - const pageCanvas = document.createElement('canvas'); - pageCanvas.width = canvas.width; - pageCanvas.height = sliceHeight; - - const ctx = pageCanvas.getContext('2d'); - - // Draw the slice of original canvas onto pageCanvas - ctx.drawImage( - canvas, - 0, - offsetY, - canvas.width, - sliceHeight, - 0, - 0, - canvas.width, - sliceHeight - ); - - const imgData = pageCanvas.toDataURL('image/jpeg', 0.7); - - // Calculate image height in PDF units keeping aspect ratio - const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width; - - if (page > 0) pdf.addPage(); - - if (isDarkMode) { - pdf.setFillColor(0, 0, 0); - pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg - } - - pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMM, imgHeightMM); - - offsetY += sliceHeight; - page++; - } - - pdf.save(`chat-${chat.chat.title}.pdf`); - - showFullMessages = false; - } catch (error) { - console.error('Error generating PDF', error); - } + await downloadChatPdf({ + title: chat.chat.title, + stylizedPdfExport: $settings?.stylizedPdfExport ?? true, + containerElementId: 'full-messages-container', + chatText: await getChatAsText(), + async onBeforeRender() { + showFullMessages = true; + await tick(); + }, + onAfterRender() { + showFullMessages = false; } - } else { - console.log('Downloading PDF'); - - const chatText = await getChatAsText(); - - const doc = new jsPDF(); - - // Margins - const left = 15; - const top = 20; - const right = 15; - const bottom = 20; - - const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const usableWidth = pageWidth - left - right; - const usableHeight = pageHeight - top - bottom; - - // Font size and line height - const fontSize = 8; - doc.setFontSize(fontSize); - const lineHeight = fontSize * 1; // adjust if needed - - // Split the markdown into lines (handles \n) - const paragraphs = chatText.split('\n'); - - let y = top; - - for (let paragraph of paragraphs) { - // Wrap each paragraph to fit the width - const lines = doc.splitTextToSize(paragraph, usableWidth); - - for (let line of lines) { - // If the line would overflow the bottom, add a new page - if (y + lineHeight > pageHeight - bottom) { - doc.addPage(); - y = top; - } - doc.text(line, left, y); - y += lineHeight * 0.5; - } - // Add empty line at paragraph breaks - y += lineHeight * 0.1; - } - - doc.save(`chat-${chat.chat.title}.pdf`); - } + }); }; const downloadJSONExport = async () => { diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index f367a94eb2..c7b4c19172 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -25,7 +25,7 @@ } from '$lib/apis/chats'; import { chats, folders, settings, theme, user } from '$lib/stores'; import { createMessagesList } from '$lib/utils'; - import { downloadChatAsPDF } from '$lib/apis/utils'; + import { downloadChatPdf } from '$lib/utils/pdf'; import Download from '$lib/components/icons/Download.svelte'; import Folder from '$lib/components/icons/Folder.svelte'; import Messages from '$lib/components/chat/Messages.svelte'; @@ -84,162 +84,20 @@ const downloadPdf = async () => { chat = await getChatById(localStorage.token, chatId); - if (!chat) { - return; - } - - const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ - import('jspdf'), - import('html2canvas-pro') - ]); - - if ($settings?.stylizedPdfExport ?? true) { - showFullMessages = true; - await tick(); - - const containerElement = document.getElementById('full-messages-container'); - if (containerElement) { - try { - const isDarkMode = document.documentElement.classList.contains('dark'); - const virtualWidth = 800; // px, fixed width for cloned element - - // Clone and style - const clonedElement = containerElement.cloneNode(true); - clonedElement.classList.add('text-black'); - clonedElement.classList.add('dark:text-white'); - clonedElement.style.width = `${virtualWidth}px`; - clonedElement.style.position = 'absolute'; - clonedElement.style.left = '-9999px'; - clonedElement.style.height = 'auto'; - document.body.appendChild(clonedElement); - - // Wait for DOM update/layout - await new Promise((r) => setTimeout(r, 100)); - - // Render entire content once - const canvas = await html2canvas(clonedElement, { - backgroundColor: isDarkMode ? '#000' : '#fff', - useCORS: true, - scale: 2, // increase resolution - width: virtualWidth - }); - - document.body.removeChild(clonedElement); - - const pdf = new jsPDF('p', 'mm', 'a4'); - const pageWidthMM = 210; - const pageHeightMM = 297; - - // Convert page height in mm to px on canvas scale for cropping - // Get canvas DPI scale: - const pxPerMM = canvas.width / virtualWidth; // width in px / width in px? - // Since 1 page width is 210 mm, but canvas width is 800 px at scale 2 - // Assume 1 mm = px / (pageWidthMM scaled) - // Actually better: Calculate scale factor from px/mm: - // virtualWidth px corresponds directly to 210mm in PDF, so pxPerMM: - const pxPerPDFMM = canvas.width / pageWidthMM; // canvas px per PDF mm - - // Height in px for one page slice: - const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM); - - let offsetY = 0; - let page = 0; - - while (offsetY < canvas.height) { - // Height of slice - const sliceHeight = Math.min(pagePixelHeight, canvas.height - offsetY); - - // Create temp canvas for slice - const pageCanvas = document.createElement('canvas'); - pageCanvas.width = canvas.width; - pageCanvas.height = sliceHeight; - - const ctx = pageCanvas.getContext('2d'); - - // Draw the slice of original canvas onto pageCanvas - ctx.drawImage( - canvas, - 0, - offsetY, - canvas.width, - sliceHeight, - 0, - 0, - canvas.width, - sliceHeight - ); - - const imgData = pageCanvas.toDataURL('image/jpeg', 0.7); - - // Calculate image height in PDF units keeping aspect ratio - const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width; - - if (page > 0) pdf.addPage(); - - if (isDarkMode) { - pdf.setFillColor(0, 0, 0); - pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg - } - - pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMM, imgHeightMM); - - offsetY += sliceHeight; - page++; - } - - pdf.save(`chat-${chat.chat.title}.pdf`); - + if (chat) { + await downloadChatPdf({ + title: chat.chat.title, + stylizedPdfExport: $settings?.stylizedPdfExport ?? true, + containerElementId: 'full-messages-container', + chatText: await getChatAsText(chat), + async onBeforeRender() { + showFullMessages = true; + await tick(); + }, + onAfterRender() { showFullMessages = false; - } catch (error) { - console.error('Error generating PDF', error); } - } - } else { - console.log('Downloading PDF'); - - const chatText = await getChatAsText(chat); - - const doc = new jsPDF(); - - // Margins - const left = 15; - const top = 20; - const right = 15; - const bottom = 20; - - const pageWidth = doc.internal.pageSize.getWidth(); - const pageHeight = doc.internal.pageSize.getHeight(); - const usableWidth = pageWidth - left - right; - const usableHeight = pageHeight - top - bottom; - - // Font size and line height - const fontSize = 8; - doc.setFontSize(fontSize); - const lineHeight = fontSize * 1; // adjust if needed - - // Split the markdown into lines (handles \n) - const paragraphs = chatText.split('\n'); - - let y = top; - - for (let paragraph of paragraphs) { - // Wrap each paragraph to fit the width - const lines = doc.splitTextToSize(paragraph, usableWidth); - - for (let line of lines) { - // If the line would overflow the bottom, add a new page - if (y + lineHeight > pageHeight - bottom) { - doc.addPage(); - y = top; - } - doc.text(line, left, y); - y += lineHeight * 0.5; - } - // Add empty line at paragraph breaks - y += lineHeight * 0.1; - } - - doc.save(`chat-${chat.chat.title}.pdf`); + }); } }; diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index f49d8bb7d0..26b73846db 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -38,7 +38,7 @@ WEBUI_NAME } from '$lib/stores'; - import { downloadPdf } from './utils'; + import { downloadNotePdf } from '$lib/utils/pdf'; import Controls from './NoteEditor/Controls.svelte'; import Chat from './NoteEditor/Chat.svelte'; @@ -568,7 +568,7 @@ ${content} saveAs(blob, `${note.title}.md`); } else if (type === 'pdf') { try { - await downloadPdf(note); + await downloadNotePdf(note); } catch (error) { toast.error(`${error}`); } diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 2b377bda6c..41cbef6086 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -36,7 +36,8 @@ import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; - import { downloadPdf, createNoteHandler } from './utils'; + import { createNoteHandler } from './utils'; + import { downloadNotePdf } from '$lib/utils/pdf'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; @@ -110,7 +111,7 @@ saveAs(blob, `${selectedNote.title}.md`); } else if (type === 'pdf') { try { - await downloadPdf(selectedNote); + await downloadNotePdf(selectedNote); } catch (error) { toast.error(`${error}`); } diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts index 5d398ebaf2..c61f81e1bd 100644 --- a/src/lib/components/notes/utils.ts +++ b/src/lib/components/notes/utils.ts @@ -1,112 +1,7 @@ -import DOMPurify from 'dompurify'; import { toast } from 'svelte-sonner'; import { createNewNote } from '$lib/apis/notes'; -export const downloadPdf = async (note) => { - const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([ - import('jspdf'), - import('html2canvas-pro') - ]); - - // Define a fixed virtual screen size - const virtualWidth = 1024; // Fixed width (adjust as needed) - const virtualHeight = 1400; // Fixed height (adjust as needed) - - // STEP 1. Get a DOM node to render - const html = DOMPurify.sanitize(note.data?.content?.html ?? ''); - const isDarkMode = document.documentElement.classList.contains('dark'); - - let node; - if (html instanceof HTMLElement) { - node = html; - } else { - const virtualWidth = 800; // px, fixed width for cloned element - - // Clone and style - node = document.createElement('div'); - - // title node - const titleNode = document.createElement('div'); - titleNode.textContent = note.title; - titleNode.style.fontSize = '24px'; - titleNode.style.fontWeight = 'medium'; - titleNode.style.paddingBottom = '20px'; - titleNode.style.color = isDarkMode ? 'white' : 'black'; - node.appendChild(titleNode); - - const contentNode = document.createElement('div'); - - contentNode.innerHTML = html; - - node.appendChild(contentNode); - - node.classList.add('text-black'); - node.classList.add('dark:text-white'); - node.style.width = `${virtualWidth}px`; - node.style.position = 'absolute'; - node.style.left = '-9999px'; - node.style.height = 'auto'; - node.style.padding = '40px 40px'; - - console.log(node); - document.body.appendChild(node); - } - - // Render to canvas with predefined width - const canvas = await html2canvas(node, { - useCORS: true, - backgroundColor: isDarkMode ? '#000' : '#fff', - scale: 2, // Keep at 1x to avoid unexpected enlargements - width: virtualWidth, // Set fixed virtual screen width - windowWidth: virtualWidth, // Ensure consistent rendering - windowHeight: virtualHeight - }); - - // Remove hidden node if needed - if (!(html instanceof HTMLElement)) { - document.body.removeChild(node); - } - - const imgData = canvas.toDataURL('image/jpeg', 0.7); - - // A4 page settings - const pdf = new jsPDF('p', 'mm', 'a4'); - const imgWidth = 210; // A4 width in mm - const pageWidthMM = 210; // A4 width in mm - const pageHeight = 297; // A4 height in mm - const pageHeightMM = 297; // A4 height in mm - - if (isDarkMode) { - pdf.setFillColor(0, 0, 0); - pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg - } - - // Maintain aspect ratio - const imgHeight = (canvas.height * imgWidth) / canvas.width; - let heightLeft = imgHeight; - let position = 0; - - pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); - heightLeft -= pageHeight; - - // Handle additional pages - while (heightLeft > 0) { - position -= pageHeight; - pdf.addPage(); - - if (isDarkMode) { - pdf.setFillColor(0, 0, 0); - pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg - } - - pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight); - heightLeft -= pageHeight; - } - - pdf.save(`${note.title}.pdf`); -}; - export const createNoteHandler = async (title: string, content?: string) => { // $i18n.t('New Note'), const res = await createNewNote(localStorage.token, { diff --git a/src/lib/utils/pdf.ts b/src/lib/utils/pdf.ts new file mode 100644 index 0000000000..d68575bde1 --- /dev/null +++ b/src/lib/utils/pdf.ts @@ -0,0 +1,430 @@ +import DOMPurify from 'dompurify'; +import type JSPDF from 'jspdf'; + +// ==================== Type Definitions ==================== + +/** + * Options for rendering element to canvas + */ +interface RenderCanvasOptions { + /** Virtual height for rendering */ + virtualHeight?: number; + /** Window width for rendering */ + windowWidth?: number; + /** Window height for rendering */ + windowHeight?: number; +} + +/** + * Note data structure + */ +interface NoteData { + /** Note title */ + title: string; + /** Note content data */ + data?: { + /** Content object */ + content?: { + /** HTML content (can be string or HTMLElement) */ + html?: string | HTMLElement; + }; + }; +} + +/** + * Options for downloading chat PDF + */ +interface ChatPdfOptions { + /** ID of the container element to render (for stylized mode) */ + containerElementId?: string; + /** Plain text content (for plain text mode) */ + chatText?: string; + /** PDF filename (without .pdf extension) */ + title: string; + /** Whether to use stylized PDF export (default: true) */ + stylizedPdfExport?: boolean; + /** Optional callback before rendering (for showing full messages) */ + onBeforeRender?: () => Promise | void; + /** Optional callback after rendering (for hiding full messages) */ + onAfterRender?: () => Promise | void; +} + +// ==================== Shared Constants ==================== + +/** A4 page width in millimeters */ +const A4_PAGE_WIDTH_MM = 210; +/** A4 page height in millimeters */ +const A4_PAGE_HEIGHT_MM = 297; +/** Default virtual width in pixels for cloned element */ +const DEFAULT_VIRTUAL_WIDTH = 800; +/** Canvas scale factor for increased resolution */ +const CANVAS_SCALE = 2; +/** JPEG quality for image compression (0.0 to 1.0) */ +const JPEG_QUALITY = 0.95; + +// ==================== Shared Utility Functions ==================== + +/** + * Check if dark mode is enabled in the document + * @returns True if dark mode is enabled, false otherwise + */ +const isDarkMode = (): boolean => { + return document.documentElement.classList.contains('dark'); +}; + +/** + * Create a new A4 PDF document + * @returns New jsPDF instance configured for A4 portrait orientation + */ +const createPdfDocument = async () => { + const { jsPDF } = await import('jspdf'); + return new jsPDF('p', 'mm', 'a4'); +}; + +/** + * Apply dark mode background to PDF page if dark mode is enabled + * @param pdf - jsPDF instance + * @param pageWidthMM - Page width in millimeters + * @param pageHeightMM - Page height in millimeters + */ +const applyDarkModeBackground = (pdf: JSPDF, pageWidthMM: number, pageHeightMM: number): void => { + if (isDarkMode()) { + pdf.setFillColor(0, 0, 0); + pdf.rect(0, 0, pageWidthMM, pageHeightMM, 'F'); // black bg + } +}; + +/** + * Style a DOM element for PDF rendering (hidden, positioned off-screen) + * @param element - DOM element to style + * @param virtualWidth - Virtual width in pixels for the element + */ +const styleElementForRendering = (element: HTMLElement, virtualWidth: number): void => { + element.classList.add('text-black'); + element.classList.add('dark:text-white'); + element.style.width = `${virtualWidth}px`; + element.style.position = 'absolute'; + element.style.left = '-9999px'; + element.style.height = 'auto'; +}; + +/** + * Render DOM element to canvas using html2canvas + * @param element - DOM element to render + * @param virtualWidth - Virtual width in pixels + * @param options - Optional rendering options + * @returns Promise that resolves to a canvas element + */ +const renderElementToCanvas = async ( + element: HTMLElement, + virtualWidth: number, + options?: RenderCanvasOptions +): Promise => { + const { default: html2canvas } = await import('html2canvas-pro'); + + const isDark = isDarkMode(); + const canvasOptions: Record = { + useCORS: true, + backgroundColor: isDark ? '#000' : '#fff', + scale: CANVAS_SCALE, + width: virtualWidth + }; + + // Add optional window dimensions for Note rendering + if (options?.windowWidth !== undefined) { + canvasOptions.windowWidth = options.windowWidth; + } + if (options?.windowHeight !== undefined) { + canvasOptions.windowHeight = options.windowHeight; + } + + return await html2canvas(element, canvasOptions); +}; + +/** + * Convert canvas to PDF with proper pagination using slice-based method + * This is the more accurate pagination method that slices the canvas into page-sized chunks + * @param pdf - jsPDF instance + * @param canvas - Canvas element containing the rendered content + * @param virtualWidth - Virtual width in pixels used for rendering + * @param pageWidthMM - Page width in millimeters + * @param pageHeightMM - Page height in millimeters + */ +const canvasToPdfWithSlicing = ( + pdf: JSPDF, + canvas: HTMLCanvasElement, + virtualWidth: number, + pageWidthMM: number, + pageHeightMM: number +): void => { + // Convert page height in mm to px on canvas scale for cropping + // Calculate scale factor from px/mm: + // virtualWidth px corresponds directly to 210mm in PDF, so pxPerMM: + const pxPerPDFMM = canvas.width / pageWidthMM; // canvas px per PDF mm + + // Height in px for one page slice: + const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM); + + let offsetY = 0; + let page = 0; + + while (offsetY < canvas.height) { + // Height of slice + const sliceHeight = Math.min(pagePixelHeight, canvas.height - offsetY); + + // Create temp canvas for slice + const pageCanvas = document.createElement('canvas'); + pageCanvas.width = canvas.width; + pageCanvas.height = sliceHeight; + + const ctx = pageCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); + } + + // Draw the slice of original canvas onto pageCanvas + ctx.drawImage(canvas, 0, offsetY, canvas.width, sliceHeight, 0, 0, canvas.width, sliceHeight); + + const imgData = pageCanvas.toDataURL('image/jpeg', JPEG_QUALITY); + + // Calculate image height in PDF units keeping aspect ratio + const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width; + + if (page > 0) pdf.addPage(); + + applyDarkModeBackground(pdf, pageWidthMM, pageHeightMM); + + pdf.addImage(imgData, 'JPEG', 0, 0, pageWidthMM, imgHeightMM); + + offsetY += sliceHeight; + page++; + } +}; + +/** + * Create a styled container element for rendering + * @param virtualWidth - Virtual width in pixels + * @param padding - Optional padding CSS value (e.g., '40px 40px') + * @returns Styled container element + */ +const createStyledContainer = (virtualWidth: number, padding?: string): HTMLElement => { + const container = document.createElement('div'); + styleElementForRendering(container, virtualWidth); + if (padding) { + container.style.padding = padding; + } + return container; +}; + +// ==================== Note-specific Functions ==================== + +/** + * Create DOM node from note HTML content + * If the HTML is already an HTMLElement, returns it directly. + * Otherwise, creates a new container with title and content nodes. + * @param note - Note object containing title and HTML content + * @param virtualWidth - Virtual width in pixels for the container + * @returns DOM element ready for rendering + */ +const createNoteNode = (note: NoteData, virtualWidth: number): HTMLElement => { + const htmlContent = note.data?.content?.html; + const html = typeof htmlContent === 'string' ? DOMPurify.sanitize(htmlContent) : ''; + const isDark = isDarkMode(); + + // If html is already an HTMLElement, return it + if (htmlContent && typeof htmlContent === 'object' && htmlContent instanceof HTMLElement) { + return htmlContent; + } + + // Create container + const node = createStyledContainer(virtualWidth, '40px 40px'); + + // Create title node + const titleNode = document.createElement('div'); + titleNode.textContent = note.title; + titleNode.style.fontSize = '24px'; + titleNode.style.fontWeight = 'medium'; + titleNode.style.paddingBottom = '20px'; + titleNode.style.color = isDark ? 'white' : 'black'; + node.appendChild(titleNode); + + // Create content node + const contentNode = document.createElement('div'); + contentNode.innerHTML = html; + node.appendChild(contentNode); + + console.log(node); + document.body.appendChild(node); + + return node; +}; + +// ==================== Chat-specific Functions ==================== + +/** + * Clone and style an existing DOM element for PDF rendering + * @param element - DOM element to clone + * @param virtualWidth - Virtual width in pixels for the cloned element + * @returns Cloned and styled element + */ +const cloneElementForRendering = (element: HTMLElement, virtualWidth: number): HTMLElement => { + const clonedElement = element.cloneNode(true) as HTMLElement; + styleElementForRendering(clonedElement, virtualWidth); + document.body.appendChild(clonedElement); + return clonedElement; +}; + +/** + * Export plain text content to PDF + * @param text - Plain text content to export + * @param filename - Filename for the PDF file + */ +const exportPlainTextToPdf = async (text: string, filename: string): Promise => { + const doc = await createPdfDocument(); + + // Margins + const left = 15; + const top = 20; + const right = 15; + const bottom = 20; + + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const usableWidth = pageWidth - left - right; + + // Font size and line height + const fontSize = 8; + doc.setFontSize(fontSize); + const lineHeight = fontSize * 1; // adjust if needed + + // Split the markdown into lines (handles \n) + const paragraphs = text.split('\n'); + + let y = top; + + for (const paragraph of paragraphs) { + // Wrap each paragraph to fit the width + const lines = doc.splitTextToSize(paragraph, usableWidth); + + for (const line of lines) { + // If the line would overflow the bottom, add a new page + if (y + lineHeight > pageHeight - bottom) { + doc.addPage(); + y = top; + } + doc.text(line, left, y); + y += lineHeight * 0.5; + } + // Add empty line at paragraph breaks + y += lineHeight * 0.1; + } + + doc.save(filename); +}; + +// ==================== Public API Functions ==================== + +/** + * Download PDF from HTML content (for notes) + * Creates a PDF from note content including title and HTML body. + * Uses slice-based pagination for accurate page breaks. + * @param note - Note object with title and data.content.html + * @throws Error if PDF generation fails + */ +export const downloadNotePdf = async (note: NoteData): Promise => { + // Define a fixed virtual screen size + const virtualWidth = 1024; // Fixed width (adjust as needed) + const virtualHeight = 1400; // Fixed height (adjust as needed) + + // Create DOM node from note content + const node = createNoteNode(note, DEFAULT_VIRTUAL_WIDTH); + const htmlContent = note.data?.content?.html; + const shouldRemoveNode = !( + htmlContent && + typeof htmlContent === 'object' && + htmlContent instanceof HTMLElement + ); + + try { + // Render to canvas + const [canvas, pdf] = await Promise.all([ + renderElementToCanvas(node, virtualWidth, { + windowWidth: virtualWidth, + windowHeight: virtualHeight + }), + createPdfDocument() + ]); + + canvasToPdfWithSlicing(pdf, canvas, virtualWidth, A4_PAGE_WIDTH_MM, A4_PAGE_HEIGHT_MM); + + pdf.save(`${note.title}.pdf`); + } finally { + // Clean up: remove hidden node if needed + if (shouldRemoveNode && node.parentNode) { + document.body.removeChild(node); + } + } +}; + +/** + * Download PDF from chat (supports stylized and plain text modes) + * + * Stylized mode: Renders the chat messages container as an image using html2canvas, + * then converts it to PDF with proper pagination. + * + * Plain text mode: Exports chat content as plain text with basic formatting. + * + * @param options - Configuration object + * @param options.containerElementId - ID of the container element to render (for stylized mode). + * @param options.chatText - Plain text content (required for plain text mode) + * @param options.title - PDF filename (without .pdf extension) + * @param options.stylizedPdfExport - Whether to use stylized PDF export (default: true) + * @param options.onBeforeRender - Optional callback before rendering (for showing full messages) + * @param options.onAfterRender - Optional callback after rendering (for hiding full messages) + * @throws Error if PDF generation fails or if chatText is missing in plain text mode + */ +export const downloadChatPdf = async (options: ChatPdfOptions): Promise => { + console.log('Downloading PDF', options); + + if ((options.stylizedPdfExport ?? true) && options.containerElementId) { + await options.onBeforeRender?.(); + + const containerElement = document.getElementById(options.containerElementId); + try { + if (containerElement) { + const virtualWidth = DEFAULT_VIRTUAL_WIDTH; + + // Clone and style element for rendering + const clonedElement = cloneElementForRendering(containerElement, virtualWidth); + + // Wait for DOM update/layout + await new Promise((r) => setTimeout(r, 100)); + + // Render entire content once + const [canvas, pdf] = await Promise.all([ + renderElementToCanvas(clonedElement, virtualWidth), + createPdfDocument() + ]); + + // Clean up cloned element + document.body.removeChild(clonedElement); + + // Create PDF and convert canvas + canvasToPdfWithSlicing(pdf, canvas, virtualWidth, A4_PAGE_WIDTH_MM, A4_PAGE_HEIGHT_MM); + + pdf.save(`chat-${options.title}.pdf`); + } + } finally { + await options.onAfterRender?.(); + } + + return; + } + + if (options.chatText) { + await exportPlainTextToPdf(options.chatText, `chat-${options.title}.pdf`); + return; + } + + throw new Error('Either containerElementId or chatText is required'); +};