"use client"; import { FileDownIcon } from "lucide-react"; import { useState, type RefObject } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; type ResponsePdfDownloadButtonProps = { contentRef: RefObject; fileName?: string; siteLogo?: string | null; siteName?: string | null; title?: string; }; function escapeHtml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function getSafeTitle(value?: string | null) { return String(value || "").replace(/\s+/g, " ").trim(); } function getSafeFileName(value?: string | null) { const normalized = getSafeTitle(value || "Claw 分析报告") .replace(/[\\/:*?"<>|]/g, "_") .slice(0, 80); return normalized || "Claw 分析报告"; } function clonePrintableStyles(targetWindow: Window) { document .querySelectorAll( 'link[rel="stylesheet"], style' ) .forEach((node) => { targetWindow.document.head.appendChild(node.cloneNode(true)); }); } function createPrintOverrides(targetWindow: Window) { const style = targetWindow.document.createElement("style"); style.textContent = ` * { box-sizing: border-box; } :root { --background: #ffffff; --foreground: #111827; --border: #e5e7eb; --card: #ffffff; --muted: #f3f4f6; --muted-foreground: #6b7280; --primary: #111827; } html, body { margin: 0; background: #ffffff; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } .claw-pdf-shell { box-sizing: border-box; width: 1120px; max-width: 100%; margin: 0 auto; padding: 32px; background: #ffffff; } .claw-pdf-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-bottom: 18px; margin-bottom: 24px; border-bottom: 1px solid #e5e7eb; } .claw-pdf-brand { display: flex; min-width: 0; align-items: center; gap: 12px; } .claw-pdf-logo, .claw-pdf-logo-fallback { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 10px; } .claw-pdf-logo { object-fit: contain; background: #ffffff; } .claw-pdf-logo-fallback { display: inline-flex; align-items: center; justify-content: center; background: #111827; color: #ffffff; font-size: 15px; font-weight: 700; } .claw-pdf-site { font-size: 18px; line-height: 1.2; font-weight: 700; color: #111827; } .claw-pdf-title { margin-top: 4px; font-size: 12px; line-height: 1.4; color: #6b7280; } .claw-pdf-time { flex: 0 0 auto; text-align: right; font-size: 11px; color: #6b7280; } .claw-pdf-content { font-size: 14px; line-height: 1.7; overflow: visible; } .claw-pdf-content, .claw-pdf-content * { max-width: 100%; } [data-pdf-exclude="true"] { display: none !important; } .claw-pdf-table-card { break-inside: auto; page-break-inside: auto; overflow: visible !important; width: 100% !important; max-width: 100% !important; border-radius: 10px !important; } .claw-pdf-split-table-group { display: block; width: 100%; margin: 12px 0; break-inside: auto; page-break-inside: auto; } .claw-pdf-split-card { width: 100%; margin: 0 0 10px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 10px; break-inside: auto; page-break-inside: auto; background: #ffffff; } .claw-pdf-split-caption { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; background: #f8fafc; color: #6b7280; font-size: 10px; line-height: 1.4; font-weight: 600; text-align: right; } .chat-card-x-scroll { display: block !important; overflow: visible !important; width: 100% !important; max-width: 100% !important; padding-bottom: 0 !important; scrollbar-width: none !important; } .chat-card-x-scroll::-webkit-scrollbar { display: none !important; } table { width: 100% !important; min-width: 0 !important; max-width: 100% !important; table-layout: fixed !important; border-collapse: collapse !important; page-break-inside: auto; break-inside: auto; font-size: 12px !important; line-height: 1.5 !important; } .claw-pdf-split-table { margin: 0 !important; border: 0 !important; border-radius: 0 !important; font-size: 11.5px !important; } .claw-pdf-wide-table { font-size: 11px !important; } .claw-pdf-extra-wide-table { font-size: 10px !important; } thead { display: table-header-group; } tbody { display: table-row-group; } tr { page-break-inside: avoid; break-inside: avoid; } th, td { min-width: 0 !important; width: auto !important; max-width: none !important; white-space: normal !important; overflow-wrap: anywhere !important; word-break: break-word !important; text-overflow: clip !important; vertical-align: top !important; padding: 5px 7px !important; line-height: 1.5 !important; } th *, td * { min-width: 0 !important; max-width: 100% !important; white-space: normal !important; overflow-wrap: anywhere !important; word-break: break-word !important; text-overflow: clip !important; } th { text-align: left !important; font-weight: 700 !important; } .claw-pdf-wide-table th, .claw-pdf-wide-table td, .claw-pdf-extra-wide-table th, .claw-pdf-extra-wide-table td { padding-left: 5px !important; padding-right: 5px !important; } svg, img { page-break-inside: avoid; break-inside: avoid; } svg { max-width: 100% !important; height: auto; } @page { size: A4 landscape; margin: 12mm; } @media print { .claw-pdf-shell { width: auto; max-width: none; padding: 0; } } `; targetWindow.document.head.appendChild(style); } function getTableColumnCount(table: HTMLTableElement) { return Array.from(table.rows).reduce( (maxColumns, row) => Math.max(maxColumns, row.cells.length), 0 ); } const PRINTABLE_TABLE_SPLIT_THRESHOLD = 5; const PRINTABLE_TABLE_PINNED_COLUMNS = 1; const PRINTABLE_TABLE_MAX_COLUMNS_PER_SEGMENT = 4; function getPrintableTableColumnGroups(columnCount: number) { if (columnCount < PRINTABLE_TABLE_SPLIT_THRESHOLD) return null; const pinnedColumnCount = Math.min( PRINTABLE_TABLE_PINNED_COLUMNS, columnCount ); const pinnedColumns = Array.from( { length: pinnedColumnCount }, (_, index) => index ); const remainingColumns = Array.from( { length: columnCount - pinnedColumnCount }, (_, index) => index + pinnedColumnCount ); const chunkSize = Math.max( 1, PRINTABLE_TABLE_MAX_COLUMNS_PER_SEGMENT - pinnedColumnCount ); const columnGroups: number[][] = []; for (let index = 0; index < remainingColumns.length; index += chunkSize) { columnGroups.push([ ...pinnedColumns, ...remainingColumns.slice(index, index + chunkSize), ]); } return columnGroups.length > 1 ? columnGroups : null; } function getPrintableTableRows(table: HTMLTableElement) { let headRows = table.tHead ? Array.from(table.tHead.rows) : []; const allRows = Array.from(table.rows); if (!headRows.length && allRows[0]) { const firstRow = allRows[0]; if (Array.from(firstRow.cells).some((cell) => cell.tagName === "TH")) { headRows = [firstRow]; } } const headRowSet = new Set(headRows); const bodyRows = allRows.filter((row) => !headRowSet.has(row)); return { headRows, bodyRows }; } function appendSplitTableRows( targetDocument: Document, targetSection: HTMLTableSectionElement, sourceRows: HTMLTableRowElement[], columnIndexes: number[], fallbackCellTag: "td" | "th" ) { sourceRows.forEach((sourceRow) => { const targetRow = targetDocument.createElement("tr"); columnIndexes.forEach((columnIndex) => { const sourceCell = sourceRow.cells[columnIndex]; const cellTag = sourceCell?.tagName.toLowerCase() === "th" || fallbackCellTag === "th" ? "th" : "td"; const targetCell = targetDocument.createElement(cellTag); if (sourceCell) { targetCell.innerHTML = sourceCell.innerHTML; } targetRow.appendChild(targetCell); }); targetSection.appendChild(targetRow); }); } function createSplitPrintableTableGroup( targetDocument: Document, table: HTMLTableElement, columnGroups: number[][] ) { const { headRows, bodyRows } = getPrintableTableRows(table); const group = targetDocument.createElement("div"); group.className = "claw-pdf-split-table-group"; columnGroups.forEach((columnIndexes, index) => { const card = targetDocument.createElement("section"); card.className = "claw-pdf-split-card"; const caption = targetDocument.createElement("div"); caption.className = "claw-pdf-split-caption"; caption.textContent = `续表 ${index + 1}/${columnGroups.length}`; card.appendChild(caption); const splitTable = targetDocument.createElement("table"); splitTable.className = "claw-pdf-split-table"; splitTable.dataset.pdfColumnCount = String(columnIndexes.length); if (headRows.length) { const thead = targetDocument.createElement("thead"); appendSplitTableRows( targetDocument, thead, headRows, columnIndexes, "th" ); splitTable.appendChild(thead); } const tbody = targetDocument.createElement("tbody"); appendSplitTableRows( targetDocument, tbody, bodyRows, columnIndexes, "td" ); splitTable.appendChild(tbody); card.appendChild(splitTable); group.appendChild(card); }); return group; } function preparePrintableTables(root: HTMLElement) { root.querySelectorAll("table").forEach((table) => { if (!table.isConnected) return; const columnCount = getTableColumnCount(table); const columnGroups = getPrintableTableColumnGroups(columnCount); const scrollWrapper = table.closest(".chat-card-x-scroll"); const tableCard = scrollWrapper?.parentElement; if (columnGroups) { const splitGroup = createSplitPrintableTableGroup( root.ownerDocument, table, columnGroups ); (tableCard || table).replaceWith(splitGroup); return; } table.dataset.pdfColumnCount = String(columnCount); table.classList.toggle("claw-pdf-wide-table", columnCount >= 5); table.classList.toggle("claw-pdf-extra-wide-table", columnCount >= 7); tableCard?.classList.add("claw-pdf-table-card"); }); } function waitForFrame(targetWindow: Window) { return new Promise((resolve) => { targetWindow.requestAnimationFrame(() => resolve()); }); } async function waitForPrintableAssets(targetWindow: Window, root: HTMLElement) { const fontReady = "fonts" in targetWindow.document ? targetWindow.document.fonts.ready.catch(() => undefined) : Promise.resolve(); const images = Array.from(root.querySelectorAll("img")); const imageReady = Promise.all( images.map((image) => { if (image.complete && image.naturalWidth > 0) return Promise.resolve(); if (typeof image.decode === "function") { return image.decode().catch(() => undefined); } return new Promise((resolve) => { image.addEventListener("load", () => resolve(), { once: true }); image.addEventListener("error", () => resolve(), { once: true }); }); }) ); await Promise.race([ Promise.all([fontReady, imageReady]), new Promise((resolve) => targetWindow.setTimeout(resolve, 1800)), ]); await waitForFrame(targetWindow); await waitForFrame(targetWindow); } export function ResponsePdfDownloadButton({ contentRef, fileName, siteLogo, siteName, title = "Claw 分析报告", }: ResponsePdfDownloadButtonProps) { const [isPreparing, setIsPreparing] = useState(false); const resolvedSiteName = getSafeTitle(siteName) || "AI Chatbot"; const resolvedTitle = getSafeTitle(title) || "Claw 分析报告"; const handleDownloadPdf = () => { const contentElement = contentRef.current; if (!contentElement) { toast.error("未找到可导出的内容"); return; } const printWindow = window.open( "", "_blank", "width=1200,height=820" ); if (!printWindow) { toast.error("浏览器阻止了 PDF 导出窗口,请允许弹窗后重试"); return; } setIsPreparing(true); try { const safeDocumentTitle = getSafeFileName(fileName || resolvedTitle); printWindow.document.open(); printWindow.document.write( `${escapeHtml( safeDocumentTitle )}` ); printWindow.document.close(); clonePrintableStyles(printWindow); createPrintOverrides(printWindow); const shell = printWindow.document.createElement("main"); shell.className = "claw-pdf-shell"; const header = printWindow.document.createElement("header"); header.className = "claw-pdf-header"; const brand = printWindow.document.createElement("div"); brand.className = "claw-pdf-brand"; if (siteLogo) { const logo = printWindow.document.createElement("img"); logo.className = "claw-pdf-logo"; logo.alt = resolvedSiteName; logo.src = siteLogo; brand.appendChild(logo); } else { const fallback = printWindow.document.createElement("span"); fallback.className = "claw-pdf-logo-fallback"; fallback.textContent = resolvedSiteName.slice(0, 1).toUpperCase(); brand.appendChild(fallback); } const brandText = printWindow.document.createElement("div"); brandText.innerHTML = `
${escapeHtml( resolvedSiteName )}
${escapeHtml(resolvedTitle)}
`; brand.appendChild(brandText); const time = printWindow.document.createElement("div"); time.className = "claw-pdf-time"; time.textContent = new Date().toLocaleString("zh-CN", { hour12: false, }); header.appendChild(brand); header.appendChild(time); shell.appendChild(header); const content = printWindow.document.createElement("section"); content.className = "claw-pdf-content"; const clonedContent = contentElement.cloneNode(true) as HTMLElement; clonedContent .querySelectorAll('[data-pdf-exclude="true"]') .forEach((node) => node.remove()); preparePrintableTables(clonedContent); content.appendChild(clonedContent); shell.appendChild(content); printWindow.document.body.appendChild(shell); void waitForPrintableAssets(printWindow, shell) .catch((error) => { console.warn("[Response PDF] asset readiness check failed:", error); }) .finally(() => { setIsPreparing(false); if (printWindow.closed) return; printWindow.focus(); printWindow.print(); }); } catch (error) { setIsPreparing(false); printWindow.close(); console.error("[Response PDF] export failed:", error); toast.error("PDF 导出失败,请稍后重试"); } }; return ( ); }