diff --git a/apps/web/src/components/ui/report/hooks/buildExportHtml2.ts b/apps/web/src/components/ui/report/hooks/buildExportHtml2.ts
deleted file mode 100644
index 53a2c80ee..000000000
--- a/apps/web/src/components/ui/report/hooks/buildExportHtml2.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { PAGE_CONTROLLER_ID } from '@/controllers/ReportPageControllers/ReportPageController';
-import type { PlateEditor } from 'platejs/react';
-import { ReportPageController } from '@/controllers/ReportPageControllers/ReportPageController';
-
-export const useBuildExportHtml2 = () => {
- return async ({ reportId }: { reportId: string }) => {
- return '';
- };
-};
diff --git a/apps/web/src/components/ui/report/hooks/buildExportHtml2.tsx b/apps/web/src/components/ui/report/hooks/buildExportHtml2.tsx
new file mode 100644
index 000000000..bebaf9326
--- /dev/null
+++ b/apps/web/src/components/ui/report/hooks/buildExportHtml2.tsx
@@ -0,0 +1,196 @@
+'use client';
+
+import { PAGE_CONTROLLER_ID, ReportPageController } from '@/controllers/ReportPageControllers/ReportPageController';
+import { BusterReactQueryProvider } from '@/context/BusterReactQuery/BusterReactQueryAndApi';
+import { SupabaseContextProvider, useSupabaseContext } from '@/context/Supabase/SupabaseContextProvider';
+import type { UseSupabaseUserContextType } from '@/lib/supabase';
+import { printHTMLPage } from './printHTMLPage';
+import { useMemoizedFn } from '@/hooks/useMemoizedFn';
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+export const useBuildExportHtml2 = ({ reportId: defaultReportId }: { reportId?: string } = {}) => {
+ const user = useSupabaseContext((s) => s.user);
+ const accessToken = useSupabaseContext((s) => s.accessToken);
+
+ const build = useMemoizedFn(
+ async ({
+ reportId = defaultReportId,
+ filename = 'Buster Report',
+ triggerPrint = false
+ }: {
+ reportId?: string;
+ filename?: string;
+ triggerPrint?: boolean;
+ } = {}) => {
+ if (!reportId) throw new Error('reportId is required');
+
+ // 1) Create a hidden, offscreen container
+ const container = document.createElement('div');
+ container.setAttribute('data-report-export-container', reportId);
+ Object.assign(container.style, {
+ position: 'fixed',
+ left: '-10000px',
+ top: '0',
+ width: '850px',
+ maxWidth: '850px',
+ pointerEvents: 'none',
+ opacity: '0',
+ visibility: 'hidden'
+ } as CSSStyleDeclaration);
+ document.body.appendChild(container);
+
+ // 2) Mount the controller wrapped with minimal providers so hooks work
+ const supabaseContext = { user, accessToken } as UseSupabaseUserContextType;
+
+ const root = createRoot(container);
+ root.render(
+
+
+
+
+
+ );
+
+ try {
+ // 3) Wait for the editor DOM to be ready in the hidden mount
+ const controllerSelector = `#${CSS.escape(PAGE_CONTROLLER_ID(reportId))}`;
+ const editorRoot = await waitForElement(
+ () => container.querySelector(`${controllerSelector} [contenteditable="true"]`) as HTMLElement | null,
+ { timeoutMs: 10000 }
+ );
+
+ // Small settle time for charts/canvases/fonts
+ await delay(150);
+
+ // 4) Build portable HTML (inline styles, snapshot canvases)
+ const contentHtml = await buildPortableHtmlFromRoot(editorRoot);
+
+ const html = `
+
+
+
+
+
+ ${escapeHtml(filename)}
+
+
+
+ ${contentHtml}
+
+`;
+
+ if (triggerPrint) {
+ printHTMLPage({ html, filename });
+ }
+
+ return html;
+ } finally {
+ // 5) Cleanup
+ try {
+ root.unmount();
+ } catch {
+ // noop
+ }
+ container.remove();
+ }
+ }
+ );
+
+ return build;
+};
+
+async function buildPortableHtmlFromRoot(liveRoot: HTMLElement): Promise {
+ // Clone live DOM subtree and inline computed styles
+ const clonedRoot = liveRoot.cloneNode(true) as HTMLElement;
+
+ // Snapshot metric canvases from the LIVE DOM and replace in the clone
+ const liveMetricCanvases = Array.from(
+ liveRoot.querySelectorAll('[data-export-metric] canvas')
+ ) as HTMLCanvasElement[];
+ const cloneMetricCanvases = Array.from(
+ clonedRoot.querySelectorAll('[data-export-metric] canvas')
+ ) as HTMLCanvasElement[];
+
+ for (let i = 0; i < Math.min(liveMetricCanvases.length, cloneMetricCanvases.length); i++) {
+ const liveCanvas = liveMetricCanvases[i];
+ const cloneCanvas = cloneMetricCanvases[i];
+ try {
+ const dataUrl = liveCanvas.toDataURL('image/png');
+ if (dataUrl) {
+ const img = document.createElement('img');
+ img.src = dataUrl;
+ img.alt = 'Metric';
+ const computed = window.getComputedStyle(liveCanvas);
+ const width = computed.getPropertyValue('width');
+ const height = computed.getPropertyValue('height');
+ if (width) (img.style as CSSStyleDeclaration).width = width;
+ if (height) (img.style as CSSStyleDeclaration).height = height;
+ (img.style as CSSStyleDeclaration).display = 'block';
+ cloneCanvas.replaceWith(img);
+ }
+ } catch (e) {
+ // If canvas is tainted or fails, leave the canvas as-is
+ // eslint-disable-next-line no-console
+ console.error('Failed to snapshot metric canvas for HTML export', e);
+ }
+ }
+
+ // Inline computed styles by pairing original and clone nodes in order
+ const originals = [liveRoot, ...Array.from(liveRoot.querySelectorAll('*'))];
+ const clones = [clonedRoot, ...Array.from(clonedRoot.querySelectorAll('*'))];
+
+ for (let i = 0; i < Math.min(originals.length, clones.length); i++) {
+ const original = originals[i] as HTMLElement;
+ const clone = clones[i] as HTMLElement;
+ const computed = window.getComputedStyle(original);
+ const cssText = Array.from(computed)
+ .map((prop) => `${prop}: ${computed.getPropertyValue(prop)};`)
+ .join(' ');
+ clone.setAttribute('style', cssText);
+ }
+
+ // Return inner HTML of cloned editor root
+ return clonedRoot.outerHTML;
+}
+
+async function waitForElement(
+ getter: () => T,
+ { timeoutMs = 8000, intervalMs = 50 }: { timeoutMs?: number; intervalMs?: number } = {}
+): Promise> {
+ const start = Date.now();
+ // First try immediate
+ const immediate = getter();
+ if (immediate) return immediate as NonNullable;
+
+ return new Promise>((resolve, reject) => {
+ const timer = setInterval(() => {
+ const el = getter();
+ if (el) {
+ clearInterval(timer);
+ resolve(el as NonNullable);
+ } else if (Date.now() - start > timeoutMs) {
+ clearInterval(timer);
+ reject(new Error('Timed out waiting for element'));
+ }
+ }, intervalMs);
+ });
+}
+
+function delay(ms: number) {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\"/g, '"')
+ .replace(/'/g, ''');
+}
\ No newline at end of file
diff --git a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/ReportContainerHeaderButtons/ReportThreeDotMenu.tsx b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/ReportContainerHeaderButtons/ReportThreeDotMenu.tsx
index f44674fe2..fbfc556c9 100644
--- a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/ReportContainerHeaderButtons/ReportThreeDotMenu.tsx
+++ b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/ReportContainerHeaderButtons/ReportThreeDotMenu.tsx
@@ -320,7 +320,7 @@ const useDownloadPdfSelectMenu = ({ reportId }: { reportId: string }): DropdownI
const onClick = async () => {
try {
- const html = await buildExportHtml2({ reportId });
+ const html = await buildExportHtml2({ reportId, filename: reportName, triggerPrint: true });
console.log('html', html);
// const editor = getReportEditor(reportId);