open-webui/src/lib/components/notes/utils.ts
G30 f78e46ef44
Merge pull request #32459 from silentoplayz/fix/long-pdf-export-sliced
fix: long notes and chats download as PDF instead of failing
2026-10-15 13:11:40 +04:00

204 lines
6 KiB
TypeScript

import DOMPurify from 'dompurify';
import { toast } from 'svelte-sonner';
import { createNewNote } from '$lib/apis/notes';
import { safeImageUrl } from '$lib/utils/safeImageUrl';
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;
for (const img of contentNode.querySelectorAll('img[src^="data://"]')) {
const fileId = img.getAttribute('src').replace('data://', '');
const file = (note.data?.files ?? []).find((f) => f.id === fileId);
if (file) {
img.setAttribute('src', safeImageUrl(file.url || ''));
}
}
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);
}
await Promise.all(
Array.from(node.querySelectorAll('img')).map((img) => img.decode().catch(() => {}))
);
// A4 page settings
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidthMM = 210; // A4 width in mm
const pageHeightMM = 297; // A4 height in mm
const pagePixelHeight = Math.floor((virtualWidth * pageHeightMM) / pageWidthMM);
const rootTop = node.getBoundingClientRect().top;
const contentHeight = Math.ceil(node.getBoundingClientRect().height);
const blocks = [];
const trackedElements = new Set();
const collectBlocks = (element) => {
const children = Array.from(element.children);
const splittable =
children.length > 0 &&
(getComputedStyle(element).display === 'contents' ||
(element.getBoundingClientRect().height > pagePixelHeight / 4 &&
!Array.from(element.childNodes).some(
(childNode) => childNode.nodeType === Node.TEXT_NODE && childNode.textContent?.trim()
) &&
children.every((child) => !getComputedStyle(child).display.startsWith('inline'))));
if (element !== node) trackedElements.add(element);
if (splittable) {
children.forEach(collectBlocks);
} else {
const { top, bottom } = element.getBoundingClientRect();
blocks.push({ element, top: top - rootTop, bottom: bottom - rootTop });
}
};
collectBlocks(node);
node.setAttribute('data-pdf-root', '');
let offsetY = 0;
let page = 0;
while (offsetY < contentHeight) {
const sliceHeight = Math.min(pagePixelHeight, contentHeight - offsetY);
const sliceBlocks = blocks.filter(
(block) => block.bottom > offsetY && block.top < offsetY + sliceHeight
);
const keptElements = new Set();
for (const { element } of sliceBlocks) {
for (let current = element; current && current !== node; current = current.parentElement) {
keptElements.add(current);
}
}
const anchor = sliceBlocks.find((block) => block.bottom > block.top);
anchor?.element.setAttribute('data-pdf-anchor', '');
const canvas = await html2canvas(node, {
useCORS: true,
logging: false,
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,
height: sliceHeight,
y: offsetY,
ignoreElements: (element) =>
(element.parentElement === document.body &&
element !== node &&
!['STYLE', 'LINK'].includes(element.tagName)) ||
(trackedElements.has(element) && !keptElements.has(element)),
onclone: (clonedDocument) => {
const clonedRoot = clonedDocument.querySelector('[data-pdf-root]');
const clonedAnchor = clonedDocument.querySelector('[data-pdf-anchor]');
if (!clonedRoot || !clonedAnchor) return;
const spacer = clonedDocument.createElement('div');
clonedRoot.prepend(spacer);
let spacerHeight = 0;
for (let pass = 0; pass < 2; pass++) {
spacerHeight +=
anchor.top -
(clonedAnchor.getBoundingClientRect().top - clonedRoot.getBoundingClientRect().top);
spacer.style.height = `${spacerHeight}px`;
}
}
});
anchor?.element.removeAttribute('data-pdf-anchor');
const imgData = canvas.toDataURL('image/jpeg', 0.7);
const imgHeightMM = (sliceHeight * pageWidthMM) / virtualWidth;
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++;
}
// Remove hidden node if needed
if (!(html instanceof HTMLElement)) {
document.body.removeChild(node);
}
pdf.save(`${note.title}.pdf`);
};
export const createNoteHandler = async (title: string, md?: string, html?: string) => {
// $i18n.t('New Note'),
const res = await createNewNote(localStorage.token, {
// YYYY-MM-DD
title: title,
data: {
content: {
json: null,
html: html || md || '',
md: md || ''
}
},
meta: null,
access_grants: []
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
return res;
}
};