|
|
@@ -0,0 +1,2109 @@
|
|
|
+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<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
|
|
|
+ List<Block> 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<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
|
|
|
+ List<Question> 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<Question> wrongQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
|
|
|
+ QuestionStatus.ANSWERED_WRONG).stream()
|
|
|
+ .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ List<Question> weakQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
|
|
|
+ QuestionStatus.ANSWERED_WEAK).stream()
|
|
|
+ .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 错误知识点统计(来自答错的题目,统计次数)
|
|
|
+ List<WeakPointVO> wrongKnowledgePoints = buildKnowledgePointVOList(wrongQuestionList, true);
|
|
|
+ report.setWrongKnowledgePoints(wrongKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
|
|
|
+
|
|
|
+ // 薄弱知识点统计(来自标记薄弱的题目,不需要次数)
|
|
|
+ List<WeakPointVO> weakKnowledgePoints = buildKnowledgePointVOList(weakQuestionList, false);
|
|
|
+ report.setWeakKnowledgePoints(weakKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
|
|
|
+
|
|
|
+ // 错题列表(包含答错和薄弱的题目)
|
|
|
+ List<WrongQuestionVO> 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<WeakPointVO> buildKnowledgePointVOList(List<Question> questions, boolean includeCount) {
|
|
|
+ // 按知识点ID分组统计次数
|
|
|
+ Map<Long, List<Question>> questionsByKpId = questions.stream()
|
|
|
+ .filter(q -> q.getKnowledgePointId() != null)
|
|
|
+ .collect(Collectors.groupingBy(Question::getKnowledgePointId));
|
|
|
+
|
|
|
+ List<WeakPointVO> result = new ArrayList<>();
|
|
|
+ for (Map.Entry<Long, List<Question>> 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<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
|
|
|
+ List<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 获取所有知识点
|
|
|
+ List<KnowledgePoint> allKnowledgePoints = knowledgePointRepository.findByBlockIdIn(blockIds);
|
|
|
+
|
|
|
+ // 获取所有题目(非废弃的)
|
|
|
+ List<Question> allQuestions = questionRepository.findByBlockIdIn(blockIds).stream()
|
|
|
+ .filter(q -> q.getStatus() != QuestionStatus.DEPRECATED)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 构建知识点状态映射(根据题目状态判断知识点状态,只看CURRENT_BLOCK来源的题目)
|
|
|
+ // 只考虑CURRENT_BLOCK来源的题目来判断知识点状态
|
|
|
+ List<Question> currentBlockQuestions = allQuestions.stream()
|
|
|
+ .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 错误知识点ID集合
|
|
|
+ Set<Long> wrongKpIds = currentBlockQuestions.stream()
|
|
|
+ .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WRONG && q.getKnowledgePointId() != null)
|
|
|
+ .map(Question::getKnowledgePointId)
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+
|
|
|
+ // 薄弱知识点ID集合
|
|
|
+ Set<Long> weakKpIds = currentBlockQuestions.stream()
|
|
|
+ .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WEAK && q.getKnowledgePointId() != null)
|
|
|
+ .map(Question::getKnowledgePointId)
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+
|
|
|
+ // 正确回答的知识点ID集合
|
|
|
+ Set<Long> correctKpIds = currentBlockQuestions.stream()
|
|
|
+ .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_RIGHT && q.getKnowledgePointId() != null)
|
|
|
+ .map(Question::getKnowledgePointId)
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+
|
|
|
+ // 有题目关联的知识点ID(已复习过的,也只看CURRENT_BLOCK)
|
|
|
+ Set<Long> 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<Block> blocks,
|
|
|
+ List<KnowledgePoint> allKnowledgePoints,
|
|
|
+ List<Question> allQuestions,
|
|
|
+ Set<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> correctKpIds,
|
|
|
+ Set<Long> reviewedKpIds) {
|
|
|
+ StringBuilder html = new StringBuilder();
|
|
|
+ html.append("<!DOCTYPE html><html><head>");
|
|
|
+ html.append("<meta charset=\"UTF-8\">");
|
|
|
+ html.append("<style>");
|
|
|
+
|
|
|
+ // 基础样式 - 现代美观设计(兼容iText,使用table布局替代flex)
|
|
|
+ html.append("* { margin: 0; padding: 0; box-sizing: border-box; }");
|
|
|
+ html.append(
|
|
|
+ "body { font-family: 'Segoe UI Emoji', 'Microsoft YaHei', 'SimHei', 'SimSun', sans-serif; padding: 30px; line-height: 1.8; font-size: 14px; background: #f8f9fa; color: #333; }");
|
|
|
+
|
|
|
+ // 标题样式
|
|
|
+ html.append(
|
|
|
+ "h1 { color: #2c3e50; font-size: 28px; text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 3px solid #3498db; }");
|
|
|
+ html.append(
|
|
|
+ "h2 { color: #fff; font-size: 20px; margin: 30px 0 20px 0; padding: 12px 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; }");
|
|
|
+ html.append(
|
|
|
+ "h3 { color: #2c3e50; font-size: 16px; margin: 20px 0 10px 0; padding-left: 12px; border-left: 4px solid #3498db; }");
|
|
|
+
|
|
|
+ // 统计卡片样式 - 使用table布局
|
|
|
+ html.append(".stats-table { width: 100%; margin: 20px 0; border-collapse: separate; border-spacing: 10px; }");
|
|
|
+ html.append(".stat-card { padding: 20px; border-radius: 12px; color: #fff; text-align: center; }");
|
|
|
+ html.append(".stat-card.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }");
|
|
|
+ html.append(".stat-card.correct { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }");
|
|
|
+ html.append(".stat-card.wrong { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); }");
|
|
|
+ html.append(".stat-card.weak { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }");
|
|
|
+ html.append(".stat-card.progress { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }");
|
|
|
+ html.append(".stat-number { font-size: 32px; font-weight: bold; }");
|
|
|
+ html.append(".stat-label { font-size: 14px; opacity: 0.9; margin-top: 5px; }");
|
|
|
+
|
|
|
+ // 进度条样式
|
|
|
+ html.append(
|
|
|
+ ".progress-bar-container { background: #e0e0e0; border-radius: 10px; height: 20px; margin: 15px 0; overflow: hidden; }");
|
|
|
+ html.append(
|
|
|
+ ".progress-bar { height: 100%; background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%); border-radius: 10px; }");
|
|
|
+
|
|
|
+ // 知识点表格样式
|
|
|
+ html.append(".kp-table { width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #ddd; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
|
|
|
+ html.append(".kp-table td { padding: 12px 15px; border: 1px solid #ddd; background: #fff; }");
|
|
|
+ html.append(".kp-table tr:nth-child(even) td { background: #f9f9f9; }");
|
|
|
+ html.append(
|
|
|
+ ".count-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-weight: bold; font-size: 12px; }");
|
|
|
+ html.append(".count-badge.wrong { background: #ffebee; color: #c62828; }");
|
|
|
+ html.append(".count-badge.weak { background: #fff3e0; color: #ef6c00; }");
|
|
|
+
|
|
|
+ // 知识点高亮样式
|
|
|
+ html.append(
|
|
|
+ ".kp-wrong { background-color: rgba(244, 67, 54, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #f44336; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-weak { background-color: rgba(255, 152, 0, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #FF9800; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-correct { background-color: rgba(76, 175, 80, 0.2); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #4CAF50; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-unreviewed { background-color: rgba(158, 158, 158, 0.15); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #9e9e9e; }");
|
|
|
+
|
|
|
+ // 知识点代码块高亮样式
|
|
|
+ html.append(
|
|
|
+ ".kp-block-wrong { border: 2px solid #f44336; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-block-wrong pre { background-color: #3d1d1d !important; margin: 0 !important; border-radius: 0 !important; }");
|
|
|
+
|
|
|
+ html.append(
|
|
|
+ ".kp-block-weak { border: 2px solid #FF9800; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-block-weak pre { background-color: #3d2d1d !important; margin: 0 !important; border-radius: 0 !important; }");
|
|
|
+
|
|
|
+ html.append(
|
|
|
+ ".kp-block-correct { border: 2px solid #4CAF50; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-block-correct pre { background-color: #1d3d1d !important; margin: 0 !important; border-radius: 0 !important; }");
|
|
|
+
|
|
|
+ html.append(
|
|
|
+ ".kp-block-unreviewed { border: 2px solid #9e9e9e; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
|
|
|
+ html.append(
|
|
|
+ ".kp-block-unreviewed pre { background-color: #3d3d3d !important; margin: 0 !important; border-radius: 0 !important; }");
|
|
|
+
|
|
|
+ // Block内容样式 - Markdown渲染
|
|
|
+ html.append(
|
|
|
+ ".block-content { background: #fff; padding: 25px; margin: 15px 0; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border: 1px solid #e8e8e8; }");
|
|
|
+ html.append(
|
|
|
+ ".block-content h1, .block-content h2, .block-content h3, .block-content h4, .block-content h5, .block-content h6 { color: #2c3e50; margin: 15px 0 10px 0; padding: 0; background: none; border: none; border-radius: 0; }");
|
|
|
+ html.append(".block-content h1 { font-size: 22px; border-bottom: 2px solid #eee; padding-bottom: 8px; }");
|
|
|
+ html.append(".block-content h2 { font-size: 18px; color: #34495e; }");
|
|
|
+ html.append(".block-content h3 { font-size: 16px; color: #555; }");
|
|
|
+ html.append(".block-content h4 { font-size: 15px; color: #666; }");
|
|
|
+ html.append(".block-content p { margin: 10px 0; line-height: 1.8; }");
|
|
|
+ html.append(".block-content ul, .block-content ol { margin: 10px 0; padding-left: 25px; }");
|
|
|
+ html.append(".block-content li { margin: 5px 0; line-height: 1.6; }");
|
|
|
+ html.append(
|
|
|
+ ".block-content code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
|
|
|
+ html.append(
|
|
|
+ ".block-content pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; white-space: pre-wrap; word-wrap: break-word; }");
|
|
|
+ html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
|
|
|
+ html.append(
|
|
|
+ ".block-content blockquote { border-left: 4px solid #3498db; padding-left: 15px; margin: 15px 0; color: #666; background: #f9f9f9; padding: 10px 15px; border-radius: 0 8px 8px 0; }");
|
|
|
+ html.append(".block-content strong, .block-content b { color: #2c3e50; font-weight: bold; }");
|
|
|
+ html.append(".block-content em, .block-content i { font-style: italic; color: #555; }");
|
|
|
+ html.append(".block-content del { text-decoration: line-through; color: #999; }");
|
|
|
+ // Markdown表格样式 - 增强边框
|
|
|
+ html.append(
|
|
|
+ ".block-content table { border-collapse: collapse; width: 100%; margin: 15px 0; border: 2px solid #667eea; }");
|
|
|
+ html.append(
|
|
|
+ ".block-content table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
|
|
|
+ html.append(".block-content table td { padding: 10px 15px; border: 1px solid #ddd; background: #fff; }");
|
|
|
+ html.append(".block-content table tr:nth-child(even) td { background: #f9f9f9; }");
|
|
|
+ // 代码块样式 - 增加语言标签支持
|
|
|
+ html.append(
|
|
|
+ ".block-content pre { background: #2d2d2d; color: #f8f8f2; padding: 25px 15px 15px 15px; border-radius: 8px; overflow-x: auto; margin: 15px 0; white-space: pre-wrap; word-wrap: break-word; position: relative; }");
|
|
|
+ html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
|
|
|
+ html.append(
|
|
|
+ ".code-lang { position: absolute; top: 0; right: 0; background: #444; color: #fff; padding: 2px 8px; border-radius: 0 8px 0 8px; font-size: 12px; font-family: sans-serif; }");
|
|
|
+ // 数学公式样式(以代码块形式显示)
|
|
|
+ html.append(
|
|
|
+ ".math { font-family: 'Consolas', 'Microsoft YaHei', monospace; background: #f8f8f8; padding: 10px 15px; border-radius: 6px; margin: 10px 0; border-left: 3px solid #9b59b6; color: #333; }");
|
|
|
+ html.append(
|
|
|
+ ".inline-math { font-family: 'Consolas', 'Microsoft YaHei', monospace; background: #f4f4f4; padding: 2px 6px; border-radius: 4px; color: #9b59b6; }");
|
|
|
+ // 水平分割线
|
|
|
+ html.append(
|
|
|
+ ".block-content hr { border: none; height: 2px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 20px 0; border-radius: 1px; }");
|
|
|
+
|
|
|
+ // 图例样式 - 使用table布局
|
|
|
+ html.append(
|
|
|
+ ".legend-table { width: 100%; background: #fff; border-radius: 8px; margin: 15px 0; border: 1px solid #eee; }");
|
|
|
+ html.append(".legend-table td { padding: 10px 15px; }");
|
|
|
+ html.append(
|
|
|
+ ".legend-color { display: inline-block; width: 20px; height: 20px; border-radius: 4px; vertical-align: middle; margin-right: 8px; }");
|
|
|
+ html.append(".legend-color-wrong { background-color: rgba(244, 67, 54, 0.35); border: 2px solid #f44336; }");
|
|
|
+ html.append(".legend-color-weak { background-color: rgba(255, 152, 0, 0.35); border: 2px solid #FF9800; }");
|
|
|
+ html.append(".legend-color-correct { background-color: rgba(76, 175, 80, 0.25); border: 2px solid #4CAF50; }");
|
|
|
+ html.append(
|
|
|
+ ".legend-color-unreviewed { background-color: rgba(158, 158, 158, 0.25); border: 2px solid #9e9e9e; }");
|
|
|
+
|
|
|
+ // 题目卡片样式
|
|
|
+ html.append(
|
|
|
+ ".question-card { background: #fff; margin: 15px 0; padding: 20px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border-left: 5px solid #ddd; }");
|
|
|
+ html.append(".question-card.wrong { border-left-color: #f44336; }");
|
|
|
+ html.append(".question-card.weak { border-left-color: #FF9800; }");
|
|
|
+ html.append(".question-card.correct { border-left-color: #4CAF50; }");
|
|
|
+ html.append(".question-card.active { border-left-color: #2196F3; }");
|
|
|
+ html.append(".question-header { margin-bottom: 15px; }");
|
|
|
+ html.append(".question-header-table { width: 100%; }");
|
|
|
+ html.append(".question-text { font-size: 15px; line-height: 1.7; color: #2c3e50; }");
|
|
|
+ html.append(
|
|
|
+ ".question-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; white-space: nowrap; }");
|
|
|
+ html.append(".badge-wrong { background: #eb3349; color: #fff; }");
|
|
|
+ html.append(".badge-weak { background: #f5576c; color: #fff; }");
|
|
|
+ html.append(".badge-correct { background: #11998e; color: #fff; }");
|
|
|
+ html.append(".badge-active { background: #4facfe; color: #fff; }");
|
|
|
+ html.append(
|
|
|
+ ".question-type { display: inline-block; padding: 2px 8px; background: #f0f0f0; border-radius: 4px; font-size: 12px; color: #666; margin-bottom: 10px; }");
|
|
|
+ html.append(".options { margin: 15px 0; padding: 10px 15px; background: #f8f9fa; border-radius: 8px; }");
|
|
|
+ html.append(
|
|
|
+ ".options p { margin: 8px 0; padding: 8px 12px; background: #fff; border-radius: 6px; border: 1px solid #eee; }");
|
|
|
+ html.append(".answer-section { margin-top: 15px; padding-top: 15px; border-top: 1px dashed #ddd; }");
|
|
|
+ html.append(".user-answer { color: #f44336; font-weight: bold; }");
|
|
|
+ html.append(".correct-answer { color: #4CAF50; font-weight: bold; }");
|
|
|
+ html.append(
|
|
|
+ ".explanation { margin-top: 15px; padding: 15px; background: #f5f7fa; border-radius: 8px; font-size: 14px; line-height: 1.8; }");
|
|
|
+ html.append(".explanation-title { font-weight: bold; color: #2c3e50; margin-bottom: 8px; }");
|
|
|
+ // 题目区域Markdown样式(加粗、斜体等)
|
|
|
+ html.append(
|
|
|
+ ".question-card strong, .question-card b { color: #2c3e50; font-weight: bold; font-family: 'SimHei', 'Microsoft YaHei', sans-serif; }");
|
|
|
+ html.append(".question-card em, .question-card i { font-style: italic; color: #555; }");
|
|
|
+ html.append(
|
|
|
+ ".question-card code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
|
|
|
+ html.append(".question-card p { margin: 8px 0; line-height: 1.7; }");
|
|
|
+ html.append(".question-card ul, .question-card ol { margin: 8px 0; padding-left: 20px; }");
|
|
|
+ html.append(".question-card li { margin: 4px 0; }");
|
|
|
+
|
|
|
+ // 柱状图样式
|
|
|
+ html.append(
|
|
|
+ ".bar-chart { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
|
|
|
+ html.append(".bar-item { margin: 12px 0; }");
|
|
|
+ html.append(".bar-label-row { margin-bottom: 5px; }");
|
|
|
+ html.append(".bar-label-table { width: 100%; }");
|
|
|
+ html.append(".bar-label { font-size: 13px; color: #333; }");
|
|
|
+ html.append(".bar-count { font-size: 13px; font-weight: bold; text-align: right; }");
|
|
|
+ html.append(".bar-bg { background: #ffebee; border-radius: 4px; height: 12px; overflow: hidden; }");
|
|
|
+ html.append(".bar-fill { height: 100%; background: linear-gradient(90deg, #f44336 0%, #ff7043 100%); }");
|
|
|
+ html.append(".bar-fill-weak { background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%); }");
|
|
|
+
|
|
|
+ // 标签卡片样式
|
|
|
+ html.append(
|
|
|
+ ".tag-container { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
|
|
|
+ html.append(
|
|
|
+ ".tag { display: inline-block; padding: 8px 16px; background: #fff3e0; border: 1px solid #FFB74D; border-radius: 20px; font-size: 13px; color: #e65100; margin: 5px; }");
|
|
|
+
|
|
|
+ // 分页样式
|
|
|
+ html.append(".page-break { page-break-before: always; margin-top: 40px; }");
|
|
|
+ html.append(
|
|
|
+ ".section-divider { height: 3px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 30px 0; border-radius: 2px; }");
|
|
|
+
|
|
|
+ // 白色背景区块
|
|
|
+ html.append(
|
|
|
+ ".white-box { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
|
|
|
+
|
|
|
+ html.append("</style></head><body>");
|
|
|
+
|
|
|
+ // ==================== 报告标题 ====================
|
|
|
+ html.append("<h1>复习报告-").append(escapeHtml(report.getDocumentTitle())).append("</h1>");
|
|
|
+ // ==================== 第一部分:统计信息 ====================
|
|
|
+ html.append("<h2>第一部分:学习统计</h2>");
|
|
|
+
|
|
|
+ // 统计卡片 - 使用table布局,四个卡片等宽(各25%)
|
|
|
+ html.append("<table class=\"stats-table\"><tr>");
|
|
|
+ html.append("<td width=\"25%\" class=\"stat-card total\"><div class=\"stat-number\">")
|
|
|
+ .append(report.getStatistics().getTotalQuestions())
|
|
|
+ .append("</div><div class=\"stat-label\">总题数</div></td>");
|
|
|
+ html.append("<td width=\"25%\" class=\"stat-card correct\"><div class=\"stat-number\">")
|
|
|
+ .append(report.getStatistics().getCorrectCount())
|
|
|
+ .append("</div><div class=\"stat-label\">正确</div></td>");
|
|
|
+ html.append("<td width=\"25%\" class=\"stat-card wrong\"><div class=\"stat-number\">")
|
|
|
+ .append(report.getStatistics().getWrongCount()).append("</div><div class=\"stat-label\">错误</div></td>");
|
|
|
+ html.append("<td width=\"25%\" class=\"stat-card weak\"><div class=\"stat-number\">")
|
|
|
+ .append(report.getStatistics().getWeakCount()).append("</div><div class=\"stat-label\">薄弱</div></td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+
|
|
|
+ // 答题分布 - 使用柱状图替代饼图(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("<h3>答题分布图</h3>");
|
|
|
+ html.append("<div class=\"bar-chart\">");
|
|
|
+
|
|
|
+ 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("<div class=\"bar-item\">");
|
|
|
+ html.append("<table class=\"bar-label-table\"><tr>");
|
|
|
+ html.append("<td class=\"bar-label\" style=\"color: #4CAF50;\">正确</td>");
|
|
|
+ html.append("<td class=\"bar-count\" style=\"color: #4CAF50;\">").append(correct).append(" (")
|
|
|
+ .append(String.format("%.1f", correctPct)).append("%)</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("<div style=\"background: #e8f5e9; border-radius: 4px; height: 16px; overflow: hidden;\">");
|
|
|
+ html.append("<div style=\"height: 100%; background: #4CAF50; width: ")
|
|
|
+ .append(String.format("%.1f", correctPct)).append("%;\"></div>");
|
|
|
+ html.append("</div></div>");
|
|
|
+
|
|
|
+ // 错误题目
|
|
|
+ html.append("<div class=\"bar-item\">");
|
|
|
+ html.append("<table class=\"bar-label-table\"><tr>");
|
|
|
+ html.append("<td class=\"bar-label\" style=\"color: #f44336;\">错误</td>");
|
|
|
+ html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wrong).append(" (")
|
|
|
+ .append(String.format("%.1f", wrongPct)).append("%)</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("<div style=\"background: #ffebee; border-radius: 4px; height: 16px; overflow: hidden;\">");
|
|
|
+ html.append("<div style=\"height: 100%; background: #f44336; width: ")
|
|
|
+ .append(String.format("%.1f", wrongPct)).append("%;\"></div>");
|
|
|
+ html.append("</div></div>");
|
|
|
+
|
|
|
+ // 薄弱题目
|
|
|
+ html.append("<div class=\"bar-item\">");
|
|
|
+ html.append("<table class=\"bar-label-table\"><tr>");
|
|
|
+ html.append("<td class=\"bar-label\" style=\"color: #FF9800;\">薄弱</td>");
|
|
|
+ html.append("<td class=\"bar-count\" style=\"color: #FF9800;\">").append(weak).append(" (")
|
|
|
+ .append(String.format("%.1f", weakPct)).append("%)</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("<div style=\"background: #fff3e0; border-radius: 4px; height: 16px; overflow: hidden;\">");
|
|
|
+ html.append("<div style=\"height: 100%; background: #FF9800; width: ")
|
|
|
+ .append(String.format("%.1f", weakPct)).append("%;\"></div>");
|
|
|
+ html.append("</div></div>");
|
|
|
+
|
|
|
+ // 未答题目
|
|
|
+ html.append("<div class=\"bar-item\">");
|
|
|
+ html.append("<table class=\"bar-label-table\"><tr>");
|
|
|
+ html.append("<td class=\"bar-label\" style=\"color: #9e9e9e;\">未答</td>");
|
|
|
+ html.append("<td class=\"bar-count\" style=\"color: #9e9e9e;\">").append(unanswered).append(" (")
|
|
|
+ .append(String.format("%.1f", unansweredPct)).append("%)</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("<div style=\"background: #f5f5f5; border-radius: 4px; height: 16px; overflow: hidden;\">");
|
|
|
+ html.append("<div style=\"height: 100%; background: #9e9e9e; width: ")
|
|
|
+ .append(String.format("%.1f", unansweredPct)).append("%;\"></div>");
|
|
|
+ html.append("</div></div>");
|
|
|
+
|
|
|
+ html.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 进度信息和正确率 - 合并为一行,避免跨页
|
|
|
+ html.append("<h3>复习进度与答题正确率</h3>");
|
|
|
+ html.append("<div class=\"white-box\">");
|
|
|
+ html.append("<table width=\"100%\"><tr>");
|
|
|
+ // 左边:复习进度
|
|
|
+ html.append("<td width=\"50%\" style=\"padding-right: 15px; vertical-align: top;\">");
|
|
|
+ html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">复习进度</p>");
|
|
|
+ html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">总内容块:<b>")
|
|
|
+ .append(report.getProgress().getTotalBlocks()).append("</b> | ");
|
|
|
+ html.append("已完成:<b style=\"color: #4CAF50;\">").append(report.getProgress().getCompletedBlocks())
|
|
|
+ .append("</b> | ");
|
|
|
+ html.append("学习中:<b style=\"color: #2196F3;\">").append(report.getProgress().getReadingBlocks())
|
|
|
+ .append("</b></p>");
|
|
|
+ html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
|
|
|
+ .append(String.format("%.1f", report.getProgress().getProgressPercent())).append("%;\"></div></div>");
|
|
|
+ html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: #4CAF50;\">完成率:")
|
|
|
+ .append(String.format("%.1f%%", report.getProgress().getProgressPercent())).append("</p>");
|
|
|
+ html.append("</td>");
|
|
|
+ // 右边:答题正确率
|
|
|
+ html.append(
|
|
|
+ "<td width=\"50%\" style=\"padding-left: 15px; vertical-align: top; border-left: 1px solid #eee;\">");
|
|
|
+ html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">答题正确率</p>");
|
|
|
+ double correctRate = report.getStatistics().getCorrectRate();
|
|
|
+ String rateColor = correctRate >= 80 ? "#4CAF50" : (correctRate >= 60 ? "#FF9800" : "#f44336");
|
|
|
+ html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">正确 ")
|
|
|
+ .append(report.getStatistics().getCorrectCount()).append(" / 总 ")
|
|
|
+ .append(report.getStatistics().getTotalQuestions()).append(" 题</p>");
|
|
|
+ html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
|
|
|
+ .append(String.format("%.1f", correctRate)).append("%; background: ").append(rateColor)
|
|
|
+ .append(";\"></div></div>");
|
|
|
+ html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: ").append(rateColor)
|
|
|
+ .append(";\">正确率:").append(String.format("%.1f%%", correctRate)).append("</p>");
|
|
|
+ html.append("</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("</div>");
|
|
|
+
|
|
|
+ // 错误知识点 - 使用柱状图展示
|
|
|
+ if (report.getWrongKnowledgePoints() != null && !report.getWrongKnowledgePoints().isEmpty()) {
|
|
|
+ html.append("<h3>错误知识点分布</h3>");
|
|
|
+ html.append("<div class=\"bar-chart\">");
|
|
|
+
|
|
|
+ // 找出最大次数用于计算柱状图宽度
|
|
|
+ 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("<div class=\"bar-item\">");
|
|
|
+ html.append("<table class=\"bar-label-table\"><tr>");
|
|
|
+ html.append("<td class=\"bar-label\">").append(escapeHtml(wp.getKnowledgePoint())).append("</td>");
|
|
|
+ html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wp.getCount())
|
|
|
+ .append(" 次</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+ html.append("<div class=\"bar-bg\">");
|
|
|
+ html.append("<div class=\"bar-fill\" style=\"width: ").append(String.format("%.1f", barWidth))
|
|
|
+ .append("%;\"></div>");
|
|
|
+ html.append("</div>");
|
|
|
+ html.append("</div>");
|
|
|
+ }
|
|
|
+ html.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 薄弱知识点 - 使用标签卡片展示(不显示次数)
|
|
|
+ if (report.getWeakKnowledgePoints() != null && !report.getWeakKnowledgePoints().isEmpty()) {
|
|
|
+ html.append("<h3>薄弱知识点</h3>");
|
|
|
+ html.append("<div class=\"tag-container\">");
|
|
|
+ for (WeakPointVO wp : report.getWeakKnowledgePoints()) {
|
|
|
+ html.append("<span class=\"tag\">");
|
|
|
+ html.append(escapeHtml(wp.getKnowledgePoint()));
|
|
|
+ html.append("</span>");
|
|
|
+ }
|
|
|
+ html.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 第二部分:原笔记再现 ====================
|
|
|
+ html.append("<div class=\"page-break\"></div>");
|
|
|
+ html.append("<h2>第二部分:笔记内容(知识点标注)</h2>");
|
|
|
+
|
|
|
+ // 图例说明 - 使用table布局
|
|
|
+ html.append("<table class=\"legend-table\"><tr>");
|
|
|
+ html.append("<td><span class=\"legend-color legend-color-wrong\"></span>错误知识点</td>");
|
|
|
+ html.append("<td><span class=\"legend-color legend-color-weak\"></span>薄弱知识点</td>");
|
|
|
+ html.append("<td><span class=\"legend-color legend-color-correct\"></span>已掌握知识点</td>");
|
|
|
+ html.append("<td><span class=\"legend-color legend-color-unreviewed\"></span>未复习知识点</td>");
|
|
|
+ html.append("</tr></table>");
|
|
|
+
|
|
|
+ // 按Block分组知识点
|
|
|
+ Map<Long, List<KnowledgePoint>> 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("<div class=\"block-content\">");
|
|
|
+ // 获取该Block的知识点并渲染高亮
|
|
|
+ List<KnowledgePoint> blockKps = kpByBlockId.getOrDefault(block.getId(), Collections.emptyList());
|
|
|
+ String highlightedContent = highlightKnowledgePointsInMarkdown(content, blockKps, wrongKpIds, weakKpIds,
|
|
|
+ correctKpIds, reviewedKpIds);
|
|
|
+ html.append(highlightedContent);
|
|
|
+ html.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 第三部分:题目展示 ====================
|
|
|
+ html.append("<div class=\"page-break\"></div>");
|
|
|
+ html.append("<h2>第三部分:题目汇总</h2>");
|
|
|
+ html.append("<p style=\"color: #666; margin-bottom: 20px;\">共 <b>").append(allQuestions.size())
|
|
|
+ .append("</b> 道题目</p>");
|
|
|
+
|
|
|
+ // 按状态分组展示题目
|
|
|
+ Map<QuestionStatus, List<Question>> questionsByStatus = allQuestions.stream()
|
|
|
+ .collect(Collectors.groupingBy(Question::getStatus));
|
|
|
+
|
|
|
+ // 错误题目
|
|
|
+ List<Question> wrongQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WRONG,
|
|
|
+ Collections.emptyList());
|
|
|
+ if (!wrongQuestions.isEmpty()) {
|
|
|
+ html.append("<h3>错误题目 (").append(wrongQuestions.size()).append("题)</h3>");
|
|
|
+ for (Question q : wrongQuestions) {
|
|
|
+ html.append(renderQuestionCard(q, "wrong", "错误"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 薄弱题目
|
|
|
+ List<Question> weakQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WEAK,
|
|
|
+ Collections.emptyList());
|
|
|
+ if (!weakQuestions.isEmpty()) {
|
|
|
+ html.append("<h3>薄弱题目 (").append(weakQuestions.size()).append("题)</h3>");
|
|
|
+ for (Question q : weakQuestions) {
|
|
|
+ html.append(renderQuestionCard(q, "weak", "薄弱"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正确题目
|
|
|
+ List<Question> correctQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_RIGHT,
|
|
|
+ Collections.emptyList());
|
|
|
+ if (!correctQuestions.isEmpty()) {
|
|
|
+ html.append("<h3>已掌握题目 (").append(correctQuestions.size()).append("题)</h3>");
|
|
|
+ for (Question q : correctQuestions) {
|
|
|
+ html.append(renderQuestionCard(q, "correct", "正确"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 未作答题目
|
|
|
+ List<Question> activeQuestions = questionsByStatus.getOrDefault(QuestionStatus.ACTIVE, Collections.emptyList());
|
|
|
+ if (!activeQuestions.isEmpty()) {
|
|
|
+ html.append("<h3>未作答题目 (").append(activeQuestions.size()).append("题)</h3>");
|
|
|
+ for (Question q : activeQuestions) {
|
|
|
+ html.append(renderQuestionCard(q, "active", "未作答"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ html.append("</body></html>");
|
|
|
+
|
|
|
+ // 后处理HTML:为代码块添加语言标签
|
|
|
+ String finalHtml = html.toString();
|
|
|
+ // 查找 <code class="language-xxx"> 并添加标签
|
|
|
+ finalHtml = finalHtml.replaceAll("<code class=\"language-([^\"]+)\">",
|
|
|
+ "<code class=\"language-$1\"><div class=\"code-lang\">$1</div>");
|
|
|
+
|
|
|
+ return finalHtml;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在Markdown内容中高亮知识点,然后渲染为HTML
|
|
|
+ * 策略:
|
|
|
+ * 1. 根据 sourceStart 和 sourceEnd 在原始 Markdown 中插入 HTML 注释标记
|
|
|
+ * 2. 渲染 Markdown 为 HTML(注释会被保留)
|
|
|
+ * 3. 找到注释位置,向外扩展到合适的标签边界,插入 span
|
|
|
+ * 4. 删除原始注释标记
|
|
|
+ */
|
|
|
+ private String highlightKnowledgePointsInMarkdown(String content, List<KnowledgePoint> knowledgePoints,
|
|
|
+ Set<Long> wrongKpIds, Set<Long> weakKpIds,
|
|
|
+ Set<Long> correctKpIds, Set<Long> 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<KnowledgePoint> 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</div>");
|
|
|
+ // 插入开始标签(注意添加换行符)
|
|
|
+ markedContent.insert(start, "<div class=\"" + blockClass + "\">\n\n");
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 找到第一个正常文字的相对位置(跳过Markdown格式标记)
|
|
|
+ int firstNormalCharOffset = findFirstNormalCharOffset(sourceText);
|
|
|
+ // 找到最后一个正常文字之后的相对位置
|
|
|
+ int lastNormalCharOffset = findLastNormalCharOffset(sourceText);
|
|
|
+
|
|
|
+ // 计算实际插入位置
|
|
|
+ int actualStart = start + firstNormalCharOffset;
|
|
|
+ int actualEnd = start + lastNormalCharOffset;
|
|
|
+
|
|
|
+ // 插入结束标记(先插入结束,因为从后往前)
|
|
|
+ String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
|
|
|
+ markedContent.insert(actualEnd, endMarker);
|
|
|
+
|
|
|
+ // 插入开始标记
|
|
|
+ String startMarker = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
|
|
|
+ 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 = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
|
|
|
+ String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
|
|
|
+
|
|
|
+ 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<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> 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<KnowledgePoint> knowledgePoints,
|
|
|
+ Set<Long> wrongKpIds, Set<Long> weakKpIds,
|
|
|
+ Set<Long> correctKpIds, Set<Long> reviewedKpIds) {
|
|
|
+ // 处理数学公式
|
|
|
+ String processedContent = processLatexFormulas(content);
|
|
|
+ // 渲染Markdown为HTML
|
|
|
+ Node document = markdownParser.parse(processedContent);
|
|
|
+ String html = htmlRenderer.render(document);
|
|
|
+
|
|
|
+ // 筛选有效的知识点(必须有sourceText)
|
|
|
+ List<KnowledgePoint> 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<Character> contentChars = new ArrayList<>();
|
|
|
+ List<Integer> 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();
|
|
|
+ // 移除开头的 </ 或 <
|
|
|
+ String tagName;
|
|
|
+ if (tagLower.startsWith("</")) {
|
|
|
+ tagName = tagLower.substring(2);
|
|
|
+ } else {
|
|
|
+ tagName = tagLower.substring(1);
|
|
|
+ }
|
|
|
+ // 移除 > 和属性
|
|
|
+ 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 - 4);
|
|
|
+ if (commentStart != -1) {
|
|
|
+ i = commentStart;
|
|
|
+ log.debug("expandStart: 跳过HTML注释,继续向前到 {}", i);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查前面是否有标签
|
|
|
+ int tagEnd = i - 1;
|
|
|
+ if (tagEnd >= 0 && html.charAt(tagEnd) == '>') {
|
|
|
+ // 前面有标签结束符,查找这个标签的开始
|
|
|
+ int tagStart = html.lastIndexOf('<', tagEnd);
|
|
|
+ if (tagStart != -1) {
|
|
|
+ String tag = html.substring(tagStart, tagEnd + 1);
|
|
|
+
|
|
|
+ // 跳过HTML注释
|
|
|
+ if (tag.startsWith("<!--")) {
|
|
|
+ i = tagStart;
|
|
|
+ log.debug("expandStart: 跳过HTML注释标签,继续向前到 {}", i);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.debug("expandStart: 发现标签 '{}' at {}-{}", tag, tagStart, tagEnd);
|
|
|
+
|
|
|
+ // 检查是否是开始标签(不是结束标签,也不是自闭合标签)
|
|
|
+ if (!tag.startsWith("</") && !tag.endsWith("/>")) {
|
|
|
+ // 检查是否是内联标签
|
|
|
+ if (isInlineTag(tag)) {
|
|
|
+ // 这是一个内联开始标签,我们应该在它之前插入 span
|
|
|
+ i = tagStart;
|
|
|
+ log.debug("expandStart: 是内联开始标签,继续向前到 {}", i);
|
|
|
+ continue;
|
|
|
+ } else {
|
|
|
+ // 这是块级开始标签(如 li, p),不应该跨越它
|
|
|
+ log.debug("expandStart: 是块级开始标签,停在 {}", i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 这是结束标签或自闭合标签,停在这里(标签之后)
|
|
|
+ log.debug("expandStart: 是结束/自闭合标签,停在 {}", i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 前面不是标签,停在当前位置
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.debug("expandToTagBoundaryStart: {} -> {}", pos, i);
|
|
|
+ return i;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 向后扩展到合适的标签边界
|
|
|
+ * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签
|
|
|
+ * 只扩展内联标签(如 /strong, /em),不扩展块级标签(如 /li, /p)
|
|
|
+ */
|
|
|
+ private int expandToTagBoundaryEnd(String html, int pos) {
|
|
|
+ int i = pos;
|
|
|
+
|
|
|
+ // 向后查找,跳过所有紧邻的内联结束标签和HTML注释
|
|
|
+ while (i < html.length()) {
|
|
|
+ // 跳过HTML注释
|
|
|
+ if (i + 4 <= html.length() && html.substring(i, Math.min(i + 4, html.length())).equals("<!--")) {
|
|
|
+ // 后面有HTML注释开始,查找注释结束
|
|
|
+ int commentEnd = html.indexOf("-->", 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("<!--")) {
|
|
|
+ i = tagEnd + 1;
|
|
|
+ log.debug("expandEnd: 跳过HTML注释标签,继续向后到 {}", i);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.debug("expandEnd: 发现标签 '{}' at {}-{}", tag, i, tagEnd);
|
|
|
+
|
|
|
+ // 检查是否是结束标签
|
|
|
+ if (tag.startsWith("</")) {
|
|
|
+ // 检查是否是内联标签
|
|
|
+ if (isInlineTag(tag)) {
|
|
|
+ // 这是一个内联结束标签,我们应该在它之后插入 span 结束
|
|
|
+ i = tagEnd + 1;
|
|
|
+ log.debug("expandEnd: 是内联结束标签,继续向后到 {}", i);
|
|
|
+ continue;
|
|
|
+ } else {
|
|
|
+ // 这是块级结束标签(如 /li, /p),不应该跨越它
|
|
|
+ log.debug("expandEnd: 是块级结束标签,停在 {}", i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 这是开始标签或自闭合标签,停在这里(标签之前)
|
|
|
+ log.debug("expandEnd: 是开始/自闭合标签,停在 {}", i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 后面不是标签,停在当前位置
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.debug("expandToTagBoundaryEnd: {} -> {}", pos, i);
|
|
|
+ return i;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查当前位置是否已经在kp-span内
|
|
|
+ */
|
|
|
+ private boolean isInsideKpSpan(String htmlBefore) {
|
|
|
+ int lastKpStart = htmlBefore.lastIndexOf("<span class=\"kp-");
|
|
|
+ int lastSpanEnd = htmlBefore.lastIndexOf("</span>");
|
|
|
+ return lastKpStart > lastSpanEnd && lastKpStart != -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 简单的HTML高亮方式(回退方案)
|
|
|
+ */
|
|
|
+ private String simpleHighlightInHtml(String html, String sourceText, String cssClass) {
|
|
|
+ // 提取sourceText中的关键文本片段进行高亮
|
|
|
+ List<String> segments = extractTextSegments(sourceText);
|
|
|
+
|
|
|
+ String result = html;
|
|
|
+ for (String segment : segments) {
|
|
|
+ if (segment.length() >= 2) {
|
|
|
+ result = smartHighlightInHtml(result, segment, cssClass);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从知识点文本中提取多个文本片段(按标点和格式分割)
|
|
|
+ * 注意:sourceText应该已经是不含公式的纯文本,这里做二次清理以确保安全
|
|
|
+ */
|
|
|
+ private List<String> extractTextSegments(String kpText) {
|
|
|
+ List<String> segments = new ArrayList<>();
|
|
|
+
|
|
|
+ // 先清理Markdown格式标记
|
|
|
+ String cleaned = kpText;
|
|
|
+
|
|
|
+ // 二次清理:移除任何残留的公式标记(理论上sourceText应该已经不包含公式)
|
|
|
+ cleaned = cleaned.replaceAll("\\$\\$[^$]*\\$\\$", " ");
|
|
|
+ cleaned = cleaned.replaceAll("\\$[^$]*\\$", " ");
|
|
|
+ cleaned = cleaned.replaceAll("\\\\\\[[^\\]]*\\\\\\]", " ");
|
|
|
+ cleaned = cleaned.replaceAll("\\\\\\([^)]*\\\\\\)", " ");
|
|
|
+
|
|
|
+ // 提取粗体内容作为单独片段(不包含公式)
|
|
|
+ java.util.regex.Pattern boldPattern = java.util.regex.Pattern.compile("\\*\\*([^*]+)\\*\\*");
|
|
|
+ java.util.regex.Matcher boldMatcher = boldPattern.matcher(cleaned);
|
|
|
+ while (boldMatcher.find()) {
|
|
|
+ String boldText = boldMatcher.group(1).trim();
|
|
|
+ // 再次检查,确保不包含公式标记
|
|
|
+ if (!boldText.isEmpty() && !containsFormulaMarkers(boldText)) {
|
|
|
+ segments.add(boldText);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除Markdown格式标记
|
|
|
+ cleaned = cleaned.replaceAll("\\*\\*([^*]+)\\*\\*", "$1");
|
|
|
+ cleaned = cleaned.replaceAll("\\*([^*]+)\\*", "$1");
|
|
|
+ cleaned = cleaned.replaceAll("__([^_]+)__", "$1");
|
|
|
+ cleaned = cleaned.replaceAll("_([^_]+)_", "$1");
|
|
|
+ cleaned = cleaned.replaceAll("`([^`]+)`", "$1");
|
|
|
+
|
|
|
+ // 移除列表标记
|
|
|
+ cleaned = cleaned.replaceAll("^[*\\-+]\\s+", "");
|
|
|
+ cleaned = cleaned.replaceAll("\\n[*\\-+]\\s+", "\n");
|
|
|
+
|
|
|
+ // 按标点符号分割成片段
|
|
|
+ String[] parts = cleaned.split("[,。、:;!?\\n\\r]+");
|
|
|
+ for (String part : parts) {
|
|
|
+ String trimmed = part.trim();
|
|
|
+ // 只添加有意义的片段(至少2个字符,不重复,不包含公式)
|
|
|
+ if (trimmed.length() >= 2 && !segments.contains(trimmed) && !containsFormulaMarkers(trimmed)) {
|
|
|
+ segments.add(trimmed);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return segments;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 智能地在HTML中高亮文本,保持HTML标签不被破坏
|
|
|
+ * 支持跨越HTML标签的文本匹配(如 <strong>机器学习</strong>:让模型...)
|
|
|
+ */
|
|
|
+ private String smartHighlightInHtml(String html, String searchText, String cssClass) {
|
|
|
+ if (searchText == null || searchText.isEmpty()) {
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否已经被精确高亮过(完整的span标签包裹searchText)
|
|
|
+ if (html.contains("<span class=\"" + cssClass + "\">" + searchText + "</span>")) {
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 尝试直接匹配(文本不跨越标签的情况)
|
|
|
+ int idx = findTextOutsideTags(html, searchText);
|
|
|
+ if (idx >= 0) {
|
|
|
+ // 检查这个位置是否已经在kp-span内
|
|
|
+ String beforeText = html.substring(0, idx);
|
|
|
+ int lastKpStart = beforeText.lastIndexOf("<span class=\"kp-");
|
|
|
+ int lastSpanEnd = beforeText.lastIndexOf("</span>");
|
|
|
+ if (lastKpStart > lastSpanEnd && lastKpStart != -1) {
|
|
|
+ // 已经在kp-span内,跳过
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+
|
|
|
+ String before = html.substring(0, idx);
|
|
|
+ String after = html.substring(idx + searchText.length());
|
|
|
+ return before + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + after;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果直接匹配失败,尝试在HTML标签内部查找(如<strong>文本</strong>中的"文本")
|
|
|
+ // 使用正则查找被标签包裹的文本
|
|
|
+ String escapedSearch = Pattern.quote(searchText);
|
|
|
+
|
|
|
+ // 匹配 >searchText< 模式(文本在标签之间)
|
|
|
+ Pattern pattern = Pattern.compile("(>)" + escapedSearch + "(<)");
|
|
|
+ java.util.regex.Matcher matcher = pattern.matcher(html);
|
|
|
+ if (matcher.find()) {
|
|
|
+ // 检查是否已在kp-span内
|
|
|
+ String beforeMatch = html.substring(0, matcher.start());
|
|
|
+ int lastKpStart = beforeMatch.lastIndexOf("<span class=\"kp-");
|
|
|
+ int lastSpanEnd = beforeMatch.lastIndexOf("</span>");
|
|
|
+ if (lastKpStart <= lastSpanEnd || lastKpStart == -1) {
|
|
|
+ return html.substring(0, matcher.start()) +
|
|
|
+ ">" + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + "<" +
|
|
|
+ html.substring(matcher.end());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在HTML中查找文本,确保不在标签属性内
|
|
|
+ */
|
|
|
+ private int findTextOutsideTags(String html, String searchText) {
|
|
|
+ int searchLen = searchText.length();
|
|
|
+ int htmlLen = html.length();
|
|
|
+
|
|
|
+ boolean inTag = false;
|
|
|
+ int i = 0;
|
|
|
+ while (i <= htmlLen - searchLen) {
|
|
|
+ char c = html.charAt(i);
|
|
|
+
|
|
|
+ if (c == '<') {
|
|
|
+ inTag = true;
|
|
|
+ i++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (c == '>') {
|
|
|
+ inTag = false;
|
|
|
+ i++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!inTag) {
|
|
|
+ // 检查从这个位置开始是否匹配
|
|
|
+ if (html.regionMatches(i, searchText, 0, searchLen)) {
|
|
|
+ return i;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ i++;
|
|
|
+ }
|
|
|
+ return -1;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检测文本是否包含公式标记
|
|
|
+ */
|
|
|
+ private boolean containsFormulaMarkers(String text) {
|
|
|
+ if (text == null || text.isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 检查各种公式标记
|
|
|
+ return text.contains("$$") ||
|
|
|
+ text.contains("\\[") ||
|
|
|
+ text.contains("\\]") ||
|
|
|
+ text.contains("\\(") ||
|
|
|
+ text.contains("\\)") ||
|
|
|
+ (text.contains("$") && text.indexOf("$") != text.lastIndexOf("$"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 渲染题目卡片 - 美观设计版本(兼容iText PDF)
|
|
|
+ */
|
|
|
+ private String renderQuestionCard(Question question, String statusClass, String statusLabel) {
|
|
|
+ StringBuilder card = new StringBuilder();
|
|
|
+ card.append("<div class=\"question-card ").append(statusClass).append("\">");
|
|
|
+
|
|
|
+ // 题目头部:题目内容 + 状态标签 - 使用table布局
|
|
|
+ card.append("<div class=\"question-header\">");
|
|
|
+ card.append("<table class=\"question-header-table\"><tr>");
|
|
|
+ card.append("<td class=\"question-text\">");
|
|
|
+ // 渲染题目文本的Markdown(包含公式解析)
|
|
|
+ String questionHtml = renderMarkdownInline(question.getQuestionText());
|
|
|
+ card.append(questionHtml);
|
|
|
+ card.append("</td>");
|
|
|
+ card.append("<td style=\"width: 80px; text-align: right; vertical-align: top;\">");
|
|
|
+ card.append("<span class=\"question-badge badge-").append(statusClass).append("\">").append(statusLabel)
|
|
|
+ .append("</span>");
|
|
|
+ card.append("</td>");
|
|
|
+ card.append("</tr></table>");
|
|
|
+ card.append("</div>");
|
|
|
+
|
|
|
+ // 题目类型
|
|
|
+ String typeLabel;
|
|
|
+ switch (question.getType()) {
|
|
|
+ case SINGLE:
|
|
|
+ typeLabel = "单选题";
|
|
|
+ break;
|
|
|
+ case MULTIPLE:
|
|
|
+ typeLabel = "多选题";
|
|
|
+ break;
|
|
|
+ case ESSAY:
|
|
|
+ typeLabel = "问答题";
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ typeLabel = "未知";
|
|
|
+ }
|
|
|
+ card.append("<span class=\"question-type\">").append(typeLabel).append("</span>");
|
|
|
+
|
|
|
+ // 题目来源
|
|
|
+ String sourceLabel;
|
|
|
+ if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.REVIEW) {
|
|
|
+ sourceLabel = "复习题";
|
|
|
+ } else if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) {
|
|
|
+ sourceLabel = "划词提问";
|
|
|
+ } else {
|
|
|
+ sourceLabel = "知识点";
|
|
|
+ }
|
|
|
+ card.append("<span class=\"question-type\" style=\"margin-left: 10px; background: #e3f2fd; color: #1565c0;\">")
|
|
|
+ .append(sourceLabel).append("</span>");
|
|
|
+
|
|
|
+ // 选项(如果有)- 也要解析公式
|
|
|
+ if (question.getOptions() != null && !question.getOptions().isEmpty()) {
|
|
|
+ card.append("<div class=\"options\">");
|
|
|
+ char optionLabel = 'A';
|
|
|
+ for (String option : question.getOptions()) {
|
|
|
+ String trimmedOption = option.trim();
|
|
|
+ // 检查选项是否已经以A. B. C. D.等开头
|
|
|
+ boolean hasPrefix = trimmedOption.length() >= 2 &&
|
|
|
+ Character.isLetter(trimmedOption.charAt(0)) &&
|
|
|
+ (trimmedOption.charAt(1) == '.' || trimmedOption.charAt(1) == '、' ||
|
|
|
+ trimmedOption.charAt(1) == ')' || trimmedOption.charAt(1) == ')');
|
|
|
+
|
|
|
+ // 处理选项中的公式和Markdown格式
|
|
|
+ String processedOption = renderMarkdownInline(option);
|
|
|
+ // 移除Markdown生成的外层p标签(因为我们自己添加了p标签)
|
|
|
+ processedOption = processedOption.replaceAll("^<p>", "").replaceAll("</p>\\s*$", "");
|
|
|
+
|
|
|
+ if (hasPrefix) {
|
|
|
+ // 已有前缀,直接显示
|
|
|
+ card.append("<p>").append(processedOption).append("</p>");
|
|
|
+ } else {
|
|
|
+ // 没有前缀,添加标签
|
|
|
+ card.append("<p>").append(optionLabel).append(". ").append(processedOption).append("</p>");
|
|
|
+ }
|
|
|
+ optionLabel++;
|
|
|
+ }
|
|
|
+ card.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 答案区域
|
|
|
+ card.append("<div class=\"answer-section\">");
|
|
|
+
|
|
|
+ // 用户答案(如果是错误或薄弱题目)
|
|
|
+ if (statusClass.equals("wrong") || statusClass.equals("weak")) {
|
|
|
+ userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId())
|
|
|
+ .ifPresent(answer -> {
|
|
|
+ String processedUserAnswer = processLatexFormulas(answer.getUserAnswer());
|
|
|
+ card.append("<p><span class=\"user-answer\">你的答案:</span>")
|
|
|
+ .append(processedUserAnswer).append("</p>");
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正确答案(也要处理公式)
|
|
|
+ String processedCorrectAnswer = processLatexFormulas(question.getCorrectAnswer());
|
|
|
+ card.append("<p><span class=\"correct-answer\">正确答案:</span>").append(processedCorrectAnswer).append("</p>");
|
|
|
+ card.append("</div>");
|
|
|
+
|
|
|
+ // 解析
|
|
|
+ if (question.getExplanation() != null && !question.getExplanation().isEmpty()) {
|
|
|
+ card.append("<div class=\"explanation\">");
|
|
|
+ card.append("<div class=\"explanation-title\">解析</div>");
|
|
|
+ // 渲染解析中的Markdown(包含公式解析)
|
|
|
+ String explanationHtml = renderMarkdownInline(question.getExplanation());
|
|
|
+ card.append(explanationHtml);
|
|
|
+ card.append("</div>");
|
|
|
+ }
|
|
|
+
|
|
|
+ card.append("</div>");
|
|
|
+ return card.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final Set<String> STRICT_TAGS = new HashSet<>(Arrays.asList(
|
|
|
+ "ul", "ol", "dl", "table", "thead", "tbody", "tfoot", "tr", "colgroup"));
|
|
|
+
|
|
|
+ private static final Set<String> ITEM_TAGS = new HashSet<>(Arrays.asList(
|
|
|
+ "li", "dt", "dd", "td", "th", "caption"));
|
|
|
+
|
|
|
+ private static final Set<String> FLOW_TAGS = new HashSet<>(Arrays.asList(
|
|
|
+ "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote",
|
|
|
+ "form", "section", "header", "footer", "article", "aside", "main",
|
|
|
+ "nav", "figure", "figcaption", "hr"));
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 智能应用高亮到HTML内容,处理块级标签
|
|
|
+ * 避免将块级标签(如ul, div)包裹在span中,而是将span推入到内容层
|
|
|
+ */
|
|
|
+ private String applyHighlightToHtml(String html, String cssClass) {
|
|
|
+ if (html == null || html.isEmpty()) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ // 初始状态:开启高亮
|
|
|
+ boolean inSpan = true;
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+
|
|
|
+ // 正则匹配HTML标签:</?tagName...>
|
|
|
+ // 捕获组1:是否为结束标签 /
|
|
|
+ // 捕获组2:标签名
|
|
|
+ Pattern tagPattern = Pattern.compile("<(/)?([a-zA-Z0-9]+)[^>]*>", Pattern.CASE_INSENSITIVE);
|
|
|
+ java.util.regex.Matcher matcher = tagPattern.matcher(html);
|
|
|
+
|
|
|
+ int lastEnd = 0;
|
|
|
+ while (matcher.find()) {
|
|
|
+ // 添加标签前的文本
|
|
|
+ String text = html.substring(lastEnd, matcher.start());
|
|
|
+ sb.append(text);
|
|
|
+
|
|
|
+ String isEndTag = matcher.group(1); // "/" or null
|
|
|
+ String tagName = matcher.group(2).toLowerCase();
|
|
|
+ String fullTag = matcher.group(0);
|
|
|
+
|
|
|
+ boolean isStartTag = (isEndTag == null);
|
|
|
+
|
|
|
+ if (STRICT_TAGS.contains(tagName)) {
|
|
|
+ // 严格容器(ul, table等):不能包含文本,必须关闭span
|
|
|
+ if (isStartTag) {
|
|
|
+ // 开始标签:关闭当前span
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ inSpan = false;
|
|
|
+ }
|
|
|
+ sb.append(fullTag);
|
|
|
+ } else {
|
|
|
+ // 结束标签:重新开启span(如果需要)
|
|
|
+ sb.append(fullTag);
|
|
|
+ if (!inSpan) {
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if (ITEM_TAGS.contains(tagName)) {
|
|
|
+ // 项目容器(li, td等):内容需要高亮
|
|
|
+ if (isStartTag) {
|
|
|
+ // 开始标签:先关闭(以防万一),添加标签,然后开启
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ }
|
|
|
+ sb.append(fullTag);
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ } else {
|
|
|
+ // 结束标签:关闭当前span
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ inSpan = false;
|
|
|
+ }
|
|
|
+ sb.append(fullTag);
|
|
|
+ // 结束后通常紧跟空白或下一个项目开始,暂时不开启,由下一个显式内容或父级结束来处理
|
|
|
+ // 但为了保持连续性(如li之间的空白),我们可以选择开启
|
|
|
+ // 这里选择开启,以便捕获标签间的空白(虽然通常不可见)
|
|
|
+ // 或者更安全地:不开启,等待下一个显式内容?
|
|
|
+ // 根据之前的分析:</li>后通常是<li>或</ul>。
|
|
|
+ // 如果是<li>,它会处理开启。如果是</ul>,它会处理开启。
|
|
|
+ // 所以这里不开启是安全的,除了丢失空白的高亮(无所谓)。
|
|
|
+ // 但是!如果</li>后面有文本节点(不规范HTML),则不会高亮。
|
|
|
+ // 为了稳健性,我们还是开启吧,反正空白高亮看不见。
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ }
|
|
|
+ } else if (FLOW_TAGS.contains(tagName)) {
|
|
|
+ // 流式容器(p, div等):内容需要高亮
|
|
|
+ if (isStartTag) {
|
|
|
+ // 开始标签:先关闭,添加标签,然后开启
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ }
|
|
|
+ sb.append(fullTag);
|
|
|
+ // 对于自闭合标签如 <hr>,不需要开启
|
|
|
+ if (!fullTag.endsWith("/>") && !tagName.equals("hr")) {
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ } else {
|
|
|
+ // 自闭合标签后,恢复开启状态(因为我们假设整个段落都要高亮)
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 结束标签:关闭当前span,添加标签,然后重新开启
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ }
|
|
|
+ sb.append(fullTag);
|
|
|
+ sb.append("<span class=\"").append(cssClass).append("\">");
|
|
|
+ inSpan = true;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 内联标签或其他:直接添加,保持span状态
|
|
|
+ sb.append(fullTag);
|
|
|
+ }
|
|
|
+
|
|
|
+ lastEnd = matcher.end();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加剩余文本
|
|
|
+ sb.append(html.substring(lastEnd));
|
|
|
+
|
|
|
+ // 结束时关闭span
|
|
|
+ if (inSpan) {
|
|
|
+ sb.append("</span>");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理空span
|
|
|
+ String result = sb.toString();
|
|
|
+ String emptySpan = "<span class=\"" + cssClass + "\"></span>";
|
|
|
+ // 循环替换直到没有空span(处理嵌套产生的空span)
|
|
|
+ while (result.contains(emptySpan)) {
|
|
|
+ result = result.replace(emptySpan, "");
|
|
|
+ }
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 渲染内联Markdown(用于题目和解析),同时处理数学公式
|
|
|
+ */
|
|
|
+ private String renderMarkdownInline(String text) {
|
|
|
+ if (text == null || text.isEmpty()) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ // 先处理数学公式,将其转换为HTML样式显示
|
|
|
+ String processedText = processLatexFormulas(text);
|
|
|
+ Node document = markdownParser.parse(processedText);
|
|
|
+ return htmlRenderer.render(document);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理LaTeX数学公式,将公式渲染为图片(Base64)嵌入HTML
|
|
|
+ * 支持多种格式:$$...$$ 块级公式,$...$ 行内公式,\[...\] 块级,\(...\) 行内
|
|
|
+ */
|
|
|
+ private String processLatexFormulas(String text) {
|
|
|
+ if (text == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+
|
|
|
+ String result = text;
|
|
|
+
|
|
|
+ // 1. 处理块级公式 \[...\]
|
|
|
+ result = processFormulaPatternToImage(result, "\\[", "\\]", true);
|
|
|
+
|
|
|
+ // 2. 处理块级公式 $$...$$
|
|
|
+ result = processFormulaPatternToImage(result, "$$", "$$", true);
|
|
|
+
|
|
|
+ // 3. 处理行内公式 \(...\)
|
|
|
+ result = processFormulaPatternToImage(result, "\\(", "\\)", false);
|
|
|
+
|
|
|
+ // 4. 处理行内公式 $...$ (需要特别处理避免误匹配$$)
|
|
|
+ result = processDollarFormulasToImage(result);
|
|
|
+
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用公式模式处理 - 渲染为图片
|
|
|
+ */
|
|
|
+ private String processFormulaPatternToImage(String text, String startDelim, String endDelim, boolean isBlock) {
|
|
|
+ StringBuilder result = new StringBuilder();
|
|
|
+ int i = 0;
|
|
|
+ while (i < text.length()) {
|
|
|
+ int start = text.indexOf(startDelim, i);
|
|
|
+ if (start == -1) {
|
|
|
+ result.append(text.substring(i));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ result.append(text, i, start);
|
|
|
+
|
|
|
+ int end = text.indexOf(endDelim, start + startDelim.length());
|
|
|
+ if (end == -1) {
|
|
|
+ result.append(text.substring(start));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ String formula = text.substring(start + startDelim.length(), end);
|
|
|
+ String renderedFormula = renderLatexToImage(formula.trim(), isBlock);
|
|
|
+ result.append(renderedFormula);
|
|
|
+
|
|
|
+ i = end + endDelim.length();
|
|
|
+ }
|
|
|
+ return result.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 专门处理 $...$ 行内公式(避免误匹配 $$)- 渲染为图片
|
|
|
+ * 更严格的匹配规则:
|
|
|
+ * 1. $ 前后不能紧邻字母数字(避免误匹配如 $10 价格)
|
|
|
+ * 2. 公式内容必须包含LaTeX特征字符(如 \、^、_、{、})或是纯数学表达式
|
|
|
+ */
|
|
|
+ private String processDollarFormulasToImage(String text) {
|
|
|
+ StringBuilder result = new StringBuilder();
|
|
|
+ int i = 0;
|
|
|
+ while (i < text.length()) {
|
|
|
+ int inlineStart = text.indexOf("$", i);
|
|
|
+ if (inlineStart == -1) {
|
|
|
+ result.append(text.substring(i));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否是 $$ 开头(跳过,因为已处理过或不应处理)
|
|
|
+ if (inlineStart + 1 < text.length() && text.charAt(inlineStart + 1) == '$') {
|
|
|
+ result.append(text, i, inlineStart + 2);
|
|
|
+ i = inlineStart + 2;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查 $ 前面是否紧邻字母数字(如果是,可能不是公式)
|
|
|
+ if (inlineStart > 0) {
|
|
|
+ char prevChar = text.charAt(inlineStart - 1);
|
|
|
+ if (Character.isLetterOrDigit(prevChar)) {
|
|
|
+ result.append(text, i, inlineStart + 1);
|
|
|
+ i = inlineStart + 1;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ result.append(text, i, inlineStart);
|
|
|
+
|
|
|
+ // 查找配对的 $
|
|
|
+ int inlineEnd = text.indexOf("$", inlineStart + 1);
|
|
|
+ if (inlineEnd == -1) {
|
|
|
+ result.append(text.substring(inlineStart));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查结束符是否是 $$ 的一部分
|
|
|
+ if (inlineEnd + 1 < text.length() && text.charAt(inlineEnd + 1) == '$') {
|
|
|
+ result.append(text, inlineStart, inlineEnd + 2);
|
|
|
+ i = inlineEnd + 2;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ String formula = text.substring(inlineStart + 1, inlineEnd);
|
|
|
+ // 公式内容不为空,且看起来像LaTeX公式才处理
|
|
|
+ if (!formula.trim().isEmpty() && looksLikeLatexFormula(formula)) {
|
|
|
+ String renderedFormula = renderLatexToImage(formula.trim(), false);
|
|
|
+ result.append(renderedFormula);
|
|
|
+ } else {
|
|
|
+ // 不像公式,保留原文
|
|
|
+ result.append("$").append(formula).append("$");
|
|
|
+ }
|
|
|
+
|
|
|
+ i = inlineEnd + 1;
|
|
|
+ }
|
|
|
+ return result.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 判断内容是否看起来像LaTeX公式
|
|
|
+ * 包含LaTeX特征字符或是数学表达式
|
|
|
+ */
|
|
|
+ private boolean looksLikeLatexFormula(String content) {
|
|
|
+ if (content == null || content.trim().isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ // 包含LaTeX命令(以\开头)
|
|
|
+ if (content.contains("\\")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 包含上下标
|
|
|
+ if (content.contains("^") || content.contains("_")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 包含花括号(LaTeX分组)
|
|
|
+ if (content.contains("{") || content.contains("}")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 包含数学运算符
|
|
|
+ if (content.contains("+") || content.contains("-") || content.contains("=") ||
|
|
|
+ content.contains("*") || content.contains("/") || content.contains("×") ||
|
|
|
+ content.contains("÷") || content.contains("≠") || content.contains("≤") ||
|
|
|
+ content.contains("≥") || content.contains("±")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 包含希腊字母名称
|
|
|
+ if (content.matches(".*\\b(alpha|beta|gamma|delta|theta|pi|sigma|omega|lambda|mu)\\b.*")) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 纯数字和简单变量(如 x, y, a, b 等)
|
|
|
+ if (content.matches("^[a-zA-Z0-9\\s.,]+$") && content.length() <= 3) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将LaTeX公式渲染为Base64图片
|
|
|
+ *
|
|
|
+ * @param latex LaTeX公式文本
|
|
|
+ * @param isBlock 是否为块级公式(块级字体更大,居中显示)
|
|
|
+ * @return HTML img标签,包含Base64编码的图片
|
|
|
+ */
|
|
|
+ private String renderLatexToImage(String latex, boolean isBlock) {
|
|
|
+ if (latex == null || latex.trim().isEmpty()) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 创建 TeXFormula
|
|
|
+ TeXFormula formula = new TeXFormula(latex);
|
|
|
+
|
|
|
+ // 设置字体大小:块级公式稍大,行内公式与正文匹配
|
|
|
+ float fontSize = isBlock ? 22f : 15f;
|
|
|
+
|
|
|
+ // 创建 TeXIcon
|
|
|
+ TeXIcon icon = formula.createTeXIcon(TeXConstants.STYLE_DISPLAY, fontSize);
|
|
|
+ icon.setInsets(new Insets(2, 2, 2, 2));
|
|
|
+
|
|
|
+ // 创建图片
|
|
|
+ BufferedImage image = new BufferedImage(
|
|
|
+ icon.getIconWidth(),
|
|
|
+ icon.getIconHeight(),
|
|
|
+ BufferedImage.TYPE_INT_ARGB);
|
|
|
+
|
|
|
+ Graphics2D g2 = image.createGraphics();
|
|
|
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
|
|
+ g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
|
|
+ g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
|
|
+
|
|
|
+ // 设置白色背景
|
|
|
+ g2.setColor(Color.WHITE);
|
|
|
+ g2.fillRect(0, 0, icon.getIconWidth(), icon.getIconHeight());
|
|
|
+
|
|
|
+ // 绘制公式(黑色)
|
|
|
+ JLabel label = new JLabel();
|
|
|
+ label.setForeground(new Color(51, 51, 51)); // 深灰色 #333
|
|
|
+ icon.paintIcon(label, g2, 0, 0);
|
|
|
+ g2.dispose();
|
|
|
+
|
|
|
+ // 转换为 Base64
|
|
|
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
|
|
+ ImageIO.write(image, "PNG", baos);
|
|
|
+ String base64 = Base64.getEncoder().encodeToString(baos.toByteArray());
|
|
|
+
|
|
|
+ // 返回HTML img标签
|
|
|
+ if (isBlock) {
|
|
|
+ return "<div style=\"text-align: center; margin: 15px 0;\"><img src=\"data:image/png;base64," + base64
|
|
|
+ + "\" alt=\"formula\" style=\"max-width: 100%;\"/></div>";
|
|
|
+ } else {
|
|
|
+ // 行内公式:垂直居中,高度自适应,不限制最大高度
|
|
|
+ return "<img src=\"data:image/png;base64," + base64
|
|
|
+ + "\" alt=\"formula\" style=\"vertical-align: middle; height: auto;\"/>";
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("LaTeX公式渲染失败: {}, 错误: {}", latex, e.getMessage());
|
|
|
+ // 渲染失败时,返回带样式的源代码显示
|
|
|
+ String escapedLatex = escapeHtmlForFormula(latex);
|
|
|
+ if (isBlock) {
|
|
|
+ return "<div class=\"math\">" + escapedLatex + "</div>";
|
|
|
+ } else {
|
|
|
+ return "<span class=\"inline-math\">" + escapedLatex + "</span>";
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对公式内容进行HTML转义(保留部分符号的可读性)
|
|
|
+ */
|
|
|
+ private String escapeHtmlForFormula(String formula) {
|
|
|
+ if (formula == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return formula.trim()
|
|
|
+ .replace("&", "&")
|
|
|
+ .replace("<", "<")
|
|
|
+ .replace(">", ">")
|
|
|
+ .replace("\"", """);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * HTML转义
|
|
|
+ */
|
|
|
+ private String escapeHtml(String text) {
|
|
|
+ if (text == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return text.replace("&", "&")
|
|
|
+ .replace("<", "<")
|
|
|
+ .replace(">", ">")
|
|
|
+ .replace("\"", """)
|
|
|
+ .replace("'", "'")
|
|
|
+ .replace("\n", "<br/>");
|
|
|
+ }
|
|
|
+
|
|
|
+}
|