From d93828e923cf4445d6213f639e0a58fcb242143b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 15 Mar 2025 17:11:14 +0000 Subject: [PATCH] enh: client-side pdf generation --- package-lock.json | 18 +++-- package.json | 1 + src/lib/components/layout/Navbar/Menu.svelte | 56 ++++++++++----- .../components/layout/Sidebar/ChatMenu.svelte | 68 ++++++++++++------- 4 files changed, 96 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14a44e2df..8c4ff6a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "file-saver": "^2.0.5", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", @@ -3884,7 +3885,6 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6.0" } @@ -4759,7 +4759,6 @@ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "license": "MIT", - "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -6842,6 +6841,19 @@ "node": ">=8.0.0" } }, + "node_modules/html2canvas-pro": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", + "integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -11472,7 +11484,6 @@ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "license": "MIT", - "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -11821,7 +11832,6 @@ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "license": "MIT", - "optional": true, "dependencies": { "base64-arraybuffer": "^1.0.2" } diff --git a/package.json b/package.json index dbfb13350..bef3de16b 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "file-saver": "^2.0.5", "fuse.js": "^7.0.0", "highlight.js": "^11.9.0", + "html2canvas-pro": "^1.5.8", "i18next": "^23.10.0", "i18next-browser-languagedetector": "^7.2.0", "i18next-resources-to-backend": "^1.2.0", diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 5be5092e6..a9012c3c6 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -6,6 +6,9 @@ import fileSaver from 'file-saver'; const { saveAs } = fileSaver; + import jsPDF from 'jspdf'; + import html2canvas from 'html2canvas-pro'; + import { downloadChatAsPDF } from '$lib/apis/utils'; import { copyToClipboard, createMessagesList } from '$lib/utils'; @@ -14,7 +17,8 @@ showControls, showArtifacts, mobile, - temporaryChatEnabled + temporaryChatEnabled, + theme } from '$lib/stores'; import { flyAndScale } from '$lib/utils/transitions'; @@ -58,27 +62,45 @@ }; const downloadPdf = async () => { - const history = chat.chat.history; - const messages = createMessagesList(history, history.currentId); - const blob = await downloadChatAsPDF(localStorage.token, chat.chat.title, messages); + const containerElement = document.getElementById('messages-container'); - // Create a URL for the blob - const url = window.URL.createObjectURL(blob); + if (containerElement) { + try { + const canvas = await html2canvas(containerElement, { + backgroundColor: $theme.includes('dark') ? '#000' : '#fff', + scale: 2, // Increases resolution for better quality + height: containerElement.scrollHeight, + windowHeight: containerElement.scrollHeight + }); - // Create a link element to trigger the download - const a = document.createElement('a'); - a.href = url; - a.download = `chat-${chat.chat.title}.pdf`; + const imgData = canvas.toDataURL('image/png'); - // Append the link to the body and click it programmatically - document.body.appendChild(a); - a.click(); + // A4 size in mm + const pdf = new jsPDF('p', 'mm', 'a4'); + const imgWidth = 210; // A4 width in mm + const pageHeight = 297; // A4 height in mm - // Remove the link from the body - document.body.removeChild(a); + const imgHeight = (canvas.height * imgWidth) / canvas.width; // Maintain aspect ratio + let heightLeft = imgHeight; + let position = 0; - // Revoke the URL to release memory - window.URL.revokeObjectURL(url); + // First page + pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + + // If content overflows, add new pages + while (heightLeft > 0) { + position -= pageHeight; + pdf.addPage(); + pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + } + + pdf.save('document.pdf'); + } catch (error) { + console.error('Error generating PDF', error); + } + } }; const downloadJSONExport = async () => { diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 8f38ba520..a6e8c63bc 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -6,6 +6,9 @@ import fileSaver from 'file-saver'; const { saveAs } = fileSaver; + import jsPDF from 'jspdf'; + import html2canvas from 'html2canvas-pro'; + const dispatch = createEventDispatcher(); import Dropdown from '$lib/components/common/Dropdown.svelte'; @@ -23,7 +26,7 @@ getChatPinnedStatusById, toggleChatPinnedStatusById } from '$lib/apis/chats'; - import { chats } from '$lib/stores'; + import { chats, theme } from '$lib/stores'; import { createMessagesList } from '$lib/utils'; import { downloadChatAsPDF } from '$lib/apis/utils'; import Download from '$lib/components/icons/Download.svelte'; @@ -76,32 +79,45 @@ }; const downloadPdf = async () => { - const chat = await getChatById(localStorage.token, chatId); - if (!chat) { - return; + const containerElement = document.getElementById('messages-container'); + + if (containerElement) { + try { + const canvas = await html2canvas(containerElement, { + backgroundColor: $theme.includes('dark') ? '#1a202c' : '#fff', + scale: 2, // Increases resolution for better quality + height: containerElement.scrollHeight, + windowHeight: containerElement.scrollHeight + }); + + const imgData = canvas.toDataURL('image/png'); + + // A4 size in mm + const pdf = new jsPDF('p', 'mm', 'a4'); + const imgWidth = 210; // A4 width in mm + const pageHeight = 297; // A4 height in mm + + const imgHeight = (canvas.height * imgWidth) / canvas.width; // Maintain aspect ratio + let heightLeft = imgHeight; + let position = 0; + + // First page + pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + + // If content overflows, add new pages + while (heightLeft > 0) { + position -= pageHeight; + pdf.addPage(); + pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); + heightLeft -= pageHeight; + } + + pdf.save('document.pdf'); + } catch (error) { + console.error('Error generating PDF', error); + } } - - const history = chat.chat.history; - const messages = createMessagesList(history, history.currentId); - const blob = await downloadChatAsPDF(localStorage.token, chat.chat.title, messages); - - // Create a URL for the blob - const url = window.URL.createObjectURL(blob); - - // Create a link element to trigger the download - const a = document.createElement('a'); - a.href = url; - a.download = `chat-${chat.chat.title}.pdf`; - - // Append the link to the body and click it programmatically - document.body.appendChild(a); - a.click(); - - // Remove the link from the body - document.body.removeChild(a); - - // Revoke the URL to release memory - window.URL.revokeObjectURL(url); }; const downloadJSONExport = async () => {