iTextPDF生成手册时目录页码与实际页码不匹配问题求助

各位CSDN的大佬们,大家好!最近在开发基于iTextPDF的技术手册生成功能时,遇到了一个诡异的页码匹配问题,卡了有一段时间了,特地整理出来向大家求助,希望能得到各位的指点~

先简单介绍下项目背景:我们的系统是通过RocketMQ消费消息触发PDF生成,生成的PDF包含封面、目录和章节内容三部分。核心逻辑是采用"预生成+正式生成"两阶段模式:第一阶段预生成PDF,收集各章节的实际页码和目录页数;第二阶段基于预生成的结果,生成包含正确目录跳转、页眉、页码、水印的最终PDF。技术栈是Java + iTextPDF 7,涉及HTML转PDF、目录生成、页码精准控制等功能。

一、核心问题现象

某本技术手册的章节 4.2.3.7.9 出现目录页码与实际内容页码不匹配的情况,具体表现为:

  • 目录页中,章节4.2.3.7.9对应的页码显示为 4

  • 实际生成的PDF中,章节4.2.3.7.9的内容底部显示的页码为 5,并且与后续的章节4.2.3.7.10处于同一页面中;

  • 其他章节的目录页码与实际页码均匹配正常,仅这一个章节出现异常。

二、相关代码逻辑说明

为了精准控制页码,我设计了两阶段生成逻辑,关键代码模块如下:

1. 预生成阶段(收集页码映射)

在预生成阶段,会先生成一份临时PDF,核心目的是:

  • 收集所有章节的标题对应的实际页码,存入chapterPageMap(key为章节的destination,value为页码);

  • 计算目录本身占用的页数(tocPages),用于正式生成时确定内容起始页的偏移。

预生成时处理章节的核心逻辑:创建章节标题段落(设置了keepTogether和keepWithNext属性,避免标题跨页),添加到文档后,记录标题所在的页码,并存入chapterPageMap。

2. 正式生成阶段(生成最终PDF)

基于预生成得到的chapterPageMap、tocPages等信息,生成最终PDF:

  • 先生成封面、目录(目录中的页码通过chapterPageMap和内容起始页计算得到:相对页码 = 绝对页码 - 内容起始页 + 1);

  • 设置页眉、页码、水印处理器(仅内容页显示页码,从1开始计数);

  • 按照预生成时的顺序和逻辑,生成各章节内容,确保与预生成时的布局一致,从而保证页码匹配。

3. 针对章节4.2.3.7.9的特殊日志

为了排查问题,我在预生成和正式生成阶段都添加了针对章节4.2.3.7.9的详细日志,关键日志信息如下(简化):

复制代码
【目录生成】处理章节4.2.3.7.9目录条目
2026-01-06 16:32:53.087 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】entry.destination: chapter_2007989918047793153
2026-01-06 16:32:53.087 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】chapterPageMap内容: {}
2026-01-06 16:32:53.087 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】章节4.2.3.7.9的绝对页码: null
2026-01-06 16:32:53.254 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 2, 逻辑页码基准: 1, 最终盖在纸上的数字: 2
2026-01-06 16:32:53.254 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=2, 内容起始页=1, 相对页码=2
2026-01-06 16:32:53.723 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 3, 逻辑页码基准: 1, 最终盖在纸上的数字: 3
2026-01-06 16:32:53.723 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=3, 内容起始页=1, 相对页码=3
2026-01-06 16:32:54.088 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 4, 逻辑页码基准: 1, 最终盖在纸上的数字: 4
2026-01-06 16:32:54.088 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=4, 内容起始页=1, 相对页码=4
2026-01-06 16:32:54.590 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 5, 逻辑页码基准: 1, 最终盖在纸上的数字: 5
2026-01-06 16:32:54.590 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=5, 内容起始页=1, 相对页码=5
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成-开始】章节4.2.3.7.9处理开始,当前页码: 7
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成】章节4.2.3.7.9标题: 4.2.3.7.9 目录新增手册测试章节4.2.3.7.9
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成-关键】章节4.2.3.7.9页码记录: 添加前页=7, 添加后页=7, 最终记录页=7
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成】章节4.2.3.7.9的destination: chapter_2007989918047793153
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成】章节4.2.3.7.9已记录到chapterPageMap: chapter_2007989918047793153 -> 7
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成】章节4.2.3.7.9内容处理完成,当前页码: 7
2026-01-06 16:32:54.595 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成-完成】章节4.2.3.7.9全部处理完成,最终页码: 7
2026-01-06 16:32:54.598 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 6, 逻辑页码基准: 1, 最终盖在纸上的数字: 6
2026-01-06 16:32:54.598 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=6, 内容起始页=1, 相对页码=6
2026-01-06 16:32:54.599 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成】检测到标题由于位置不足自动跳转至新页: 4.2.3.7.10 目录新增手册测试章节4.2.3.7.10 | 原页: 7 | 新页: 8
2026-01-06 16:32:54.795 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 7, 逻辑页码基准: 1, 最终盖在纸上的数字: 7
2026-01-06 16:32:54.795 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=7, 内容起始页=1, 相对页码=7
2026-01-06 16:32:54.816 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 8, 逻辑页码基准: 1, 最终盖在纸上的数字: 8
2026-01-06 16:32:54.816 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=8, 内容起始页=1, 相对页码=8
2026-01-06 16:32:54.826 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 9, 逻辑页码基准: 1, 最终盖在纸上的数字: 9
2026-01-06 16:32:54.826 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=9, 内容起始页=1, 相对页码=9
2026-01-06 16:32:54.832 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 10, 逻辑页码基准: 1, 最终盖在纸上的数字: 10
2026-01-06 16:32:54.833 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=10, 内容起始页=1, 相对页码=10
2026-01-06 16:32:54.846 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 11, 逻辑页码基准: 1, 最终盖在纸上的数字: 11
2026-01-06 16:32:54.846 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=11, 内容起始页=1, 相对页码=11
2026-01-06 16:32:54.850 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 12, 逻辑页码基准: 1, 最终盖在纸上的数字: 12
2026-01-06 16:32:54.850 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=12, 内容起始页=1, 相对页码=12
2026-01-06 16:32:55.053 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【预生成结果】总目录页数: 2
2026-01-06 16:32:55.053 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] WARN  c.f.m.s.impl.PdfGenerateConsumer - 封面图片链接为空,将使用文字封面
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】处理章节4.2.3.7.9目录条目
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】entry.destination: chapter_2007989918047793153
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】chapterPageMap内容: {chapter_1998284434654994434=7, chapter_1996022249093914626=4, chapter_2007990025119985666=8, chapter_2007990055444803586=8, chapter_1996022337132355585=5, chapter_1998285002093993985=7, chapter_1996022594205442050=6, chapter_1998284413356318722=7, chapter_1998284484391051266=8, chapter_1998284616322883586=7, chapter_1998284955331698689=7, chapter_1998284977964163073=7, chapter_1996022047100428289=4, chapter_1996022444447817729=5, chapter_2007989846526521345=7, chapter_1998284505370959874=12, chapter_2007989476077203458=8, chapter_2007989893360119810=7, chapter_2007989945084276737=8, chapter_1998284164088832001=12, chapter_1998284138990116866=7, chapter_2007990090337218562=8, chapter_1998284686241931265=7, chapter_2007989820681220097=7, chapter_1998284105481822210=7, chapter_2007990116207685634=8, chapter_1998284662611222530=7, chapter_2007989800179462145=7, chapter_2007989969419628545=8, chapter_2007989918047793153=7, chapter_1996022146253774849=4, chapter_1998284456238882817=7, chapter_2007989869238677505=7, chapter_1998284638921793537=7, chapter_2007989730004561922=7, chapter_2007989755627565058=7, chapter_1998283981540139010=7, chapter_2007989444582174721=8, chapter_2007989778150977537=7}
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】章节4.2.3.7.9的绝对页码: 7
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】章节4.2.3.7.9的相对页码: 4
2026-01-06 16:32:55.058 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【目录生成】章节4.2.3.7.9目录最终显示页码: 4
2026-01-06 16:32:55.060 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - ✅ 目录页数匹配,使用预设的内容起始页码: 4
2026-01-06 16:32:55.062 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 2, 逻辑页码基准: 4, 最终盖在纸上的数字: -1
2026-01-06 16:32:55.223 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 3, 逻辑页码基准: 4, 最终盖在纸上的数字: 0
2026-01-06 16:32:55.223 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 添加章节分页
2026-01-06 16:32:55.398 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 4, 逻辑页码基准: 4, 最终盖在纸上的数字: 1
2026-01-06 16:32:55.398 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=4, 内容起始页=4, 相对页码=1
2026-01-06 16:32:55.398 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 添加章节分页
2026-01-06 16:32:55.609 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 5, 逻辑页码基准: 4, 最终盖在纸上的数字: 2
2026-01-06 16:32:55.610 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=5, 内容起始页=4, 相对页码=2
2026-01-06 16:32:55.610 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 添加章节分页
2026-01-06 16:32:55.611 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成-开始】章节4.2.3.7.9处理开始,当前页码: 7
2026-01-06 16:32:55.611 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】章节4.2.3.7.9标题: 4.2.3.7.9 目录新增手册测试章节4.2.3.7.9
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】内容起始页: 4
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】预生成阶段记录的页码: 7
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】期望的相对页码: 4
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成-关键】章节4.2.3.7.9实际渲染: 添加前页=7, 添加后页=7
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】章节4.2.3.7.9的destination: chapter_2007989918047793153
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】章节4.2.3.7.9实际相对页码: 4
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】章节4.2.3.7.9内容处理完成,当前页码: 7
2026-01-06 16:32:55.612 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成-完成】章节4.2.3.7.9全部处理完成,最终页码: 7
2026-01-06 16:32:55.614 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 6, 逻辑页码基准: 4, 最终盖在纸上的数字: 3
2026-01-06 16:32:55.614 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=6, 内容起始页=4, 相对页码=3
2026-01-06 16:32:55.615 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【正式生成】标题跨页检测: 4.2.3.7.10 目录新增手册测试章节4.2.3.7.10 | 尝试添加页: 7 | 实际渲染页: 8
2026-01-06 16:32:55.699 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 7, 逻辑页码基准: 4, 最终盖在纸上的数字: 4
2026-01-06 16:32:55.699 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=7, 内容起始页=4, 相对页码=4
2026-01-06 16:32:55.704 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 8, 逻辑页码基准: 4, 最终盖在纸上的数字: 5
2026-01-06 16:32:55.704 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=8, 内容起始页=4, 相对页码=5
2026-01-06 16:32:55.709 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 9, 逻辑页码基准: 4, 最终盖在纸上的数字: 6
2026-01-06 16:32:55.709 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=9, 内容起始页=4, 相对页码=6
2026-01-06 16:32:55.714 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 10, 逻辑页码基准: 4, 最终盖在纸上的数字: 7
2026-01-06 16:32:55.714 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=10, 内容起始页=4, 相对页码=7
2026-01-06 16:32:55.717 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - ✅ PDF生成完成,总页数: 12
2026-01-06 16:32:55.719 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 11, 逻辑页码基准: 4, 最终盖在纸上的数字: 8
2026-01-06 16:32:55.719 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=11, 内容起始页=4, 相对页码=8
2026-01-06 16:32:55.721 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 【排查-页码监听】正在处理物理页: 12, 逻辑页码基准: 4, 最终盖在纸上的数字: 9
2026-01-06 16:32:55.721 [ConsumeMessageThread_pdf_export_consumer_group_fixed_1] INFO  c.f.m.s.impl.PdfGenerateConsumer - 页码计算:当前页=12, 内容起始页=4, 相对页码=9

从日志看,预生成时章节4.2.3.7.9的页码被记录为Y,对应目录中的相对页码是4,但正式生成后,该章节实际显示的页码是5。

三、已排查的方向

针对这个问题,我已经排查了以下几个方向,但都没有找到根源:

  1. 检查keepTogether和keepWithNext属性:章节标题段落已经设置了这两个属性,理论上不会出现标题跨页的情况,但不排除该章节内容过短,导致标题和内容被挤压到下一页?

  2. 检查目录页数计算:预生成时计算的tocPages与正式生成时实际的目录页数是否匹配?日志中显示"目录页数匹配",但不排除特殊情况下目录页数有微小偏差?

  3. 检查内容起始页的计算:正式生成时,内容起始页 = 目录起始页 + tocPages,日志中显示内容起始页设置正确,但可能由于添加AreaBreak时的页码偏移导致实际起始页变化?

  4. 检查章节内容的长度:章节4.2.3.7.9的内容是否过短,导致正式生成时,该章节的标题和内容被后续章节(4.2.3.7.10)挤压到同一页,从而导致页码从4变成5?

四、求助问题

想向各位大佬请教以下几个问题:

  1. 这种"预生成记录页码,正式生成使用页码"的方案,是否存在固有的缺陷?比如预生成和正式生成时的布局微小差异,导致页码偏移?

  2. iTextPDF中,keepTogether和keepWithNext属性是否能100%保证标题不跨页?如果章节内容过少,是否会出现标题和下一章内容挤在同一页的情况,从而导致页码变化?

  3. 目录页码的计算逻辑(相对页码 = 绝对页码 - 内容起始页 + 1)是否合理?有没有可能由于内容起始页的计算错误,导致目录页码与实际页码偏差1?

  4. 除了上述方向,还有哪些可能导致这种单章节页码不匹配的情况?比如HTML转PDF时的内容高度计算偏差、字体渲染差异等?

五、完整代码片段

以下是完整的PDF生成消费者代码(已简化部分无关逻辑),核心逻辑集中在preGenerateForPageNumbers(预生成)、generateFinalPdf(正式生成)、processChapterForMapping(预生成处理章节)、processChapterFinal(正式生成处理章节)等方法中:

复制代码
package com.forcartech.manual.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.forcartech.common.core.utils.StringUtils;
import com.forcartech.manual.domain.FkdManualTask;
import com.forcartech.manual.enums.ContentTypeEnum;
import com.forcartech.manual.enums.HasWatermarkEnum;
import com.forcartech.manual.enums.ManualTaskStatusEnum;
import com.forcartech.manual.mapper.FkdManualTaskMapper;
import com.forcartech.manual.model.PdfGenerateMessage;
import com.forcartech.manual.model.vo.FkdManualContentVo;
import com.forcartech.manual.model.vo.FkdChapterContentVo;
import com.forcartech.manual.service.IFkdFileService;
import com.forcartech.manual.service.IFkdTechManualService;
import com.forcartech.manual.utils.HierarchicalNumberComparator;
import com.forcartech.manual.utils.ImageUtils;
import com.itextpdf.html2pdf.ConverterProperties;
import com.itextpdf.html2pdf.HtmlConverter;
import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
import com.itextpdf.io.font.FontProgram;
import com.itextpdf.io.font.FontProgramFactory;
import com.itextpdf.io.font.PdfEncodings;
import com.itextpdf.io.image.ImageData;
import com.itextpdf.io.image.ImageDataFactory;
import com.itextpdf.io.source.ByteArrayOutputStream;
import com.itextpdf.kernel.colors.ColorConstants;
import com.itextpdf.kernel.colors.DeviceRgb;
import com.itextpdf.kernel.events.Event;
import com.itextpdf.kernel.events.IEventHandler;
import com.itextpdf.kernel.events.PdfDocumentEvent;
import com.itextpdf.kernel.font.PdfFont;
import com.itextpdf.kernel.font.PdfFontFactory;
import com.itextpdf.kernel.geom.AffineTransform;
import com.itextpdf.kernel.geom.PageSize;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.kernel.pdf.*;
import com.itextpdf.kernel.pdf.action.PdfAction;
import com.itextpdf.kernel.pdf.canvas.PdfCanvas;
import com.itextpdf.kernel.pdf.canvas.draw.DottedLine;
import com.itextpdf.kernel.pdf.extgstate.PdfExtGState;
import com.itextpdf.kernel.pdf.xobject.PdfFormXObject;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.borders.Border;
import com.itextpdf.layout.element.*;
import com.itextpdf.layout.font.FontProvider;
import com.itextpdf.layout.properties.*;
import com.itextpdf.svg.converter.SvgConverter;
import com.itextpdf.svg.exceptions.SvgProcessingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.*;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;

    /**
     *  PDF生成消费者
     */
    @Component
    @RocketMQMessageListener(
            topic = "pdf_export_topic",
            consumerGroup = "pdf_export_consumer_group_fixed",
            consumeThreadNumber = 5,
            maxReconsumeTimes = 2,
            consumeTimeout = 30,
            suspendCurrentQueueTimeMillis = 5000,
            delayLevelWhenNextConsume = 3
    )
    @Slf4j
    @RequiredArgsConstructor
    public class PdfGenerateConsumer implements RocketMQListener<PdfGenerateMessage> {

        @Value("${pdf.task.expire-minutes:5}")
        private int expireMinutes;

        @Value("${pdf.watermark.text}")
        private String watermarkText;

        @Value("${pdf.styles.css-path:/static/css/manual-pdf-styles.css}")
        private String cssPath;

        @Value("${pdf.styles.custom-css-path:}")
        private String customCssPath;

        @Value("${pdf.header.logo-path:/static/header-logo.svg}")
        private String headerLogoPath;

        private final FkdManualTaskMapper manualTaskMapper;
        private final IFkdTechManualService techManualService;
        private final IFkdFileService fileService;

        private static final float PDF_MARGIN_TOP = 50;
        private static final float PDF_MARGIN_RIGHT = 40;
        private static final float PDF_MARGIN_BOTTOM = 50;
        private static final float PDF_MARGIN_LEFT = 40;

        // 页眉坐标常量
        private static final float HEADER_TOP_OFFSET = 5; // 距离页面顶部偏移(
        private static final float HEADER_LOGO_Y_OFFSET = 20; // Logo在页眉的Y偏移
        private static final float HEADER_TEXT_Y_OFFSET = 15; // 公司名称Y偏移
        // 页码坐标常量
        private static final float PAGE_NUMBER_Y = 30;
        // 目录布局常量
        private static final float TOC_START_Y = 80; // 目录起始Y偏移
        private static final float TOC_BOTTOM_MARGIN = 50;
        // 水印边距常量
        private static final float WATERMARK_PAGE_MARGIN = 80;
        private static final ReentrantLock FONT_CACHE_LOCK = new ReentrantLock();
        private FontProgram cachedFontProgram;
        private String cachedCssStyles;
        private long cssCacheTime = 0;
        private static final long CSS_CACHE_DURATION = 10 * 60 * 1000; // 10分钟

        /**
         *  目录条目类
         */
        private static class TocEntry {
            String title;
            String destination;
            int level;

            TocEntry(String title, String destination, int level) {
                this.title = title;
                this.destination = destination;
                this.level = level;
            }
        }

        /**
         *  RocketMQ消息处理入口
         */
        @Override
        public void onMessage(PdfGenerateMessage message) {
            FkdManualTask task = manualTaskMapper.selectById(message.getTaskId());
            if (task == null) {
                return;
            }

            try {
                task.setTaskStatus(ManualTaskStatusEnum.ING.getCode());
                manualTaskMapper.updateById(task);
                List<FkdManualContentVo> chapters = collectChaptersFixed(message, task);
                if (chapters.isEmpty()) {
                    log.error("没有收集到任何章节,无法生成PDF");
                    task.setTaskStatus(ManualTaskStatusEnum.FAIL.getCode());
                    manualTaskMapper.updateById(task);
                    return;
                }

                ByteArrayOutputStream pdfStream = generatePdf(
                        chapters,
                        message.getHasWatermark(),
                        task.getManualName(),
                        message.getCoverImageUrl()
                );

                if (pdfStream.size() == 0) {
                    throw new RuntimeException("生成的PDF文件为空");
                }

                Long fileId = fileService.insert(pdfStream, task.getManualName() + "_" + System.currentTimeMillis() + ".pdf");

                if (fileId == null) {
                    throw new RuntimeException("文件保存失败");
                }

                task.setFileId(fileId);
                task.setTaskStatus(ManualTaskStatusEnum.BORNED.getCode());
                task.setExpireTime(LocalDateTime.now().plusMinutes(expireMinutes));
                manualTaskMapper.updateById(task);
                log.info("PDF生成成功: taskId={}, fileId={}", task.getTaskId(), fileId);
            } catch (Exception e) {
                log.error("PDF生成失败: taskId={}, error: {}", task.getTaskId(), e.getMessage(), e);
                task.setTaskStatus(ManualTaskStatusEnum.FAIL.getCode());
                manualTaskMapper.updateById(task);
            }
        }

        /**
         *  收集章节数据 - 支持任意章节生成
         */
        private List<FkdManualContentVo> collectChaptersFixed(PdfGenerateMessage message, FkdManualTask task) {
            log.info("开始收集章节: manualId={}, chapterIds={}, versionId={}",
                    message.getManualId(), message.getChapterIds(), task.getVersionId());

            Set<String> processedChapterIds = new HashSet<>();
            List<FkdManualContentVo> allChapters = new ArrayList<>();

            if (message.getChapterIds() == null || message.getChapterIds().isEmpty()) {
                return new ArrayList<>();
            }
            for (Long chapterId : message.getChapterIds()) {
                List<FkdManualContentVo> queryResult = techManualService.queryManualChapterContent(
                        message.getManualId(),
                        chapterId,
                        task.getVersionId()
                );

                if (queryResult == null || queryResult.isEmpty()) {
                    log.warn("未查询到章节内容,chapterId={}", chapterId);
                    continue;
                }
                FkdManualContentVo queryChapter = queryResult.get(0);

                if ("0".equals(queryChapter.getChapterId())) {
                    if (queryChapter.getChildren() != null && !queryChapter.getChildren().isEmpty()) {
                        for (FkdManualContentVo child : queryChapter.getChildren()) {
                            if (!processedChapterIds.contains(child.getChapterId())) {
                                FkdManualContentVo clonedChapter = deepCloneChapter(child);
                                allChapters.add(clonedChapter);
                                markAllSubChaptersAsProcessed(child, processedChapterIds);
                            }
                        }
                    }
                } else {
                    if (!processedChapterIds.contains(queryChapter.getChapterId())) {
                        FkdManualContentVo clonedChapter = deepCloneChapter(queryChapter);
                        allChapters.add(clonedChapter);
                        markAllSubChaptersAsProcessed(queryChapter, processedChapterIds);

                    }
                }
            }

            if (allChapters.isEmpty()) {
                log.warn("通过指定章节ID未收集到章节,尝试获取所有顶层章节");
                allChapters = getAllTopLevelChapters(message.getManualId(), task.getVersionId());
            }

            allChapters.sort(Comparator.comparing(
                    FkdManualContentVo::getChapterNumber,
                    new HierarchicalNumberComparator()
            ));
            return allChapters;
        }

        /**
         *  获取章节层级
         */
        private int getChapterLevel(FkdManualContentVo chapter) {
            if (chapter == null || chapter.getChapterNumber() == null) {
                return 0;
            }
            return chapter.getChapterNumber().split("\\.").length;
        }

        /**
         *  获取所有顶层章节
         */
        private List<FkdManualContentVo> getAllTopLevelChapters(Long manualId, Long versionId) {
            List<FkdManualContentVo> queryResult = techManualService.queryManualChapterContent(
                    manualId,
                    0L,
                    versionId
            );

            if (queryResult == null || queryResult.isEmpty()) {
                log.warn("未查询到根节点");
                return new ArrayList<>();
            }

            FkdManualContentVo root = queryResult.get(0);
            if (root.getChildren() == null || root.getChildren().isEmpty()) {
                log.warn("根节点没有子章节");
                return new ArrayList<>();
            }

            List<FkdManualContentVo> allTopChapters = new ArrayList<>();
            for (FkdManualContentVo child : root.getChildren()) {
                FkdManualContentVo clonedChapter = deepCloneChapter(child);
                allTopChapters.add(clonedChapter);
                log.debug("添加顶层章节: chapterId={}, title={}",
                        child.getChapterId(), child.getChapterTitle());
            }

            allTopChapters.sort(Comparator.comparing(
                    FkdManualContentVo::getChapterNumber,
                    new HierarchicalNumberComparator()
            ));
            return allTopChapters;
        }

        /**
         *  递归标记所有子章节为已处理
         */
        private void markAllSubChaptersAsProcessed(FkdManualContentVo chapter, Set<String> processed) {
            if (chapter == null || chapter.getChapterId() == null) return;
            processed.add(chapter.getChapterId());
            if (chapter.getChildren() != null) {
                for (FkdManualContentVo child : chapter.getChildren()) {
                    markAllSubChaptersAsProcessed(child, processed);
                }
            }
        }

        /**
         *  深拷贝章节
         */
        private FkdManualContentVo deepCloneChapter(FkdManualContentVo original) {
            if (original == null) return null;
            FkdManualContentVo clone = new FkdManualContentVo();
            clone.setManualId(original.getManualId());
            clone.setCarTypeId(original.getCarTypeId());
            clone.setChapterId(original.getChapterId());
            clone.setParentId(original.getParentId());
            clone.setVersionId(original.getVersionId());
            clone.setName(original.getName());
            clone.setManualType(original.getManualType());
            clone.setChapterNumber(original.getChapterNumber());
            clone.setChapterTitle(original.getChapterTitle());
            clone.setChapterHighlight(original.isChapterHighlight());
            clone.setContentDiffs(original.getContentDiffs());

            // 复制章节内容
            if (original.getChapterContent() != null) {
                clone.setChapterContent(new ArrayList<>(original.getChapterContent()));
            }

            // 递归复制子节点
            if (original.getChildren() != null && !original.getChildren().isEmpty()) {
                clone.setChildren(original.getChildren().stream()
                        .map(this::deepCloneChapter)
                        .collect(Collectors.toList()));
            }

            return clone;
        }


        /**
         *  生成PDF主方法
         */
        private ByteArrayOutputStream generatePdf(List<FkdManualContentVo> chapters,
                                                  Integer hasWatermark,
                                                  String manualName, String coverImageUrl) throws IOException {
            String cssStyles = loadCssStyles();
            Set<String> sharedDestinationSet = new HashSet<>();
            PreGenerateResult preGenerateResult = preGenerateForPageNumbers(
                    chapters,
                    manualName,
                    sharedDestinationSet,
                    cssStyles ,
                    coverImageUrl
            );
            Map<String, Integer> chapterPageMap = preGenerateResult.chapterPageMap;
            int tocPagesFromPreGenerate = preGenerateResult.tocPages;
            sharedDestinationSet.clear();
            return generateFinalPdf(
                    chapters,
                    hasWatermark,
                    manualName,
                    chapterPageMap,
                    sharedDestinationSet,
                    preGenerateResult.tocEntries,
                    tocPagesFromPreGenerate,
                    cssStyles  ,
                    coverImageUrl
            );
        }

        /**
         *  第一阶段:预生成PDF,收集各章节的实际页码和目录页数
         */
        private PreGenerateResult preGenerateForPageNumbers(
                List<FkdManualContentVo> chapters,
                String manualName,
                Set<String> destinationSet,
                String cssStyles,
                String coverImageUrl) throws IOException {

            ByteArrayOutputStream tempStream = new ByteArrayOutputStream();
            PdfWriter tempWriter = new PdfWriter(tempStream);
            PdfDocument tempPdfDoc = new PdfDocument(tempWriter);
            Document tempDoc = new Document(tempPdfDoc, PageSize.A4);

            PdfFont font = getCachedPdfFont();
            tempDoc.setFont(font);
            tempDoc.setMargins(PDF_MARGIN_TOP, PDF_MARGIN_RIGHT, PDF_MARGIN_BOTTOM, PDF_MARGIN_LEFT);

            generateCover(tempDoc, manualName, coverImageUrl);
            tempDoc.add(new AreaBreak(AreaBreakType.NEXT_PAGE));

            // --- 修改处 1: 预生成阶段记录目录起始页 ---
            int tocStartPage = tempPdfDoc.getNumberOfPages();

            List<TocEntry> tempTocEntries = new ArrayList<>();
            for (FkdManualContentVo chapter : chapters) {
                collectTocEntriesForPhase(chapter, tempTocEntries, 0, destinationSet);
            }

            // --- 修改处 2: 使用与正式阶段相同的生成逻辑,确保页数计算准确 ---
            // 传入空的 chapterPageMap 仅为了测算目录自身占用的页数
            generateTableOfContents(tempDoc, tempTocEntries, font, new HashMap<>(), 0);

            int tocEndPage = tempPdfDoc.getNumberOfPages();
            int actualTocPages = tocEndPage - tocStartPage + 1;

            tempDoc.add(new AreaBreak(AreaBreakType.NEXT_PAGE));

            Map<String, Integer> chapterPageMap = new HashMap<>();
            generateContentPagesForMapping(tempDoc, tempPdfDoc, chapters, font, destinationSet, chapterPageMap, cssStyles);

            tempDoc.close();

            PreGenerateResult result = new PreGenerateResult();
            result.chapterPageMap = chapterPageMap;
            result.tocPages = actualTocPages;
            result.tocEntries = tempTocEntries;

            log.info("【预生成结果】总目录页数: {}", actualTocPages);
            return result;
        }
        /**
         *  预生成结果类
         */
        private static class PreGenerateResult {
            Map<String, Integer> chapterPageMap;
            int tocPages;
            List<TocEntry> tocEntries;

            PreGenerateResult() {
                this.chapterPageMap = new HashMap<>();
                this.tocPages = 1;
                this.tocEntries = new ArrayList<>();
            }
        }
        private ByteArrayOutputStream generateFinalPdf(List<FkdManualContentVo> chapters,
                                                       Integer hasWatermark,
                                                       String manualName,
                                                       Map<String, Integer> chapterPageMap,
                                                       Set<String> destinationSet,
                                                       List<TocEntry> tocEntries,
                                                       int tocPagesFromPreGenerate,
                                                       String cssStyles,
                                                       String coverImageUrl) throws IOException {

            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            PdfWriter writer = new PdfWriter(outputStream);
            PdfDocument pdfDoc = new PdfDocument(writer);
            Document document = new Document(pdfDoc, PageSize.A4);

            PdfFont font = getCachedPdfFont();
            document.setFont(font);
            // ========== 调整3:修改最终生成阶段的基础边距 ==========
            document.setMargins(PDF_MARGIN_TOP, PDF_MARGIN_RIGHT, PDF_MARGIN_BOTTOM, PDF_MARGIN_LEFT);
            generateCover(document, manualName, coverImageUrl);
            document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
            int beforeTocPage = pdfDoc.getNumberOfPages();
            int tocStartPage = beforeTocPage ;
            int contentStartPage = tocStartPage + tocPagesFromPreGenerate;
            generateTableOfContents(document, tocEntries, font, chapterPageMap, contentStartPage);

            int afterTocPage = pdfDoc.getNumberOfPages();
            int actualTocPages = afterTocPage - beforeTocPage + 1;
            if (actualTocPages != tocPagesFromPreGenerate) {
                log.warn("⚠️ 目录页数不匹配: 第一阶段计算={}, 实际生成={}",
                        tocPagesFromPreGenerate, actualTocPages);
                contentStartPage = afterTocPage + 1;
            } else {
                log.info("✅ 目录页数匹配,使用预设的内容起始页码: {}", contentStartPage);
            }

            HeaderHandler headerHandler = new HeaderHandler(font, contentStartPage, headerLogoPath);
            pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, headerHandler);
            PageNumberHandler pageNumberHandler = new PageNumberHandler(font, contentStartPage);
            pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, pageNumberHandler);
            if (HasWatermarkEnum.HAS.getCode().equals(hasWatermark)) {
                WatermarkHandler watermarkHandler = new WatermarkHandler(watermarkText, font, contentStartPage);
                pdfDoc.addEventHandler(PdfDocumentEvent.END_PAGE, watermarkHandler);
            }
            document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
            int afterBreakPage = pdfDoc.getNumberOfPages();
            if (afterBreakPage != contentStartPage) {
                log.warn("⚠️ 内容起始页码调整: 原计算={}, 实际={}",
                        contentStartPage, afterBreakPage);
                contentStartPage = afterBreakPage;
            }
            generateContentPagesFinal(document, pdfDoc, chapters, font, destinationSet,
                    chapterPageMap, contentStartPage, cssStyles);

            log.info("✅ PDF生成完成,总页数: {}", pdfDoc.getNumberOfPages());
            document.close();
            pdfDoc.close();
            writer.close();
            return outputStream;
        }
        private void generateContentPagesFinal(Document document,
                                               PdfDocument pdfDoc,
                                               List<FkdManualContentVo> chapters,
                                               PdfFont font,
                                               Set<String> destinationSet,
                                               Map<String, Integer> chapterPageMap,
                                               int contentStartPage,String cssStyles) {

            List<FkdManualContentVo> sortedChapters = chapters.stream()
                    .sorted(Comparator.comparing(
                            FkdManualContentVo::getChapterNumber,
                            new HierarchicalNumberComparator()
                    ))
                    .collect(Collectors.toList());

            for (int i = 0; i < sortedChapters.size(); i++) {
                FkdManualContentVo chapter = sortedChapters.get(i);

                try {
                    processChapterFinal(document, pdfDoc, chapter, font, 0, destinationSet,
                            chapterPageMap, contentStartPage, cssStyles);
                } catch (Exception e) {
                    log.error("生成第 {} 个章节失败: {}", i + 1, chapter.getChapterTitle(), e);
                }

                if (i < sortedChapters.size() - 1) {
                    try {
                        document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
                        log.info("添加章节分页");
                    } catch (Exception e) {
                        log.error("添加分页失败", e);
                    }
                }
            }
        }
        private Paragraph createChapterTitle(String title, String destination, PdfFont font, int level) {
            float fontSize = 18f - level * 1.5f;
            if (fontSize < 10f) fontSize = 10f;

            float marginTop = (level == 0) ? 10f : 2f;
            float marginBottom = 4f;

            Paragraph paragraph = new Paragraph(title)
                    .setFont(font)
                    .setFontSize(fontSize)
                    .setBold()
                    .setDestination(destination)
                    .setMarginTop(marginTop)
                    .setMarginBottom(marginBottom)
                    .setMarginLeft(level * 12);

            paragraph.setKeepTogether(true);
            paragraph.setKeepWithNext(true);

            return paragraph;
        }


        /**
         *  处理章节(最终阶段)- 依赖 keepTogether/keepWithNext
         */
        private void processChapterFinal(Document document,
                                         PdfDocument pdfDoc,
                                         FkdManualContentVo chapter,
                                         PdfFont font,
                                         int level,
                                         Set<String> destinationSet,
                                         Map<String, Integer> chapterPageMap,
                                         int contentStartPage,
                                         String cssStyles) {

            String chapterTitle = buildChapterTitle(chapter);
            String destination = generateUniqueDestination(chapter.getChapterId(), destinationSet);

            // 添加章节4.2.3.7.9的特殊日志
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【正式生成-开始】章节4.2.3.7.9处理开始,当前页码: {}", pdfDoc.getNumberOfPages());
                log.info("【正式生成】章节4.2.3.7.9标题: {}", chapterTitle);
                log.info("【正式生成】内容起始页: {}", contentStartPage);

                // 检查预生成阶段记录的页码
                Integer preGeneratedPage = chapterPageMap.get(destination);
                log.info("【正式生成】预生成阶段记录的页码: {}", preGeneratedPage);
                if (preGeneratedPage != null) {
                    int expectedRelativePage = preGeneratedPage - contentStartPage + 1;
                    log.info("【正式生成】期望的相对页码: {}", expectedRelativePage);
                }
            }

            int pageBeforeAdd = pdfDoc.getNumberOfPages();
            Paragraph titlePara = createChapterTitle(chapterTitle, destination, font, level);
            document.add(titlePara);
            int pageAfterAdd = pdfDoc.getNumberOfPages();

            // 专门为章节4.2.3.7.9添加详细日志
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【正式生成-关键】章节4.2.3.7.9实际渲染: 添加前页={}, 添加后页={}",
                        pageBeforeAdd, pageAfterAdd);
                log.info("【正式生成】章节4.2.3.7.9的destination: {}", destination);

                // 计算实际相对页码
                int actualRelativePage = pageAfterAdd - contentStartPage + 1;
                log.info("【正式生成】章节4.2.3.7.9实际相对页码: {}", actualRelativePage);
            }

            // 记录日志:如果添加标题前后页码变了,说明发生了换页
            if (pageBeforeAdd != pageAfterAdd) {
                log.info("【正式生成】标题跨页检测: {} | 尝试添加页: {} | 实际渲染页: {}",
                        chapterTitle, pageBeforeAdd, pageAfterAdd);
            }

            int chapterLevel = getChapterLevel(chapter);
            int finalLevel = Math.max(level, chapterLevel - 1);
            try {
                processChapterContent(document, chapter, cssStyles);

                // 记录章节内容处理后的页码
                if (chapterTitle.contains("4.2.3.7.9")) {
                    log.info("【正式生成】章节4.2.3.7.9内容处理完成,当前页码: {}", pdfDoc.getNumberOfPages());
                }

                if (chapter.getChildren() != null && !chapter.getChildren().isEmpty()) {
                    List<FkdManualContentVo> sortedChildren = chapter.getChildren().stream()
                            .sorted(Comparator.comparing(
                                    FkdManualContentVo::getChapterNumber,
                                    new HierarchicalNumberComparator()
                            ))
                            .collect(Collectors.toList());

                    for (int j = 0; j < sortedChildren.size(); j++) {
                        FkdManualContentVo child = sortedChildren.get(j);
                        processChapterFinal(document, pdfDoc, child, font, finalLevel + 1,
                                destinationSet, chapterPageMap, contentStartPage, cssStyles);
                    }
                }

            } catch (Exception e) {
                log.error("【最终生成】处理章节失败: {}", chapterTitle, e);
                throw e;
            }

            // 最终记录
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【正式生成-完成】章节4.2.3.7.9全部处理完成,最终页码: {}", pdfDoc.getNumberOfPages());
            }
        }
        private void generateTableOfContents(Document document,
                                             List<TocEntry> tocEntries,
                                             PdfFont font,
                                             Map<String, Integer> chapterPageMap,
                                             int contentStartPage) {
            if (tocEntries == null || tocEntries.isEmpty()) return;

            // 目录标题
            Paragraph tocTitle = new Paragraph("目 录")
                    .setFont(font)
                    .setFontSize(18)
                    .setBold()
                    .setTextAlignment(TextAlignment.CENTER)
                    .setMarginBottom(10);
            document.add(tocTitle);

            float effectiveWidth = 520f;

            for (TocEntry entry : tocEntries) {
                // 专门记录章节4.2.3.7.9的目录信息
                if (entry.title.contains("4.2.3.7.9")) {
                    log.info("【目录生成】处理章节4.2.3.7.9目录条目");
                    log.info("【目录生成】entry.destination: {}", entry.destination);
                    log.info("【目录生成】chapterPageMap内容: {}", chapterPageMap);

                    Integer absolutePageNum = chapterPageMap.get(entry.destination);
                    log.info("【目录生成】章节4.2.3.7.9的绝对页码: {}", absolutePageNum);

                    if (absolutePageNum != null) {
                        int relativePageNum = absolutePageNum - contentStartPage + 1;
                        log.info("【目录生成】章节4.2.3.7.9的相对页码: {}", relativePageNum);
                    }
                }

                float fontSize = 12f - entry.level * 0.5f;
                if (fontSize < 9f) fontSize = 9f;

                // 获取页码
                String pageNumText = "";
                if (chapterPageMap != null && chapterPageMap.containsKey(entry.destination)) {
                    int absolutePageNum = chapterPageMap.get(entry.destination);
                    pageNumText = String.valueOf(absolutePageNum - contentStartPage + 1);

                    // 记录章节4.2.3.7.9的最终页码
                    if (entry.title.contains("4.2.3.7.9")) {
                        log.info("【目录生成】章节4.2.3.7.9目录最终显示页码: {}", pageNumText);
                    }
                }

                // 创建段落...(其余代码不变)
                Paragraph p = new Paragraph()
                        .setFont(font)
                        .setFontSize(fontSize)
                        .setMultipliedLeading(1.2f)
                        .setPaddingLeft(entry.level * 20f)
                        .setMarginBottom(4f);

                Text titleText = new Text(entry.title);
                if (entry.destination != null) {
                    titleText.setAction(PdfAction.createGoTo(entry.destination));
                }
                p.add(titleText);

                p.addTabStops(new TabStop(effectiveWidth, TabAlignment.RIGHT, new DottedLine(1f, 2f)));
                p.add(new Tab());

                Text pageText = new Text(pageNumText);
                if (entry.destination != null) {
                    pageText.setAction(PdfAction.createGoTo(entry.destination));
                }
                p.add(pageText);

                document.add(p);
            }
        }
        private void collectTocEntriesForPhase(FkdManualContentVo chapter,
                                               List<TocEntry> tocEntries,
                                               int level,
                                               Set<String> destinationSet) {
            String chapterTitle = buildChapterTitle(chapter);
            String destination = generateUniqueDestination(chapter.getChapterId(), destinationSet);
            int chapterLevel = getChapterLevel(chapter);
            int finalLevel = Math.max(level, chapterLevel - 1);
            tocEntries.add(new TocEntry(chapterTitle, destination, finalLevel));
            if (chapter.getChildren() != null && !chapter.getChildren().isEmpty()) {
                List<FkdManualContentVo> sortedChildren = chapter.getChildren().stream()
                        .sorted(Comparator.comparing(
                                FkdManualContentVo::getChapterNumber,
                                new HierarchicalNumberComparator()
                        ))
                        .collect(Collectors.toList());
                for (FkdManualContentVo child : sortedChildren) {
                    collectTocEntriesForPhase(child, tocEntries, finalLevel + 1, destinationSet);
                }
            }
        }

        /**
         *  专门用于收集页码映射的内容页生成
         */
        private void generateContentPagesForMapping(Document document,
                                                    PdfDocument pdfDoc,
                                                    List<FkdManualContentVo> chapters,
                                                    PdfFont font,
                                                    Set<String> destinationSet,
                                                    Map<String, Integer> chapterPageMap,String cssStyles) {

            List<FkdManualContentVo> sortedChapters = chapters.stream()
                    .sorted(Comparator.comparing(
                            FkdManualContentVo::getChapterNumber,
                            new HierarchicalNumberComparator()
                    ))
                    .collect(Collectors.toList());

            for (int i = 0; i < sortedChapters.size(); i++) {
                FkdManualContentVo chapter = sortedChapters.get(i);
                processChapterForMapping(document, pdfDoc, chapter, font, 0, destinationSet, chapterPageMap,cssStyles);
                if (i < sortedChapters.size() - 1) {
                    document.add(new AreaBreak(AreaBreakType.NEXT_PAGE));
                }
            }
        }

        /**
         *  处理章节(用于页码收集)- 与最终生成阶段保持完全一致(不再手动算标题高度)
         */
        private void processChapterForMapping(Document document,
                                              PdfDocument pdfDoc,
                                              FkdManualContentVo chapter,
                                              PdfFont font,
                                              int level,
                                              Set<String> destinationSet,
                                              Map<String, Integer> chapterPageMap,
                                              String cssStyles) {
            String chapterTitle = buildChapterTitle(chapter);
            String destination = generateUniqueDestination(chapter.getChapterId(), destinationSet);

            // 添加章节4.2.3.7.9的特殊日志
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【预生成-开始】章节4.2.3.7.9处理开始,当前页码: {}", pdfDoc.getNumberOfPages());
                log.info("【预生成】章节4.2.3.7.9标题: {}", chapterTitle);
            }

            int pageBefore = pdfDoc.getNumberOfPages();
            Paragraph titlePara = createChapterTitle(chapterTitle, destination, font, level);
            document.add(titlePara);
            int pageAfter = pdfDoc.getNumberOfPages();

            int actualTitlePage = (pageBefore != pageAfter) ? pageAfter : pageBefore;

            // 专门为章节4.2.3.7.9添加详细日志
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【预生成-关键】章节4.2.3.7.9页码记录: 添加前页={}, 添加后页={}, 最终记录页={}",
                        pageBefore, pageAfter, actualTitlePage);
                log.info("【预生成】章节4.2.3.7.9的destination: {}", destination);
            }

            if (pageBefore != pageAfter) {
                log.info("【预生成】检测到标题由于位置不足自动跳转至新页: {} | 原页: {} | 新页: {}",
                        chapterTitle, pageBefore, pageAfter);
            }

            chapterPageMap.put(destination, actualTitlePage);

            // 记录章节4.2.3.7.9的页码映射
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【预生成】章节4.2.3.7.9已记录到chapterPageMap: {} -> {}", destination, actualTitlePage);
            }

            int chapterLevel = getChapterLevel(chapter);
            int finalLevel = Math.max(level, chapterLevel - 1);
            processChapterContentForMapping(document, chapter, cssStyles);

            // 记录章节内容处理后的页码
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【预生成】章节4.2.3.7.9内容处理完成,当前页码: {}", pdfDoc.getNumberOfPages());
            }

            if (chapter.getChildren() != null && !chapter.getChildren().isEmpty()) {
                List<FkdManualContentVo> sortedChildren = chapter.getChildren().stream()
                        .sorted(Comparator.comparing(
                                FkdManualContentVo::getChapterNumber,
                                new HierarchicalNumberComparator()
                        ))
                        .collect(Collectors.toList());
                for (FkdManualContentVo child : sortedChildren) {
                    processChapterForMapping(document, pdfDoc, child, font, finalLevel + 1,
                            destinationSet, chapterPageMap, cssStyles);
                }
            }

            // 最终记录
            if (chapterTitle.contains("4.2.3.7.9")) {
                log.info("【预生成-完成】章节4.2.3.7.9全部处理完成,最终页码: {}", pdfDoc.getNumberOfPages());
            }
        }
        /**
         *  用于页码收集的章节内容处理 - 保证与最终生成阶段 HTML + CSS 结构一致
         */
        private void processChapterContentForMapping(Document document, FkdManualContentVo chapter,String cssStyles) {
            if (chapter.getChapterContent() == null || chapter.getChapterContent().isEmpty()) {
                log.debug("【预生成】章节 {} 没有内容", chapter.getChapterTitle());
                return;
            }
            log.debug("【预生成】处理章节内容: chapterId={}, 内容块数={}",
                    chapter.getChapterId(), chapter.getChapterContent().size());
            for (FkdChapterContentVo content : chapter.getChapterContent()) {
                try {
                    String html = StringEscapeUtils.unescapeHtml4(
                            content.getTextContent() != null ? content.getTextContent() : ""
                    );
                    if (content.getMediaUrl() != null && !content.getMediaUrl().isEmpty()
                            && !"string".equals(content.getMediaUrl().trim())) {
                        html = processImagesInHtmlForMapping(html, content.getMediaUrl(), content.getContentType());
                    }
                    html = html.replaceAll("<a\\s+class=\"dLink customLink\"\\s+href=\"#\"\\s+data-manultype=\"[^\"]+\"\\s+data-contentid=\"([^\"]+)\"\\s+data-chapterid=\"([^\"]+)\">([^<]+)</a>",
                            "<a class=\"dLink customLink\" href=\"#chapter_$2\">$3</a>");  // 将链接指向PDF中的目标ID
                    String fullHtml = wrapHtmlWithStyles(html, cssStyles);
                    ConverterProperties props = createConverterProperties();
                    List<IElement> elements = HtmlConverter.convertToElements(fullHtml, props);
                    for (IElement element : elements) {
                        if (element instanceof IBlockElement) {
                            document.add((IBlockElement) element);
                        } else if (element instanceof Image) {
                            Image img = (Image) element;
                            img.setAutoScale(true);
                            img.setHorizontalAlignment(HorizontalAlignment.CENTER);
                            document.add(img);
                        }
                    }
                } catch (Exception e) {
                    log.error("【预生成】处理章节内容失败: chapterId={}", chapter.getChapterId(), e);
                    document.add(new Paragraph("【内容解析错误】")
                            .setFontSize(10)
                            .setFontColor(ColorConstants.RED));
                }
            }
        }
        /**
         *  用于页码收集的HTML图片处理 - 确保与最终生成阶段完全一致
         */
        private String processImagesInHtmlForMapping(String html, String mediaUrls, String contentType) {
            return processMultimediaInHtml(html, mediaUrls, contentType);
        }

        /**
         *  生成唯一的destination字符串 - 确保两个阶段使用相同的destination
         */
        private String generateUniqueDestination(String chapterId, Set<String> existingDestinations) {
            String baseDestination = "chapter_" + chapterId;
            String destination = baseDestination;
            existingDestinations.add(destination);
            return destination;
        }
        /**
         *  处理章节内容
         */
        private void processChapterContent(Document document, FkdManualContentVo chapter, String cssStyles) {
            if (chapter.getChapterContent() == null || chapter.getChapterContent().isEmpty()) {
                log.debug("章节 {} 没有内容", chapter.getChapterTitle());
                return;
            }
            log.debug("处理章节内容: chapterId={}, 内容块数={}",
                    chapter.getChapterId(), chapter.getChapterContent().size());

            for (FkdChapterContentVo content : chapter.getChapterContent()) {
                try {
                    String originalHtml = StringEscapeUtils.unescapeHtml4(
                            content.getTextContent() != null ? content.getTextContent() : ""
                    );
                    String processedHtml = originalHtml;
                    if (content.getMediaUrl() != null && !content.getMediaUrl().isEmpty()
                            && !"string".equals(content.getMediaUrl().trim())) {
                        processedHtml = processMultimediaInHtml(originalHtml,
                                content.getMediaUrl(),
                                content.getContentType());
                    }
                    processedHtml = processedHtml.replaceAll("<a\\s+class=\"dLink customLink\"\\s+href=\"#\"\\s+data-manultype=\"[^\"]+\"\\s+data-contentid=\"([^\"]+)\"\\s+data-chapterid=\"([^\"]+)\">([^<]+)</a>",
                            "<a class=\"dLink customLink\" href=\"#chapter_$2\">$3</a>");
                    String fullHtml = wrapHtmlWithStyles(processedHtml, cssStyles);
                    ConverterProperties props = createConverterProperties();
                    List<IElement> elements = HtmlConverter.convertToElements(fullHtml, props);
                    for (IElement element : elements) {
                        if (element instanceof IBlockElement) {
                            document.add((IBlockElement) element);
                        } else if (element instanceof Image) {
                            Image img = (Image) element;
                            img.setAutoScale(true);
                            img.setHorizontalAlignment(HorizontalAlignment.CENTER);
                            document.add(img);
                        }
                    }
                } catch (Exception e) {
                    log.error("处理章节内容失败: chapterId={}", chapter.getChapterId(), e);
                    document.add(new Paragraph("【内容解析错误】")
                            .setFontSize(10)
                            .setFontColor(ColorConstants.RED));
                }
            }
        }

        /**
         *  生成封面
         */
        private void generateCover(Document document, String manualName, String coverImageUrl) {
            if (coverImageUrl != null && !coverImageUrl.trim().isEmpty()) {
                HttpURLConnection connection = null;
                InputStream inputStream = null;
                try {
                    URL url = new URL(coverImageUrl);
                    connection = (HttpURLConnection) url.openConnection();
                    connection.setRequestMethod("GET");
                    connection.setConnectTimeout(5000);
                    connection.setReadTimeout(10000);
                    int responseCode = connection.getResponseCode();
                    if (responseCode == HttpURLConnection.HTTP_OK) {
                        inputStream = connection.getInputStream();
                        byte[] imageBytes = IOUtils.toByteArray(inputStream);
                        ImageData imageData = ImageDataFactory.create(imageBytes);
                        Image coverImage = new Image(imageData);
                        coverImage.setWidth(PageSize.A4.getWidth());
                        coverImage.setHeight(PageSize.A4.getHeight());
                        coverImage.setFixedPosition(0, 0);
                        document.add(coverImage);
                        return;
                    } else {
                        log.warn("封面图片链接响应失败,响应码:{},链接:{}", responseCode, coverImageUrl);
                    }
                } catch (Exception e) {
                    log.warn("封面网络图片加载失败(链接:{}),将使用文字封面", coverImageUrl, e);
                } finally {
                    try {
                        if (inputStream != null) inputStream.close();
                        if (connection != null) connection.disconnect();
                    } catch (IOException e) {
                        log.error("关闭网络资源失败", e);
                    }
                }
            } else {
                log.warn("封面图片链接为空,将使用文字封面");
            }

            // 2. 图片加载失败/链接为空时,使用文字封面
            // ========== 调整6:封面标题间距适配新边距 ==========
            Paragraph title = new Paragraph(manualName)
                    .setFontSize(24)
                    .setBold()
                    .setTextAlignment(TextAlignment.CENTER)
                    .setMarginTop(250); // 原300,适配更大的top边距
            document.add(title);
            Paragraph subtitle = new Paragraph("技术手册")
                    .setFontSize(18)
                    .setTextAlignment(TextAlignment.CENTER)
                    .setMarginTop(20);
            document.add(subtitle);
        }

        /**
         *  页码处理器 - 只有内容页显示页码,从1开始
         */
        private static class PageNumberHandler implements IEventHandler {
            private PdfFont font;
            private int contentStartPage;

            PageNumberHandler(PdfFont font, int contentStartPage) {
                this.font = font;
                this.contentStartPage = contentStartPage;
            }

            @Override
            public void handleEvent(Event event) {
                PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
                PdfPage page = docEvent.getPage();
                PdfDocument pdfDoc = docEvent.getDocument();
                int absolutePageNum = docEvent.getDocument().getPageNumber(docEvent.getPage());
                // 打印所有页面的绝对页码,看看正文到底是从物理第几页开始盖"1"这个戳的
                log.info("【排查-页码监听】正在处理物理页: {}, 逻辑页码基准: {}, 最终盖在纸上的数字: {}",
                        absolutePageNum, contentStartPage, (absolutePageNum - contentStartPage + 1));

                int currentPage = pdfDoc.getPageNumber(page);
                if (currentPage < contentStartPage) {
                    return;
                }

                int contentPageNum = currentPage - contentStartPage + 1;
                log.info("页码计算:当前页={}, 内容起始页={}, 相对页码={}", currentPage, contentStartPage, contentPageNum);
                Rectangle pageSize = page.getPageSize();
                PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdfDoc);
                try {
                    // ========== 调整7:页码Y坐标适配新边距 ==========
                    canvas.beginText()
                            .setFontAndSize(font, 10)
                            .setColor(ColorConstants.BLACK, true)
                            .moveText(pageSize.getWidth() / 2 - 10, PAGE_NUMBER_Y)
                            .showText(String.valueOf(contentPageNum))
                            .endText();
                } finally {
                    canvas.release();
                }
            }
        }

        /**
         *页眉处理器 - 左侧显示SVG Logo
         */
        private class HeaderHandler implements IEventHandler {
            private final PdfFont font;
            private final int contentStartPage;
            private final String headerLogoPath;
            private byte[] svgData;
            HeaderHandler(PdfFont font, int contentStartPage, String headerLogoPath) {
                this.font = font;
                this.contentStartPage = contentStartPage;
                this.headerLogoPath = headerLogoPath;
                loadSvgData();
            }

            /**
             * 抽离:加载SVG文件到内存
             */
            private void loadSvgData() {
                if (headerLogoPath == null || headerLogoPath.isEmpty()) {
                    log.warn("SVG路径为空,无法加载Logo");
                    return;
                }
                try (InputStream svgStream = getClass().getResourceAsStream(headerLogoPath)) {
                    if (svgStream != null) {
                        this.svgData = IOUtils.toByteArray(svgStream);
                    } else {
                        log.warn("SVG图片未找到(路径错误?): {}", headerLogoPath);
                    }
                } catch (Exception e) {
                    log.error("SVG图片加载失败: {}", headerLogoPath, e);
                    this.svgData = null;
                }
            }

            @Override
            public void handleEvent(Event event) {
                PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
                PdfPage page = docEvent.getPage();
                PdfDocument pdfDoc = docEvent.getDocument();
                int currentPage = pdfDoc.getPageNumber(page);
                if (currentPage == 1) return;
                Rectangle pageSize = page.getPageSize();
                PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdfDoc);
                try {
                    // ========== 调整8:页眉顶部偏移适配新边距 ==========
                    float headerY = pageSize.getHeight() - HEADER_TOP_OFFSET;
              /*  // 1. 绘制页眉上边框   框线
                canvas.setStrokeColor(ColorConstants.LIGHT_GRAY)
                        .setLineWidth(0.5f)
                        .moveTo(30, headerY - 30)
                        .lineTo(pageSize.getWidth() - 30, headerY - 30)
                        .stroke();*/
                    drawSvgLogo(canvas, pageSize, headerY,pdfDoc);
                    drawCompanyText(canvas, pageSize, headerY);
                } finally {
                    canvas.release();
                }
            }

            /**
             * 绘制SVG Logo
             */
            private void drawSvgLogo(PdfCanvas canvas, Rectangle pageSize, float headerY, PdfDocument pdfDoc) {
                final float logoX = 30;
                // ========== 调整9:Logo Y坐标适配新边距 ==========
                final float logoY = headerY - HEADER_LOGO_Y_OFFSET;
                final float logoWidth = 80;
                final float logoHeight = 25;
                try {
                    if (svgData != null && svgData.length > 0) {
                        try (InputStream svgStream = new ByteArrayInputStream(svgData)) {
                            PdfFormXObject svgObject = SvgConverter.convertToXObject(svgStream, pdfDoc);
                            Rectangle svgRect = svgObject.getBBox().toRectangle();
                            float scaleX = logoWidth / svgRect.getWidth();
                            float scaleY = logoHeight / svgRect.getHeight();
                            canvas.saveState();
                            canvas.concatMatrix(scaleX, 0, 0, scaleY, 0, 0);
                            canvas.addXObjectAt(svgObject,
                                    logoX / scaleX,
                                    logoY / scaleY);
                            canvas.restoreState();
                            log.debug("SVG Logo绘制成功,位置:({}, {}), 尺寸:{}x{}",
                                    logoX, logoY, logoWidth, logoHeight);
                            return;
                        }
                    }
                    log.warn("SVG不可用,使用占位符");
                    drawLogoPlaceholder(canvas, logoX, logoY, logoWidth, logoHeight);
                } catch (SvgProcessingException e) {
                    log.error("SVG解析失败,使用占位符", e);
                    drawLogoPlaceholder(canvas, logoX, logoY, logoWidth, logoHeight);
                } catch (Exception e) {
                    log.error("Logo绘制异常,使用占位符", e);
                    drawLogoPlaceholder(canvas, logoX, logoY, logoWidth, logoHeight);
                }
            }

            /**
             * 绘制Logo占位符(原矩形+文字)
             */
            private void drawLogoPlaceholder(PdfCanvas canvas, float x, float y, float width, float height) {
                canvas.setFillColor(new DeviceRgb(70, 130, 180)) // 钢蓝色
                        .rectangle(x, y, width, height)
                        .fill();
                canvas.beginText()
                        .setFontAndSize(font, 10)
                        .setColor(ColorConstants.WHITE, true)
                        .moveText(x + 15, y + 8)
                        .showText("Logo")
                        .endText();
            }

            /**
             * 右上角显示公司名称
             */
            private void drawCompanyText(PdfCanvas canvas, Rectangle pageSize, float headerY) {
                String companyText = "武汉福卡迪汽车技术有限公司";
                float fontSize = 12;
                float textWidth = font.getWidth(companyText, fontSize);
                float rightX = pageSize.getWidth() - 40 - textWidth;
                // ========== 调整10:公司名称Y坐标适配新边距 ==========
                float rightY = headerY - HEADER_TEXT_Y_OFFSET;

                canvas.beginText()
                        .setFontAndSize(font, fontSize)
                        .setColor(ColorConstants.DARK_GRAY, true)
                        .moveText(rightX, rightY)
                        .showText(companyText)
                        .endText();
            }
        }

        /**
         *  水印处理器
         */
        private static class WatermarkHandler implements IEventHandler {
            private final String text;
            private final PdfFont font;
            private final int contentStartPage;

            private final float fontSize = 28f;
            private final float opacity = 0.15f;
            private final float angle = 30f;
            private final float horizontalSpacing = 220f;
            private final float verticalSpacing = 160f;
            // ========== 调整11:水印页面边距适配新边距 ==========
            private final float pageMargin = WATERMARK_PAGE_MARGIN;

            WatermarkHandler(String text, PdfFont font, int contentStartPage) {
                this.text = text;
                this.font = font;
                this.contentStartPage = contentStartPage;
            }

            @Override
            public void handleEvent(Event event) {
                PdfDocumentEvent docEvent = (PdfDocumentEvent) event;
                PdfPage page = docEvent.getPage();
                PdfDocument pdfDoc = docEvent.getDocument();
                int currentPage = pdfDoc.getPageNumber(page);
                if (currentPage < contentStartPage) {
                    return;
                }

                Rectangle pageSize = page.getPageSize();
                PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdfDoc);

                canvas.saveState();
                PdfExtGState gs = new PdfExtGState();
                gs.setFillOpacity(opacity);
                canvas.setExtGState(gs);
                canvas.setFillColor(new DeviceRgb(170, 170, 170));
                float pageWidth = pageSize.getWidth();
                float pageHeight = pageSize.getHeight();
                double radianAngle = Math.toRadians(angle);
                canvas.concatMatrix(AffineTransform.getRotateInstance(
                        radianAngle,
                        pageWidth / 2,
                        pageHeight / 2
                ));

                float startX = -pageWidth + pageMargin;
                float endX = pageWidth * 2 - pageMargin;
                float startY = -pageHeight + pageMargin;
                float endY = pageHeight * 2 - pageMargin;
                int rowCounter = 0;
                for (float x = startX; x < endX; x += horizontalSpacing) {
                    float rowOffset = (rowCounter % 2 == 0) ? 0 : verticalSpacing / 2;
                    for (float y = startY; y < endY; y += verticalSpacing) {
                        float posX = x;
                        float posY = y + rowOffset;
                        float randomOffsetX = (float) (Math.random() * 8 - 4);
                        float randomOffsetY = (float) (Math.random() * 8 - 4);
                        canvas.beginText()
                                .setFontAndSize(font, fontSize)
                                .moveText(posX + randomOffsetX, posY + randomOffsetY)
                                .showText(text)
                                .endText();
                    }
                    rowCounter++;
                }
                canvas.restoreState();
                canvas.release();
            }
        }

        /**
         *  创建HTML转换器配置
         */
        private ConverterProperties createConverterProperties() {
            ConverterProperties props = new ConverterProperties();
            FontProvider fontProvider = new DefaultFontProvider(true, true, true);
            try {
                fontProvider.addFont(loadFontProgram());
            } catch (IOException e) {
                log.warn("加载字体到HTML转换器失败", e);
            }
            props.setFontProvider(fontProvider);
            return props;
        }

        /**
         *  处理HTML中的多媒体内容
         */
        private String processMultimediaInHtml(String html, String mediaUrls, String contentType) {
            if (StringUtils.isEmpty(mediaUrls) || "string".equals(mediaUrls.trim())) {
                return html;
            }
            String[] urls = mediaUrls.split(",");
            StringBuilder result = new StringBuilder();
            ContentTypeEnum contentTypeEnum = ContentTypeEnum.valueOfCode(contentType);
            if (contentTypeEnum == null) {
                contentTypeEnum = ContentTypeEnum.TEXT;
            }
            switch (contentTypeEnum) {
                case LEFT_IMAGE_RIGHT_TEXT:
                case VIDEO_TEXT:
                    // 使用表格布局替代 Flex 布局
                    result.append("<table style='width: 100%; border-collapse: collapse; margin: 15px 0; table-layout: fixed;'>");
                    result.append("<tr>");

                    // 左侧单元格:图片/视频占 40%
                    result.append("<td style='width: 40%; vertical-align: top; padding-right: 15px;'>");
                    for (String url : urls) {
                        String trimmedUrl = url.trim();
                        if (StringUtils.isEmpty(trimmedUrl)) continue;
                        result.append(generateMediaElement(trimmedUrl));
                    }
                    result.append("</td>");

                    // 右侧单元格:文本内容占 60%
                    result.append("<td style='width: 60%; vertical-align: top;'>");
                    result.append("<div style='display: block;'>"); // 确保内部是块级显示
                    result.append(html);
                    result.append("</div>");
                    result.append("</td>");

                    result.append("</tr>");
                    result.append("</table>");
                    break;

                case UPLOAD_IMAGE:
                    for (String url : urls) {
                        String trimmedUrl = url.trim();
                        if (StringUtils.isEmpty(trimmedUrl)) continue;

                        if (isImageUrl(trimmedUrl)) {
                            result.append(generateImageElement(trimmedUrl));
                        } else {
                            result.append(generateMediaElement(trimmedUrl));
                        }
                    }
                    result.append(html);
                    break;

                case THREE_D:
                    result.append("<div style='margin: 20px 0;'>");
                    for (String url : urls) {
                        String trimmedUrl = url.trim();
                        if (StringUtils.isEmpty(trimmedUrl)) continue;

                        result.append(generate3DPdfEmbed(trimmedUrl));
                    }
                    result.append("</div>");
                    result.append(html);
                    break;

                case VIDEO:
                    result.append("<div style='margin: 20px 0;'>");
                    for (String url : urls) {
                        String trimmedUrl = url.trim();
                        if (StringUtils.isEmpty(trimmedUrl)) continue;

                        result.append(generateVideoPdfEmbed(trimmedUrl));
                    }
                    result.append("</div>");
                    result.append(html);
                    break;

                default:
                    result.append(html);
                    for (String url : urls) {
                        String trimmedUrl = url.trim();
                        if (StringUtils.isEmpty(trimmedUrl)) continue;

                        if (isImageUrl(trimmedUrl)) {
                            result.append(generateImageElement(trimmedUrl));
                        } else {
                            result.append(generateMediaElement(trimmedUrl));
                        }
                    }
                    break;
            }

            return result.toString();
        }

        /**
         *  判断是否为图片URL
         */
        private boolean isImageUrl(String url) {
            String lowerUrl = url.toLowerCase();
            return lowerUrl.endsWith(".jpg") || lowerUrl.endsWith(".jpeg") ||
                    lowerUrl.endsWith(".png") || lowerUrl.endsWith(".gif") ||
                    lowerUrl.endsWith(".bmp") || lowerUrl.endsWith(".webp");
        }

        /**
         *  判断是否为视频URL
         */
        private boolean isVideoUrl(String url) {
            String lowerUrl = url.toLowerCase();
            return lowerUrl.endsWith(".mp4") || lowerUrl.endsWith(".avi") ||
                    lowerUrl.endsWith(".mov") || lowerUrl.endsWith(".wmv") ||
                    lowerUrl.endsWith(".flv") || lowerUrl.endsWith(".webm");
        }

        /**
         *  判断是否为3D文件URL
         */
        private boolean is3DModelUrl(String url) {
            String lowerUrl = url.toLowerCase();
            return lowerUrl.endsWith(".glb") || lowerUrl.endsWith(".gltf") ||
                    lowerUrl.endsWith(".obj") || lowerUrl.endsWith(".stl") ||
                    lowerUrl.endsWith(".fbx") || lowerUrl.endsWith(".3ds") ||
                    lowerUrl.endsWith(".ply") || lowerUrl.endsWith(".dae");
        }

        /**
         *  生成图片元素
         */
        private String generateImageElement(String imageUrl) {
            return String.format(
                    "<div style='text-align: center; margin: 15px 0;'>" +
                            "<img src='%s' style='max-width: 100%%; height: auto;' alt='图片' " +
                            "onerror=\"this.src='%s'; this.alt='图片加载失败';\"/>" +
                            "</div>",
                    imageUrl,
                    ImageUtils.getImagePlaceholder()
            );
        }

        /**
         * 生成视频PDF嵌入 (已移除 Flex)
         */
        private String generateVideoPdfEmbed(String videoUrl) {
            String fileName = videoUrl.substring(videoUrl.lastIndexOf("/") + 1);
            return String.format(
                    "<table style='width: 100%%; margin: 20px 0; border: 1px solid #ddd; border-radius: 8px; background-color: #f9f9f9; border-collapse: collapse;'>" +
                            "  <tr><td style='padding: 20px;'>" +
                            "    <table style='width: 100%%; border-collapse: collapse;'>" +
                            "      <tr>" +
                            "        <td style='width: 30px; font-size: 18px;'>📹</td>" +
                            "        <td><strong style='font-size: 14px;'>视频文件: %s</strong></td>" +
                            "      </tr>" +
                            "    </table>" +
                            "    <div style='margin-top: 10px; padding: 15px; background-color: white; border: 1px dashed #ccc;'>" +
                            "      <p style='margin: 0 0 10px 0; color: #666;'>点击播放按钮可查看视频。</p>" +
                            "      <a href='%s' target='_blank' style='color: #3498db; text-decoration: none;'>▶ 点击此处在新窗口中查看视频</a>" +
                            "    </div>" +
                            "    <div style='margin-top: 10px; font-size: 12px; color: #999;'>注意:部分PDF阅读器可能不支持内嵌视频播放</div>" +
                            "  </td></tr>" +
                            "</table>",
                    fileName, videoUrl
            );
        }

        /**
         * 生成3DPdf嵌入 (已移除 Flex)
         */
        private String generate3DPdfEmbed(String modelUrl) {
            String fileName = modelUrl.substring(modelUrl.lastIndexOf("/") + 1);
            return String.format(
                    "<table style='width: 100%%; margin: 20px 0; border: 1px solid #ddd; border-radius: 8px; background-color: #f9f9f9; border-collapse: collapse;'>" +
                            "  <tr><td style='padding: 20px;'>" +
                            "    <table style='width: 100%%; border-collapse: collapse; margin-bottom: 10px;'>" +
                            "      <tr><td style='width: 30px;'>🧊</td><td><strong>3D模型: %s</strong></td></tr>" +
                            "    </table>" +
                            "    <table style='width: 100%%; border-collapse: collapse; background-color: white; border: 1px dashed #ccc;'>" +
                            "      <tr>" +
                            "        <td style='width: 100px; padding: 10px;'>" +
                            "          <div style='width: 80px; height: 80px; background: #667eea; color: white; text-align: center; line-height: 80px;'>3D</div>" +
                            "        </td>" +
                            "        <td style='vertical-align: middle; padding: 10px;'>" +
                            "          <p style='margin: 0; color: #666;'>可旋转、缩放、平移的3D模型</p>" +
                            "          <a href='%s' target='_blank' style='display: block; margin-top: 10px; color: #3498db;'>查看3D模型</a>" +
                            "        </td>" +
                            "      </tr>" +
                            "    </table>" +
                            "  </td></tr>" +
                            "</table>",
                    fileName, modelUrl
            );
        }
        /**
         *  生成通用媒体元素
         */
        private String generateMediaElement(String mediaUrl) {
            String fileName = mediaUrl.substring(mediaUrl.lastIndexOf("/") + 1);
            if (isVideoUrl(mediaUrl)) {
                return generateVideoPdfEmbed(mediaUrl);
            } else if (is3DModelUrl(mediaUrl)) {
                return generate3DPdfEmbed(mediaUrl);
            } else if (isImageUrl(mediaUrl)) {
                return generateImageElement(mediaUrl);
            } else {
                // 【核心修改】彻底弃用 flex,改用 table 布局来实现垂直居中对齐
                return String.format(
                        "<table style='width: 100%%; margin: 15px 0; border: 1px dashed #ccc; background-color: #f8f9fa; border-collapse: collapse;'>" +
                                "  <tr>" +
                                "    <td style='padding: 15px; width: 40px; vertical-align: middle; text-align: center; color: #666; font-size: 20px;'>" +
                                "      📄" +
                                "    </td>" +
                                "    <td style='padding: 15px; vertical-align: middle;'>" +
                                "      <strong style='display: block; margin-bottom: 5px;'>%s</strong>" +
                                "      <a href='%s' target='_blank' style='color: #3498db; font-size: 12px; text-decoration: none;'>下载文件</a>" +
                                "    </td>" +
                                "  </tr>" +
                                "</table>",
                        fileName, mediaUrl
                );
            }
        }

        /**
         *  构建章节标题
         */
        private String buildChapterTitle(FkdManualContentVo chapter) {
            String number = chapter.getChapterNumber() != null ? chapter.getChapterNumber() : "";
            String title = chapter.getChapterTitle() != null ? chapter.getChapterTitle() : "";
            title = title.replaceAll("<[^>]*>", "").trim();
            if (title.isEmpty() && !number.isEmpty()) {
                title = "未命名章节";
            }
            return (number + " " + title).trim();
        }

        /**
         *  加载字体
         */
        private FontProgram loadFontProgram() throws IOException {
            try (InputStream stream = getClass().getResourceAsStream("/static/fonts/SourceHanSansHWSC-VF.ttf")) {
                if (stream == null) {
                    throw new IOException("字体文件未找到");
                }
                return FontProgramFactory.createFont(IOUtils.toByteArray(stream));
            }
        }

        private PdfFont getCachedPdfFont() throws IOException {
            FONT_CACHE_LOCK.lock();
            try {
                if (cachedFontProgram == null) {
                    cachedFontProgram = loadFontProgram(); // 加载字体并缓存
                }
                return PdfFontFactory.createFont(
                        cachedFontProgram,
                        PdfEncodings.IDENTITY_H,
                        PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED
                );
            } finally {
                FONT_CACHE_LOCK.unlock();
            }
        }



        /**
         *  加载CSS样式
         */
        private String loadCssStyles() {
            if (cachedCssStyles != null && System.currentTimeMillis() - cssCacheTime < CSS_CACHE_DURATION) {
                return cachedCssStyles;
            }

            String baseCss = loadBaseCssStyles();
            String customCss = loadCustomCssStyles();

            cachedCssStyles = baseCss + customCss;
            cssCacheTime = System.currentTimeMillis();

            return cachedCssStyles;
        }

        private String loadBaseCssStyles() {
            try (InputStream cssStream = getClass().getResourceAsStream(cssPath)) {
                if (cssStream != null) {
                    return IOUtils.toString(cssStream, StandardCharsets.UTF_8);
                }
                log.warn("基础CSS文件未找到: {}", cssPath);
                return "";
            } catch (IOException e) {
                log.error("加载基础CSS文件失败: {}", cssPath, e);
                return "";
            }
        }

        private String loadCustomCssStyles() {
            if (StringUtils.isEmpty(customCssPath)) {
                return "";
            }

            try {
                File customFile = new File(customCssPath);
                if (customFile.exists() && customFile.isFile()) {
                    return "/* 自定义CSS */\n" + Files.readString(customFile.toPath(), StandardCharsets.UTF_8);
                }
                log.warn("自定义CSS文件不存在: {}", customCssPath);
                return "";
            } catch (IOException e) {
                log.warn("加载自定义CSS文件失败: {}", customCssPath, e);
                return "";
            }
        }

        /**
         *  包装HTML内容,添加CSS样式
         */
        private String wrapHtmlWithStyles(String html, String cssStyles) {
            String strongReset = """
        <style>
        /* 1. 允许自然的布局流,移除强制高度 */
        .pdf-container div { display: block; } 
        
        /* 2. 核心:必须保证 Table 单元格不被 block !important 破坏 */
        table { display: table !important; width: 100% !important; table-layout: fixed; border-collapse: collapse; }
        tr { display: table-row !important; }
        td { display: table-cell !important; vertical-align: top !important; }
        
        /* 3. 防止标题和内容被切断在两页,这会导致页码跳变 */
        h1, h2, h3, h4, h5, h6 { break-after: avoid; break-inside: avoid; }
        
        /* 4. 图片必须撑开 */
        img { max-width: 100% !important; height: auto !important; display: block; }
    </style>
    """;
            return String.format("<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><style>%s</style>%s</head><body class=\"pdf-container\">%s</body></html>",
                    cssStyles, strongReset, html);
        }
    }

六、补充说明

1.生成的目录

内容页

  1. 正式生成时,章节4.2.3.7.9和4.2.3.7.10在同一页,页码显示为5;

  2. 预生成时记录的章节4.2.3.7.9的页码对应的相对页码是4,但实际显示的是5。

4.这几个章节里面都没有内容 我写了一页多的标题 就是想要测试标题跨页会不会页码记录错误

希望各位大佬能帮忙看看,问题可能出在哪里?如果需要更多的日志信息或代码细节,我可以提供

相关推荐
SimonKing几秒前
基于Netty的TCP协议的Socket服务端
java·后端·程序员
予枫的编程笔记几秒前
Elasticsearch深度搜索与查询DSL实战:精准定位数据的核心技法
java·大数据·人工智能·elasticsearch·搜索引擎·全文检索
while(1){yan}3 分钟前
拦截器(详解)
数据库·spring boot·spring·java-ee·拦截器
荒诞硬汉4 分钟前
面向对象(三)
java·开发语言
柒.梧.7 分钟前
Spring Boot集成JWT Token实现认证授权完整实践
java·spring boot·后端
白露与泡影7 分钟前
放弃 IntelliJ IDEA,转 VS Code 了。。
java·ide·intellij-idea
迷雾骑士9 分钟前
IDEA中将项目提交到Gitee仓库
java·gitee·intellij-idea
菜鸟233号11 分钟前
力扣416 分割等和子串 java实现
java·数据结构·算法·leetcode
奔波霸的伶俐虫14 分钟前
redisTemplate.opsForList()里面方法怎么用
java·开发语言·数据库·python·sql
自在极意功。16 分钟前
简单介绍SpringAOP
java·spring·aop思想