天呢这个功能写了好久...
需求是页面上有一个简历预览,然后这个简历要能导出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文件:
javascript
"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>
<Button
className="px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2
disabled: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>
<iframe
ref={printFrameRef}
style={{
position: "absolute",
width: "210mm",
height: "297mm",
visibility: "hidden",
zIndex: -1
}}
title="Print Frame"
/>
</>
);
};
export default PdfExport;
配置文件:
javascript
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)写了一个
javascript
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}`);
});
很好用!完美解决分页问题!