import { toast } from "sonner"; import { PDF_EXPORT_CONFIG } from "@/config"; import { normalizeFontFamily } from "@/utils/fonts"; import { ResumeData } from "@/types/resume"; import { generateResumeMarkdown, ResumeMarkdownOptions } from "@/utils/markdown"; const INVALID_FILE_NAME_CHAR_REGEX = /[\\/:*?"<>|]/g; const getSafeFileName = (title?: string) => { const normalized = (title || "resume") .trim() .replace(INVALID_FILE_NAME_CHAR_REGEX, "_") .replace(/\s+/g, " "); return normalized || "resume"; }; const downloadBlob = (blob: Blob, fileName: string) => { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); window.URL.revokeObjectURL(url); }; const downloadTextFile = (content: string, fileName: string, mimeType: string) => { const blob = new Blob([content], { type: mimeType }); downloadBlob(blob, fileName); }; export const getOptimizedStyles = () => { const styleCache = new Map(); const startTime = performance.now(); const styles = Array.from(document.styleSheets) .map((sheet) => { try { return Array.from(sheet.cssRules) .filter((rule) => { const ruleText = rule.cssText; const normalizedRuleText = ruleText.toLowerCase(); if (styleCache.has(ruleText)) return false; styleCache.set(ruleText, true); if (rule instanceof CSSFontFaceRule) return false; if (rule instanceof CSSImportRule) return false; if (normalizedRuleText.includes("fonts.googleapis.com")) return false; if (normalizedRuleText.includes("fonts.gstatic.com")) return false; if (ruleText.includes("font-family")) return false; if (ruleText.includes("@keyframes")) return false; if (ruleText.includes("animation")) return false; if (ruleText.includes("transition")) return false; if (ruleText.includes("hover")) return false; return true; }) .map((rule) => rule.cssText) .join("\n"); } catch (e) { console.warn("Style processing error:", e); return ""; } }) .join("\n"); console.log(`Style processing took ${performance.now() - startTime}ms`); return styles; }; export const optimizeImages = async (element: HTMLElement) => { const startTime = performance.now(); const images = element.getElementsByTagName("img"); const imagePromises = Array.from(images) .filter((img) => !img.src.startsWith("data:")) .map(async (img) => { try { const response = await fetch(img.src); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => { img.src = reader.result as string; resolve(); }; reader.readAsDataURL(blob); }); } catch (error) { console.error("Image conversion error:", error); return Promise.resolve(); } }); await Promise.all(imagePromises); console.log(`Image processing took ${performance.now() - startTime}ms`); }; export interface ExportToPdfOptions { elementId: string; title: string; pagePadding: number; fontFamily?: string; onStart?: () => void; onEnd?: () => void; successMessage?: string; errorMessage?: string; } interface ExportResumeFileOptions { resume?: ResumeData | null; title?: string; onStart?: () => void; onEnd?: () => void; successMessage?: string; errorMessage?: string; } interface ExportResumeMarkdownOptions extends ExportResumeFileOptions { markdownOptions?: ResumeMarkdownOptions; } export const exportResumeAsJson = ({ resume, title, onStart, onEnd, successMessage, errorMessage }: ExportResumeFileOptions) => { onStart?.(); try { if (!resume) { throw new Error("No active resume"); } const json = JSON.stringify(resume, null, 2); const fileName = `${getSafeFileName(title || resume.title)}.json`; downloadTextFile(json, fileName, "application/json;charset=utf-8"); if (successMessage) toast.success(successMessage); } catch (error) { console.error("JSON export error:", error); if (errorMessage) toast.error(errorMessage); } finally { onEnd?.(); } }; export const exportResumeAsMarkdown = ({ resume, title, onStart, onEnd, successMessage, errorMessage, markdownOptions }: ExportResumeMarkdownOptions) => { onStart?.(); try { if (!resume) { throw new Error("No active resume"); } const markdown = generateResumeMarkdown(resume, markdownOptions); const fileName = `${getSafeFileName(title || resume.title)}.md`; downloadTextFile(markdown, fileName, "text/markdown;charset=utf-8"); if (successMessage) toast.success(successMessage); } catch (error) { console.error("Markdown export error:", error); if (errorMessage) toast.error(errorMessage); } finally { onEnd?.(); } }; export const exportToPdf = async ({ elementId, title, pagePadding, fontFamily, onStart, onEnd, successMessage, errorMessage }: ExportToPdfOptions) => { const exportStartTime = performance.now(); onStart?.(); try { const pdfElement = document.querySelector(`#${elementId}`); if (!pdfElement) { throw new Error(`PDF element #${elementId} not found`); } const clonedElement = pdfElement.cloneNode(true) as HTMLElement; const selectedFontFamily = normalizeFontFamily(fontFamily); const transformValue = clonedElement.style.transform || ""; const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/); if (scaleMatch) { const scale = Number(scaleMatch[1]); if (Number.isFinite(scale) && scale > 0 && scale < 1) { // 服务端导出前将 transform 缩放转为 zoom,避免分页计算偏差 clonedElement.style.removeProperty("transform"); clonedElement.style.removeProperty("transform-origin"); clonedElement.style.setProperty("width", "100%", "important"); clonedElement.style.setProperty("zoom", String(scale)); } } // 采用 PdfExport.tsx 中的逻辑,统一宽度和 padding 处理 clonedElement.style.setProperty("width", "100%", "important"); clonedElement.style.setProperty("padding", "0", "important"); clonedElement.style.setProperty("box-sizing", "border-box"); clonedElement.style.setProperty("font-family", selectedFontFamily, "important"); const pageBreakLines = clonedElement.querySelectorAll(".page-break-line"); pageBreakLines.forEach((line) => { line.style.display = "none"; }); const [capturedStyles] = await Promise.all([ getOptimizedStyles(), optimizeImages(clonedElement) ]); // 注入 PdfExport.tsx 中的样式增强 const styles = ` ${capturedStyles} html, body { background: white !important; background-color: white !important; } html, body, #${elementId} { background: white !important; background-color: white !important; font-family: ${selectedFontFamily} !important; } `; const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: clonedElement.outerHTML, styles, margin: pagePadding }), mode: "cors", signal: AbortSignal.timeout(PDF_EXPORT_CONFIG.TIMEOUT) }); if (!response.ok) { throw new Error(`PDF generation failed: ${response.status}`); } const blob = await response.blob(); const fileName = `${getSafeFileName(title)}.pdf`; downloadBlob(blob, fileName); if (successMessage) toast.success(successMessage); console.log(`Total export took ${performance.now() - exportStartTime}ms`); } catch (error) { console.error("Export error:", error); if (errorMessage) toast.error(errorMessage); } finally { onEnd?.(); } };