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

复制代码
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;
        }
    }
}
相关推荐
静心观复2 小时前
foreach中使用remove踩坑
java
内存不泄露2 小时前
基于 Spring Boot 的医院预约挂号系统(全端协同)设计与实现
java·vue.js·spring boot·python·flask
袁慎建@ThoughtWorks2 小时前
如何发布自定义 Spring Boot Starter
java·spring boot·后端
开开心心_Every2 小时前
强制打字练习工具:打够百字才可退出
java·游戏·微信·eclipse·pdf·excel·语音识别
xiaolyuh1232 小时前
Redis 核心业务流程
java·redis·spring
BD_Marathon2 小时前
MyBatis——封装SqlSessionUtils工具类并测试功能
java·windows·mybatis
浩瀚地学2 小时前
【Java】集合-Collection
java·开发语言·经验分享·笔记·学习
wangkay882 小时前
【Java 转运营】Day03:抖音直播间自然流运营
java·开发语言·新媒体运营
计算机程序设计小李同学2 小时前
平价药店销售与管理系统
java·mysql·spring·spring cloud·ssm