当前位置: 首页 > news >正文

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缓存的话,轮询是优于长连接的。

  • 注意轮询开启与关闭

开启轮询的时机:

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

关闭轮询的时机:

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

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

http://www.dtcms.com/a/548711.html

相关文章:

  • 20251030在AIO-3576Q38开发板的Android14下确认TF卡
  • 《模仿人类皮肤层与环层小体的社交交互机器人皮肤》2024 IEEE/ASME TMECH 论文解读
  • PHP Laravel 10 框架:使用队列处理异步任务(邮件发送 / 数据导出)
  • 拉丝机东莞网站建设怎样做软件开发
  • 苍山网站建设网站设计分享
  • 一个蛇形填充n×n矩阵的算法
  • git index lockFile exists 问题
  • 一段音频/视频分离成人声与伴奏,Windows + Anaconda 快速跑通 Spleeter(离线可用)
  • RecyclerView设置边缘渐变失效
  • 网站续费模版徐州建设工程网上交易平台
  • [系统架构设计师]云原生架构
  • spec-kit深度解析:AI驱动的规范驱动开发(SDD)的架构、哲学与实践启示
  • 怎么学做网站哈尔滨十大广告公司
  • AI应用开发
  • Rust 中精确大小迭代器(ExactSizeIterator)的深度解析与实践
  • 培训/课程预约小程序开发-提升教育机构的管理效率和用户体验
  • 【Unity基础详解】(3)Unity核心类:GameObject
  • oj题 ——— 单链表oj题
  • 企业网站建设推广含义网站建设开发上线流程
  • Unity-AutoHand插件手势跟踪响应研究
  • 数据结构——三十三、Dijkstra算法(王道408)
  • MQTTX:全能的 MQTT 客户端工具简化物联网开发
  • 抗体芯片技术:超越Western Blot的高通量蛋白分析方案
  • 佛山公司网站建设价格企业查询平台
  • k8s介绍+k8s部署
  • 【H5工具】一个简约高级感渐变海报H5设计工具
  • 未来之窗昭和仙君(四十五)时钟应用场景——东方仙盟筑基期
  • TTL转485电路
  • Milvus知识
  • 实战:动态线程池应对短视频转码百倍流量洪峰