puppeteer 生成pdf,含动态目录,目录带页码

效果如下:目录中带页码,点击目录标题可直接跳转到文档中对应位置。

实现:

JS文件:

javascript 复制代码
// 引入必要的Node.js模块
const express = require('express')          // Express框架,用于创建web服务器
const bodyParser = require('body-parser')   // 解析HTTP请求体
const puppeteer = require('puppeteer')      // 无头浏览器控制库
const { PDFDocument } = require('pdf-lib')  // PDF操作库
const path = require('path')                // 路径处理模块
const cors = require('cors')                // 跨域支持中间件
const axios = require('axios')              // HTTP请求库
const pdfParse = require('pdf-parse')       // PDF解析库
const fs = require('fs')                    // 文件系统模块

// 主函数:生成带目录的PDF
async function generatePDFWithTOC() {
    // 启动Chromium浏览器实例
    const browser = await puppeteer.launch()
    // 创建新页面标签页
    const page = await browser.newPage()

    // 获取模板HTML文件路径(__dirname表示当前脚本所在目录)
    const TEMPLATE_PATH = path.join(__dirname, 'templates/evaluation-report.html')
    // 异步读取HTML模板文件内容(UTF-8编码)
    let html = await require('fs').promises.readFile(TEMPLATE_PATH, 'utf8')

    // 准备动态数据对象(用于替换模板中的占位符)
    let data = {
        currentUser: curEvalResult.username,      // 当前用户名
        time: curEvalResult.time,                // 评估时间
        flightPlanName: curEvalResult.PlanName,  // 飞行计划名称
        basicDes,                                // 基本信息描述
        simCount: curEvalResult.simulatedCount,  // 仿真次数
        windDes,                                 // 风场描述
        tempDes,                                 // 温度描述
    }

    // 使用正则表达式替换HTML模板中的${variable}占位符
    // /\$\{(\w+)\}/g 匹配所有${...}格式的占位符
    let compiledHtml = html.replace(/\$\{(\w+)\}/g, (_, key) => data[key] || '')

    // 将编译后的HTML内容加载到页面中
    // networkidle0表示等待网络空闲(500ms内无请求)
    await page.setContent(compiledHtml, { waitUntil: 'networkidle0' })

    // 生成PDF(第一版,此时目录页码尚未修正)
    let pdf = await page.pdf({
        format: 'A4',                     // 纸张尺寸
        timeout: 500000,                  // 超时时间(500秒)
        displayHeaderFooter: false,       // 不显示默认页眉页脚
        margin: { top: '50px', bottom: '40px' }, // 页边距设置
        printBackground: true,            // 打印背景(保留CSS样式)
    })

    // 使用pdf-parse解析生成的PDF,提取文本内容
    let pdfData = await pdfParse(pdf)

    // 创建章节-页码映射表(二维数组)
    const sectionPageMap = []
    // 初始化当前页码(-3是因为前两页是封面和目录)
    let currentPage = -3

    // 按行遍历PDF文本内容
    pdfData.text.split('\n').forEach((line) => {
        // 使用正则匹配章节标题(如"1. 基本信息"或带数字编号的标题)
        const sectionMatch = line.match(
            /^(?:\d+(?:\.\d+)*)\s+[^\n\r]+$|^(?:1\. 基本信息|2\. 评估结果|3\. 环境描述|4\. 评估检查项说明|5\. 评估检查异常详情说明)$/,
        )
        
        if (sectionMatch) {
            // 如果匹配到章节标题,记录章节ID和当前页码
            const sectionId = sectionMatch[0]
            sectionPageMap.push([sectionId, currentPage])
        }
        
        // 遇到空行时增加页码计数
        if (line.trim() === '') {
            currentPage++
        }
    })

    // 将章节-页码映射表转换为对象数组(当前代码实际未转换,保留原数组)
    const sectionPageObject = sectionPageMap

    // 在页面中执行JavaScript代码(用于更新目录页码)
    await page.evaluate((sectionPageObject) => {
        // 选择所有章节元素(通过ID选择器)
        const sections = document.querySelectorAll(
            '[id^="section-"],[id^="abo-"],[id$="-abo"],[id$="-result"],[id$="-env"]'
        )

        // 遍历每个章节元素
        sections.forEach((section, index) => {
            // 获取对应页码(从映射表中获取)
            const pageNum = sectionPageObject[index][1]
            
            // 查找目录中对应的链接项
            const tocItem = document.querySelector(`a[href="#${section.id}"]`)
            
            // 修改目录项样式和内容
            tocItem.style.display = 'flex'
            tocItem.style.justifyContent = 'space-between'
            
            if (tocItem) {
                // 在目录项右侧添加页码(使用虚线分隔)
                tocItem.innerHTML += `
                    <div style="flex-grow: 1; margin: 0 10px; overflow: hidden; white-space: nowrap;">
                        ------------------------------------------------------------------
                    </div>
                    <span style="float:right; padding-right:20px">${pageNum}</span>`
            }
        })
    }, sectionPageObject)

    // 重新生成PDF(此时目录已包含正确页码)
    pdf = await page.pdf({
        format: 'A4',
        timeout: 500000,
        displayHeaderFooter: false,
        margin: { top: '50px', bottom: '40px' },
        printBackground: true,
    })

    // 使用pdf-lib进行PDF最终处理
    const pdfDoc = await PDFDocument.load(pdf)
    // 嵌入Helvetica字体(用于页码标注)
    const helveticaFont = await pdfDoc.embedFont('Helvetica')

    // 遍历所有PDF页面
    pdfDoc.getPages().forEach((page, index) => {
        // 跳过封面和目录页(前两页)
        if (index >= 2) {
            // 获取页面宽度
            const { width } = page.getSize()
            // 在页面底部中央添加页码(格式如"- 1 -")
            page.drawText(`- ${index - 1} -`, {
                x: width / 2 - 15,  // 水平居中(减去文本宽度的一半)
                y: 25,              // 距底部25像素
                size: 10,           // 字体大小
                font: helveticaFont, // 使用Helvetica字体
            })
        }
    })
    
    // 返回处理后的PDF二进制数据
    return await pdfDoc.save()
}

// 调用主函数(示例调用)
generatePDFWithTOC()

关键流程说明:

模板处理阶段​​:

1.读取HTML模板文件

2.使用动态数据替换模板中的占位符(如${currentUser})

​​首次PDF生成​​:

1.通过Puppeteer将HTML渲染为PDF

2.此时目录页码是临时的

​​页码分析阶段​​:

1.解析PDF文本内容

2.通过正则匹配识别章节标题

3.建立章节标题与实际页码的映射关系

目录更新阶段​​:

1.在浏览器环境中动态修改目录DOM

2.为每个目录项添加正确的页码

最终处理阶段​​:

1.重新生成包含正确页码的PDF

2.使用pdf-lib添加页面底部页码标记

3.返回最终的PDF二进制数据

注意事项:

1.正则表达式/^(?:\d+(?:\.\d+)*)\s+[^\n\r]+$/用于匹配数字编号的标题(如"1.2.3 标题")2.page.evaluate()是在浏览器上下文中执行的代码,不能直接访问Node.js变量

3.页码计算从-3开始是为了补偿封面和目录页的偏移

4.实际使用时需要确保curEvalResult等数据对象已正确定义

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <style>
      body {
        margin:0 2cm;
        font-family:"FangSong";
      font-size: 14pt;
        line-height: 1.5;
      }
.cover-page {
  height: 100vh;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      text-align: center;
      page-break-after: always;
    }
    .main-title {
      font-size: 26pt;
    }
    .main-title:first-child {
      margin-top: 9.5cm;
    }
    .footer {
      margin-bottom: 5.5cm;
    }
      h1 {
        font-family: "SimSun";
        font-size: 22pt;
      }
      h2 {
        font-family: "SimSun";
        font-size: 18pt;
      }
      p{
        text-indent: 2em;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin-top: 20px;
      }
      caption{
        font-family: "SimSun";
      }

      th,
      td {
        border: 1px solid #ddd;
        padding: 8px;
        text-align: center;
      }
      .table-notes{
        font-size: 0.8em;
         line-height: 1.1;
      }
      .table-notes p {
        margin: 0;
        text-indent: 0;
      }
      a[href^="#"] {
  color: #0066cc;
  text-decoration: none;
  white-space: nowrap;
}


h1[id] {
  text-align: left;
}

  </style>
</head>
<body>
 
  <div class="cover-page" >
    <div>
      <div class="main-title">PBN飞行程序安全评估系统</div>
      <div class="main-title">
        飞行计划验证评估报告
      </div>
    </div>
    
    <div class="footer">
      <div>
        验证用户:${currentUser}
        </div>
      <div class="sub-title">
        报告时间:${time}
      </div>
    </div>
  </div>

  <div class="toc-page" style="page-break-after: always;">
    <h1 style="text-align:center; font-family:SimSun; font-size:22pt;">目录</h1>
    <ul id="toc-list" style="list-style-type:none; padding-left:0; font-family:FangSong; font-size:14pt;"></ul>
  </div>

  <div class="content-page">

  <h1 id="section-basic">1. 基本信息</h1>
    <p>本次飞行计划安全评估的飞行计划名称为:${flightPlanName}。</p>
    <p>涵盖飞行程序如下:</p>
    <p>${basicDes}</p>
    <p>本次评估过程共执行仿真${simCount}次,具体环境设置详见环境描述章节。</p>

  <h1 id="section-result">2. 评估结果</h1>

//......
    </div>
    <script>
      document.addEventListener('DOMContentLoaded', async() => {
    // 1. 获取所有需要编入目录的标题
    const sections = [
      { id: 'section-basic', title: '1. 基本信息' },
      { id: 'section-result', title: '2. 评估结果' }
    ];

sections.push({ id: 'section-env', title: '3. 环境描述' },
      { id: 'one-env', title: '&nbsp;3.1 风场描述' },
      { id: 'two-env', title: '&nbsp;3.2 温度描述' },
      { id: 'section-check', title: '4. 评估检查项说明' },
      { id: 'section-detail', title: '5. 评估检查异常详情说明' });

  // 计算每部分起始页码(正文从第3页开始)
    let pageCounter = 3;
    const tocList = document.getElementById('toc-list');
    
    sections.forEach(section => {
        const element = document.getElementById(section.id);
        if (element) {
            const rect = element.getBoundingClientRect();
            const pageNum = Math.floor(rect.top / 1123) + pageCounter; // 1123为A4页高度
            
            const li = document.createElement('li');
            li.style.margin = '10px 0';
            li.innerHTML = `
                <a href="#${section.id}" style="color:#0066cc; text-decoration:none;">
                    ${section.title}
                    <span style="float:right;">${pageNum}</span>
                </a>
            `;
            tocList.appendChild(li);
        }
    });
  });
    </script>
</body>
</html>

关键技术点​

动态数据绑定​ :

1.使用${variable}占位符,后期通过JavaScript替换

相关推荐
ydjs678899-bot17 小时前
如何在Java中打印pdf静默打印和打印对话框选择
java·python·pdf
SamChan9018 小时前
PDF 翻译后 1,250 变成 1.250,是错了吗?用 Python 核对数字格式
python·pdf·ocr·数据校验·文档翻译
开开心心就好21 小时前
打印机卡住打不了?一键清理卡死打印队列
服务器·开发语言·算法·pdf·ocr·idea·visual studio code
打工仔折腾 AI2 天前
在线PDF工具隐私焦虑?把BentoPDF部署到极空间NAS本地处理
人工智能·后端·python·pdf·ai agent 实战
霸道流氓气质2 天前
AI 简历制作平台:结构化输出解析、多模板渲染与 PDF 导出的 Vue3 + Spring AI 全栈实战
人工智能·spring·pdf
开开心心_Every2 天前
黄金价格软件国内外价格实时监控超方便
运维·服务器·游戏·智能手机·pdf·lua·perl
JasonSJX2 天前
DRM-X 5.0 PDF 保护更新:Windows 与 Mac 打印保留用户水印,ZJGet iOS 版支持 PDF 阅读
macos·pdf
shmily麻瓜小菜鸡3 天前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
ComPDFKit3 天前
PDF 涂黑后还能复制?用 ComPDF 检查敏感文字是否真正删除
pdf
飞塔老梅子3 天前
21. 按页数或章节分割大PDF文件 ❀ 老梅子学AI
pdf·分割·basic·pdfsam·裁切