mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 20:35:19 +00:00
Merge 4d80bf889d into 1ea555a5ac
This commit is contained in:
commit
e40ab2c7a9
6 changed files with 461 additions and 417 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
430
src/lib/utils/pdf.ts
Normal file
430
src/lib/utils/pdf.ts
Normal file
|
|
@ -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> | void;
|
||||
/** Optional callback after rendering (for hiding full messages) */
|
||||
onAfterRender?: () => Promise<void> | 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<HTMLCanvasElement> => {
|
||||
const { default: html2canvas } = await import('html2canvas-pro');
|
||||
|
||||
const isDark = isDarkMode();
|
||||
const canvasOptions: Record<string, unknown> = {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
// 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<void> => {
|
||||
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');
|
||||
};
|
||||
Loading…
Reference in a new issue