package com.smartreview.service.impl; 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.layout.font.FontProvider; import com.smartreview.enums.BlockStatus; import com.smartreview.enums.QuestionStatus; import com.smartreview.exception.SmartReviewException; import com.smartreview.po.*; import com.smartreview.repository.*; import com.smartreview.service.ReportService; import com.smartreview.vo.report.*; import com.vladsch.flexmark.ext.tables.TablesExtension; import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; import com.vladsch.flexmark.ext.autolink.AutolinkExtension; import com.vladsch.flexmark.ext.toc.TocExtension; import com.vladsch.flexmark.html.HtmlRenderer; import com.vladsch.flexmark.parser.Parser; import com.vladsch.flexmark.util.ast.Node; import com.vladsch.flexmark.util.data.MutableDataSet; import lombok.extern.slf4j.Slf4j; import org.scilab.forge.jlatexmath.TeXConstants; import org.scilab.forge.jlatexmath.TeXFormula; import org.scilab.forge.jlatexmath.TeXIcon; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import javax.swing.*; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.Base64; import java.util.*; import java.util.List; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * 报告服务实现 */ @Slf4j @Service public class ReportServiceImpl implements ReportService { private final DocumentRepository documentRepository; private final BlockRepository blockRepository; private final UserAnswerRepository userAnswerRepository; private final QuestionRepository questionRepository; private final KnowledgePointRepository knowledgePointRepository; // Markdown解析器 private final Parser markdownParser; private final HtmlRenderer htmlRenderer; public ReportServiceImpl(DocumentRepository documentRepository, BlockRepository blockRepository, UserAnswerRepository userAnswerRepository, QuestionRepository questionRepository, KnowledgePointRepository knowledgePointRepository) { this.documentRepository = documentRepository; this.blockRepository = blockRepository; this.userAnswerRepository = userAnswerRepository; this.questionRepository = questionRepository; this.knowledgePointRepository = knowledgePointRepository; // 初始化Markdown解析器 - 启用扩展支持表格、删除线、自动链接等 MutableDataSet options = new MutableDataSet(); options.set(Parser.EXTENSIONS, Arrays.asList( TablesExtension.create(), StrikethroughExtension.create(), AutolinkExtension.create(), TocExtension.create())); // 表格配置 options.set(TablesExtension.WITH_CAPTION, false); options.set(TablesExtension.COLUMN_SPANS, false); options.set(TablesExtension.MIN_HEADER_ROWS, 1); options.set(TablesExtension.MAX_HEADER_ROWS, 1); options.set(TablesExtension.APPEND_MISSING_COLUMNS, true); options.set(TablesExtension.DISCARD_EXTRA_COLUMNS, true); options.set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true); // HTML渲染器配置 - 不转义嵌入的HTML标签 options.set(HtmlRenderer.ESCAPE_HTML, false); options.set(HtmlRenderer.ESCAPE_HTML_BLOCKS, false); options.set(HtmlRenderer.ESCAPE_HTML_COMMENT_BLOCKS, false); options.set(HtmlRenderer.ESCAPE_INLINE_HTML, false); options.set(HtmlRenderer.ESCAPE_INLINE_HTML_COMMENTS, false); options.set(HtmlRenderer.SUPPRESS_HTML, false); options.set(HtmlRenderer.SUPPRESS_HTML_BLOCKS, false); options.set(HtmlRenderer.SUPPRESS_HTML_COMMENT_BLOCKS, false); options.set(HtmlRenderer.SUPPRESS_INLINE_HTML, false); options.set(HtmlRenderer.SUPPRESS_INLINE_HTML_COMMENTS, false); this.markdownParser = Parser.builder(options).build(); this.htmlRenderer = HtmlRenderer.builder(options).build(); } @Override public ReviewReportVO getReport(String documentId, Long userId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> SmartReviewException.notFound("文档不存在")); if (!document.getUserId().equals(userId)) { throw SmartReviewException.forbidden("无权访问此文档"); } ReviewReportVO report = new ReviewReportVO(); report.setDocumentId(documentId); report.setDocumentTitle(document.getTitle()); // 进度统计(只统计LEARNABLE类型的block) List blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId); List learnableBlocks = blocks.stream() .filter(b -> b.getType() == com.smartreview.enums.BlockType.LEARNABLE) .collect(Collectors.toList()); ProgressSummaryVO progress = new ProgressSummaryVO(); progress.setTotalBlocks(learnableBlocks.size()); progress.setCompletedBlocks((int) learnableBlocks.stream() .filter(b -> b.getStatus() == BlockStatus.COMPLETED || b.getStatus() == BlockStatus.GENERATED_QUESTION) .count()); progress.setReadingBlocks((int) learnableBlocks.stream() .filter(b -> b.getStatus() == BlockStatus.READING).count()); progress.setProgressPercent(!learnableBlocks.isEmpty() ? (progress.getCompletedBlocks() * 100.0 / learnableBlocks.size()) : 0); report.setProgress(progress); // 答题统计 List blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList()); List questions = questionRepository.findByBlockIdIn(blockIds); // 根据题目状态统计(过滤掉已废弃的题目) int correctCount = 0; int wrongCount = 0; int weakCount = 0; int validTotal = 0; for (Question question : questions) { if (question.getStatus() == QuestionStatus.DEPRECATED || question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) { continue; } validTotal++; if (question.getStatus() == QuestionStatus.ANSWERED_RIGHT) { correctCount++; } else if (question.getStatus() == QuestionStatus.ANSWERED_WRONG) { wrongCount++; } else if (question.getStatus() == QuestionStatus.ANSWERED_WEAK) { weakCount++; } } AnswerStatisticsVO statistics = new AnswerStatisticsVO(); statistics.setTotalQuestions(validTotal); statistics.setCorrectCount(correctCount); statistics.setWrongCount(wrongCount); statistics.setWeakCount(weakCount); statistics.setCorrectRate(validTotal > 0 ? (correctCount * 100.0 / validTotal) : 0); report.setStatistics(statistics); // 直接从答错/薄弱题目中获取知识点(只看CURRENT_BLOCK来源的题目,即第一次生成的题目) List wrongQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds, QuestionStatus.ANSWERED_WRONG).stream() .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK) .collect(Collectors.toList()); List weakQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds, QuestionStatus.ANSWERED_WEAK).stream() .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK) .collect(Collectors.toList()); // 错误知识点统计(来自答错的题目,统计次数) List wrongKnowledgePoints = buildKnowledgePointVOList(wrongQuestionList, true); report.setWrongKnowledgePoints(wrongKnowledgePoints.stream().limit(10).collect(Collectors.toList())); // 薄弱知识点统计(来自标记薄弱的题目,不需要次数) List weakKnowledgePoints = buildKnowledgePointVOList(weakQuestionList, false); report.setWeakKnowledgePoints(weakKnowledgePoints.stream().limit(10).collect(Collectors.toList())); // 错题列表(包含答错和薄弱的题目) List wrongQuestions = new ArrayList<>(); for (Question question : wrongQuestionList) { wrongQuestions.add(buildWrongQuestionVO(question, false)); } for (Question question : weakQuestionList) { wrongQuestions.add(buildWrongQuestionVO(question, true)); } report.setWrongQuestions(wrongQuestions); return report; } /** * 构建知识点VO列表(按知识点分组统计次数) * * @param questions 题目列表 * @param includeCount 是否包含次数统计 */ private List buildKnowledgePointVOList(List questions, boolean includeCount) { // 按知识点ID分组统计次数 Map> questionsByKpId = questions.stream() .filter(q -> q.getKnowledgePointId() != null) .collect(Collectors.groupingBy(Question::getKnowledgePointId)); List result = new ArrayList<>(); for (Map.Entry> entry : questionsByKpId.entrySet()) { Long kpId = entry.getKey(); int count = entry.getValue().size(); KnowledgePoint kp = knowledgePointRepository.findById(kpId).orElse(null); if (kp != null) { Block block = blockRepository.findById(kp.getBlockId()).orElse(null); WeakPointVO vo = new WeakPointVO(); vo.setKnowledgePointId(kpId); vo.setKnowledgePoint(kp.getContent()); vo.setBlockId(kp.getBlockId()); vo.setBlockPreview(block != null && block.getContent() != null && block.getContent().length() > 30 ? block.getContent().substring(0, 30) + "..." : (block != null ? block.getContent() : null)); vo.setCount(includeCount ? count : 0); // 薄弱知识点不需要次数 result.add(vo); } } // 按次数降序排序(如果需要次数的话) if (includeCount) { result.sort((a, b) -> Integer.compare(b.getCount(), a.getCount())); } return result; } /** * 构建错题VO */ private WrongQuestionVO buildWrongQuestionVO(Question question, boolean isWeak) { WrongQuestionVO wq = new WrongQuestionVO(); wq.setQuestionId(question.getId()); wq.setBlockId(question.getBlockId()); wq.setType(question.getType()); wq.setQuestionText(question.getQuestionText()); wq.setCorrectAnswer(question.getCorrectAnswer()); wq.setExplanation(question.getExplanation()); wq.setIsWeak(isWeak); // 获取最近的用户答案 userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId()) .ifPresent(answer -> wq.setUserAnswer(answer.getUserAnswer())); return wq; } @Override public byte[] exportPdfReport(String documentId, Long userId) { Document document = documentRepository.findById(documentId) .orElseThrow(() -> SmartReviewException.notFound("文档不存在")); if (!document.getUserId().equals(userId)) { throw SmartReviewException.forbidden("无权访问此文档"); } ReviewReportVO report = getReport(documentId, userId); // 获取所有Block List blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId); List blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList()); // 获取所有知识点 List allKnowledgePoints = knowledgePointRepository.findByBlockIdIn(blockIds); // 获取所有题目(非废弃的) List allQuestions = questionRepository.findByBlockIdIn(blockIds).stream() .filter(q -> q.getStatus() != QuestionStatus.DEPRECATED) .collect(Collectors.toList()); // 构建知识点状态映射(根据题目状态判断知识点状态,只看CURRENT_BLOCK来源的题目) // 只考虑CURRENT_BLOCK来源的题目来判断知识点状态 List currentBlockQuestions = allQuestions.stream() .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK) .collect(Collectors.toList()); // 错误知识点ID集合 Set wrongKpIds = currentBlockQuestions.stream() .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WRONG && q.getKnowledgePointId() != null) .map(Question::getKnowledgePointId) .collect(Collectors.toSet()); // 薄弱知识点ID集合 Set weakKpIds = currentBlockQuestions.stream() .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WEAK && q.getKnowledgePointId() != null) .map(Question::getKnowledgePointId) .collect(Collectors.toSet()); // 正确回答的知识点ID集合 Set correctKpIds = currentBlockQuestions.stream() .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_RIGHT && q.getKnowledgePointId() != null) .map(Question::getKnowledgePointId) .collect(Collectors.toSet()); // 有题目关联的知识点ID(已复习过的,也只看CURRENT_BLOCK) Set reviewedKpIds = currentBlockQuestions.stream() .filter(q -> q.getKnowledgePointId() != null) .map(Question::getKnowledgePointId) .collect(Collectors.toSet()); // 调试日志 log.info("==== PDF报告知识点状态调试 ===="); log.info("总知识点数: {}", allKnowledgePoints.size()); log.info("CURRENT_BLOCK来源的题目数: {}", currentBlockQuestions.size()); log.info("错误知识点IDs: {}", wrongKpIds); log.info("薄弱知识点IDs: {}", weakKpIds); log.info("正确知识点IDs: {}", correctKpIds); log.info("已复习知识点IDs: {}", reviewedKpIds); for (Question q : currentBlockQuestions) { log.info("题目ID:{}, 状态:{}, 来源:{}, 关联知识点ID:{}", q.getId(), q.getStatus(), q.getQuestionSource(), q.getKnowledgePointId()); } // 生成HTML String html = generateFullReportHtml(report, blocks, allKnowledgePoints, allQuestions, wrongKpIds, weakKpIds, correctKpIds, reviewedKpIds); log.debug("html: {}", html); // 转换为PDF(配置中文字体支持) try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { ConverterProperties converterProperties = new ConverterProperties(); FontProvider fontProvider = new DefaultFontProvider(false, false, false); // 从项目资源目录加载字体文件 try { // 字体文件配置:文件名 -> 字体集合索引(ttc文件需要指定索引,ttf文件为-1) String[][] fontConfigs = { { "simsun.ttc", "0" }, // 宋体 { "simhei.ttf", "-1" }, // 黑体 { "msyh.ttc", "0" }, // 微软雅黑 Regular { "msyhbd.ttc", "0" }, // 微软雅黑 Bold { "simfang.ttf", "-1" }, // 仿宋 { "seguiemj.ttf", "-1" }, // Segoe UI Emoji { "segoeui.ttf", "-1" }, // Segoe UI { "segoeuib.ttf", "-1" } // Segoe UI Bold }; for (String[] config : fontConfigs) { String fontFileName = config[0]; int ttcIndex = Integer.parseInt(config[1]); try { org.springframework.core.io.ClassPathResource fontResource = new org.springframework.core.io.ClassPathResource( "font/" + fontFileName); if (fontResource.exists()) { byte[] fontBytes = fontResource.getInputStream().readAllBytes(); if (ttcIndex >= 0) { // TTC字体集合需要写入临时文件后用路径方式加载 java.io.File tempFile = java.io.File.createTempFile("font_", "_" + fontFileName); tempFile.deleteOnExit(); java.nio.file.Files.write(tempFile.toPath(), fontBytes); // 使用 "路径,索引" 格式加载TTC中的指定字体 fontProvider.addFont(tempFile.getAbsolutePath() + "," + ttcIndex); } else { // TTF字体直接从字节数组加载 fontProvider.addFont(fontBytes); } log.debug("成功加载字体: {}", fontFileName); } else { log.warn("字体文件不存在: {}", fontFileName); } } catch (Exception e) { log.warn("加载字体文件失败: {}, 原因: {}", fontFileName, e.getMessage()); } } } catch (Exception fontEx) { log.warn("加载项目字体失败,尝试使用默认字体: {}", fontEx.getMessage()); // 如果特定字体加载失败,使用默认字体提供器 fontProvider = new DefaultFontProvider(true, false, false); } converterProperties.setFontProvider(fontProvider); HtmlConverter.convertToPdf(html, baos, converterProperties); return baos.toByteArray(); } catch (Exception e) { log.error("生成PDF报告失败", e); throw SmartReviewException.error("生成PDF报告失败:" + e.getMessage()); } } /** * 生成完整报告HTML(包含三部分)- 美观设计版本(兼容iText PDF) */ private String generateFullReportHtml(ReviewReportVO report, List blocks, List allKnowledgePoints, List allQuestions, Set wrongKpIds, Set weakKpIds, Set correctKpIds, Set reviewedKpIds) { StringBuilder html = new StringBuilder(); html.append(""); html.append(""); html.append(""); // ==================== 报告标题 ==================== html.append("

复习报告-").append(escapeHtml(report.getDocumentTitle())).append("

"); // ==================== 第一部分:统计信息 ==================== html.append("

第一部分:学习统计

"); // 统计卡片 - 使用table布局,四个卡片等宽(各25%) html.append(""); html.append(""); html.append(""); html.append(""); html.append(""); html.append("
") .append(report.getStatistics().getTotalQuestions()) .append("
总题数
") .append(report.getStatistics().getCorrectCount()) .append("
正确
") .append(report.getStatistics().getWrongCount()).append("
错误
") .append(report.getStatistics().getWeakCount()).append("
薄弱
"); // 答题分布 - 使用柱状图替代饼图(iText不支持conic-gradient) int total = report.getStatistics().getTotalQuestions(); int correct = report.getStatistics().getCorrectCount(); int wrong = report.getStatistics().getWrongCount(); int weak = report.getStatistics().getWeakCount(); int unanswered = total - correct - wrong - weak; if (total > 0) { html.append("

答题分布图

"); html.append("
"); double correctPct = correct * 100.0 / total; double wrongPct = wrong * 100.0 / total; double weakPct = weak * 100.0 / total; double unansweredPct = unanswered * 100.0 / total; // 正确题目 html.append("
"); html.append(""); html.append(""); html.append(""); html.append("
正确").append(correct).append(" (") .append(String.format("%.1f", correctPct)).append("%)
"); html.append("
"); html.append("
"); html.append("
"); // 错误题目 html.append("
"); html.append(""); html.append(""); html.append(""); html.append("
错误").append(wrong).append(" (") .append(String.format("%.1f", wrongPct)).append("%)
"); html.append("
"); html.append("
"); html.append("
"); // 薄弱题目 html.append("
"); html.append(""); html.append(""); html.append(""); html.append("
薄弱").append(weak).append(" (") .append(String.format("%.1f", weakPct)).append("%)
"); html.append("
"); html.append("
"); html.append("
"); // 未答题目 html.append("
"); html.append(""); html.append(""); html.append(""); html.append("
未答").append(unanswered).append(" (") .append(String.format("%.1f", unansweredPct)).append("%)
"); html.append("
"); html.append("
"); html.append("
"); html.append("
"); } // 进度信息和正确率 - 合并为一行,避免跨页 html.append("

复习进度与答题正确率

"); html.append("
"); html.append(""); // 左边:复习进度 html.append(""); // 右边:答题正确率 html.append( ""); html.append("
"); html.append("

复习进度

"); html.append("

总内容块:") .append(report.getProgress().getTotalBlocks()).append(" | "); html.append("已完成:").append(report.getProgress().getCompletedBlocks()) .append(" | "); html.append("学习中:").append(report.getProgress().getReadingBlocks()) .append("

"); html.append("
"); html.append("

完成率:") .append(String.format("%.1f%%", report.getProgress().getProgressPercent())).append("

"); html.append("
"); html.append("

答题正确率

"); double correctRate = report.getStatistics().getCorrectRate(); String rateColor = correctRate >= 80 ? "#4CAF50" : (correctRate >= 60 ? "#FF9800" : "#f44336"); html.append("

正确 ") .append(report.getStatistics().getCorrectCount()).append(" / 总 ") .append(report.getStatistics().getTotalQuestions()).append(" 题

"); html.append("
"); html.append("

正确率:").append(String.format("%.1f%%", correctRate)).append("

"); html.append("
"); html.append("
"); // 错误知识点 - 使用柱状图展示 if (report.getWrongKnowledgePoints() != null && !report.getWrongKnowledgePoints().isEmpty()) { html.append("

错误知识点分布

"); html.append("
"); // 找出最大次数用于计算柱状图宽度 int maxCount = report.getWrongKnowledgePoints().stream().mapToInt(WeakPointVO::getCount).max().orElse(1); for (WeakPointVO wp : report.getWrongKnowledgePoints()) { double barWidth = (wp.getCount() * 100.0 / maxCount); html.append("
"); html.append(""); html.append(""); html.append(""); html.append("
").append(escapeHtml(wp.getKnowledgePoint())).append("").append(wp.getCount()) .append(" 次
"); html.append("
"); html.append("
"); html.append("
"); html.append("
"); } html.append("
"); } // 薄弱知识点 - 使用标签卡片展示(不显示次数) if (report.getWeakKnowledgePoints() != null && !report.getWeakKnowledgePoints().isEmpty()) { html.append("

薄弱知识点

"); html.append("
"); for (WeakPointVO wp : report.getWeakKnowledgePoints()) { html.append(""); html.append(escapeHtml(wp.getKnowledgePoint())); html.append(""); } html.append("
"); } // ==================== 第二部分:原笔记再现 ==================== html.append("
"); html.append("

第二部分:笔记内容(知识点标注)

"); // 图例说明 - 使用table布局 html.append(""); html.append(""); html.append(""); html.append(""); html.append(""); html.append("
错误知识点薄弱知识点已掌握知识点未复习知识点
"); // 按Block分组知识点 Map> kpByBlockId = allKnowledgePoints.stream() .collect(Collectors.groupingBy(KnowledgePoint::getBlockId)); for (Block block : blocks) { String content = block.getContent(); if (content == null || content.trim().isEmpty()) { continue; } html.append("
"); // 获取该Block的知识点并渲染高亮 List blockKps = kpByBlockId.getOrDefault(block.getId(), Collections.emptyList()); String highlightedContent = highlightKnowledgePointsInMarkdown(content, blockKps, wrongKpIds, weakKpIds, correctKpIds, reviewedKpIds); html.append(highlightedContent); html.append("
"); } // ==================== 第三部分:题目展示 ==================== html.append("
"); html.append("

第三部分:题目汇总

"); html.append("

").append(allQuestions.size()) .append(" 道题目

"); // 按状态分组展示题目 Map> questionsByStatus = allQuestions.stream() .collect(Collectors.groupingBy(Question::getStatus)); // 错误题目 List wrongQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WRONG, Collections.emptyList()); if (!wrongQuestions.isEmpty()) { html.append("

错误题目 (").append(wrongQuestions.size()).append("题)

"); for (Question q : wrongQuestions) { html.append(renderQuestionCard(q, "wrong", "错误")); } } // 薄弱题目 List weakQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WEAK, Collections.emptyList()); if (!weakQuestions.isEmpty()) { html.append("

薄弱题目 (").append(weakQuestions.size()).append("题)

"); for (Question q : weakQuestions) { html.append(renderQuestionCard(q, "weak", "薄弱")); } } // 正确题目 List correctQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_RIGHT, Collections.emptyList()); if (!correctQuestions.isEmpty()) { html.append("

已掌握题目 (").append(correctQuestions.size()).append("题)

"); for (Question q : correctQuestions) { html.append(renderQuestionCard(q, "correct", "正确")); } } // 未作答题目 List activeQuestions = questionsByStatus.getOrDefault(QuestionStatus.ACTIVE, Collections.emptyList()); if (!activeQuestions.isEmpty()) { html.append("

未作答题目 (").append(activeQuestions.size()).append("题)

"); for (Question q : activeQuestions) { html.append(renderQuestionCard(q, "active", "未作答")); } } html.append(""); // 后处理HTML:为代码块添加语言标签 String finalHtml = html.toString(); // 查找 并添加标签 finalHtml = finalHtml.replaceAll("", "
$1
"); return finalHtml; } /** * 在Markdown内容中高亮知识点,然后渲染为HTML * 策略: * 1. 根据 sourceStart 和 sourceEnd 在原始 Markdown 中插入 HTML 注释标记 * 2. 渲染 Markdown 为 HTML(注释会被保留) * 3. 找到注释位置,向外扩展到合适的标签边界,插入 span * 4. 删除原始注释标记 */ private String highlightKnowledgePointsInMarkdown(String content, List knowledgePoints, Set wrongKpIds, Set weakKpIds, Set correctKpIds, Set reviewedKpIds) { // 如果没有知识点,直接渲染返回 if (knowledgePoints == null || knowledgePoints.isEmpty()) { String processedContent = processLatexFormulas(content); Node document = markdownParser.parse(processedContent); return htmlRenderer.render(document); } // 1. 预处理:修正知识点索引(数据库中的索引可能与实际内容不一致) for (KnowledgePoint kp : knowledgePoints) { fixKpIndices(content, kp); } // 2. 筛选有效的知识点(必须有 sourceStart 和 sourceEnd) List validKps = knowledgePoints.stream() .filter(kp -> kp.getSourceStart() != null && kp.getSourceEnd() != null && kp.getSourceStart() >= 0 && kp.getSourceEnd() > kp.getSourceStart() && kp.getSourceEnd() <= content.length()) .collect(Collectors.toList()); // 如果没有有效位置信息的知识点,回退到简单匹配 if (validKps.isEmpty()) { log.debug("没有有效位置信息的知识点,回退到简单匹配"); return highlightKnowledgePointsByTextOnly(content, knowledgePoints, wrongKpIds, weakKpIds, correctKpIds, reviewedKpIds); } // 3. 按 sourceStart 降序排序,从后往前插入标记避免位置偏移 validKps.sort((a, b) -> Integer.compare(b.getSourceStart(), a.getSourceStart())); // 4. 在原始 Markdown 中插入 HTML 注释标记 StringBuilder markedContent = new StringBuilder(content); for (KnowledgePoint kp : validKps) { int start = kp.getSourceStart(); int end = kp.getSourceEnd(); // 确定CSS类 String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds); // 提取sourceText(用于查找第一个和最后一个正常文字) String sourceText = content.substring(start, end); // 检查是否为代码块(以```开头和结尾) if (sourceText.trim().startsWith("```") && sourceText.trim().endsWith("```")) { // 是代码块,使用div包裹,调整背景色 String blockClass = cssClass.replace("kp-", "kp-block-"); // 插入结束标签(注意添加换行符以确保Markdown解析器正确识别代码块) markedContent.insert(end, "\n\n"); // 插入开始标签(注意添加换行符) markedContent.insert(start, "
\n\n"); continue; } // 找到第一个正常文字的相对位置(跳过Markdown格式标记) int firstNormalCharOffset = findFirstNormalCharOffset(sourceText); // 找到最后一个正常文字之后的相对位置 int lastNormalCharOffset = findLastNormalCharOffset(sourceText); // 计算实际插入位置 int actualStart = start + firstNormalCharOffset; int actualEnd = start + lastNormalCharOffset; // 插入结束标记(先插入结束,因为从后往前) String endMarker = ""; markedContent.insert(actualEnd, endMarker); // 插入开始标记 String startMarker = ""; markedContent.insert(actualStart, startMarker); log.debug("插入标记: kpId={}, sourceStart={}, sourceEnd={}, actualStart={}, actualEnd={}, cssClass={}", kp.getId(), start, end, actualStart, actualEnd, cssClass); } // 5. 处理数学公式 String processedContent = processLatexFormulas(markedContent.toString()); // 6. 渲染 Markdown 为 HTML Node document = markdownParser.parse(processedContent); String html = htmlRenderer.render(document); log.debug("渲染后HTML长度: {}", html.length()); // 7. 在 HTML 中找到注释标记,向外扩展并插入 span for (KnowledgePoint kp : validKps) { String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds); String startMarker = ""; String endMarker = ""; int startMarkerPos = html.indexOf(startMarker); int endMarkerPos = html.indexOf(endMarker); if (startMarkerPos == -1 || endMarkerPos == -1) { log.warn("未找到标记: kpId={}, startFound={}, endFound={}", kp.getId(), startMarkerPos != -1, endMarkerPos != -1); // 移除可能存在的单个标记 html = html.replace(startMarker, "").replace(endMarker, ""); continue; } log.debug("找到标记位置: kpId={}, startMarkerPos={}, endMarkerPos={}", kp.getId(), startMarkerPos, endMarkerPos); // 计算实际内容的位置(去除标记后) int contentStart = startMarkerPos + startMarker.length(); int contentEnd = endMarkerPos; // 向外扩展到合适的标签边界 // 从开始标记之前开始向前扩展(包括可能在标记前的内联标签) int expandedStart = expandToTagBoundaryStart(html, startMarkerPos); // 从结束标记之后开始向后扩展(包括可能在标记后的内联标签) int expandedEnd = expandToTagBoundaryEnd(html, endMarkerPos + endMarker.length()); log.debug("扩展位置: expandedStart={}, expandedEnd={}", expandedStart, expandedEnd); // 检查是否已在 kp-span 内 if (isInsideKpSpan(html.substring(0, startMarkerPos))) { log.debug("已在 kp-span 内,跳过: kpId={}", kp.getId()); html = html.replace(startMarker, "").replace(endMarker, ""); continue; } // 构建新的 HTML // expandedStart 应该在 startMarkerPos 之前(向前扩展到内联标签开始) // expandedEnd 应该在 endMarkerPos + endMarker.length() 之后(向后扩展到内联标签结束) StringBuilder result = new StringBuilder(); result.append(html.substring(0, expandedStart)); // 提取需要高亮的内容片段 String contentToHighlight = html.substring(expandedStart, startMarkerPos) + html.substring(contentStart, endMarkerPos) + html.substring(endMarkerPos + endMarker.length(), expandedEnd); // 智能应用高亮(处理块级标签) result.append(applyHighlightToHtml(contentToHighlight, cssClass)); result.append(html.substring(expandedEnd)); html = result.toString(); } return html; } /** * 获取知识点对应的CSS类 */ private String getCssClassForKp(Long kpId, Set wrongKpIds, Set weakKpIds, Set correctKpIds) { if (wrongKpIds.contains(kpId)) { return "kp-wrong"; } else if (weakKpIds.contains(kpId)) { return "kp-weak"; } else if (correctKpIds.contains(kpId)) { return "kp-correct"; } else { return "kp-unreviewed"; } } /** * 纯文本匹配方式高亮知识点(后备方案) */ private String highlightKnowledgePointsByTextOnly(String content, List knowledgePoints, Set wrongKpIds, Set weakKpIds, Set correctKpIds, Set reviewedKpIds) { // 处理数学公式 String processedContent = processLatexFormulas(content); // 渲染Markdown为HTML Node document = markdownParser.parse(processedContent); String html = htmlRenderer.render(document); // 筛选有效的知识点(必须有sourceText) List validKps = knowledgePoints.stream() .filter(kp -> kp.getSourceText() != null && !kp.getSourceText().isEmpty()) .collect(Collectors.toList()); if (validKps.isEmpty()) { return html; } // 按sourceText长度降序排序 validKps.sort((a, b) -> Integer.compare(b.getSourceText().length(), a.getSourceText().length())); // 对每个知识点进行高亮 for (KnowledgePoint kp : validKps) { String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds); html = simpleHighlightInHtml(html, kp.getSourceText(), cssClass); } return html; } /** * 修正知识点在内容中的索引位置 * 尝试解决数据库索引与实际内容不匹配的问题 */ private void fixKpIndices(String content, KnowledgePoint kp) { if (kp.getSourceText() == null || kp.getSourceText().isEmpty()) { return; } Integer start = kp.getSourceStart(); Integer end = kp.getSourceEnd(); String sourceText = kp.getSourceText(); // 1. 检查当前索引是否有效且匹配 if (start != null && end != null && start >= 0 && end <= content.length() && start < end) { String extracted = content.substring(start, end); if (extracted.equals(sourceText)) { return; // 索引正确,无需修正 } } // 2. 尝试精确查找 int index = content.indexOf(sourceText); if (index != -1) { kp.setSourceStart(index); kp.setSourceEnd(index + sourceText.length()); log.debug("修正KP索引(精确匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, index, index + sourceText.length()); return; } // 3. 尝试模糊查找(忽略空白字符) int[] fuzzyIndices = findFuzzyMatch(content, sourceText); if (fuzzyIndices != null) { kp.setSourceStart(fuzzyIndices[0]); kp.setSourceEnd(fuzzyIndices[1]); log.debug("修正KP索引(模糊匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, fuzzyIndices[0], fuzzyIndices[1]); } else { log.warn("无法修正KP索引: id={}, sourceText='{}'", kp.getId(), sourceText); } } /** * 在内容中模糊查找文本(忽略空白字符) * * @return [start, end] 或 null */ private int[] findFuzzyMatch(String content, String sourceText) { // 移除sourceText中的所有空白字符 String cleanSource = sourceText.replaceAll("\\s+", ""); if (cleanSource.isEmpty()) return null; // 构建content的非空白字符映射 // contentChars: 存储非空白字符 // contentIndices: 存储这些字符在原content中的索引 List contentChars = new ArrayList<>(); List contentIndices = new ArrayList<>(); for (int i = 0; i < content.length(); i++) { char c = content.charAt(i); if (!Character.isWhitespace(c)) { contentChars.add(c); contentIndices.add(i); } } // 将content非空白字符转为字符串进行查找 StringBuilder sb = new StringBuilder(contentChars.size()); for (Character c : contentChars) sb.append(c); String cleanContent = sb.toString(); int matchIndex = cleanContent.indexOf(cleanSource); if (matchIndex != -1) { // 映射回原始索引 int startOriginalIndex = contentIndices.get(matchIndex); // 结束索引是匹配的最后一个字符的索引 + 1 int endMatchIndex = matchIndex + cleanSource.length() - 1; int endOriginalIndex = contentIndices.get(endMatchIndex) + 1; return new int[] { startOriginalIndex, endOriginalIndex }; } return null; } /** * 找到sourceText中第一个正常文字的偏移量(跳过Markdown格式标记) * 特别处理:如果内容以公式开头,在整个公式之前插入标记(避免破坏公式渲染) */ private int findFirstNormalCharOffset(String sourceText) { if (sourceText == null || sourceText.isEmpty()) { return 0; } int i = 0; int len = sourceText.length(); while (i < len) { char c = sourceText.charAt(i); // 跳过Markdown格式标记 if (c == '*' || c == '_' || c == '`' || c == '~' || c == '#') { i++; continue; } // 跳过列表标记(- 、+ 、* 后跟空格) if ((c == '-' || c == '+') && i + 1 < len && sourceText.charAt(i + 1) == ' ') { i += 2; continue; } // 跳过数字列表标记(1. 、2. 等) if (Character.isDigit(c)) { int j = i; while (j < len && Character.isDigit(sourceText.charAt(j))) { j++; } if (j < len && sourceText.charAt(j) == '.' && j + 1 < len && sourceText.charAt(j + 1) == ' ') { i = j + 2; continue; } } // 跳过空白字符 if (Character.isWhitespace(c)) { i++; continue; } // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分 // 如果是Markdown链接 [text](url),标记加在 [ 前面也是可以的 // 特别处理:如果遇到公式开始标记,检查是否是完整公式 if (c == '$') { // 检查是否是双美元符号(块级公式) if (i + 1 < len && sourceText.charAt(i + 1) == '$') { // 块级公式 $$...$$,在公式前插入标记 return i; } else { // 行内公式 $...$,也在公式前插入标记 return i; } } // 找到第一个正常字符 break; } return i; } /** * 找到sourceText中最后一个正常文字之后的偏移量(跳过Markdown格式标记) * 特别处理:如果内容以公式结尾,在整个公式之后插入标记(避免破坏公式渲染) */ private int findLastNormalCharOffset(String sourceText) { if (sourceText == null || sourceText.isEmpty()) { return 0; } int i = sourceText.length() - 1; // 先跳过尾部的空白字符和格式标记 while (i >= 0) { char c = sourceText.charAt(i); // 跳过Markdown格式标记 if (c == '*' || c == '_' || c == '`' || c == '~') { i--; continue; } // 跳过空白字符 if (Character.isWhitespace(c)) { i--; continue; } // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分 // 特别处理:如果遇到公式结束标记 if (c == '$') { // 检查是否是双美元符号(块级公式) if (i > 0 && sourceText.charAt(i - 1) == '$') { // 块级公式 $$...$$,在公式后插入标记(已经在 $ 之后了) return i + 1; } else { // 行内公式 $...$,在公式后插入标记 return i + 1; } } // 找到最后一个正常字符,返回其后的位置 return i + 1; } return sourceText.length(); } /** * 检查是否是内联标签(可以被span包裹的标签) */ private boolean isInlineTag(String tag) { // 提取标签名(去除 < > 和属性) String tagLower = tag.toLowerCase(); // 移除开头的 和属性 int spaceIdx = tagName.indexOf(' '); int gtIdx = tagName.indexOf('>'); int endIdx = Math.min( spaceIdx == -1 ? tagName.length() : spaceIdx, gtIdx == -1 ? tagName.length() : gtIdx); tagName = tagName.substring(0, endIdx); // 内联标签列表 return tagName.equals("strong") || tagName.equals("b") || tagName.equals("em") || tagName.equals("i") || tagName.equals("code") || tagName.equals("mark") || tagName.equals("small") || tagName.equals("sub") || tagName.equals("sup") || tagName.equals("a") || tagName.equals("span") || tagName.equals("img") || tagName.equals("del") || tagName.equals("ins") || tagName.equals("u") || tagName.equals("s"); } /** * 向前扩展到合适的标签边界 * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签 * 只扩展内联标签(如 strong, em),不扩展块级标签(如 li, p, div) */ private int expandToTagBoundaryStart(String html, int pos) { int i = pos; // 向前查找,跳过所有紧邻的内联开始标签和HTML注释 while (i > 0) { // 跳过HTML注释 if (i >= 4 && html.substring(Math.max(0, i - 4), i).equals("-->")) { // 前面可能有HTML注释结束,查找注释开始 int commentStart = html.lastIndexOf("", i); if (commentEnd != -1) { i = commentEnd + 3; // 跳过 --> log.debug("expandEnd: 跳过HTML注释,继续向后到 {}", i); continue; } } // 检查后面是否有标签 if (i < html.length() && html.charAt(i) == '<') { // 后面有标签开始符,查找这个标签的结束 int tagEnd = html.indexOf('>', i); if (tagEnd != -1) { String tag = html.substring(i, tagEnd + 1); // 跳过HTML注释 if (tag.startsWith("