From edabcac7f2e91f1a773b7b1a477215af79f48ac9 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Fri, 8 Aug 2025 11:31:46 -0600 Subject: [PATCH] update PDF export features --- .../metric/MetricCardThreeMenuContainer.tsx | 2 +- .../elements/MetricElement/MetricElement.tsx | 5 +- .../ui/report/hooks/useExportReport.ts | 120 ++++++++++++++++-- .../useDownloadMetricDataPNG.tsx | 2 +- .../MetricThreeDotMenu.tsx | 3 +- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ui/metric/MetricCardThreeMenuContainer.tsx b/apps/web/src/components/ui/metric/MetricCardThreeMenuContainer.tsx index 8947e7f6a..a79c90c84 100644 --- a/apps/web/src/components/ui/metric/MetricCardThreeMenuContainer.tsx +++ b/apps/web/src/components/ui/metric/MetricCardThreeMenuContainer.tsx @@ -26,7 +26,7 @@ export const MetricCardThreeMenuContainer = ({ isOpen && 'pointer-events-auto block', className )}> - + {children} diff --git a/apps/web/src/components/ui/report/elements/MetricElement/MetricElement.tsx b/apps/web/src/components/ui/report/elements/MetricElement/MetricElement.tsx index 69d8f7d92..4d17835c7 100644 --- a/apps/web/src/components/ui/report/elements/MetricElement/MetricElement.tsx +++ b/apps/web/src/components/ui/report/elements/MetricElement/MetricElement.tsx @@ -47,7 +47,9 @@ export const MetricElement = withHOC( className="rounded-md" attributes={{ ...attributes, - 'data-plate-open-context-menu': true + 'data-plate-open-context-menu': true, + // Mark metric element for export so we can target it for rasterization + 'data-export-metric': true }} {...props}>
{content}
@@ -85,6 +87,7 @@ const MetricResizeContainer: React.FC = ({ children }) => { return (
{ const exportToPdf = async (editor: PlateEditor) => { try { - const canvas = await getCanvas(editor); - const PDFLib = await import('pdf-lib'); - const pdfDoc = await PDFLib.PDFDocument.create(); - const page = pdfDoc.addPage([canvas.width, canvas.height]); - const imageEmbed = await pdfDoc.embedPng(canvas.toDataURL('PNG')); - const { height, width } = imageEmbed.scale(1); - page.drawImage(imageEmbed, { - height, - width, - x: 0, - y: 0 - }); - const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true }); + const { default: html2canvas } = await import('html2canvas-pro'); + + const editorNode = editor.api.toDOMNode(editor); + if (!editorNode) throw new Error('Editor not found'); + + // Clone the editor DOM to a sandbox so we can mutate it for printing + const clonedRoot = editorNode.cloneNode(true) as HTMLElement; + + // Sandbox: attach off-screen to compute sizes and rasterize metrics + const sandbox = document.createElement('div'); + sandbox.setAttribute('data-report-print-sandbox', 'true'); + sandbox.style.position = 'fixed'; + sandbox.style.left = '-10000px'; + sandbox.style.top = '0'; + sandbox.style.width = '850px'; + sandbox.style.pointerEvents = 'none'; + sandbox.appendChild(clonedRoot); + document.body.appendChild(sandbox); + + // Ensure consistent fonts in the clone + clonedRoot.style.fontFamily = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; + + // Replace metric figures with rasterized images so only charts become images in PDF + const figureNodes = Array.from( + clonedRoot.querySelectorAll('figure[data-metric-figure]') + ); + + await Promise.all( + figureNodes.map(async (figure) => { + // Render the figure to a canvas, then replace with an image element + const canvas = await html2canvas(figure, { + backgroundColor: '#ffffff', + useCORS: true + }); + + const image = document.createElement('img'); + image.src = canvas.toDataURL('image/png'); + image.style.width = `${canvas.width}px`; + image.style.height = 'auto'; + image.setAttribute('data-exported-metric-image', 'true'); + + figure.replaceWith(image); + }) + ); + + // Build printable HTML document + const printWindow = window.open('', '_blank'); + if (!printWindow) throw new Error('Unable to open print window'); + + const headStyles = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')) + .map((n) => n.outerHTML) + .join('\n'); + + const printHtml = ` + + + + + ${headStyles} + + + +
${clonedRoot.outerHTML}
+ + `; + + printWindow.document.open(); + printWindow.document.write(printHtml); + printWindow.document.close(); + + // Wait for images to load in the print window before printing + await new Promise((resolve) => { + const imgs = Array.from(printWindow.document.images); + if (imgs.length === 0) return resolve(); + let loaded = 0; + imgs.forEach((img) => { + if (img.complete) { + loaded++; + if (loaded === imgs.length) resolve(); + } else { + img.onload = () => { + loaded++; + if (loaded === imgs.length) resolve(); + }; + img.onerror = () => { + loaded++; + if (loaded === imgs.length) resolve(); + }; + } + }); + }); + + // Cleanup sandbox + sandbox.remove(); + + // Print. User can select "Save as PDF" to get a true text PDF with charts as images. + printWindow.focus(); + printWindow.print(); - await downloadFile(pdfBase64, 'plate.pdf'); openInfoMessage(NodeTypeLabels.pdfExportedSuccessfully.label); } catch (error) { console.error(error); diff --git a/apps/web/src/context/Metrics/metricDropdownItems/useDownloadMetricDataPNG.tsx b/apps/web/src/context/Metrics/metricDropdownItems/useDownloadMetricDataPNG.tsx index a9578d2da..2f1867f7d 100644 --- a/apps/web/src/context/Metrics/metricDropdownItems/useDownloadMetricDataPNG.tsx +++ b/apps/web/src/context/Metrics/metricDropdownItems/useDownloadMetricDataPNG.tsx @@ -28,7 +28,7 @@ export const useDownloadPNGSelectMenu = ({ () => ({ label: 'Download as PNG', value: 'download-png', - disabled: !canDownload, + disabled: true, icon: , onClick: async () => { const node = document.getElementById(METRIC_CHART_CONTAINER_ID(metricId)) as HTMLElement; diff --git a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx index d8ba0a643..f1fdd5fef 100644 --- a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx +++ b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx @@ -154,8 +154,9 @@ export const ThreeDotMenuButton = React.memo( ] ); + return ( - +