package com.forcartech.manual.service.impl;
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.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> {
/**
* 内部章节链接(富文本中的“自定义链接”)替换:指向 PDF 的 destination。
*/
private static final String INTERNAL_LINK_REGEX =
"<a\\s+class=\"dLink customLink\"\\s+href=\"#\"\\s+data-manultype=\"[^\"]+\"\\s+data-contentid=\"([^\"]+)\"\\s+data-chapterid=\"([^\"]+)\">([^<]+)</a>";
private static final String INTERNAL_LINK_REPLACEMENT =
"<a class=\"dLink customLink\" href=\"#chapter_$2\">$3</a>";
@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 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) {
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));
int tocStartPage = tempPdfDoc.getNumberOfPages();
List<TocEntry> tempTocEntries = new ArrayList<>();
for (FkdManualContentVo chapter : chapters) {
collectTocEntriesForPhase(chapter, tempTocEntries, 0, destinationSet);
}
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> dummyMap = new HashMap<>();
generateContentPagesForMapping(tempDoc, tempPdfDoc, chapters, font, destinationSet, dummyMap, cssStyles);
tempDoc.close();
Set<String> allDestinations = tempTocEntries.stream()
.map(e -> e.destination)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<String, Integer> chapterPageMap = extractDestinationPageMap(tempStream.toByteArray(), allDestinations);
PreGenerateResult result = new PreGenerateResult();
result.chapterPageMap = chapterPageMap;
result.tocPages = actualTocPages;
result.tocEntries = tempTocEntries;
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);
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);
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.debug("添加章节分页");
} 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;
}
private List<FkdManualContentVo> getSortedChildren(FkdManualContentVo chapter) {
if (chapter.getChildren() == null || chapter.getChildren().isEmpty()) {
return Collections.emptyList();
}
return chapter.getChildren().stream()
.sorted(Comparator.comparing(
FkdManualContentVo::getChapterNumber,
new HierarchicalNumberComparator()
))
.collect(Collectors.toList());
}
/**
* 处理章节(最终阶段)- 依赖 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);
int pageBeforeAdd = pdfDoc.getNumberOfPages();
Paragraph titlePara = createChapterTitle(chapterTitle, destination, font, level);
document.add(titlePara);
int pageAfterAdd = pdfDoc.getNumberOfPages();
// 记录日志:如果添加标题前后页码变了,说明发生了换页
if (pageBeforeAdd != pageAfterAdd) {
log.debug("【正式生成】标题跨页检测: {} | 尝试添加页: {} | 实际渲染页: {}",
chapterTitle, pageBeforeAdd, pageAfterAdd);
}
int chapterLevel = getChapterLevel(chapter);
int finalLevel = Math.max(level, chapterLevel - 1);
try {
processChapterContent(document, chapter, cssStyles);
for (FkdManualContentVo child : getSortedChildren(chapter)) {
processChapterFinal(document, pdfDoc, child, font, finalLevel + 1,
destinationSet, chapterPageMap, contentStartPage, cssStyles);
}
} catch (Exception e) {
log.error("【最终生成】处理章节失败: {}", chapterTitle, e);
throw e;
}
}
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) {
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);
}
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));
for (FkdManualContentVo child : getSortedChildren(chapter)) {
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);
int pageBefore = pdfDoc.getNumberOfPages();
Paragraph titlePara = createChapterTitle(chapterTitle, destination, font, level);
document.add(titlePara);
int pageAfter = pdfDoc.getNumberOfPages();
int actualTitlePage = (pageBefore != pageAfter) ? pageAfter : pageBefore;
if (pageBefore != pageAfter) {
log.debug("【预生成】检测到标题由于位置不足自动跳转至新页: {} | 原页: {} | 新页: {}",
chapterTitle, pageBefore, pageAfter);
}
chapterPageMap.put(destination, actualTitlePage);
int chapterLevel = getChapterLevel(chapter);
int finalLevel = Math.max(level, chapterLevel - 1);
processChapterContentForMapping(document, chapter, cssStyles);
for (FkdManualContentVo child : getSortedChildren(chapter)) {
processChapterForMapping(document, pdfDoc, child, font, finalLevel + 1,
destinationSet, chapterPageMap, cssStyles);
}
}
/**
* 用于页码收集的章节内容处理 - 保证与最终生成阶段 HTML + CSS 结构一致
*/
private void processChapterContentForMapping(Document document, FkdManualContentVo chapter, String cssStyles) {
processChapterContentInternal(document, chapter, cssStyles, true);
}
/**
* 生成唯一的destination字符串 - 确保两个阶段使用相同的destination
*/
private String generateUniqueDestination(String chapterId, Set<String> existingDestinations) {
String destination = "chapter_" + chapterId;
if (existingDestinations != null) {
existingDestinations.add(destination);
}
return destination;
}
/**
* 处理章节内容
*/
private void processChapterContent(Document document, FkdManualContentVo chapter, String cssStyles) {
processChapterContentInternal(document, chapter, cssStyles, false);
}
/**
* 统一章节内容处理(预生成/正式生成共用),保证 HTML + CSS + 多媒体 + 内链替换行为一致。
*/
private void processChapterContentInternal(Document document,
FkdManualContentVo chapter,
String cssStyles,
boolean mappingPhase) {
List<FkdChapterContentVo> blocks = chapter.getChapterContent();
if (blocks == null || blocks.isEmpty()) {
if (mappingPhase) {
log.debug("【预生成】章节 {} 没有内容", chapter.getChapterTitle());
} else {
log.debug("章节 {} 没有内容", chapter.getChapterTitle());
}
return;
}
if (mappingPhase) {
log.debug("【预生成】处理章节内容: chapterId={}, 内容块数={}", chapter.getChapterId(), blocks.size());
} else {
log.debug("处理章节内容: chapterId={}, 内容块数={}", chapter.getChapterId(), blocks.size());
}
// 每个章节复用一次 ConverterProperties,避免重复创建 FontProvider
ConverterProperties props = createConverterProperties();
for (FkdChapterContentVo content : blocks) {
try {
String html = StringEscapeUtils.unescapeHtml4(
content.getTextContent() != null ? content.getTextContent() : ""
);
if (StringUtils.isNotEmpty(content.getMediaUrl()) && !"string".equals(content.getMediaUrl().trim())) {
html = processMultimediaInHtml(html, content.getMediaUrl(), content.getContentType());
}
// 将富文本中的自定义章节链接指向 PDF destination
html = html.replaceAll(INTERNAL_LINK_REGEX, INTERNAL_LINK_REPLACEMENT);
String fullHtml = wrapHtmlWithStyles(html, cssStyles);
List<IElement> elements = HtmlConverter.convertToElements(fullHtml, props);
addElementsToDocument(document, elements);
} catch (Exception e) {
if (mappingPhase) {
log.error("【预生成】处理章节内容失败: chapterId={}", chapter.getChapterId(), e);
} else {
log.error("处理章节内容失败: chapterId={}", chapter.getChapterId(), e);
}
document.add(new Paragraph("【内容解析错误】")
.setFontSize(10)
.setFontColor(ColorConstants.RED));
}
}
}
private void addElementsToDocument(Document document, List<IElement> elements) {
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);
}
}
}
/**
* 生成封面
*/
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. 图片加载失败/链接为空时,使用文字封面
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();
if (log.isDebugEnabled()) {
int absolutePageNum = pdfDoc.getPageNumber(page);
log.debug("【排查-页码监听】正在处理物理页: {}, 逻辑页码基准: {}, 最终盖在纸上的数字: {}",
absolutePageNum, contentStartPage, (absolutePageNum - contentStartPage + 1));
}
int currentPage = pdfDoc.getPageNumber(page);
if (currentPage < contentStartPage) {
return;
}
int contentPageNum = currentPage - contentStartPage + 1;
if (log.isDebugEnabled()) {
log.debug("页码计算:当前页={}, 内容起始页={}, 相对页码={}", currentPage, contentStartPage, contentPageNum);
}
Rectangle pageSize = page.getPageSize();
PdfCanvas canvas = new PdfCanvas(page.newContentStreamAfter(), page.getResources(), pdfDoc);
try {
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 {
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;
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;
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(getCachedFontProgram());
} 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:
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 {
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();
}
}
/**
* 复用缓存的 FontProgram,避免 HTML 转换器重复读取字体文件。
*/
private FontProgram getCachedFontProgram() throws IOException {
FONT_CACHE_LOCK.lock();
try {
if (cachedFontProgram == null) {
cachedFontProgram = loadFontProgram();
}
return cachedFontProgram;
} 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();
//优先使用css文件
cachedCssStyles = customCss + baseCss;
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);
}
private Map<String, Integer> extractDestinationPageMap(byte[] pdfBytes, Collection<String> wantedDestinations) throws IOException {
if (pdfBytes == null || pdfBytes.length == 0) return Collections.emptyMap();
Set<String> wanted = wantedDestinations == null ? null : new HashSet<>(wantedDestinations);
try (PdfReader reader = new PdfReader(new ByteArrayInputStream(pdfBytes));
PdfDocument doc = new PdfDocument(reader)) {
// page indirect ref -> page number
Map<PdfIndirectReference, Integer> pageRefToNum = new HashMap<>();
for (int i = 1; i <= doc.getNumberOfPages(); i++) {
PdfIndirectReference ref = doc.getPage(i).getPdfObject().getIndirectReference();
if (ref != null) pageRefToNum.put(ref, i);
}
PdfNameTree destTree = doc.getCatalog().getNameTree(PdfName.Dests);
Map<String, PdfObject> rawNames = destTree == null ? null : destTree.getNames();
Map<PdfString, PdfObject> names = null;
if (rawNames != null) {
names = new HashMap<>();
for (Map.Entry<String, PdfObject> entry : rawNames.entrySet()) {
names.put(new PdfString(entry.getKey()), entry.getValue());
}
}
if (names == null || names.isEmpty()) return Collections.emptyMap();
Map<String, Integer> result = new HashMap<>();
for (Map.Entry<PdfString, PdfObject> e : names.entrySet()) {
String destName = e.getKey().toUnicodeString();
if (wanted != null && !wanted.contains(destName)) continue;
PdfObject v = e.getValue();
PdfArray arr = null;
if (v instanceof PdfArray) {
arr = (PdfArray) v;
} else if (v instanceof PdfDictionary) {
PdfObject d = ((PdfDictionary) v).get(PdfName.D);
if (d instanceof PdfArray) arr = (PdfArray) d;
}
if (arr == null || arr.size() == 0) continue;
PdfObject pageObj = arr.get(0);
PdfIndirectReference pageRef = null;
if (pageObj instanceof PdfIndirectReference) {
pageRef = (PdfIndirectReference) pageObj;
} else if (pageObj instanceof PdfDictionary) {
pageRef = (pageObj).getIndirectReference();
}
Integer pageNum = pageRef == null ? null : pageRefToNum.get(pageRef);
if (pageNum != null) result.put(destName, pageNum);
}
return result;
}
}
}
解决iTextPDF生成手册时目录页码与实际页码不匹配问题
白典典2026-01-14 12:33
相关推荐
静心观复2 小时前
foreach中使用remove踩坑内存不泄露2 小时前
基于 Spring Boot 的医院预约挂号系统(全端协同)设计与实现袁慎建@ThoughtWorks2 小时前
如何发布自定义 Spring Boot Starter开开心心_Every2 小时前
强制打字练习工具:打够百字才可退出xiaolyuh1232 小时前
Redis 核心业务流程BD_Marathon2 小时前
MyBatis——封装SqlSessionUtils工具类并测试功能浩瀚地学2 小时前
【Java】集合-Collectionwangkay882 小时前
【Java 转运营】Day03:抖音直播间自然流运营计算机程序设计小李同学2 小时前
平价药店销售与管理系统