mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-18 04:43:55 +00:00
Merge pull request #32459 from silentoplayz/fix/long-pdf-export-sliced
fix: long notes and chats download as PDF instead of failing
This commit is contained in:
parent
fb5431a09c
commit
f78e46ef44
3 changed files with 269 additions and 124 deletions
|
|
@ -111,63 +111,102 @@
|
|||
// Let the browser compute layout for the cloned element
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
// 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
|
||||
const pagePixelHeight = Math.floor((virtualWidth * pageHeightMM) / pageWidthMM);
|
||||
const rootTop = clonedElement.getBoundingClientRect().top;
|
||||
const contentHeight = Math.ceil(clonedElement.getBoundingClientRect().height);
|
||||
|
||||
// Height in px for one page slice:
|
||||
const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM);
|
||||
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 !== clonedElement) trackedElements.add(element);
|
||||
|
||||
if (splittable) {
|
||||
children.forEach(collectBlocks);
|
||||
} else {
|
||||
const { top, bottom } = element.getBoundingClientRect();
|
||||
blocks.push({ element, top: top - rootTop, bottom: bottom - rootTop });
|
||||
}
|
||||
};
|
||||
collectBlocks(clonedElement);
|
||||
|
||||
clonedElement.setAttribute('data-pdf-root', '');
|
||||
|
||||
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
|
||||
while (offsetY < contentHeight) {
|
||||
const sliceHeight = Math.min(pagePixelHeight, contentHeight - offsetY);
|
||||
const sliceBlocks = blocks.filter(
|
||||
(block) => block.bottom > offsetY && block.top < offsetY + sliceHeight
|
||||
);
|
||||
|
||||
const imgData = pageCanvas.toDataURL('image/jpeg', 0.7);
|
||||
const keptElements = new Set();
|
||||
for (const { element } of sliceBlocks) {
|
||||
for (
|
||||
let current = element;
|
||||
current && current !== clonedElement;
|
||||
current = current.parentElement
|
||||
) {
|
||||
keptElements.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate image height in PDF units keeping aspect ratio
|
||||
const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width;
|
||||
const anchor = sliceBlocks.find((block) => block.bottom > block.top);
|
||||
anchor?.element.setAttribute('data-pdf-anchor', '');
|
||||
|
||||
const canvas = await html2canvas(clonedElement, {
|
||||
backgroundColor: isDarkMode ? '#000' : '#fff',
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
scale: 2, // increase resolution
|
||||
width: virtualWidth,
|
||||
height: sliceHeight,
|
||||
y: offsetY,
|
||||
ignoreElements: (element) =>
|
||||
(element.parentElement === document.body &&
|
||||
element !== clonedElement &&
|
||||
!['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();
|
||||
|
||||
|
|
@ -182,6 +221,8 @@
|
|||
page++;
|
||||
}
|
||||
|
||||
document.body.removeChild(clonedElement);
|
||||
|
||||
pdf.save(`chat-${chat.chat.title}.pdf`);
|
||||
|
||||
showFullMessages = false;
|
||||
|
|
|
|||
|
|
@ -126,63 +126,102 @@
|
|||
// Let the browser compute layout for the cloned element
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
// 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
|
||||
const pagePixelHeight = Math.floor((virtualWidth * pageHeightMM) / pageWidthMM);
|
||||
const rootTop = clonedElement.getBoundingClientRect().top;
|
||||
const contentHeight = Math.ceil(clonedElement.getBoundingClientRect().height);
|
||||
|
||||
// Height in px for one page slice:
|
||||
const pagePixelHeight = Math.floor(pxPerPDFMM * pageHeightMM);
|
||||
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 !== clonedElement) trackedElements.add(element);
|
||||
|
||||
if (splittable) {
|
||||
children.forEach(collectBlocks);
|
||||
} else {
|
||||
const { top, bottom } = element.getBoundingClientRect();
|
||||
blocks.push({ element, top: top - rootTop, bottom: bottom - rootTop });
|
||||
}
|
||||
};
|
||||
collectBlocks(clonedElement);
|
||||
|
||||
clonedElement.setAttribute('data-pdf-root', '');
|
||||
|
||||
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
|
||||
while (offsetY < contentHeight) {
|
||||
const sliceHeight = Math.min(pagePixelHeight, contentHeight - offsetY);
|
||||
const sliceBlocks = blocks.filter(
|
||||
(block) => block.bottom > offsetY && block.top < offsetY + sliceHeight
|
||||
);
|
||||
|
||||
const imgData = pageCanvas.toDataURL('image/jpeg', 0.7);
|
||||
const keptElements = new Set();
|
||||
for (const { element } of sliceBlocks) {
|
||||
for (
|
||||
let current = element;
|
||||
current && current !== clonedElement;
|
||||
current = current.parentElement
|
||||
) {
|
||||
keptElements.add(current);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate image height in PDF units keeping aspect ratio
|
||||
const imgHeightMM = (sliceHeight * pageWidthMM) / canvas.width;
|
||||
const anchor = sliceBlocks.find((block) => block.bottom > block.top);
|
||||
anchor?.element.setAttribute('data-pdf-anchor', '');
|
||||
|
||||
const canvas = await html2canvas(clonedElement, {
|
||||
backgroundColor: isDarkMode ? '#000' : '#fff',
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
scale: 2, // increase resolution
|
||||
width: virtualWidth,
|
||||
height: sliceHeight,
|
||||
y: offsetY,
|
||||
ignoreElements: (element) =>
|
||||
(element.parentElement === document.body &&
|
||||
element !== clonedElement &&
|
||||
!['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();
|
||||
|
||||
|
|
@ -197,6 +236,8 @@
|
|||
page++;
|
||||
}
|
||||
|
||||
document.body.removeChild(clonedElement);
|
||||
|
||||
pdf.save(`chat-${chat.chat.title}.pdf`);
|
||||
|
||||
showFullMessages = false;
|
||||
|
|
|
|||
|
|
@ -62,55 +62,118 @@ export const downloadPdf = async (note) => {
|
|||
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);
|
||||
await Promise.all(
|
||||
Array.from(node.querySelectorAll('img')).map((img) => img.decode().catch(() => {}))
|
||||
);
|
||||
|
||||
// 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
|
||||
}
|
||||
const pagePixelHeight = Math.floor((virtualWidth * pageHeightMM) / pageWidthMM);
|
||||
const rootTop = node.getBoundingClientRect().top;
|
||||
const contentHeight = Math.ceil(node.getBoundingClientRect().height);
|
||||
|
||||
// Maintain aspect ratio
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
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'))));
|
||||
|
||||
pdf.addImage(imgData, 'JPEG', 0, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
if (element !== node) trackedElements.add(element);
|
||||
|
||||
// Handle additional pages
|
||||
while (heightLeft > 0) {
|
||||
position -= pageHeight;
|
||||
pdf.addPage();
|
||||
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, position, imgWidth, imgHeight);
|
||||
heightLeft -= pageHeight;
|
||||
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`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue