PDF文档导出分页功能实现

天呢这个功能写了好久...

需求是页面上有一个简历预览,然后这个简历要能导出pdf。前端用的是react和typescript,最开始想用react-pdf来做简历导出,光中文字体就调了老半天(见我另一篇博客【已解决】@react-pdf/renderer 导出PDF时发生错误: Error: Could not resolve font for NotoSansSC, fontWeight 400

react-pdf确实能成功导出pdf,但问题是

  1. 需要重新写一遍导出的pdf的样式
  2. pdf分页的时候会自动截断,就会出现一行字上面一半下面一半的情况
  3. 分页我又写了好久,根据每行的高度和页面高度估算一页能放多少行、如果截断点在一个模块中间的话要怎么划分...最后分页倒是成功了,但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}`);
});

很好用!完美解决分页问题!

相关推荐
puyaCheer2 小时前
Android 打开 在线 pdf 文件
android·pdf
格鸰爱童话4 小时前
next.js(二)——从react到next.js
前端·javascript·react.js
开开心心_Every5 小时前
专业视频修复软件,简单操作效果好
学习·elasticsearch·pdf·excel·音视频·memcache·1024程序员节
im_AMBER7 小时前
Vite + React 项目启动深度踩坑指南
前端·学习·react.js·前端框架
Hammer Ray7 小时前
前端开发基础概念(React)
前端·react.js·前端框架
Sheldon一蓑烟雨任平生7 小时前
webpack 从零构建 Vue3
webpack·typescript·vue3·webpack配置·从零构建vue3
漠月瑾-西安12 小时前
React 组件二次封装实践:解决自定义 Props 传递导致的 DOM 警告问题
typescript·ant design·react hooks·react组件封装
im_AMBER12 小时前
React 18
前端·javascript·笔记·学习·react.js·前端框架
2501_9071368213 小时前
发票识别工具,支持xml、pdf、ofd文件
xml·pdf·软件需求