| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109 |
- 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/>");
- }
- }
|