PDF文档导出分页功能实现
天呢这个功能写了好久…
需求是页面上有一个简历预览,然后这个简历要能导出pdf。前端用的是react和typescript,最开始想用react-pdf来做简历导出,光中文字体就调了老半天(见我另一篇博客【已解决】@react-pdf/renderer 导出PDF时发生错误: Error: Could not resolve font for NotoSansSC, fontWeight 400)
react-pdf确实能成功导出pdf,但问题是
- 需要重新写一遍导出的pdf的样式
- pdf分页的时候会自动截断,就会出现一行字上面一半下面一半的情况
- 分页我又写了好久,根据每行的高度和页面高度估算一页能放多少行、如果截断点在一个模块中间的话要怎么划分…最后分页倒是成功了,但pdf中间会出现一个空白页,应该是哪个container在上一页被撑爆了,这个问题最后我也没解决。
最后参考了magic-resume这个repo,它是抽取本地的css和html,然后发送给托管在腾讯云上的后端服务来渲染成pdf。
magic-resume的PdfExporter.tsx文件:
"use client";
import React, { useState, useRef } from "react";
import { useTranslations } from "next-intl";
import {Download,Loader2,FileJson,Printer,ChevronDown
} from "lucide-react";
import { toast } from "sonner";
import { useResumeStore } from "@/store/useResumeStore";
import { Button } from "@/components/ui/button";
import { PDF_EXPORT_CONFIG } from "@/config";
import {DropdownMenu,DropdownMenuContent,DropdownMenuItem,DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";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;if (styleCache.has(ruleText)) return false;styleCache.set(ruleText, true);if (rule instanceof CSSFontFaceRule) 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;
};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<void>((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`);
};const PdfExport = () => {const [isExporting, setIsExporting] = useState(false);const [isExportingJson, setIsExportingJson] = useState(false);const { activeResume } = useResumeStore();const { globalSettings = {}, title } = activeResume || {};const t = useTranslations("pdfExport");const printFrameRef = useRef<HTMLIFrameElement>(null);const handleExport = async () => {const exportStartTime = performance.now();setIsExporting(true);try {const pdfElement = document.querySelector<HTMLElement>("#resume-preview");if (!pdfElement) {throw new Error("PDF element not found");}const clonedElement = pdfElement.cloneNode(true) as HTMLElement;const pageBreakLines =clonedElement.querySelectorAll<HTMLElement>(".page-break-line");pageBreakLines.forEach((line) => {line.style.display = "none";});const [styles] = await Promise.all([getOptimizedStyles(),optimizeImages(clonedElement)]);const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, {method: "POST",headers: {"Content-Type": "application/json"},body: JSON.stringify({content: clonedElement.outerHTML,styles,margin: globalSettings.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 url = window.URL.createObjectURL(blob);const link = document.createElement("a");link.href = url;link.download = `${title}.pdf`;link.click();window.URL.revokeObjectURL(url);console.log(`Total export took ${performance.now() - exportStartTime}ms`);toast.success(t("toast.success"));} catch (error) {console.error("Export error:", error);toast.error(t("toast.error"));} finally {setIsExporting(false);}};const handleJsonExport = () => {try {setIsExportingJson(true);if (!activeResume) {throw new Error("No active resume");}const jsonStr = JSON.stringify(activeResume, null, 2);const blob = new Blob([jsonStr], { type: "application/json" });const url = window.URL.createObjectURL(blob);const link = document.createElement("a");link.href = url;link.download = `${title}.json`;link.click();window.URL.revokeObjectURL(url);toast.success(t("toast.jsonSuccess"));} catch (error) {console.error("JSON export error:", error);toast.error(t("toast.jsonError"));} finally {setIsExportingJson(false);}};const handlePrint = () => {if (!printFrameRef.current) {console.error("Print frame not found");return;}const resumeContent = document.getElementById("resume-preview");if (!resumeContent) {console.error("Resume content not found");return;}const actualContent = resumeContent.parentElement;if (!actualContent) {console.error("Actual content not found");return;}console.log("Found content:", actualContent);const pagePadding = globalSettings?.pagePadding;const iframeWindow = printFrameRef.current.contentWindow;if (!iframeWindow) {console.error("IFrame window not found");return;}try {iframeWindow.document.open();const htmlContent = `<!DOCTYPE html><html><head><title>Print Resume</title><style>@font-face {font-family: "MiSans VF";src: url("/fonts/MiSans-VF.ttf") format("woff2");font-weight: normal;font-style: normal;font-display: swap;}@page {size: A4;margin: ${pagePadding}px;padding: 0;}* {box-sizing: border-box;}html, body {margin: 0;padding: 0;width: 100%;background: white;}body {font-family: sans-serif;-webkit-print-color-adjust: exact;print-color-adjust: exact;}#resume-preview {padding: 0 !important;margin: 0 !important;font-family: "MiSans VF", sans-serif !important;}#print-content {width: 210mm;min-height: 297mm;margin: 0 auto;padding: 0;background: white;box-shadow: none;}#print-content * {box-shadow: none !important;transform: none !important;scale: 1 !important;}.scale-90 {transform: none !important;}.page-break-line {display: none;}${Array.from(document.styleSheets).map((sheet) => {try {return Array.from(sheet.cssRules).map((rule) => rule.cssText).join("\n");} catch (e) {console.warn("Could not copy styles from sheet:", e);return "";}}).join("\n")}</style></head><body><div id="print-content">${actualContent.innerHTML}</div></body></html>`;iframeWindow.document.write(htmlContent);iframeWindow.document.close();setTimeout(() => {try {iframeWindow.focus();iframeWindow.print();} catch (error) {console.error("Error print:", error);}}, 1000);} catch (error) {console.error("Error setting up print:", error);}};const isLoading = isExporting || isExportingJson;const loadingText = isExporting? t("button.exporting"): isExportingJson? t("button.exportingJson"): "";return (<><DropdownMenu><DropdownMenuTrigger asChild><ButtonclassName="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2disabled:opacity-50 disabled:cursor-not-allowed"disabled={isLoading}>{isLoading ? (<><Loader2 className="w-4 h-4 animate-spin" /><span>{loadingText}</span></>) : (<><Download className="w-4 h-4" /><span>{t("button.export")}</span><ChevronDown className="w-4 h-4 ml-1" /></>)}</Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onClick={handleExport} disabled={isLoading}><Download className="w-4 h-4 mr-2" />{t("button.exportPdf")}</DropdownMenuItem><DropdownMenuItem onClick={handlePrint} disabled={isLoading}><Printer className="w-4 h-4 mr-2" />{t("button.print")}</DropdownMenuItem><DropdownMenuItem onClick={handleJsonExport} disabled={isLoading}><FileJson className="w-4 h-4 mr-2" />{t("button.exportJson")}</DropdownMenuItem></DropdownMenuContent></DropdownMenu><iframeref={printFrameRef}style={{position: "absolute",width: "210mm",height: "297mm",visibility: "hidden",zIndex: -1}}title="Print Frame"/></>);
};export default PdfExport;
配置文件:
export const PDF_EXPORT_CONFIG = {SERVER_URL:"https://1255612844-0z3iovadu8.ap-chengdu.tencentscf.com/generate-pdf",TIMEOUT: 30000, // 30秒超时MAX_RETRY: 3 // 最大重试次数
} as const;
这个后端服务没有开源,但是是用的puppeteer pdf,我就自(a)己(i)写了一个
import express from "express";
import cors from "cors";
import puppeteer from "puppeteer";const app = express();
const PORT = process.env.PDF_SERVER_PORT ? Number(process.env.PDF_SERVER_PORT) : 3333;app.use(cors());
app.use(express.json({ limit: "15mb" }));app.get("/healthz", (_req, res) => {res.json({ ok: true });
});app.post("/generate-pdf", async (req, res) => {const { content, styles, margin = "10mm" } = req.body || {};if (!content) {return res.status(400).json({ error: "Missing content" });}let browser;try {browser = await puppeteer.launch({headless: "new",args: ["--no-sandbox","--disable-setuid-sandbox","--font-render-hinting=medium","--disable-dev-shm-usage"]});const page = await browser.newPage();const html = `
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><style>${styles || ""}</style><style>/* 基础打印修饰,确保 A4 页面尺寸 */@page { size: A4; margin: ${margin}; }html, body { background: #fff; }</style></head><body>${content}</body>
</html>`;await page.setContent(html, { waitUntil: "networkidle0" });const pdf = await page.pdf({format: "A4",margin: { top: margin, bottom: margin, left: margin, right: margin },printBackground: true});// 确保以 Buffer 形式发送,避免传输过程被错误编码const pdfBuffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf);res.setHeader("Content-Type", "application/pdf");res.setHeader("Content-Disposition", `attachment; filename="resume.pdf"`);res.setHeader("Content-Length", String(pdfBuffer.length));return res.status(200).end(pdfBuffer);} catch (err) {console.error("PDF generation error:", err);return res.status(500).json({ error: "PDF generation failed", detail: String(err?.message || err) });} finally {if (browser) {try {await browser.close();} catch {}}}
});app.listen(PORT, () => {console.log(`[pdf-server] listening on http://localhost:${PORT}`);
});
很好用!完美解决分页问题!
