PDF导出服务

PDF导出服务

在前端开发中,经常遇到pdf导出的需求,处理这类需求能实现的方式也很多,包括前端导出,服务端导出。

由于有的内容过多,导出时间过长,不适宜前端导出,所以一般还是选择服务端导出,然后通知客户端导出结果。

一、前端导出

前端导出的话,适用于导出内容少,导出内容简单。这里就简单介绍下,主角不是他:

  • jsPDF

jsPDF 结合 html2canvas 来将 HTML 页面或其部分转换为画布,然后将该画布作为图像添加到 PDF 中

import { jsPDF } from "jspdf";
import html2canvas from 'html2canvas';

function exportPDF() {
  const element = document.getElementById('content-to-print'); // 要导出的HTML元素

  html2canvas(element).then(canvas => {
    const imgData = canvas.toDataURL('image/png');
    const pdf = new jsPDF();
    const imgProps = pdf.getImageProperties(imgData);
    const pdfWidth = pdf.internal.pageSize.getWidth();
    const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;

    pdf.addImage(imgData, 'PNG', 0, 0, pdfWidth, pdfHeight);
    pdf.save("download.pdf");
  });
}
  • PDFMake

PDFMake 用来创建复杂的 PDF 文档,包括表格、列表、字体样式等。基本是定制PDF每一项内容,适用于需要精细化配置到每一项的场景,使用不便利

import pdfMake from 'pdfmake/build/pdfmake';
import pdfFonts from 'pdfmake/build/vfs_fonts';
pdfMake.vfs = pdfFonts.pdfMake.vfs;

function createPdf() {
  const docDefinition = {
    content: [
      'First paragraph',
      'Another paragraph, this time a little bit longer to make sure, this line will be divided into at least two lines'
    ]
  };

  pdfMake.createPdf(docDefinition).download('document.pdf');
}

二、Puppeteer + Node

Puppeteer 是一个 Node.js 库,它提供了一个高级 API 来控制无头 Chrome 或 Chromium 浏览器。你可以用它来加载网页并直接导出为 PDF。

本文案例就是使用这种方式实现一个通用的PDF导出方案,下面介绍一些优点,解决了传统服务端导出PDF的哪些痛点:

  • 优点:
1. 需要导出的内容只需提供一个可访问的链接,这样能由前端维护导出模板,可利用前端项目中封装的公用组件,主题等
2. 服务可独立部署,与API接口服务分离开,不会影响其运行
3. 可直接预览导出效果,和导出效果一致
......
  • 解决了传统服务端导出PDF的缺陷:
前端写html模板给后端,后端套数据,CSS3的很多特性无法使用,Echarts还得用node版本的,会让pdf导出的内容主题风格与客户端不一致,组件风格也不一致,开发周期还长
1. 对接困难,后端拿到html后无法快速接入数据
2. 需求无法完全实现,本身后端去写样式这些就难为他们
3. 一个服务下,运行时可能吃内存占用,CPU计算之类,影响API服务质量
......
  • 路由

我设计PDF导出服务包含2个路由,一个预览,一个生成:

/**
 * @description: pdf生成
 * @author: mySkey
 */
router.get("/pdf/create", async (ctx) => {
  const query = ctx.request.query || {};
  const pdf_url = query?.pdf_url;
  const pdfRes = await getPdf(pdf_url);
  ctx.body = pdfRes;
});

/**
 * @description: pdf预览
 * @author: mySkey
 */
router.get("/pdf/preview", async (ctx) => {
  const query = ctx.request.query || {};
  const pdf_url = query?.pdf_url;
  const pdfRes = await getPdf(pdf_url);
  if (pdfRes["code"] === 200) {
    ctx.set("content-type", "application/pdf");
    ctx.body = pdfRes?.file;
  }
  if (pdfRes["code"] === 500) {
    ctx.body = pdfRes;
  }
});
  • 利用 Puppeteer 生成PDF的逻辑

内置一些类名,当html中的dom带上(如:pdf-header)类名时,算内置的页眉、页脚、成功、失败之类的捕获,用于回调给API服务

const puppeteer = require("puppeteer");
const lodash = require("lodash");

/** 类名前缀 */
const prefix = "pdf-";

const pdfTemplateClassEnum = {
  /** 页眉 */
  header: `${prefix}header`,
  /** 页脚 */
  footer: `${prefix}footer`,
  /** 页面加载接口成功 */
  success: `${prefix}success`,
  /** 页面加载接口失败 */
  error: `${prefix}error`,
  /** 页面加载接口失败信息 */
  errorText: `${prefix}error-text`,
};

/**
 * @description: 页眉页脚
 * @author: mySkey
 * @param {*} page
 * @param {*} className
 */
async function getTemplate(page, className) {
  let element = {
    outerHTML: undefined,
    offsetHeight: 0,
    innerText: "",
  };

  try {
    element = await page.$eval(`.${className}`, (element) => {
      const outerHTML = element?.outerHTML;
      const offsetHeight = element?.offsetHeight;
      const innerText = element?.innerText;
      element.style.display = "none";
      return {
        outerHTML,
        offsetHeight,
        innerText,
      };
    });
  } catch (e) {}

  return element;
}

/**
 * @description: 获取pdf状态
 * @author: mySkey
 * @param {*} page
 */
async function getPdfStatusRes(page) {
  const response = {
    code: 200,
    message: "success",
  };

  const pdfError = await getTemplate(page, pdfTemplateClassEnum.error);
  if (!lodash.isEmpty(pdfError?.outerHTML)) {
    const pdfErrorText = await getTemplate(
      page,
      pdfTemplateClassEnum.errorText
    );
    const { innerText = "" } = pdfErrorText;
    response["code"] = 500;
    response["message"] = innerText;
  }
  return response;
}

/**
 * @description: 生成pdf文件二进制流
 * @author: mySkey
 * @param {*} pdf_url
 */
async function getPdf(pdf_url) {
  let response = {};
  const browser = await puppeteer.launch({
    timeout: 0,
    args: ["--no-sandbox", "--disable-setuid-sandbox"],
  });
  const page = await browser.newPage();
  await page.goto(pdf_url, {
    waitUntil: [
      "load", //等待 “load” 事件触发
      "domcontentloaded", //等待 “domcontentloaded” 事件触发
      "networkidle0", //在 500ms 内没有任何网络连接
      "networkidle2", //在 500ms 内网络连接个数不超过 2 个
    ],
    timeout: 0,
  });

  const pdfStatusRes = await getPdfStatusRes(page);

  if (pdfStatusRes["code"] === 500) {
    response = pdfStatusRes;
  }

  if (pdfStatusRes["code"] === 200) {
    const file = await getPdfFile(page);
    response = {
      ...pdfStatusRes,
      file,
    };
  }

  await browser.close();

  return response;
}

/**
 * @description: 获取pdf文件
 * @author: mySkey
 * @param {*} pdf_url
 */
async function getPdfFile(page) {
  const { outerHTML: headerTemplate, offsetHeight: headerHeight } = await getTemplate(page, pdfTemplateClassEnum.header);
  const { outerHTML: footerTemplate, offsetHeight: footerHeight } = await getTemplate(page, pdfTemplateClassEnum.footer);

  const pdf = await page.pdf({
    timeout: 0,
    format: "A4",
    scale: 0.9,
    printBackground: true,
    margin: {
      top: `${20 + headerHeight}px`,
      bottom: `${30 + footerHeight}px`,
    },
    displayHeaderFooter: true,
    headerTemplate,
    footerTemplate,
  });

  return pdf;
}

module.exports = { getPdf: getPdf };

三、docker服务部署

  • 部署到docker

https://github.com/puppeteer/puppeteer/blob/main/docs/guides/docker.md

现在puppeteer的镜像已经包含了Chrome,所以我们只需要这个镜像,再把Node服务融合在一起就ok了

以下文件可参考,具体按实际情况:

# 构建阶段
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 生产阶段
FROM ghcr.io/puppeteer/puppeteer:latest AS production
WORKDIR /app
ENV NODE_ENV production
  • 字体文件

由于字体是有版权的,还是放置字体文件在镜像中,导出时都使用自己的字体

四、书写导出pdf模板

要想PDF导出的文件优雅,还是要注意一些东西。

模板中第一个类名为 pdf-header (页眉)、pdf-footer(页脚),在页眉、页脚中,你可以使用一些变量,使用类名即可如下面使用日期 ,页眉页脚上请使用内联样式

  • 1、页眉

请使用内联样式,即style上写,插入图片请使用base64地址

<div class="pdf-header" style="width: 100vw; padding: 0 10px; display: flex; justify-content: space-between; font-size: 16px;">
  <div>
    <img style="width: 20px; height: 20px;" src="data:image/png;base64," >
  </div>
  <div><span class="date"></span></div>
</div>

页眉参数

date 格式化的日期
title 网页标题
url 网页地址
pageNumber 当前页码
totalPages 总页数
  • 2、页脚

请使用内联样式,即style上写

<div class="pdf-footer" style="width: 100vw; padding: 0 10px; display: flex; justify-content: space-between; font-size: 16px;">
  <div></div>
  <div><span class="pageNumber"></span>/<span class="totalPages"></span></div>
</div>
  • 3、插入分页符
.page-break-after {
  page-break-after: always;
}
// 需要插入分页符的地方
<div class="page-break-after"></div>
  • 4、某版块不分页隔断
.page-break-inside {
	page-break-inside: avoid;
}
  • 5、超链接

超链接请使用a标签,onCLick跳转的无法在pdf上跳转

<a href="">超链接</a>

五、导出流程

客户端发起导出请求 -> API服务接收后生成导出记录,导出记录包含请求参数,向PDF导出服务发起导出请求 -> PDF导出服务接收到导出请求后,开始导出,根据结果回调给API服务。

20251024173157

  • 为什么用轮询?

导出任务还是很轻量级的,选择轮询其实对服务器是压力最小的方式,尤其是服务端有索引优化,有redis缓存的话,轮询是优于长连接的。

  • 注意轮询开启与关闭

开启轮询的时机:

开始导出 -> 开启轮询
为应对刷新浏览器,初始化时获取通知消息列表,通知消息中存在导出中的数据 -> 开启轮询

关闭轮询的时机:

通知消息队列中全部导出状态:成功或失败 -> 关闭轮询

甚至通知消息有已读、未读状态,那么就根据自己的业务调整逻辑

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值