各位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。
三、已排查的方向
针对这个问题,我已经排查了以下几个方向,但都没有找到根源:
-
检查keepTogether和keepWithNext属性:章节标题段落已经设置了这两个属性,理论上不会出现标题跨页的情况,但不排除该章节内容过短,导致标题和内容被挤压到下一页?
-
检查目录页数计算:预生成时计算的tocPages与正式生成时实际的目录页数是否匹配?日志中显示"目录页数匹配",但不排除特殊情况下目录页数有微小偏差?
-
检查内容起始页的计算:正式生成时,内容起始页 = 目录起始页 + tocPages,日志中显示内容起始页设置正确,但可能由于添加AreaBreak时的页码偏移导致实际起始页变化?
-
检查章节内容的长度:章节4.2.3.7.9的内容是否过短,导致正式生成时,该章节的标题和内容被后续章节(4.2.3.7.10)挤压到同一页,从而导致页码从4变成5?
四、求助问题
想向各位大佬请教以下几个问题:
-
这种"预生成记录页码,正式生成使用页码"的方案,是否存在固有的缺陷?比如预生成和正式生成时的布局微小差异,导致页码偏移?
-
iTextPDF中,keepTogether和keepWithNext属性是否能100%保证标题不跨页?如果章节内容过少,是否会出现标题和下一章内容挤在同一页的情况,从而导致页码变化?
-
目录页码的计算逻辑(相对页码 = 绝对页码 - 内容起始页 + 1)是否合理?有没有可能由于内容起始页的计算错误,导致目录页码与实际页码偏差1?
-
除了上述方向,还有哪些可能导致这种单章节页码不匹配的情况?比如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.生成的目录
内容页
-
正式生成时,章节4.2.3.7.9和4.2.3.7.10在同一页,页码显示为5;
-
预生成时记录的章节4.2.3.7.9的页码对应的相对页码是4,但实际显示的是5。
4.这几个章节里面都没有内容 我写了一页多的标题 就是想要测试标题跨页会不会页码记录错误
希望各位大佬能帮忙看看,问题可能出在哪里?如果需要更多的日志信息或代码细节,我可以提供