From 5351db3fd7a25b850ad4db58714730add51e2eb8 Mon Sep 17 00:00:00 2001 From: Furina <3559551198@qq.com> Date: Sun, 10 May 2026 21:47:24 +0800 Subject: [PATCH 1/2] feat: add long-page PDF export --- package.json | 1 + pnpm-lock.yaml | 3 + src/components/preview/PreviewDock.tsx | 37 ++++- src/components/preview/index.tsx | 4 +- src/components/shared/PdfExport.tsx | 39 ++++- src/config/initialResumeData.ts | 1 + src/i18n/locales/en.json | 7 + src/i18n/locales/zh.json | 7 + src/types/resume.ts | 1 + src/utils/export.ts | 195 ++++++++++++++++++++++++- 10 files changed, 286 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index cc9d51aa..25b8b7c3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "framer-motion": "^11.11.10", "html2canvas": "^1.4.1", "html2pdf.js": "^0.10.2", + "jspdf": "2.5.2", "lodash": "^4.17.21", "lucide-react": "^0.379.0", "mark.js": "^8.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d29667ba..5c9c5676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: html2pdf.js: specifier: ^0.10.2 version: 0.10.2 + jspdf: + specifier: 2.5.2 + version: 2.5.2 lodash: specifier: ^4.17.21 version: 4.17.21 diff --git a/src/components/preview/PreviewDock.tsx b/src/components/preview/PreviewDock.tsx index 1b1b9627..5eb3a6d2 100644 --- a/src/components/preview/PreviewDock.tsx +++ b/src/components/preview/PreviewDock.tsx @@ -11,7 +11,8 @@ import { FileJson, Loader2, Eye, - FileText + FileText, + EyeOff } from "lucide-react"; import { RiMarkdownLine } from "@remixicon/react"; import { toast } from "sonner"; @@ -106,6 +107,7 @@ const PreviewDock = ({ const { duplicateResume, setActiveResume, activeResumeId, activeResume, updateGlobalSettings } = useResumeStore(); const { globalSettings = {} } = activeResume || {}; + const pageBreakLinesVisible = globalSettings?.pageBreakLinesVisible !== false; const { checkConfiguration } = useAIConfiguration(); @@ -239,6 +241,39 @@ const PreviewDock = ({ + + + +
{ + updateGlobalSettings({ + pageBreakLinesVisible: !pageBreakLinesVisible + }); + toast.success( + pageBreakLinesVisible + ? t("pageBreakLine.hidden") + : t("pageBreakLine.visible") + ); + }} + > + +
+
+ +

{t("pageBreakLine.tooltip")}

+
+
+
diff --git a/src/components/preview/index.tsx b/src/components/preview/index.tsx index c8b48960..f58f6252 100644 --- a/src/components/preview/index.tsx +++ b/src/components/preview/index.tsx @@ -134,6 +134,8 @@ const PreviewPanel = React.forwardRef( const pagePadding = activeResume?.globalSettings?.pagePadding || 0; const autoOnePageEnabled = activeResume?.globalSettings?.autoOnePage || false; + const pageBreakLinesVisible = + activeResume?.globalSettings?.pageBreakLinesVisible !== false; const { scaleFactor, isScaled, cannotFit } = useAutoOnePage({ contentHeight, @@ -271,7 +273,7 @@ const PreviewPanel = React.forwardRef( } `} - {contentHeight > 0 && ( + {pageBreakLinesVisible && contentHeight > 0 && ( <>
{Array.from( diff --git a/src/components/shared/PdfExport.tsx b/src/components/shared/PdfExport.tsx index ccac0159..f18794a8 100644 --- a/src/components/shared/PdfExport.tsx +++ b/src/components/shared/PdfExport.tsx @@ -3,7 +3,12 @@ import { useTranslations } from "@/i18n/compat/client"; import { Download, Loader2, ChevronDown, ShieldCheck } from "lucide-react"; import { useResumeStore } from "@/store/useResumeStore"; import { Button } from "@/components/ui/button"; -import { exportResumeAsJson, exportResumeAsMarkdown, exportToPdf } from "@/utils/export"; +import { + exportResumeAsJson, + exportResumeAsMarkdown, + exportToLongPagePdf, + exportToPdf +} from "@/utils/export"; import { exportResumeToBrowserPrint } from "@/utils/print"; import { cn } from "@/lib/utils"; import { @@ -82,6 +87,7 @@ const ExportCard = ({ const PdfExport = ({ children }: { children?: React.ReactNode }) => { const [isOpen, setIsOpen] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingLongPage, setIsExportingLongPage] = useState(false); const [isPrinting, setIsPrinting] = useState(false); const [isExportingJson, setIsExportingJson] = useState(false); const [isExportingMarkdown, setIsExportingMarkdown] = useState(false); @@ -103,6 +109,19 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { }); }; + const handleLongPageExport = async () => { + await exportToLongPagePdf({ + elementId: "resume-preview", + title: title || "resume", + pagePadding: globalSettings?.pagePadding || 0, + fontFamily: globalSettings?.fontFamily, + onStart: () => setIsExportingLongPage(true), + onEnd: () => setIsExportingLongPage(false), + successMessage: t("toast.success"), + errorMessage: t("toast.error") + }); + }; + const handleJsonExport = () => { exportResumeAsJson({ resume: activeResume, @@ -156,9 +175,16 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { } }; - const isLoading = isExporting || isExportingJson || isExportingMarkdown || isPrinting; + const isLoading = + isExporting || + isExportingLongPage || + isExportingJson || + isExportingMarkdown || + isPrinting; const loadingText = isExporting ? t("button.exporting") + : isExportingLongPage + ? t("button.exporting") : isExportingJson ? t("button.exportingJson") : isExportingMarkdown @@ -222,6 +248,15 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { bgGradientClass="from-rose-500/10 dark:from-rose-500/20" hoverBorderClass="hover:border-rose-500/40 hover:ring-1 hover:ring-rose-500/20" /> + { + const pdfWithPageControl = pdf as jsPDF & { + getNumberOfPages?: () => number; + deletePage?: (pageNumber: number) => void; + }; + + const totalPages = pdfWithPageControl.getNumberOfPages?.() ?? 1; + if (totalPages <= 1 || !pdfWithPageControl.deletePage) { + return; + } + + for (let pageNumber = totalPages; pageNumber > 1; pageNumber -= 1) { + pdfWithPageControl.deletePage(pageNumber); + } +}; + interface ExportResumeFileOptions { resume?: ResumeData | null; title?: string; @@ -174,6 +196,172 @@ export const exportResumeAsMarkdown = ({ } }; +const hidePageBreakLines = (element: HTMLElement) => { + const pageBreakLines = element.querySelectorAll(".page-break-line"); + pageBreakLines.forEach((line) => { + line.remove(); + }); +}; + +const removeLongPageHeightConstraints = (element: HTMLElement) => { + const rootElement = element.firstElementChild as HTMLElement | null; + if (rootElement) { + rootElement.style.setProperty("height", "auto", "important"); + rootElement.style.setProperty("min-height", "0", "important"); + } + + const constrainedElements = element.querySelectorAll(".min-h-screen, .min-h-full, .editorial-print-container"); + constrainedElements.forEach((node) => { + node.style.setProperty("height", "auto", "important"); + node.style.setProperty("min-height", "0", "important"); + }); +}; + +const getPreviewScale = (element: HTMLElement) => { + const transformValue = element.style.transform || ""; + const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/); + if (!scaleMatch) return 1; + + const scale = Number(scaleMatch[1]); + return Number.isFinite(scale) && scale > 0 ? scale : 1; +}; + +const waitForImages = async (element: HTMLElement) => { + const images = Array.from(element.getElementsByTagName("img")); + await Promise.all( + images + .filter((img) => !img.complete) + .map( + (img) => + new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); + }) + ) + ); +}; + +export const exportToLongPagePdf = async ({ + elementId, + title, + pagePadding, + fontFamily, + onStart, + onEnd, + successMessage, + errorMessage +}: ExportToPdfOptions) => { + const exportStartTime = performance.now(); + onStart?.(); + + let container: HTMLDivElement | null = null; + + try { + const pdfElement = document.querySelector(`#${elementId}`); + if (!pdfElement) { + throw new Error(`PDF element #${elementId} not found`); + } + + const selectedFontFamily = normalizeFontFamily(fontFamily); + const clonedElement = pdfElement.cloneNode(true) as HTMLElement; + const previewScale = getPreviewScale(clonedElement); + hidePageBreakLines(clonedElement); + removeLongPageHeightConstraints(clonedElement); + await optimizeImages(clonedElement); + + clonedElement.style.setProperty("padding", `${pagePadding}px`, "important"); + clonedElement.style.setProperty("box-sizing", "border-box", "important"); + clonedElement.style.setProperty("background", "white", "important"); + clonedElement.style.setProperty("font-family", selectedFontFamily, "important"); + + const bottomSpacer = document.createElement("div"); + bottomSpacer.setAttribute("aria-hidden", "true"); + bottomSpacer.style.width = "100%"; + bottomSpacer.style.height = `${Math.ceil(LONG_PAGE_BOTTOM_SAFE_AREA_PX / previewScale)}px`; + bottomSpacer.style.pointerEvents = "none"; + clonedElement.appendChild(bottomSpacer); + + container = document.createElement("div"); + container.style.position = "fixed"; + container.style.left = "-10000px"; + container.style.top = "0"; + container.style.width = `${A4_WIDTH_MM}mm`; + container.style.background = "white"; + container.style.pointerEvents = "none"; + container.style.zIndex = "-1"; + + const fontStyles = document.createElement("style"); + fontStyles.textContent = await getFontFaceCss(selectedFontFamily); + container.appendChild(fontStyles); + container.appendChild(clonedElement); + document.body.appendChild(container); + + await waitForImages(clonedElement); + if (document.fonts?.ready) { + await document.fonts.ready; + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const renderedRect = clonedElement.getBoundingClientRect(); + const contentWidthPx = + renderedRect.width || clonedElement.scrollWidth || A4_WIDTH_MM * PX_PER_MM; + const contentHeightPx = Math.max( + renderedRect.height, + clonedElement.scrollHeight * previewScale, + 1 + ); + const pageHeightMm = Math.max( + contentHeightPx * (A4_WIDTH_MM / contentWidthPx) + LONG_PAGE_HEIGHT_BUFFER_MM, + 1 + ); + const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ + import("html2canvas"), + import("jspdf") + ]); + + const fileName = `${getSafeFileName(title)}.pdf`; + const canvas = await html2canvas(clonedElement, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: "#ffffff", + scrollX: 0, + scrollY: 0, + windowWidth: Math.ceil(contentWidthPx), + windowHeight: Math.ceil(contentHeightPx + LONG_PAGE_CAPTURE_BUFFER_PX) + }); + + const imageHeightMm = canvas.height * (A4_WIDTH_MM / canvas.width); + const canvasPageHeightMm = Math.max( + imageHeightMm + LONG_PAGE_HEIGHT_BUFFER_MM, + pageHeightMm + ); + const pdf = new jsPDF({ + unit: "mm", + format: [A4_WIDTH_MM, canvasPageHeightMm], + orientation: "portrait", + compress: true + }); + const imageData = canvas.toDataURL("image/png"); + pdf.addImage(imageData, "PNG", 0, 0, A4_WIDTH_MM, imageHeightMm); + keepOnlyFirstPage(pdf); + pdf.save(fileName); + + if (successMessage) toast.success(successMessage); + console.log(`Total long page export took ${performance.now() - exportStartTime}ms`); + } catch (error) { + console.error("Long page export error:", error); + if (errorMessage) toast.error(errorMessage); + } finally { + if (container?.parentNode) { + container.parentNode.removeChild(container); + } + onEnd?.(); + } +}; + export const exportToPdf = async ({ elementId, title, @@ -215,10 +403,7 @@ export const exportToPdf = async ({ 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"; - }); + hidePageBreakLines(clonedElement); const [capturedStyles] = await Promise.all([ getOptimizedStyles(), From ba4f46b297b677738717d43fce4bff4f81f420a7 Mon Sep 17 00:00:00 2001 From: Furina <3559551198@qq.com> Date: Mon, 11 May 2026 21:53:47 +0800 Subject: [PATCH 2/2] feat: add single long PNG export --- src/components/shared/GlassIcons.tsx | 25 ++++ src/components/shared/PdfExport.tsx | 53 ++++++-- src/i18n/locales/en.json | 6 +- src/i18n/locales/zh.json | 8 +- src/utils/export.ts | 174 ++++++++++++++++++++++----- 5 files changed, 223 insertions(+), 43 deletions(-) diff --git a/src/components/shared/GlassIcons.tsx b/src/components/shared/GlassIcons.tsx index 0cc23e47..c65442ab 100644 --- a/src/components/shared/GlassIcons.tsx +++ b/src/components/shared/GlassIcons.tsx @@ -116,3 +116,28 @@ export const MarkdownGlassIcon = ({ className, isLoading }: GlassIconProps) => ( ); + +export const ImageGlassIcon = ({ className, isLoading }: GlassIconProps) => ( + + + + + + + + + + + + + + + + + + + + {isLoading && } + + +); diff --git a/src/components/shared/PdfExport.tsx b/src/components/shared/PdfExport.tsx index f18794a8..288ea415 100644 --- a/src/components/shared/PdfExport.tsx +++ b/src/components/shared/PdfExport.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { exportResumeAsJson, exportResumeAsMarkdown, + exportToLongPageImage, exportToLongPagePdf, exportToPdf } from "@/utils/export"; @@ -22,6 +23,7 @@ import { import { PdfGlassIcon, + ImageGlassIcon, PrintGlassIcon, JsonGlassIcon, MarkdownGlassIcon, @@ -34,6 +36,7 @@ const ExportCard = ({ description, onClick, isLoading, + isDisabled, bgGradientClass, hoverBorderClass, }: { @@ -42,17 +45,20 @@ const ExportCard = ({ description: string, onClick: () => void, isLoading: boolean, + isDisabled?: boolean, bgGradientClass?: string, hoverBorderClass?: string, }) => { + const disabled = isLoading || isDisabled; + return (
{ - if (!isLoading) onClick(); + if (!disabled) onClick(); }} className={cn( "group relative flex flex-col justify-center overflow-hidden p-6 pl-[136px] min-h-[130px] rounded-3xl border border-border/50 bg-card text-card-foreground shadow-sm transition-all duration-500", - isLoading ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass) + disabled ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass) )} > {/* 顶部内发光高光,增加卡片立体感 */} @@ -61,14 +67,14 @@ const ExportCard = ({ {/* 底部环境光晕,让图标色渗入背景 */}
{/* 调整后的图标层:完全收入卡片内部,尺寸克制 */}
@@ -88,6 +94,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { const [isOpen, setIsOpen] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isExportingLongPage, setIsExportingLongPage] = useState(false); + const [isExportingImage, setIsExportingImage] = useState(false); const [isPrinting, setIsPrinting] = useState(false); const [isExportingJson, setIsExportingJson] = useState(false); const [isExportingMarkdown, setIsExportingMarkdown] = useState(false); @@ -122,6 +129,19 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { }); }; + const handleLongPageImageExport = async () => { + await exportToLongPageImage({ + elementId: "resume-preview", + title: title || "resume", + pagePadding: globalSettings?.pagePadding || 0, + fontFamily: globalSettings?.fontFamily, + onStart: () => setIsExportingImage(true), + onEnd: () => setIsExportingImage(false), + successMessage: t("toast.imageSuccess"), + errorMessage: t("toast.imageError") + }); + }; + const handleJsonExport = () => { exportResumeAsJson({ resume: activeResume, @@ -178,20 +198,18 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { const isLoading = isExporting || isExportingLongPage || + isExportingImage || isExportingJson || isExportingMarkdown || isPrinting; - const loadingText = isExporting - ? t("button.exporting") - : isExportingLongPage + const loadingText = + isExporting || isExportingLongPage || isExportingImage || isPrinting ? t("button.exporting") : isExportingJson ? t("button.exportingJson") : isExportingMarkdown ? t("button.exportingMarkdown") - : isPrinting - ? t("button.exporting") - : ""; + : ""; return ( { @@ -244,6 +262,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { title={t("button.exportPdf")} description={t("modal.pdfDesc")} isLoading={isExporting} + isDisabled={isLoading} onClick={handleExport} bgGradientClass="from-rose-500/10 dark:from-rose-500/20" hoverBorderClass="hover:border-rose-500/40 hover:ring-1 hover:ring-rose-500/20" @@ -253,15 +272,27 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { title={t("button.exportLongPagePdf")} description={t("modal.longPagePdfDesc")} isLoading={isExportingLongPage} + isDisabled={isLoading} onClick={handleLongPageExport} bgGradientClass="from-violet-500/10 dark:from-violet-500/20" hoverBorderClass="hover:border-violet-500/40 hover:ring-1 hover:ring-violet-500/20" /> + { title={t("button.exportJson")} description={t("modal.jsonDesc")} isLoading={isExportingJson} + isDisabled={isLoading} onClick={handleJsonExport} bgGradientClass="from-amber-500/10 dark:from-amber-500/20" hoverBorderClass="hover:border-amber-500/40 hover:ring-1 hover:ring-amber-500/20" @@ -280,6 +312,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { title={t("button.exportMarkdown")} description={t("modal.markdownDesc")} isLoading={isExportingMarkdown} + isDisabled={isLoading} onClick={handleMarkdownExport} bgGradientClass="from-indigo-500/10 dark:from-indigo-500/20" hoverBorderClass="hover:border-indigo-500/40 hover:ring-1 hover:ring-indigo-500/20" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 65ef7122..83e6480b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -88,7 +88,7 @@ }, { "question": "What export formats are supported?", - "answer": "We currently support PDF export, ensuring your resume maintains consistent formatting on any device. We plan to support more export formats in the future." + "answer": "We currently support PDF export, PNG long image export, browser print, JSON config export, and Markdown export. PDF is best for formal applications and archiving, while PNG long image is useful for mobile previews, portfolio display, or image-upload workflows." }, { "question": "How can I sync across devices?", @@ -259,6 +259,7 @@ "subtitle": "Select the format you want to export your resume", "pdfDesc": "High-precision rendering with 100% format accuracy. Recommended for job applications.", "longPagePdfDesc": "Browser-rendered as a single long page. Best for resumes that do not need pagination.", + "longPageImageDesc": "Browser-rendered as a single PNG long image. Best for mobile previews, portfolio display, or image-upload workflows.", "printDesc": "Save via system print dialog. Use as a fallback or for custom margins.", "jsonDesc": "Export your resume data as JSON to backup or transfer between devices.", "markdownDesc": "Convert to plain Markdown text for easy sharing with AI models.", @@ -268,6 +269,7 @@ "export": "Export", "exportPdf": "Export PDF (Server)", "exportLongPagePdf": "PDF (Long Page)", + "exportLongPageImage": "PNG (Long Image)", "exportJson": "Export JSON Config", "exportMarkdown": "Export Markdown", "exporting": "Exporting...", @@ -278,6 +280,8 @@ "toast": { "success": "PDF exported successfully", "error": "PDF export failed", + "imageSuccess": "PNG long image exported successfully", + "imageError": "PNG long image export failed", "jsonSuccess": "Configuration exported successfully", "jsonError": "Configuration export failed", "markdownSuccess": "Markdown exported successfully", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9b232bf5..d30c4921 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -260,6 +260,7 @@ "subtitle": "选择您希望导出简历的格式", "pdfDesc": "通过高精度渲染生成,100% 还原格式,简历投递首选。", "longPagePdfDesc": "通过浏览器渲染生成一页简历,适合不需要分页的简历。", + "longPageImageDesc": "通过浏览器渲染生成单张 PNG 长图,适合移动端预览、作品集展示或要求图片上传的场景。", "printDesc": "调用系统打印工具另存为 PDF,当主渲染拥堵或需手动干预边距时的备用方案。", "jsonDesc": "保存您的配置档案副本。日后更换设备或清理缓存后,可使用此文件一键无损导入恢复进度。", "markdownDesc": "转换为极简的文本标记语言文件,方便快速粘贴给各种 AI 模型或是其他文本编辑器。", @@ -269,6 +270,7 @@ "export": "导出", "exportPdf": "PDF", "exportLongPagePdf": "PDF(长页)", + "exportLongPageImage": "PNG(长图)", "exportJson": "JSON配置", "exportMarkdown": "Markdown", "exporting": "导出中...", @@ -279,6 +281,8 @@ "toast": { "success": "PDF导出成功", "error": "PDF导出失败", + "imageSuccess": "PNG长图导出成功", + "imageError": "PNG长图导出失败", "jsonSuccess": "配置导出成功", "jsonError": "配置导出失败", "markdownSuccess": "Markdown 导出成功", @@ -812,8 +816,8 @@ "answer": "推荐使用最新版的 Chrome (谷歌浏览器) 或 Edge 浏览器以获得最佳排版和导出体验。部分不兼容的旧版浏览器可能会导致样式错乱。" }, "export-methods": { - "question": "两种导出方式有什么不同?", - "answer": "• PDF 导出:使用后端服务进行高精度渲染,100% 还原排版,推荐用于正式发送给 HR。\n\n• 浏览器打印:调用您当前浏览器的自带打印功能(另存为 PDF)。适合在后端导出服务繁忙或您需要通过浏览器微调打印边距时使用。" + "question": "有哪些导出方式?", + "answer": "• PDF 导出:使用后端服务进行高精度渲染,100% 还原排版,推荐用于正式投递和归档。\n\n• PNG 长图:使用浏览器渲染生成单张长图,适合移动端预览、作品集展示或要求图片上传的场景。\n\n• 浏览器打印:调用您当前浏览器的自带打印功能(另存为 PDF)。适合在后端导出服务繁忙或您需要通过浏览器微调打印边距时使用。\n\n• JSON 配置:导出简历数据副本,方便备份和迁移。\n\n• Markdown:导出为纯文本格式,方便粘贴到其他工具中。" }, "export-failure": { "question": "导出失败或样式错乱怎么办?", diff --git a/src/utils/export.ts b/src/utils/export.ts index 6f40c36c..2fe483cb 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -1,4 +1,5 @@ import { toast } from "sonner"; +import type { jsPDF as JsPDF } from "jspdf"; import { PDF_EXPORT_CONFIG } from "@/config"; import { getFontFaceCss, normalizeFontFamily } from "@/utils/fonts"; import { ResumeData } from "@/types/resume"; @@ -112,8 +113,8 @@ const LONG_PAGE_HEIGHT_BUFFER_MM = 2; const LONG_PAGE_CAPTURE_BUFFER_PX = 8; const LONG_PAGE_BOTTOM_SAFE_AREA_PX = 8; -const keepOnlyFirstPage = (pdf: jsPDF) => { - const pdfWithPageControl = pdf as jsPDF & { +const keepOnlyFirstPage = (pdf: JsPDF) => { + const pdfWithPageControl = pdf as JsPDF & { getNumberOfPages?: () => number; deletePage?: (pageNumber: number) => void; }; @@ -241,19 +242,19 @@ const waitForImages = async (element: HTMLElement) => { ); }; -export const exportToLongPagePdf = async ({ +interface LongPageCapture { + container: HTMLDivElement; + clonedElement: HTMLElement; + contentWidthPx: number; + contentHeightPx: number; + pageHeightMm: number; +} + +const prepareLongPageCapture = async ({ elementId, - title, pagePadding, - fontFamily, - onStart, - onEnd, - successMessage, - errorMessage -}: ExportToPdfOptions) => { - const exportStartTime = performance.now(); - onStart?.(); - + fontFamily +}: Pick): Promise => { let container: HTMLDivElement | null = null; try { @@ -316,27 +317,91 @@ export const exportToLongPagePdf = async ({ contentHeightPx * (A4_WIDTH_MM / contentWidthPx) + LONG_PAGE_HEIGHT_BUFFER_MM, 1 ); - const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ - import("html2canvas"), - import("jspdf") - ]); - const fileName = `${getSafeFileName(title)}.pdf`; - const canvas = await html2canvas(clonedElement, { - scale: 2, - useCORS: true, - allowTaint: true, - backgroundColor: "#ffffff", - scrollX: 0, - scrollY: 0, - windowWidth: Math.ceil(contentWidthPx), - windowHeight: Math.ceil(contentHeightPx + LONG_PAGE_CAPTURE_BUFFER_PX) + return { + container, + clonedElement, + contentWidthPx, + contentHeightPx, + pageHeightMm + }; + } catch (error) { + if (container?.parentNode) { + container.parentNode.removeChild(container); + } + throw error; + } +}; + +const renderLongPageCanvas = async ({ + clonedElement, + contentWidthPx, + contentHeightPx +}: Pick) => { + const { default: html2canvas } = await import("html2canvas"); + + return html2canvas(clonedElement, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: "#ffffff", + scrollX: 0, + scrollY: 0, + windowWidth: Math.ceil(contentWidthPx), + windowHeight: Math.ceil(contentHeightPx + LONG_PAGE_CAPTURE_BUFFER_PX) + }); +}; + +const getCanvasPngBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + try { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("PNG conversion failed")); + return; + } + + resolve(blob); + }, "image/png"); + } catch (error) { + reject(error); + } + }); + +export const exportToLongPagePdf = async ({ + elementId, + title, + pagePadding, + fontFamily, + onStart, + onEnd, + successMessage, + errorMessage +}: ExportToPdfOptions) => { + const exportStartTime = performance.now(); + onStart?.(); + + let capture: LongPageCapture | null = null; + let canvas: HTMLCanvasElement | null = null; + + try { + capture = await prepareLongPageCapture({ + elementId, + pagePadding, + fontFamily }); + const fileName = `${getSafeFileName(title)}.pdf`; + const [{ jsPDF }, renderedCanvas] = await Promise.all([ + import("jspdf"), + renderLongPageCanvas(capture) + ]); + canvas = renderedCanvas; + const imageHeightMm = canvas.height * (A4_WIDTH_MM / canvas.width); const canvasPageHeightMm = Math.max( imageHeightMm + LONG_PAGE_HEIGHT_BUFFER_MM, - pageHeightMm + capture.pageHeightMm ); const pdf = new jsPDF({ unit: "mm", @@ -355,8 +420,57 @@ export const exportToLongPagePdf = async ({ console.error("Long page export error:", error); if (errorMessage) toast.error(errorMessage); } finally { - if (container?.parentNode) { - container.parentNode.removeChild(container); + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (capture?.container.parentNode) { + capture.container.parentNode.removeChild(capture.container); + } + onEnd?.(); + } +}; + +export const exportToLongPageImage = async ({ + elementId, + title, + pagePadding, + fontFamily, + onStart, + onEnd, + successMessage, + errorMessage +}: ExportToPdfOptions) => { + const exportStartTime = performance.now(); + onStart?.(); + + let capture: LongPageCapture | null = null; + let canvas: HTMLCanvasElement | null = null; + + try { + capture = await prepareLongPageCapture({ + elementId, + pagePadding, + fontFamily + }); + + canvas = await renderLongPageCanvas(capture); + const blob = await getCanvasPngBlob(canvas); + const fileName = `${getSafeFileName(title)}.png`; + downloadBlob(blob, fileName); + + if (successMessage) toast.success(successMessage); + console.log(`Total long page image export took ${performance.now() - exportStartTime}ms`); + } catch (error) { + console.error("Long page image export error:", error); + if (errorMessage) toast.error(errorMessage); + } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (capture?.container.parentNode) { + capture.container.parentNode.removeChild(capture.container); } onEnd?.(); }