diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index cd3b1f5f37..ffdf1c4be9 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -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; diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 1cd001f75a..45c7e15fd4 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -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; diff --git a/src/lib/components/notes/utils.ts b/src/lib/components/notes/utils.ts index 6a3739740d..74b0bddf05 100644 --- a/src/lib/components/notes/utils.ts +++ b/src/lib/components/notes/utils.ts @@ -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`);