ReportServiceImpl.java 99 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109
  1. package com.smartreview.service.impl;
  2. import com.itextpdf.html2pdf.ConverterProperties;
  3. import com.itextpdf.html2pdf.HtmlConverter;
  4. import com.itextpdf.html2pdf.resolver.font.DefaultFontProvider;
  5. import com.itextpdf.io.font.FontProgram;
  6. import com.itextpdf.io.font.FontProgramFactory;
  7. import com.itextpdf.layout.font.FontProvider;
  8. import com.smartreview.enums.BlockStatus;
  9. import com.smartreview.enums.QuestionStatus;
  10. import com.smartreview.exception.SmartReviewException;
  11. import com.smartreview.po.*;
  12. import com.smartreview.repository.*;
  13. import com.smartreview.service.ReportService;
  14. import com.smartreview.vo.report.*;
  15. import com.vladsch.flexmark.ext.tables.TablesExtension;
  16. import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
  17. import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
  18. import com.vladsch.flexmark.ext.toc.TocExtension;
  19. import com.vladsch.flexmark.html.HtmlRenderer;
  20. import com.vladsch.flexmark.parser.Parser;
  21. import com.vladsch.flexmark.util.ast.Node;
  22. import com.vladsch.flexmark.util.data.MutableDataSet;
  23. import lombok.extern.slf4j.Slf4j;
  24. import org.scilab.forge.jlatexmath.TeXConstants;
  25. import org.scilab.forge.jlatexmath.TeXFormula;
  26. import org.scilab.forge.jlatexmath.TeXIcon;
  27. import org.springframework.stereotype.Service;
  28. import javax.imageio.ImageIO;
  29. import javax.swing.*;
  30. import java.awt.*;
  31. import java.awt.image.BufferedImage;
  32. import java.io.ByteArrayOutputStream;
  33. import java.util.Base64;
  34. import java.util.*;
  35. import java.util.List;
  36. import java.util.regex.Pattern;
  37. import java.util.stream.Collectors;
  38. /**
  39. * 报告服务实现
  40. */
  41. @Slf4j
  42. @Service
  43. public class ReportServiceImpl implements ReportService {
  44. private final DocumentRepository documentRepository;
  45. private final BlockRepository blockRepository;
  46. private final UserAnswerRepository userAnswerRepository;
  47. private final QuestionRepository questionRepository;
  48. private final KnowledgePointRepository knowledgePointRepository;
  49. // Markdown解析器
  50. private final Parser markdownParser;
  51. private final HtmlRenderer htmlRenderer;
  52. public ReportServiceImpl(DocumentRepository documentRepository,
  53. BlockRepository blockRepository,
  54. UserAnswerRepository userAnswerRepository,
  55. QuestionRepository questionRepository,
  56. KnowledgePointRepository knowledgePointRepository) {
  57. this.documentRepository = documentRepository;
  58. this.blockRepository = blockRepository;
  59. this.userAnswerRepository = userAnswerRepository;
  60. this.questionRepository = questionRepository;
  61. this.knowledgePointRepository = knowledgePointRepository;
  62. // 初始化Markdown解析器 - 启用扩展支持表格、删除线、自动链接等
  63. MutableDataSet options = new MutableDataSet();
  64. options.set(Parser.EXTENSIONS, Arrays.asList(
  65. TablesExtension.create(),
  66. StrikethroughExtension.create(),
  67. AutolinkExtension.create(),
  68. TocExtension.create()));
  69. // 表格配置
  70. options.set(TablesExtension.WITH_CAPTION, false);
  71. options.set(TablesExtension.COLUMN_SPANS, false);
  72. options.set(TablesExtension.MIN_HEADER_ROWS, 1);
  73. options.set(TablesExtension.MAX_HEADER_ROWS, 1);
  74. options.set(TablesExtension.APPEND_MISSING_COLUMNS, true);
  75. options.set(TablesExtension.DISCARD_EXTRA_COLUMNS, true);
  76. options.set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true);
  77. // HTML渲染器配置 - 不转义嵌入的HTML标签
  78. options.set(HtmlRenderer.ESCAPE_HTML, false);
  79. options.set(HtmlRenderer.ESCAPE_HTML_BLOCKS, false);
  80. options.set(HtmlRenderer.ESCAPE_HTML_COMMENT_BLOCKS, false);
  81. options.set(HtmlRenderer.ESCAPE_INLINE_HTML, false);
  82. options.set(HtmlRenderer.ESCAPE_INLINE_HTML_COMMENTS, false);
  83. options.set(HtmlRenderer.SUPPRESS_HTML, false);
  84. options.set(HtmlRenderer.SUPPRESS_HTML_BLOCKS, false);
  85. options.set(HtmlRenderer.SUPPRESS_HTML_COMMENT_BLOCKS, false);
  86. options.set(HtmlRenderer.SUPPRESS_INLINE_HTML, false);
  87. options.set(HtmlRenderer.SUPPRESS_INLINE_HTML_COMMENTS, false);
  88. this.markdownParser = Parser.builder(options).build();
  89. this.htmlRenderer = HtmlRenderer.builder(options).build();
  90. }
  91. @Override
  92. public ReviewReportVO getReport(String documentId, Long userId) {
  93. Document document = documentRepository.findById(documentId)
  94. .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
  95. if (!document.getUserId().equals(userId)) {
  96. throw SmartReviewException.forbidden("无权访问此文档");
  97. }
  98. ReviewReportVO report = new ReviewReportVO();
  99. report.setDocumentId(documentId);
  100. report.setDocumentTitle(document.getTitle());
  101. // 进度统计(只统计LEARNABLE类型的block)
  102. List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
  103. List<Block> learnableBlocks = blocks.stream()
  104. .filter(b -> b.getType() == com.smartreview.enums.BlockType.LEARNABLE)
  105. .collect(Collectors.toList());
  106. ProgressSummaryVO progress = new ProgressSummaryVO();
  107. progress.setTotalBlocks(learnableBlocks.size());
  108. progress.setCompletedBlocks((int) learnableBlocks.stream()
  109. .filter(b -> b.getStatus() == BlockStatus.COMPLETED || b.getStatus() == BlockStatus.GENERATED_QUESTION)
  110. .count());
  111. progress.setReadingBlocks((int) learnableBlocks.stream()
  112. .filter(b -> b.getStatus() == BlockStatus.READING).count());
  113. progress.setProgressPercent(!learnableBlocks.isEmpty()
  114. ? (progress.getCompletedBlocks() * 100.0 / learnableBlocks.size())
  115. : 0);
  116. report.setProgress(progress);
  117. // 答题统计
  118. List<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
  119. List<Question> questions = questionRepository.findByBlockIdIn(blockIds);
  120. // 根据题目状态统计(过滤掉已废弃的题目)
  121. int correctCount = 0;
  122. int wrongCount = 0;
  123. int weakCount = 0;
  124. int validTotal = 0;
  125. for (Question question : questions) {
  126. if (question.getStatus() == QuestionStatus.DEPRECATED
  127. || question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) {
  128. continue;
  129. }
  130. validTotal++;
  131. if (question.getStatus() == QuestionStatus.ANSWERED_RIGHT) {
  132. correctCount++;
  133. } else if (question.getStatus() == QuestionStatus.ANSWERED_WRONG) {
  134. wrongCount++;
  135. } else if (question.getStatus() == QuestionStatus.ANSWERED_WEAK) {
  136. weakCount++;
  137. }
  138. }
  139. AnswerStatisticsVO statistics = new AnswerStatisticsVO();
  140. statistics.setTotalQuestions(validTotal);
  141. statistics.setCorrectCount(correctCount);
  142. statistics.setWrongCount(wrongCount);
  143. statistics.setWeakCount(weakCount);
  144. statistics.setCorrectRate(validTotal > 0 ? (correctCount * 100.0 / validTotal) : 0);
  145. report.setStatistics(statistics);
  146. // 直接从答错/薄弱题目中获取知识点(只看CURRENT_BLOCK来源的题目,即第一次生成的题目)
  147. List<Question> wrongQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
  148. QuestionStatus.ANSWERED_WRONG).stream()
  149. .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
  150. .collect(Collectors.toList());
  151. List<Question> weakQuestionList = questionRepository.findByBlockIdInAndStatus(blockIds,
  152. QuestionStatus.ANSWERED_WEAK).stream()
  153. .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
  154. .collect(Collectors.toList());
  155. // 错误知识点统计(来自答错的题目,统计次数)
  156. List<WeakPointVO> wrongKnowledgePoints = buildKnowledgePointVOList(wrongQuestionList, true);
  157. report.setWrongKnowledgePoints(wrongKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
  158. // 薄弱知识点统计(来自标记薄弱的题目,不需要次数)
  159. List<WeakPointVO> weakKnowledgePoints = buildKnowledgePointVOList(weakQuestionList, false);
  160. report.setWeakKnowledgePoints(weakKnowledgePoints.stream().limit(10).collect(Collectors.toList()));
  161. // 错题列表(包含答错和薄弱的题目)
  162. List<WrongQuestionVO> wrongQuestions = new ArrayList<>();
  163. for (Question question : wrongQuestionList) {
  164. wrongQuestions.add(buildWrongQuestionVO(question, false));
  165. }
  166. for (Question question : weakQuestionList) {
  167. wrongQuestions.add(buildWrongQuestionVO(question, true));
  168. }
  169. report.setWrongQuestions(wrongQuestions);
  170. return report;
  171. }
  172. /**
  173. * 构建知识点VO列表(按知识点分组统计次数)
  174. *
  175. * @param questions 题目列表
  176. * @param includeCount 是否包含次数统计
  177. */
  178. private List<WeakPointVO> buildKnowledgePointVOList(List<Question> questions, boolean includeCount) {
  179. // 按知识点ID分组统计次数
  180. Map<Long, List<Question>> questionsByKpId = questions.stream()
  181. .filter(q -> q.getKnowledgePointId() != null)
  182. .collect(Collectors.groupingBy(Question::getKnowledgePointId));
  183. List<WeakPointVO> result = new ArrayList<>();
  184. for (Map.Entry<Long, List<Question>> entry : questionsByKpId.entrySet()) {
  185. Long kpId = entry.getKey();
  186. int count = entry.getValue().size();
  187. KnowledgePoint kp = knowledgePointRepository.findById(kpId).orElse(null);
  188. if (kp != null) {
  189. Block block = blockRepository.findById(kp.getBlockId()).orElse(null);
  190. WeakPointVO vo = new WeakPointVO();
  191. vo.setKnowledgePointId(kpId);
  192. vo.setKnowledgePoint(kp.getContent());
  193. vo.setBlockId(kp.getBlockId());
  194. vo.setBlockPreview(block != null && block.getContent() != null && block.getContent().length() > 30
  195. ? block.getContent().substring(0, 30) + "..."
  196. : (block != null ? block.getContent() : null));
  197. vo.setCount(includeCount ? count : 0); // 薄弱知识点不需要次数
  198. result.add(vo);
  199. }
  200. }
  201. // 按次数降序排序(如果需要次数的话)
  202. if (includeCount) {
  203. result.sort((a, b) -> Integer.compare(b.getCount(), a.getCount()));
  204. }
  205. return result;
  206. }
  207. /**
  208. * 构建错题VO
  209. */
  210. private WrongQuestionVO buildWrongQuestionVO(Question question, boolean isWeak) {
  211. WrongQuestionVO wq = new WrongQuestionVO();
  212. wq.setQuestionId(question.getId());
  213. wq.setBlockId(question.getBlockId());
  214. wq.setType(question.getType());
  215. wq.setQuestionText(question.getQuestionText());
  216. wq.setCorrectAnswer(question.getCorrectAnswer());
  217. wq.setExplanation(question.getExplanation());
  218. wq.setIsWeak(isWeak);
  219. // 获取最近的用户答案
  220. userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId())
  221. .ifPresent(answer -> wq.setUserAnswer(answer.getUserAnswer()));
  222. return wq;
  223. }
  224. @Override
  225. public byte[] exportPdfReport(String documentId, Long userId) {
  226. Document document = documentRepository.findById(documentId)
  227. .orElseThrow(() -> SmartReviewException.notFound("文档不存在"));
  228. if (!document.getUserId().equals(userId)) {
  229. throw SmartReviewException.forbidden("无权访问此文档");
  230. }
  231. ReviewReportVO report = getReport(documentId, userId);
  232. // 获取所有Block
  233. List<Block> blocks = blockRepository.findByDocumentIdOrderBySortOrder(documentId);
  234. List<Long> blockIds = blocks.stream().map(Block::getId).collect(Collectors.toList());
  235. // 获取所有知识点
  236. List<KnowledgePoint> allKnowledgePoints = knowledgePointRepository.findByBlockIdIn(blockIds);
  237. // 获取所有题目(非废弃的)
  238. List<Question> allQuestions = questionRepository.findByBlockIdIn(blockIds).stream()
  239. .filter(q -> q.getStatus() != QuestionStatus.DEPRECATED)
  240. .collect(Collectors.toList());
  241. // 构建知识点状态映射(根据题目状态判断知识点状态,只看CURRENT_BLOCK来源的题目)
  242. // 只考虑CURRENT_BLOCK来源的题目来判断知识点状态
  243. List<Question> currentBlockQuestions = allQuestions.stream()
  244. .filter(q -> q.getQuestionSource() == com.smartreview.enums.QuestionSource.CURRENT_BLOCK)
  245. .collect(Collectors.toList());
  246. // 错误知识点ID集合
  247. Set<Long> wrongKpIds = currentBlockQuestions.stream()
  248. .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WRONG && q.getKnowledgePointId() != null)
  249. .map(Question::getKnowledgePointId)
  250. .collect(Collectors.toSet());
  251. // 薄弱知识点ID集合
  252. Set<Long> weakKpIds = currentBlockQuestions.stream()
  253. .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_WEAK && q.getKnowledgePointId() != null)
  254. .map(Question::getKnowledgePointId)
  255. .collect(Collectors.toSet());
  256. // 正确回答的知识点ID集合
  257. Set<Long> correctKpIds = currentBlockQuestions.stream()
  258. .filter(q -> q.getStatus() == QuestionStatus.ANSWERED_RIGHT && q.getKnowledgePointId() != null)
  259. .map(Question::getKnowledgePointId)
  260. .collect(Collectors.toSet());
  261. // 有题目关联的知识点ID(已复习过的,也只看CURRENT_BLOCK)
  262. Set<Long> reviewedKpIds = currentBlockQuestions.stream()
  263. .filter(q -> q.getKnowledgePointId() != null)
  264. .map(Question::getKnowledgePointId)
  265. .collect(Collectors.toSet());
  266. // 调试日志
  267. log.info("==== PDF报告知识点状态调试 ====");
  268. log.info("总知识点数: {}", allKnowledgePoints.size());
  269. log.info("CURRENT_BLOCK来源的题目数: {}", currentBlockQuestions.size());
  270. log.info("错误知识点IDs: {}", wrongKpIds);
  271. log.info("薄弱知识点IDs: {}", weakKpIds);
  272. log.info("正确知识点IDs: {}", correctKpIds);
  273. log.info("已复习知识点IDs: {}", reviewedKpIds);
  274. for (Question q : currentBlockQuestions) {
  275. log.info("题目ID:{}, 状态:{}, 来源:{}, 关联知识点ID:{}",
  276. q.getId(), q.getStatus(), q.getQuestionSource(), q.getKnowledgePointId());
  277. }
  278. // 生成HTML
  279. String html = generateFullReportHtml(report, blocks, allKnowledgePoints, allQuestions,
  280. wrongKpIds, weakKpIds, correctKpIds, reviewedKpIds);
  281. log.debug("html: {}", html);
  282. // 转换为PDF(配置中文字体支持)
  283. try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
  284. ConverterProperties converterProperties = new ConverterProperties();
  285. FontProvider fontProvider = new DefaultFontProvider(false, false, false);
  286. // 从项目资源目录加载字体文件
  287. try {
  288. // 字体文件配置:文件名 -> 字体集合索引(ttc文件需要指定索引,ttf文件为-1)
  289. String[][] fontConfigs = {
  290. { "simsun.ttc", "0" }, // 宋体
  291. { "simhei.ttf", "-1" }, // 黑体
  292. { "msyh.ttc", "0" }, // 微软雅黑 Regular
  293. { "msyhbd.ttc", "0" }, // 微软雅黑 Bold
  294. { "simfang.ttf", "-1" }, // 仿宋
  295. { "seguiemj.ttf", "-1" }, // Segoe UI Emoji
  296. { "segoeui.ttf", "-1" }, // Segoe UI
  297. { "segoeuib.ttf", "-1" } // Segoe UI Bold
  298. };
  299. for (String[] config : fontConfigs) {
  300. String fontFileName = config[0];
  301. int ttcIndex = Integer.parseInt(config[1]);
  302. try {
  303. org.springframework.core.io.ClassPathResource fontResource = new org.springframework.core.io.ClassPathResource(
  304. "font/" + fontFileName);
  305. if (fontResource.exists()) {
  306. byte[] fontBytes = fontResource.getInputStream().readAllBytes();
  307. if (ttcIndex >= 0) {
  308. // TTC字体集合需要写入临时文件后用路径方式加载
  309. java.io.File tempFile = java.io.File.createTempFile("font_", "_" + fontFileName);
  310. tempFile.deleteOnExit();
  311. java.nio.file.Files.write(tempFile.toPath(), fontBytes);
  312. // 使用 "路径,索引" 格式加载TTC中的指定字体
  313. fontProvider.addFont(tempFile.getAbsolutePath() + "," + ttcIndex);
  314. } else {
  315. // TTF字体直接从字节数组加载
  316. fontProvider.addFont(fontBytes);
  317. }
  318. log.debug("成功加载字体: {}", fontFileName);
  319. } else {
  320. log.warn("字体文件不存在: {}", fontFileName);
  321. }
  322. } catch (Exception e) {
  323. log.warn("加载字体文件失败: {}, 原因: {}", fontFileName, e.getMessage());
  324. }
  325. }
  326. } catch (Exception fontEx) {
  327. log.warn("加载项目字体失败,尝试使用默认字体: {}", fontEx.getMessage());
  328. // 如果特定字体加载失败,使用默认字体提供器
  329. fontProvider = new DefaultFontProvider(true, false, false);
  330. }
  331. converterProperties.setFontProvider(fontProvider);
  332. HtmlConverter.convertToPdf(html, baos, converterProperties);
  333. return baos.toByteArray();
  334. } catch (Exception e) {
  335. log.error("生成PDF报告失败", e);
  336. throw SmartReviewException.error("生成PDF报告失败:" + e.getMessage());
  337. }
  338. }
  339. /**
  340. * 生成完整报告HTML(包含三部分)- 美观设计版本(兼容iText PDF)
  341. */
  342. private String generateFullReportHtml(ReviewReportVO report, List<Block> blocks,
  343. List<KnowledgePoint> allKnowledgePoints,
  344. List<Question> allQuestions,
  345. Set<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> correctKpIds,
  346. Set<Long> reviewedKpIds) {
  347. StringBuilder html = new StringBuilder();
  348. html.append("<!DOCTYPE html><html><head>");
  349. html.append("<meta charset=\"UTF-8\">");
  350. html.append("<style>");
  351. // 基础样式 - 现代美观设计(兼容iText,使用table布局替代flex)
  352. html.append("* { margin: 0; padding: 0; box-sizing: border-box; }");
  353. html.append(
  354. "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; }");
  355. // 标题样式
  356. html.append(
  357. "h1 { color: #2c3e50; font-size: 28px; text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 3px solid #3498db; }");
  358. html.append(
  359. "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; }");
  360. html.append(
  361. "h3 { color: #2c3e50; font-size: 16px; margin: 20px 0 10px 0; padding-left: 12px; border-left: 4px solid #3498db; }");
  362. // 统计卡片样式 - 使用table布局
  363. html.append(".stats-table { width: 100%; margin: 20px 0; border-collapse: separate; border-spacing: 10px; }");
  364. html.append(".stat-card { padding: 20px; border-radius: 12px; color: #fff; text-align: center; }");
  365. html.append(".stat-card.total { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }");
  366. html.append(".stat-card.correct { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); }");
  367. html.append(".stat-card.wrong { background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); }");
  368. html.append(".stat-card.weak { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }");
  369. html.append(".stat-card.progress { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }");
  370. html.append(".stat-number { font-size: 32px; font-weight: bold; }");
  371. html.append(".stat-label { font-size: 14px; opacity: 0.9; margin-top: 5px; }");
  372. // 进度条样式
  373. html.append(
  374. ".progress-bar-container { background: #e0e0e0; border-radius: 10px; height: 20px; margin: 15px 0; overflow: hidden; }");
  375. html.append(
  376. ".progress-bar { height: 100%; background: linear-gradient(90deg, #11998e 0%, #38ef7d 100%); border-radius: 10px; }");
  377. // 知识点表格样式
  378. html.append(".kp-table { width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #ddd; }");
  379. html.append(
  380. ".kp-table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
  381. html.append(".kp-table td { padding: 12px 15px; border: 1px solid #ddd; background: #fff; }");
  382. html.append(".kp-table tr:nth-child(even) td { background: #f9f9f9; }");
  383. html.append(
  384. ".count-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-weight: bold; font-size: 12px; }");
  385. html.append(".count-badge.wrong { background: #ffebee; color: #c62828; }");
  386. html.append(".count-badge.weak { background: #fff3e0; color: #ef6c00; }");
  387. // 知识点高亮样式
  388. html.append(
  389. ".kp-wrong { background-color: rgba(244, 67, 54, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #f44336; }");
  390. html.append(
  391. ".kp-weak { background-color: rgba(255, 152, 0, 0.25); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #FF9800; }");
  392. html.append(
  393. ".kp-correct { background-color: rgba(76, 175, 80, 0.2); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #4CAF50; }");
  394. html.append(
  395. ".kp-unreviewed { background-color: rgba(158, 158, 158, 0.15); padding: 2px 6px; border-radius: 4px; border-bottom: 2px solid #9e9e9e; }");
  396. // 知识点代码块高亮样式
  397. html.append(
  398. ".kp-block-wrong { border: 2px solid #f44336; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
  399. html.append(
  400. ".kp-block-wrong pre { background-color: #3d1d1d !important; margin: 0 !important; border-radius: 0 !important; }");
  401. html.append(
  402. ".kp-block-weak { border: 2px solid #FF9800; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
  403. html.append(
  404. ".kp-block-weak pre { background-color: #3d2d1d !important; margin: 0 !important; border-radius: 0 !important; }");
  405. html.append(
  406. ".kp-block-correct { border: 2px solid #4CAF50; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
  407. html.append(
  408. ".kp-block-correct pre { background-color: #1d3d1d !important; margin: 0 !important; border-radius: 0 !important; }");
  409. html.append(
  410. ".kp-block-unreviewed { border: 2px solid #9e9e9e; margin: 10px 0; border-radius: 8px; overflow: hidden; }");
  411. html.append(
  412. ".kp-block-unreviewed pre { background-color: #3d3d3d !important; margin: 0 !important; border-radius: 0 !important; }");
  413. // Block内容样式 - Markdown渲染
  414. html.append(
  415. ".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; }");
  416. html.append(
  417. ".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; }");
  418. html.append(".block-content h1 { font-size: 22px; border-bottom: 2px solid #eee; padding-bottom: 8px; }");
  419. html.append(".block-content h2 { font-size: 18px; color: #34495e; }");
  420. html.append(".block-content h3 { font-size: 16px; color: #555; }");
  421. html.append(".block-content h4 { font-size: 15px; color: #666; }");
  422. html.append(".block-content p { margin: 10px 0; line-height: 1.8; }");
  423. html.append(".block-content ul, .block-content ol { margin: 10px 0; padding-left: 25px; }");
  424. html.append(".block-content li { margin: 5px 0; line-height: 1.6; }");
  425. html.append(
  426. ".block-content code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
  427. html.append(
  428. ".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; }");
  429. html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
  430. html.append(
  431. ".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; }");
  432. html.append(".block-content strong, .block-content b { color: #2c3e50; font-weight: bold; }");
  433. html.append(".block-content em, .block-content i { font-style: italic; color: #555; }");
  434. html.append(".block-content del { text-decoration: line-through; color: #999; }");
  435. // Markdown表格样式 - 增强边框
  436. html.append(
  437. ".block-content table { border-collapse: collapse; width: 100%; margin: 15px 0; border: 2px solid #667eea; }");
  438. html.append(
  439. ".block-content table th { background: #667eea; color: #fff; padding: 12px 15px; text-align: left; font-weight: bold; border: 1px solid #5a6fd6; }");
  440. html.append(".block-content table td { padding: 10px 15px; border: 1px solid #ddd; background: #fff; }");
  441. html.append(".block-content table tr:nth-child(even) td { background: #f9f9f9; }");
  442. // 代码块样式 - 增加语言标签支持
  443. html.append(
  444. ".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; }");
  445. html.append(".block-content pre code { background: none; color: inherit; padding: 0; }");
  446. html.append(
  447. ".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; }");
  448. // 数学公式样式(以代码块形式显示)
  449. html.append(
  450. ".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; }");
  451. html.append(
  452. ".inline-math { font-family: 'Consolas', 'Microsoft YaHei', monospace; background: #f4f4f4; padding: 2px 6px; border-radius: 4px; color: #9b59b6; }");
  453. // 水平分割线
  454. html.append(
  455. ".block-content hr { border: none; height: 2px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 20px 0; border-radius: 1px; }");
  456. // 图例样式 - 使用table布局
  457. html.append(
  458. ".legend-table { width: 100%; background: #fff; border-radius: 8px; margin: 15px 0; border: 1px solid #eee; }");
  459. html.append(".legend-table td { padding: 10px 15px; }");
  460. html.append(
  461. ".legend-color { display: inline-block; width: 20px; height: 20px; border-radius: 4px; vertical-align: middle; margin-right: 8px; }");
  462. html.append(".legend-color-wrong { background-color: rgba(244, 67, 54, 0.35); border: 2px solid #f44336; }");
  463. html.append(".legend-color-weak { background-color: rgba(255, 152, 0, 0.35); border: 2px solid #FF9800; }");
  464. html.append(".legend-color-correct { background-color: rgba(76, 175, 80, 0.25); border: 2px solid #4CAF50; }");
  465. html.append(
  466. ".legend-color-unreviewed { background-color: rgba(158, 158, 158, 0.25); border: 2px solid #9e9e9e; }");
  467. // 题目卡片样式
  468. html.append(
  469. ".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; }");
  470. html.append(".question-card.wrong { border-left-color: #f44336; }");
  471. html.append(".question-card.weak { border-left-color: #FF9800; }");
  472. html.append(".question-card.correct { border-left-color: #4CAF50; }");
  473. html.append(".question-card.active { border-left-color: #2196F3; }");
  474. html.append(".question-header { margin-bottom: 15px; }");
  475. html.append(".question-header-table { width: 100%; }");
  476. html.append(".question-text { font-size: 15px; line-height: 1.7; color: #2c3e50; }");
  477. html.append(
  478. ".question-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; white-space: nowrap; }");
  479. html.append(".badge-wrong { background: #eb3349; color: #fff; }");
  480. html.append(".badge-weak { background: #f5576c; color: #fff; }");
  481. html.append(".badge-correct { background: #11998e; color: #fff; }");
  482. html.append(".badge-active { background: #4facfe; color: #fff; }");
  483. html.append(
  484. ".question-type { display: inline-block; padding: 2px 8px; background: #f0f0f0; border-radius: 4px; font-size: 12px; color: #666; margin-bottom: 10px; }");
  485. html.append(".options { margin: 15px 0; padding: 10px 15px; background: #f8f9fa; border-radius: 8px; }");
  486. html.append(
  487. ".options p { margin: 8px 0; padding: 8px 12px; background: #fff; border-radius: 6px; border: 1px solid #eee; }");
  488. html.append(".answer-section { margin-top: 15px; padding-top: 15px; border-top: 1px dashed #ddd; }");
  489. html.append(".user-answer { color: #f44336; font-weight: bold; }");
  490. html.append(".correct-answer { color: #4CAF50; font-weight: bold; }");
  491. html.append(
  492. ".explanation { margin-top: 15px; padding: 15px; background: #f5f7fa; border-radius: 8px; font-size: 14px; line-height: 1.8; }");
  493. html.append(".explanation-title { font-weight: bold; color: #2c3e50; margin-bottom: 8px; }");
  494. // 题目区域Markdown样式(加粗、斜体等)
  495. html.append(
  496. ".question-card strong, .question-card b { color: #2c3e50; font-weight: bold; font-family: 'SimHei', 'Microsoft YaHei', sans-serif; }");
  497. html.append(".question-card em, .question-card i { font-style: italic; color: #555; }");
  498. html.append(
  499. ".question-card code { background: #f4f4f4; padding: 2px 6px; border-radius: 4px; font-family: 'Consolas', 'Microsoft YaHei', monospace; font-size: 13px; color: #c7254e; }");
  500. html.append(".question-card p { margin: 8px 0; line-height: 1.7; }");
  501. html.append(".question-card ul, .question-card ol { margin: 8px 0; padding-left: 20px; }");
  502. html.append(".question-card li { margin: 4px 0; }");
  503. // 柱状图样式
  504. html.append(
  505. ".bar-chart { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
  506. html.append(".bar-item { margin: 12px 0; }");
  507. html.append(".bar-label-row { margin-bottom: 5px; }");
  508. html.append(".bar-label-table { width: 100%; }");
  509. html.append(".bar-label { font-size: 13px; color: #333; }");
  510. html.append(".bar-count { font-size: 13px; font-weight: bold; text-align: right; }");
  511. html.append(".bar-bg { background: #ffebee; border-radius: 4px; height: 12px; overflow: hidden; }");
  512. html.append(".bar-fill { height: 100%; background: linear-gradient(90deg, #f44336 0%, #ff7043 100%); }");
  513. html.append(".bar-fill-weak { background: linear-gradient(90deg, #ff9800 0%, #ffb74d 100%); }");
  514. // 标签卡片样式
  515. html.append(
  516. ".tag-container { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
  517. html.append(
  518. ".tag { display: inline-block; padding: 8px 16px; background: #fff3e0; border: 1px solid #FFB74D; border-radius: 20px; font-size: 13px; color: #e65100; margin: 5px; }");
  519. // 分页样式
  520. html.append(".page-break { page-break-before: always; margin-top: 40px; }");
  521. html.append(
  522. ".section-divider { height: 3px; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); margin: 30px 0; border-radius: 2px; }");
  523. // 白色背景区块
  524. html.append(
  525. ".white-box { background: #fff; padding: 20px; border-radius: 12px; margin: 15px 0; border: 1px solid #eee; }");
  526. html.append("</style></head><body>");
  527. // ==================== 报告标题 ====================
  528. html.append("<h1>复习报告-").append(escapeHtml(report.getDocumentTitle())).append("</h1>");
  529. // ==================== 第一部分:统计信息 ====================
  530. html.append("<h2>第一部分:学习统计</h2>");
  531. // 统计卡片 - 使用table布局,四个卡片等宽(各25%)
  532. html.append("<table class=\"stats-table\"><tr>");
  533. html.append("<td width=\"25%\" class=\"stat-card total\"><div class=\"stat-number\">")
  534. .append(report.getStatistics().getTotalQuestions())
  535. .append("</div><div class=\"stat-label\">总题数</div></td>");
  536. html.append("<td width=\"25%\" class=\"stat-card correct\"><div class=\"stat-number\">")
  537. .append(report.getStatistics().getCorrectCount())
  538. .append("</div><div class=\"stat-label\">正确</div></td>");
  539. html.append("<td width=\"25%\" class=\"stat-card wrong\"><div class=\"stat-number\">")
  540. .append(report.getStatistics().getWrongCount()).append("</div><div class=\"stat-label\">错误</div></td>");
  541. html.append("<td width=\"25%\" class=\"stat-card weak\"><div class=\"stat-number\">")
  542. .append(report.getStatistics().getWeakCount()).append("</div><div class=\"stat-label\">薄弱</div></td>");
  543. html.append("</tr></table>");
  544. // 答题分布 - 使用柱状图替代饼图(iText不支持conic-gradient)
  545. int total = report.getStatistics().getTotalQuestions();
  546. int correct = report.getStatistics().getCorrectCount();
  547. int wrong = report.getStatistics().getWrongCount();
  548. int weak = report.getStatistics().getWeakCount();
  549. int unanswered = total - correct - wrong - weak;
  550. if (total > 0) {
  551. html.append("<h3>答题分布图</h3>");
  552. html.append("<div class=\"bar-chart\">");
  553. double correctPct = correct * 100.0 / total;
  554. double wrongPct = wrong * 100.0 / total;
  555. double weakPct = weak * 100.0 / total;
  556. double unansweredPct = unanswered * 100.0 / total;
  557. // 正确题目
  558. html.append("<div class=\"bar-item\">");
  559. html.append("<table class=\"bar-label-table\"><tr>");
  560. html.append("<td class=\"bar-label\" style=\"color: #4CAF50;\">正确</td>");
  561. html.append("<td class=\"bar-count\" style=\"color: #4CAF50;\">").append(correct).append(" (")
  562. .append(String.format("%.1f", correctPct)).append("%)</td>");
  563. html.append("</tr></table>");
  564. html.append("<div style=\"background: #e8f5e9; border-radius: 4px; height: 16px; overflow: hidden;\">");
  565. html.append("<div style=\"height: 100%; background: #4CAF50; width: ")
  566. .append(String.format("%.1f", correctPct)).append("%;\"></div>");
  567. html.append("</div></div>");
  568. // 错误题目
  569. html.append("<div class=\"bar-item\">");
  570. html.append("<table class=\"bar-label-table\"><tr>");
  571. html.append("<td class=\"bar-label\" style=\"color: #f44336;\">错误</td>");
  572. html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wrong).append(" (")
  573. .append(String.format("%.1f", wrongPct)).append("%)</td>");
  574. html.append("</tr></table>");
  575. html.append("<div style=\"background: #ffebee; border-radius: 4px; height: 16px; overflow: hidden;\">");
  576. html.append("<div style=\"height: 100%; background: #f44336; width: ")
  577. .append(String.format("%.1f", wrongPct)).append("%;\"></div>");
  578. html.append("</div></div>");
  579. // 薄弱题目
  580. html.append("<div class=\"bar-item\">");
  581. html.append("<table class=\"bar-label-table\"><tr>");
  582. html.append("<td class=\"bar-label\" style=\"color: #FF9800;\">薄弱</td>");
  583. html.append("<td class=\"bar-count\" style=\"color: #FF9800;\">").append(weak).append(" (")
  584. .append(String.format("%.1f", weakPct)).append("%)</td>");
  585. html.append("</tr></table>");
  586. html.append("<div style=\"background: #fff3e0; border-radius: 4px; height: 16px; overflow: hidden;\">");
  587. html.append("<div style=\"height: 100%; background: #FF9800; width: ")
  588. .append(String.format("%.1f", weakPct)).append("%;\"></div>");
  589. html.append("</div></div>");
  590. // 未答题目
  591. html.append("<div class=\"bar-item\">");
  592. html.append("<table class=\"bar-label-table\"><tr>");
  593. html.append("<td class=\"bar-label\" style=\"color: #9e9e9e;\">未答</td>");
  594. html.append("<td class=\"bar-count\" style=\"color: #9e9e9e;\">").append(unanswered).append(" (")
  595. .append(String.format("%.1f", unansweredPct)).append("%)</td>");
  596. html.append("</tr></table>");
  597. html.append("<div style=\"background: #f5f5f5; border-radius: 4px; height: 16px; overflow: hidden;\">");
  598. html.append("<div style=\"height: 100%; background: #9e9e9e; width: ")
  599. .append(String.format("%.1f", unansweredPct)).append("%;\"></div>");
  600. html.append("</div></div>");
  601. html.append("</div>");
  602. }
  603. // 进度信息和正确率 - 合并为一行,避免跨页
  604. html.append("<h3>复习进度与答题正确率</h3>");
  605. html.append("<div class=\"white-box\">");
  606. html.append("<table width=\"100%\"><tr>");
  607. // 左边:复习进度
  608. html.append("<td width=\"50%\" style=\"padding-right: 15px; vertical-align: top;\">");
  609. html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">复习进度</p>");
  610. html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">总内容块:<b>")
  611. .append(report.getProgress().getTotalBlocks()).append("</b> | ");
  612. html.append("已完成:<b style=\"color: #4CAF50;\">").append(report.getProgress().getCompletedBlocks())
  613. .append("</b> | ");
  614. html.append("学习中:<b style=\"color: #2196F3;\">").append(report.getProgress().getReadingBlocks())
  615. .append("</b></p>");
  616. html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
  617. .append(String.format("%.1f", report.getProgress().getProgressPercent())).append("%;\"></div></div>");
  618. html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: #4CAF50;\">完成率:")
  619. .append(String.format("%.1f%%", report.getProgress().getProgressPercent())).append("</p>");
  620. html.append("</td>");
  621. // 右边:答题正确率
  622. html.append(
  623. "<td width=\"50%\" style=\"padding-left: 15px; vertical-align: top; border-left: 1px solid #eee;\">");
  624. html.append("<p style=\"margin-bottom: 8px; font-weight: bold;\">答题正确率</p>");
  625. double correctRate = report.getStatistics().getCorrectRate();
  626. String rateColor = correctRate >= 80 ? "#4CAF50" : (correctRate >= 60 ? "#FF9800" : "#f44336");
  627. html.append("<p style=\"margin-bottom: 5px; font-size: 13px;\">正确 ")
  628. .append(report.getStatistics().getCorrectCount()).append(" / 总 ")
  629. .append(report.getStatistics().getTotalQuestions()).append(" 题</p>");
  630. html.append("<div class=\"progress-bar-container\"><div class=\"progress-bar\" style=\"width: ")
  631. .append(String.format("%.1f", correctRate)).append("%; background: ").append(rateColor)
  632. .append(";\"></div></div>");
  633. html.append("<p style=\"text-align: center; font-size: 14px; font-weight: bold; color: ").append(rateColor)
  634. .append(";\">正确率:").append(String.format("%.1f%%", correctRate)).append("</p>");
  635. html.append("</td>");
  636. html.append("</tr></table>");
  637. html.append("</div>");
  638. // 错误知识点 - 使用柱状图展示
  639. if (report.getWrongKnowledgePoints() != null && !report.getWrongKnowledgePoints().isEmpty()) {
  640. html.append("<h3>错误知识点分布</h3>");
  641. html.append("<div class=\"bar-chart\">");
  642. // 找出最大次数用于计算柱状图宽度
  643. int maxCount = report.getWrongKnowledgePoints().stream().mapToInt(WeakPointVO::getCount).max().orElse(1);
  644. for (WeakPointVO wp : report.getWrongKnowledgePoints()) {
  645. double barWidth = (wp.getCount() * 100.0 / maxCount);
  646. html.append("<div class=\"bar-item\">");
  647. html.append("<table class=\"bar-label-table\"><tr>");
  648. html.append("<td class=\"bar-label\">").append(escapeHtml(wp.getKnowledgePoint())).append("</td>");
  649. html.append("<td class=\"bar-count\" style=\"color: #f44336;\">").append(wp.getCount())
  650. .append(" 次</td>");
  651. html.append("</tr></table>");
  652. html.append("<div class=\"bar-bg\">");
  653. html.append("<div class=\"bar-fill\" style=\"width: ").append(String.format("%.1f", barWidth))
  654. .append("%;\"></div>");
  655. html.append("</div>");
  656. html.append("</div>");
  657. }
  658. html.append("</div>");
  659. }
  660. // 薄弱知识点 - 使用标签卡片展示(不显示次数)
  661. if (report.getWeakKnowledgePoints() != null && !report.getWeakKnowledgePoints().isEmpty()) {
  662. html.append("<h3>薄弱知识点</h3>");
  663. html.append("<div class=\"tag-container\">");
  664. for (WeakPointVO wp : report.getWeakKnowledgePoints()) {
  665. html.append("<span class=\"tag\">");
  666. html.append(escapeHtml(wp.getKnowledgePoint()));
  667. html.append("</span>");
  668. }
  669. html.append("</div>");
  670. }
  671. // ==================== 第二部分:原笔记再现 ====================
  672. html.append("<div class=\"page-break\"></div>");
  673. html.append("<h2>第二部分:笔记内容(知识点标注)</h2>");
  674. // 图例说明 - 使用table布局
  675. html.append("<table class=\"legend-table\"><tr>");
  676. html.append("<td><span class=\"legend-color legend-color-wrong\"></span>错误知识点</td>");
  677. html.append("<td><span class=\"legend-color legend-color-weak\"></span>薄弱知识点</td>");
  678. html.append("<td><span class=\"legend-color legend-color-correct\"></span>已掌握知识点</td>");
  679. html.append("<td><span class=\"legend-color legend-color-unreviewed\"></span>未复习知识点</td>");
  680. html.append("</tr></table>");
  681. // 按Block分组知识点
  682. Map<Long, List<KnowledgePoint>> kpByBlockId = allKnowledgePoints.stream()
  683. .collect(Collectors.groupingBy(KnowledgePoint::getBlockId));
  684. for (Block block : blocks) {
  685. String content = block.getContent();
  686. if (content == null || content.trim().isEmpty()) {
  687. continue;
  688. }
  689. html.append("<div class=\"block-content\">");
  690. // 获取该Block的知识点并渲染高亮
  691. List<KnowledgePoint> blockKps = kpByBlockId.getOrDefault(block.getId(), Collections.emptyList());
  692. String highlightedContent = highlightKnowledgePointsInMarkdown(content, blockKps, wrongKpIds, weakKpIds,
  693. correctKpIds, reviewedKpIds);
  694. html.append(highlightedContent);
  695. html.append("</div>");
  696. }
  697. // ==================== 第三部分:题目展示 ====================
  698. html.append("<div class=\"page-break\"></div>");
  699. html.append("<h2>第三部分:题目汇总</h2>");
  700. html.append("<p style=\"color: #666; margin-bottom: 20px;\">共 <b>").append(allQuestions.size())
  701. .append("</b> 道题目</p>");
  702. // 按状态分组展示题目
  703. Map<QuestionStatus, List<Question>> questionsByStatus = allQuestions.stream()
  704. .collect(Collectors.groupingBy(Question::getStatus));
  705. // 错误题目
  706. List<Question> wrongQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WRONG,
  707. Collections.emptyList());
  708. if (!wrongQuestions.isEmpty()) {
  709. html.append("<h3>错误题目 (").append(wrongQuestions.size()).append("题)</h3>");
  710. for (Question q : wrongQuestions) {
  711. html.append(renderQuestionCard(q, "wrong", "错误"));
  712. }
  713. }
  714. // 薄弱题目
  715. List<Question> weakQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_WEAK,
  716. Collections.emptyList());
  717. if (!weakQuestions.isEmpty()) {
  718. html.append("<h3>薄弱题目 (").append(weakQuestions.size()).append("题)</h3>");
  719. for (Question q : weakQuestions) {
  720. html.append(renderQuestionCard(q, "weak", "薄弱"));
  721. }
  722. }
  723. // 正确题目
  724. List<Question> correctQuestions = questionsByStatus.getOrDefault(QuestionStatus.ANSWERED_RIGHT,
  725. Collections.emptyList());
  726. if (!correctQuestions.isEmpty()) {
  727. html.append("<h3>已掌握题目 (").append(correctQuestions.size()).append("题)</h3>");
  728. for (Question q : correctQuestions) {
  729. html.append(renderQuestionCard(q, "correct", "正确"));
  730. }
  731. }
  732. // 未作答题目
  733. List<Question> activeQuestions = questionsByStatus.getOrDefault(QuestionStatus.ACTIVE, Collections.emptyList());
  734. if (!activeQuestions.isEmpty()) {
  735. html.append("<h3>未作答题目 (").append(activeQuestions.size()).append("题)</h3>");
  736. for (Question q : activeQuestions) {
  737. html.append(renderQuestionCard(q, "active", "未作答"));
  738. }
  739. }
  740. html.append("</body></html>");
  741. // 后处理HTML:为代码块添加语言标签
  742. String finalHtml = html.toString();
  743. // 查找 <code class="language-xxx"> 并添加标签
  744. finalHtml = finalHtml.replaceAll("<code class=\"language-([^\"]+)\">",
  745. "<code class=\"language-$1\"><div class=\"code-lang\">$1</div>");
  746. return finalHtml;
  747. }
  748. /**
  749. * 在Markdown内容中高亮知识点,然后渲染为HTML
  750. * 策略:
  751. * 1. 根据 sourceStart 和 sourceEnd 在原始 Markdown 中插入 HTML 注释标记
  752. * 2. 渲染 Markdown 为 HTML(注释会被保留)
  753. * 3. 找到注释位置,向外扩展到合适的标签边界,插入 span
  754. * 4. 删除原始注释标记
  755. */
  756. private String highlightKnowledgePointsInMarkdown(String content, List<KnowledgePoint> knowledgePoints,
  757. Set<Long> wrongKpIds, Set<Long> weakKpIds,
  758. Set<Long> correctKpIds, Set<Long> reviewedKpIds) {
  759. // 如果没有知识点,直接渲染返回
  760. if (knowledgePoints == null || knowledgePoints.isEmpty()) {
  761. String processedContent = processLatexFormulas(content);
  762. Node document = markdownParser.parse(processedContent);
  763. return htmlRenderer.render(document);
  764. }
  765. // 1. 预处理:修正知识点索引(数据库中的索引可能与实际内容不一致)
  766. for (KnowledgePoint kp : knowledgePoints) {
  767. fixKpIndices(content, kp);
  768. }
  769. // 2. 筛选有效的知识点(必须有 sourceStart 和 sourceEnd)
  770. List<KnowledgePoint> validKps = knowledgePoints.stream()
  771. .filter(kp -> kp.getSourceStart() != null && kp.getSourceEnd() != null
  772. && kp.getSourceStart() >= 0 && kp.getSourceEnd() > kp.getSourceStart()
  773. && kp.getSourceEnd() <= content.length())
  774. .collect(Collectors.toList());
  775. // 如果没有有效位置信息的知识点,回退到简单匹配
  776. if (validKps.isEmpty()) {
  777. log.debug("没有有效位置信息的知识点,回退到简单匹配");
  778. return highlightKnowledgePointsByTextOnly(content, knowledgePoints, wrongKpIds, weakKpIds, correctKpIds,
  779. reviewedKpIds);
  780. }
  781. // 3. 按 sourceStart 降序排序,从后往前插入标记避免位置偏移
  782. validKps.sort((a, b) -> Integer.compare(b.getSourceStart(), a.getSourceStart()));
  783. // 4. 在原始 Markdown 中插入 HTML 注释标记
  784. StringBuilder markedContent = new StringBuilder(content);
  785. for (KnowledgePoint kp : validKps) {
  786. int start = kp.getSourceStart();
  787. int end = kp.getSourceEnd();
  788. // 确定CSS类
  789. String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
  790. // 提取sourceText(用于查找第一个和最后一个正常文字)
  791. String sourceText = content.substring(start, end);
  792. // 检查是否为代码块(以```开头和结尾)
  793. if (sourceText.trim().startsWith("```") && sourceText.trim().endsWith("```")) {
  794. // 是代码块,使用div包裹,调整背景色
  795. String blockClass = cssClass.replace("kp-", "kp-block-");
  796. // 插入结束标签(注意添加换行符以确保Markdown解析器正确识别代码块)
  797. markedContent.insert(end, "\n\n</div>");
  798. // 插入开始标签(注意添加换行符)
  799. markedContent.insert(start, "<div class=\"" + blockClass + "\">\n\n");
  800. continue;
  801. }
  802. // 找到第一个正常文字的相对位置(跳过Markdown格式标记)
  803. int firstNormalCharOffset = findFirstNormalCharOffset(sourceText);
  804. // 找到最后一个正常文字之后的相对位置
  805. int lastNormalCharOffset = findLastNormalCharOffset(sourceText);
  806. // 计算实际插入位置
  807. int actualStart = start + firstNormalCharOffset;
  808. int actualEnd = start + lastNormalCharOffset;
  809. // 插入结束标记(先插入结束,因为从后往前)
  810. String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
  811. markedContent.insert(actualEnd, endMarker);
  812. // 插入开始标记
  813. String startMarker = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
  814. markedContent.insert(actualStart, startMarker);
  815. log.debug("插入标记: kpId={}, sourceStart={}, sourceEnd={}, actualStart={}, actualEnd={}, cssClass={}",
  816. kp.getId(), start, end, actualStart, actualEnd, cssClass);
  817. }
  818. // 5. 处理数学公式
  819. String processedContent = processLatexFormulas(markedContent.toString());
  820. // 6. 渲染 Markdown 为 HTML
  821. Node document = markdownParser.parse(processedContent);
  822. String html = htmlRenderer.render(document);
  823. log.debug("渲染后HTML长度: {}", html.length());
  824. // 7. 在 HTML 中找到注释标记,向外扩展并插入 span
  825. for (KnowledgePoint kp : validKps) {
  826. String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
  827. String startMarker = "<!--KP_START_" + kp.getId() + "_" + cssClass + "-->";
  828. String endMarker = "<!--KP_END_" + kp.getId() + "_" + cssClass + "-->";
  829. int startMarkerPos = html.indexOf(startMarker);
  830. int endMarkerPos = html.indexOf(endMarker);
  831. if (startMarkerPos == -1 || endMarkerPos == -1) {
  832. log.warn("未找到标记: kpId={}, startFound={}, endFound={}", kp.getId(), startMarkerPos != -1,
  833. endMarkerPos != -1);
  834. // 移除可能存在的单个标记
  835. html = html.replace(startMarker, "").replace(endMarker, "");
  836. continue;
  837. }
  838. log.debug("找到标记位置: kpId={}, startMarkerPos={}, endMarkerPos={}", kp.getId(), startMarkerPos, endMarkerPos);
  839. // 计算实际内容的位置(去除标记后)
  840. int contentStart = startMarkerPos + startMarker.length();
  841. int contentEnd = endMarkerPos;
  842. // 向外扩展到合适的标签边界
  843. // 从开始标记之前开始向前扩展(包括可能在标记前的内联标签)
  844. int expandedStart = expandToTagBoundaryStart(html, startMarkerPos);
  845. // 从结束标记之后开始向后扩展(包括可能在标记后的内联标签)
  846. int expandedEnd = expandToTagBoundaryEnd(html, endMarkerPos + endMarker.length());
  847. log.debug("扩展位置: expandedStart={}, expandedEnd={}", expandedStart, expandedEnd);
  848. // 检查是否已在 kp-span 内
  849. if (isInsideKpSpan(html.substring(0, startMarkerPos))) {
  850. log.debug("已在 kp-span 内,跳过: kpId={}", kp.getId());
  851. html = html.replace(startMarker, "").replace(endMarker, "");
  852. continue;
  853. }
  854. // 构建新的 HTML
  855. // expandedStart 应该在 startMarkerPos 之前(向前扩展到内联标签开始)
  856. // expandedEnd 应该在 endMarkerPos + endMarker.length() 之后(向后扩展到内联标签结束)
  857. StringBuilder result = new StringBuilder();
  858. result.append(html.substring(0, expandedStart));
  859. // 提取需要高亮的内容片段
  860. String contentToHighlight = html.substring(expandedStart, startMarkerPos) +
  861. html.substring(contentStart, endMarkerPos) +
  862. html.substring(endMarkerPos + endMarker.length(), expandedEnd);
  863. // 智能应用高亮(处理块级标签)
  864. result.append(applyHighlightToHtml(contentToHighlight, cssClass));
  865. result.append(html.substring(expandedEnd));
  866. html = result.toString();
  867. }
  868. return html;
  869. }
  870. /**
  871. * 获取知识点对应的CSS类
  872. */
  873. private String getCssClassForKp(Long kpId, Set<Long> wrongKpIds, Set<Long> weakKpIds, Set<Long> correctKpIds) {
  874. if (wrongKpIds.contains(kpId)) {
  875. return "kp-wrong";
  876. } else if (weakKpIds.contains(kpId)) {
  877. return "kp-weak";
  878. } else if (correctKpIds.contains(kpId)) {
  879. return "kp-correct";
  880. } else {
  881. return "kp-unreviewed";
  882. }
  883. }
  884. /**
  885. * 纯文本匹配方式高亮知识点(后备方案)
  886. */
  887. private String highlightKnowledgePointsByTextOnly(String content, List<KnowledgePoint> knowledgePoints,
  888. Set<Long> wrongKpIds, Set<Long> weakKpIds,
  889. Set<Long> correctKpIds, Set<Long> reviewedKpIds) {
  890. // 处理数学公式
  891. String processedContent = processLatexFormulas(content);
  892. // 渲染Markdown为HTML
  893. Node document = markdownParser.parse(processedContent);
  894. String html = htmlRenderer.render(document);
  895. // 筛选有效的知识点(必须有sourceText)
  896. List<KnowledgePoint> validKps = knowledgePoints.stream()
  897. .filter(kp -> kp.getSourceText() != null && !kp.getSourceText().isEmpty())
  898. .collect(Collectors.toList());
  899. if (validKps.isEmpty()) {
  900. return html;
  901. }
  902. // 按sourceText长度降序排序
  903. validKps.sort((a, b) -> Integer.compare(b.getSourceText().length(), a.getSourceText().length()));
  904. // 对每个知识点进行高亮
  905. for (KnowledgePoint kp : validKps) {
  906. String cssClass = getCssClassForKp(kp.getId(), wrongKpIds, weakKpIds, correctKpIds);
  907. html = simpleHighlightInHtml(html, kp.getSourceText(), cssClass);
  908. }
  909. return html;
  910. }
  911. /**
  912. * 修正知识点在内容中的索引位置
  913. * 尝试解决数据库索引与实际内容不匹配的问题
  914. */
  915. private void fixKpIndices(String content, KnowledgePoint kp) {
  916. if (kp.getSourceText() == null || kp.getSourceText().isEmpty()) {
  917. return;
  918. }
  919. Integer start = kp.getSourceStart();
  920. Integer end = kp.getSourceEnd();
  921. String sourceText = kp.getSourceText();
  922. // 1. 检查当前索引是否有效且匹配
  923. if (start != null && end != null && start >= 0 && end <= content.length() && start < end) {
  924. String extracted = content.substring(start, end);
  925. if (extracted.equals(sourceText)) {
  926. return; // 索引正确,无需修正
  927. }
  928. }
  929. // 2. 尝试精确查找
  930. int index = content.indexOf(sourceText);
  931. if (index != -1) {
  932. kp.setSourceStart(index);
  933. kp.setSourceEnd(index + sourceText.length());
  934. log.debug("修正KP索引(精确匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, index,
  935. index + sourceText.length());
  936. return;
  937. }
  938. // 3. 尝试模糊查找(忽略空白字符)
  939. int[] fuzzyIndices = findFuzzyMatch(content, sourceText);
  940. if (fuzzyIndices != null) {
  941. kp.setSourceStart(fuzzyIndices[0]);
  942. kp.setSourceEnd(fuzzyIndices[1]);
  943. log.debug("修正KP索引(模糊匹配): id={}, old={}-{}, new={}-{}", kp.getId(), start, end, fuzzyIndices[0],
  944. fuzzyIndices[1]);
  945. } else {
  946. log.warn("无法修正KP索引: id={}, sourceText='{}'", kp.getId(), sourceText);
  947. }
  948. }
  949. /**
  950. * 在内容中模糊查找文本(忽略空白字符)
  951. *
  952. * @return [start, end] 或 null
  953. */
  954. private int[] findFuzzyMatch(String content, String sourceText) {
  955. // 移除sourceText中的所有空白字符
  956. String cleanSource = sourceText.replaceAll("\\s+", "");
  957. if (cleanSource.isEmpty())
  958. return null;
  959. // 构建content的非空白字符映射
  960. // contentChars: 存储非空白字符
  961. // contentIndices: 存储这些字符在原content中的索引
  962. List<Character> contentChars = new ArrayList<>();
  963. List<Integer> contentIndices = new ArrayList<>();
  964. for (int i = 0; i < content.length(); i++) {
  965. char c = content.charAt(i);
  966. if (!Character.isWhitespace(c)) {
  967. contentChars.add(c);
  968. contentIndices.add(i);
  969. }
  970. }
  971. // 将content非空白字符转为字符串进行查找
  972. StringBuilder sb = new StringBuilder(contentChars.size());
  973. for (Character c : contentChars)
  974. sb.append(c);
  975. String cleanContent = sb.toString();
  976. int matchIndex = cleanContent.indexOf(cleanSource);
  977. if (matchIndex != -1) {
  978. // 映射回原始索引
  979. int startOriginalIndex = contentIndices.get(matchIndex);
  980. // 结束索引是匹配的最后一个字符的索引 + 1
  981. int endMatchIndex = matchIndex + cleanSource.length() - 1;
  982. int endOriginalIndex = contentIndices.get(endMatchIndex) + 1;
  983. return new int[] { startOriginalIndex, endOriginalIndex };
  984. }
  985. return null;
  986. }
  987. /**
  988. * 找到sourceText中第一个正常文字的偏移量(跳过Markdown格式标记)
  989. * 特别处理:如果内容以公式开头,在整个公式之前插入标记(避免破坏公式渲染)
  990. */
  991. private int findFirstNormalCharOffset(String sourceText) {
  992. if (sourceText == null || sourceText.isEmpty()) {
  993. return 0;
  994. }
  995. int i = 0;
  996. int len = sourceText.length();
  997. while (i < len) {
  998. char c = sourceText.charAt(i);
  999. // 跳过Markdown格式标记
  1000. if (c == '*' || c == '_' || c == '`' || c == '~' || c == '#') {
  1001. i++;
  1002. continue;
  1003. }
  1004. // 跳过列表标记(- 、+ 、* 后跟空格)
  1005. if ((c == '-' || c == '+') && i + 1 < len && sourceText.charAt(i + 1) == ' ') {
  1006. i += 2;
  1007. continue;
  1008. }
  1009. // 跳过数字列表标记(1. 、2. 等)
  1010. if (Character.isDigit(c)) {
  1011. int j = i;
  1012. while (j < len && Character.isDigit(sourceText.charAt(j))) {
  1013. j++;
  1014. }
  1015. if (j < len && sourceText.charAt(j) == '.' && j + 1 < len && sourceText.charAt(j + 1) == ' ') {
  1016. i = j + 2;
  1017. continue;
  1018. }
  1019. }
  1020. // 跳过空白字符
  1021. if (Character.isWhitespace(c)) {
  1022. i++;
  1023. continue;
  1024. }
  1025. // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分
  1026. // 如果是Markdown链接 [text](url),标记加在 [ 前面也是可以的
  1027. // 特别处理:如果遇到公式开始标记,检查是否是完整公式
  1028. if (c == '$') {
  1029. // 检查是否是双美元符号(块级公式)
  1030. if (i + 1 < len && sourceText.charAt(i + 1) == '$') {
  1031. // 块级公式 $$...$$,在公式前插入标记
  1032. return i;
  1033. } else {
  1034. // 行内公式 $...$,也在公式前插入标记
  1035. return i;
  1036. }
  1037. }
  1038. // 找到第一个正常字符
  1039. break;
  1040. }
  1041. return i;
  1042. }
  1043. /**
  1044. * 找到sourceText中最后一个正常文字之后的偏移量(跳过Markdown格式标记)
  1045. * 特别处理:如果内容以公式结尾,在整个公式之后插入标记(避免破坏公式渲染)
  1046. */
  1047. private int findLastNormalCharOffset(String sourceText) {
  1048. if (sourceText == null || sourceText.isEmpty()) {
  1049. return 0;
  1050. }
  1051. int i = sourceText.length() - 1;
  1052. // 先跳过尾部的空白字符和格式标记
  1053. while (i >= 0) {
  1054. char c = sourceText.charAt(i);
  1055. // 跳过Markdown格式标记
  1056. if (c == '*' || c == '_' || c == '`' || c == '~') {
  1057. i--;
  1058. continue;
  1059. }
  1060. // 跳过空白字符
  1061. if (Character.isWhitespace(c)) {
  1062. i--;
  1063. continue;
  1064. }
  1065. // 注意:不再跳过方括号和小括号,因为它们可能是公式的一部分
  1066. // 特别处理:如果遇到公式结束标记
  1067. if (c == '$') {
  1068. // 检查是否是双美元符号(块级公式)
  1069. if (i > 0 && sourceText.charAt(i - 1) == '$') {
  1070. // 块级公式 $$...$$,在公式后插入标记(已经在 $ 之后了)
  1071. return i + 1;
  1072. } else {
  1073. // 行内公式 $...$,在公式后插入标记
  1074. return i + 1;
  1075. }
  1076. }
  1077. // 找到最后一个正常字符,返回其后的位置
  1078. return i + 1;
  1079. }
  1080. return sourceText.length();
  1081. }
  1082. /**
  1083. * 检查是否是内联标签(可以被span包裹的标签)
  1084. */
  1085. private boolean isInlineTag(String tag) {
  1086. // 提取标签名(去除 < > 和属性)
  1087. String tagLower = tag.toLowerCase();
  1088. // 移除开头的 </ 或 <
  1089. String tagName;
  1090. if (tagLower.startsWith("</")) {
  1091. tagName = tagLower.substring(2);
  1092. } else {
  1093. tagName = tagLower.substring(1);
  1094. }
  1095. // 移除 > 和属性
  1096. int spaceIdx = tagName.indexOf(' ');
  1097. int gtIdx = tagName.indexOf('>');
  1098. int endIdx = Math.min(
  1099. spaceIdx == -1 ? tagName.length() : spaceIdx,
  1100. gtIdx == -1 ? tagName.length() : gtIdx);
  1101. tagName = tagName.substring(0, endIdx);
  1102. // 内联标签列表
  1103. return tagName.equals("strong") || tagName.equals("b") ||
  1104. tagName.equals("em") || tagName.equals("i") ||
  1105. tagName.equals("code") || tagName.equals("mark") ||
  1106. tagName.equals("small") || tagName.equals("sub") ||
  1107. tagName.equals("sup") || tagName.equals("a") ||
  1108. tagName.equals("span") || tagName.equals("img") ||
  1109. tagName.equals("del") || tagName.equals("ins") ||
  1110. tagName.equals("u") || tagName.equals("s");
  1111. }
  1112. /**
  1113. * 向前扩展到合适的标签边界
  1114. * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签
  1115. * 只扩展内联标签(如 strong, em),不扩展块级标签(如 li, p, div)
  1116. */
  1117. private int expandToTagBoundaryStart(String html, int pos) {
  1118. int i = pos;
  1119. // 向前查找,跳过所有紧邻的内联开始标签和HTML注释
  1120. while (i > 0) {
  1121. // 跳过HTML注释
  1122. if (i >= 4 && html.substring(Math.max(0, i - 4), i).equals("-->")) {
  1123. // 前面可能有HTML注释结束,查找注释开始
  1124. int commentStart = html.lastIndexOf("<!--", i - 4);
  1125. if (commentStart != -1) {
  1126. i = commentStart;
  1127. log.debug("expandStart: 跳过HTML注释,继续向前到 {}", i);
  1128. continue;
  1129. }
  1130. }
  1131. // 检查前面是否有标签
  1132. int tagEnd = i - 1;
  1133. if (tagEnd >= 0 && html.charAt(tagEnd) == '>') {
  1134. // 前面有标签结束符,查找这个标签的开始
  1135. int tagStart = html.lastIndexOf('<', tagEnd);
  1136. if (tagStart != -1) {
  1137. String tag = html.substring(tagStart, tagEnd + 1);
  1138. // 跳过HTML注释
  1139. if (tag.startsWith("<!--")) {
  1140. i = tagStart;
  1141. log.debug("expandStart: 跳过HTML注释标签,继续向前到 {}", i);
  1142. continue;
  1143. }
  1144. log.debug("expandStart: 发现标签 '{}' at {}-{}", tag, tagStart, tagEnd);
  1145. // 检查是否是开始标签(不是结束标签,也不是自闭合标签)
  1146. if (!tag.startsWith("</") && !tag.endsWith("/>")) {
  1147. // 检查是否是内联标签
  1148. if (isInlineTag(tag)) {
  1149. // 这是一个内联开始标签,我们应该在它之前插入 span
  1150. i = tagStart;
  1151. log.debug("expandStart: 是内联开始标签,继续向前到 {}", i);
  1152. continue;
  1153. } else {
  1154. // 这是块级开始标签(如 li, p),不应该跨越它
  1155. log.debug("expandStart: 是块级开始标签,停在 {}", i);
  1156. break;
  1157. }
  1158. } else {
  1159. // 这是结束标签或自闭合标签,停在这里(标签之后)
  1160. log.debug("expandStart: 是结束/自闭合标签,停在 {}", i);
  1161. break;
  1162. }
  1163. }
  1164. }
  1165. // 前面不是标签,停在当前位置
  1166. break;
  1167. }
  1168. log.debug("expandToTagBoundaryStart: {} -> {}", pos, i);
  1169. return i;
  1170. }
  1171. /**
  1172. * 向后扩展到合适的标签边界
  1173. * 目标:找到一个位置,使得 span 标签可以正确包裹内部的内联标签
  1174. * 只扩展内联标签(如 /strong, /em),不扩展块级标签(如 /li, /p)
  1175. */
  1176. private int expandToTagBoundaryEnd(String html, int pos) {
  1177. int i = pos;
  1178. // 向后查找,跳过所有紧邻的内联结束标签和HTML注释
  1179. while (i < html.length()) {
  1180. // 跳过HTML注释
  1181. if (i + 4 <= html.length() && html.substring(i, Math.min(i + 4, html.length())).equals("<!--")) {
  1182. // 后面有HTML注释开始,查找注释结束
  1183. int commentEnd = html.indexOf("-->", i);
  1184. if (commentEnd != -1) {
  1185. i = commentEnd + 3; // 跳过 -->
  1186. log.debug("expandEnd: 跳过HTML注释,继续向后到 {}", i);
  1187. continue;
  1188. }
  1189. }
  1190. // 检查后面是否有标签
  1191. if (i < html.length() && html.charAt(i) == '<') {
  1192. // 后面有标签开始符,查找这个标签的结束
  1193. int tagEnd = html.indexOf('>', i);
  1194. if (tagEnd != -1) {
  1195. String tag = html.substring(i, tagEnd + 1);
  1196. // 跳过HTML注释
  1197. if (tag.startsWith("<!--")) {
  1198. i = tagEnd + 1;
  1199. log.debug("expandEnd: 跳过HTML注释标签,继续向后到 {}", i);
  1200. continue;
  1201. }
  1202. log.debug("expandEnd: 发现标签 '{}' at {}-{}", tag, i, tagEnd);
  1203. // 检查是否是结束标签
  1204. if (tag.startsWith("</")) {
  1205. // 检查是否是内联标签
  1206. if (isInlineTag(tag)) {
  1207. // 这是一个内联结束标签,我们应该在它之后插入 span 结束
  1208. i = tagEnd + 1;
  1209. log.debug("expandEnd: 是内联结束标签,继续向后到 {}", i);
  1210. continue;
  1211. } else {
  1212. // 这是块级结束标签(如 /li, /p),不应该跨越它
  1213. log.debug("expandEnd: 是块级结束标签,停在 {}", i);
  1214. break;
  1215. }
  1216. } else {
  1217. // 这是开始标签或自闭合标签,停在这里(标签之前)
  1218. log.debug("expandEnd: 是开始/自闭合标签,停在 {}", i);
  1219. break;
  1220. }
  1221. }
  1222. }
  1223. // 后面不是标签,停在当前位置
  1224. break;
  1225. }
  1226. log.debug("expandToTagBoundaryEnd: {} -> {}", pos, i);
  1227. return i;
  1228. }
  1229. /**
  1230. * 检查当前位置是否已经在kp-span内
  1231. */
  1232. private boolean isInsideKpSpan(String htmlBefore) {
  1233. int lastKpStart = htmlBefore.lastIndexOf("<span class=\"kp-");
  1234. int lastSpanEnd = htmlBefore.lastIndexOf("</span>");
  1235. return lastKpStart > lastSpanEnd && lastKpStart != -1;
  1236. }
  1237. /**
  1238. * 简单的HTML高亮方式(回退方案)
  1239. */
  1240. private String simpleHighlightInHtml(String html, String sourceText, String cssClass) {
  1241. // 提取sourceText中的关键文本片段进行高亮
  1242. List<String> segments = extractTextSegments(sourceText);
  1243. String result = html;
  1244. for (String segment : segments) {
  1245. if (segment.length() >= 2) {
  1246. result = smartHighlightInHtml(result, segment, cssClass);
  1247. }
  1248. }
  1249. return result;
  1250. }
  1251. /**
  1252. * 从知识点文本中提取多个文本片段(按标点和格式分割)
  1253. * 注意:sourceText应该已经是不含公式的纯文本,这里做二次清理以确保安全
  1254. */
  1255. private List<String> extractTextSegments(String kpText) {
  1256. List<String> segments = new ArrayList<>();
  1257. // 先清理Markdown格式标记
  1258. String cleaned = kpText;
  1259. // 二次清理:移除任何残留的公式标记(理论上sourceText应该已经不包含公式)
  1260. cleaned = cleaned.replaceAll("\\$\\$[^$]*\\$\\$", " ");
  1261. cleaned = cleaned.replaceAll("\\$[^$]*\\$", " ");
  1262. cleaned = cleaned.replaceAll("\\\\\\[[^\\]]*\\\\\\]", " ");
  1263. cleaned = cleaned.replaceAll("\\\\\\([^)]*\\\\\\)", " ");
  1264. // 提取粗体内容作为单独片段(不包含公式)
  1265. java.util.regex.Pattern boldPattern = java.util.regex.Pattern.compile("\\*\\*([^*]+)\\*\\*");
  1266. java.util.regex.Matcher boldMatcher = boldPattern.matcher(cleaned);
  1267. while (boldMatcher.find()) {
  1268. String boldText = boldMatcher.group(1).trim();
  1269. // 再次检查,确保不包含公式标记
  1270. if (!boldText.isEmpty() && !containsFormulaMarkers(boldText)) {
  1271. segments.add(boldText);
  1272. }
  1273. }
  1274. // 移除Markdown格式标记
  1275. cleaned = cleaned.replaceAll("\\*\\*([^*]+)\\*\\*", "$1");
  1276. cleaned = cleaned.replaceAll("\\*([^*]+)\\*", "$1");
  1277. cleaned = cleaned.replaceAll("__([^_]+)__", "$1");
  1278. cleaned = cleaned.replaceAll("_([^_]+)_", "$1");
  1279. cleaned = cleaned.replaceAll("`([^`]+)`", "$1");
  1280. // 移除列表标记
  1281. cleaned = cleaned.replaceAll("^[*\\-+]\\s+", "");
  1282. cleaned = cleaned.replaceAll("\\n[*\\-+]\\s+", "\n");
  1283. // 按标点符号分割成片段
  1284. String[] parts = cleaned.split("[,。、:;!?\\n\\r]+");
  1285. for (String part : parts) {
  1286. String trimmed = part.trim();
  1287. // 只添加有意义的片段(至少2个字符,不重复,不包含公式)
  1288. if (trimmed.length() >= 2 && !segments.contains(trimmed) && !containsFormulaMarkers(trimmed)) {
  1289. segments.add(trimmed);
  1290. }
  1291. }
  1292. return segments;
  1293. }
  1294. /**
  1295. * 智能地在HTML中高亮文本,保持HTML标签不被破坏
  1296. * 支持跨越HTML标签的文本匹配(如 <strong>机器学习</strong>:让模型...)
  1297. */
  1298. private String smartHighlightInHtml(String html, String searchText, String cssClass) {
  1299. if (searchText == null || searchText.isEmpty()) {
  1300. return html;
  1301. }
  1302. // 检查是否已经被精确高亮过(完整的span标签包裹searchText)
  1303. if (html.contains("<span class=\"" + cssClass + "\">" + searchText + "</span>")) {
  1304. return html;
  1305. }
  1306. // 尝试直接匹配(文本不跨越标签的情况)
  1307. int idx = findTextOutsideTags(html, searchText);
  1308. if (idx >= 0) {
  1309. // 检查这个位置是否已经在kp-span内
  1310. String beforeText = html.substring(0, idx);
  1311. int lastKpStart = beforeText.lastIndexOf("<span class=\"kp-");
  1312. int lastSpanEnd = beforeText.lastIndexOf("</span>");
  1313. if (lastKpStart > lastSpanEnd && lastKpStart != -1) {
  1314. // 已经在kp-span内,跳过
  1315. return html;
  1316. }
  1317. String before = html.substring(0, idx);
  1318. String after = html.substring(idx + searchText.length());
  1319. return before + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + after;
  1320. }
  1321. // 如果直接匹配失败,尝试在HTML标签内部查找(如<strong>文本</strong>中的"文本")
  1322. // 使用正则查找被标签包裹的文本
  1323. String escapedSearch = Pattern.quote(searchText);
  1324. // 匹配 >searchText< 模式(文本在标签之间)
  1325. Pattern pattern = Pattern.compile("(>)" + escapedSearch + "(<)");
  1326. java.util.regex.Matcher matcher = pattern.matcher(html);
  1327. if (matcher.find()) {
  1328. // 检查是否已在kp-span内
  1329. String beforeMatch = html.substring(0, matcher.start());
  1330. int lastKpStart = beforeMatch.lastIndexOf("<span class=\"kp-");
  1331. int lastSpanEnd = beforeMatch.lastIndexOf("</span>");
  1332. if (lastKpStart <= lastSpanEnd || lastKpStart == -1) {
  1333. return html.substring(0, matcher.start()) +
  1334. ">" + "<span class=\"" + cssClass + "\">" + searchText + "</span>" + "<" +
  1335. html.substring(matcher.end());
  1336. }
  1337. }
  1338. return html;
  1339. }
  1340. /**
  1341. * 在HTML中查找文本,确保不在标签属性内
  1342. */
  1343. private int findTextOutsideTags(String html, String searchText) {
  1344. int searchLen = searchText.length();
  1345. int htmlLen = html.length();
  1346. boolean inTag = false;
  1347. int i = 0;
  1348. while (i <= htmlLen - searchLen) {
  1349. char c = html.charAt(i);
  1350. if (c == '<') {
  1351. inTag = true;
  1352. i++;
  1353. continue;
  1354. }
  1355. if (c == '>') {
  1356. inTag = false;
  1357. i++;
  1358. continue;
  1359. }
  1360. if (!inTag) {
  1361. // 检查从这个位置开始是否匹配
  1362. if (html.regionMatches(i, searchText, 0, searchLen)) {
  1363. return i;
  1364. }
  1365. }
  1366. i++;
  1367. }
  1368. return -1;
  1369. }
  1370. /**
  1371. * 检测文本是否包含公式标记
  1372. */
  1373. private boolean containsFormulaMarkers(String text) {
  1374. if (text == null || text.isEmpty()) {
  1375. return false;
  1376. }
  1377. // 检查各种公式标记
  1378. return text.contains("$$") ||
  1379. text.contains("\\[") ||
  1380. text.contains("\\]") ||
  1381. text.contains("\\(") ||
  1382. text.contains("\\)") ||
  1383. (text.contains("$") && text.indexOf("$") != text.lastIndexOf("$"));
  1384. }
  1385. /**
  1386. * 渲染题目卡片 - 美观设计版本(兼容iText PDF)
  1387. */
  1388. private String renderQuestionCard(Question question, String statusClass, String statusLabel) {
  1389. StringBuilder card = new StringBuilder();
  1390. card.append("<div class=\"question-card ").append(statusClass).append("\">");
  1391. // 题目头部:题目内容 + 状态标签 - 使用table布局
  1392. card.append("<div class=\"question-header\">");
  1393. card.append("<table class=\"question-header-table\"><tr>");
  1394. card.append("<td class=\"question-text\">");
  1395. // 渲染题目文本的Markdown(包含公式解析)
  1396. String questionHtml = renderMarkdownInline(question.getQuestionText());
  1397. card.append(questionHtml);
  1398. card.append("</td>");
  1399. card.append("<td style=\"width: 80px; text-align: right; vertical-align: top;\">");
  1400. card.append("<span class=\"question-badge badge-").append(statusClass).append("\">").append(statusLabel)
  1401. .append("</span>");
  1402. card.append("</td>");
  1403. card.append("</tr></table>");
  1404. card.append("</div>");
  1405. // 题目类型
  1406. String typeLabel;
  1407. switch (question.getType()) {
  1408. case SINGLE:
  1409. typeLabel = "单选题";
  1410. break;
  1411. case MULTIPLE:
  1412. typeLabel = "多选题";
  1413. break;
  1414. case ESSAY:
  1415. typeLabel = "问答题";
  1416. break;
  1417. default:
  1418. typeLabel = "未知";
  1419. }
  1420. card.append("<span class=\"question-type\">").append(typeLabel).append("</span>");
  1421. // 题目来源
  1422. String sourceLabel;
  1423. if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.REVIEW) {
  1424. sourceLabel = "复习题";
  1425. } else if (question.getQuestionSource() == com.smartreview.enums.QuestionSource.SELECTION_GENERATED) {
  1426. sourceLabel = "划词提问";
  1427. } else {
  1428. sourceLabel = "知识点";
  1429. }
  1430. card.append("<span class=\"question-type\" style=\"margin-left: 10px; background: #e3f2fd; color: #1565c0;\">")
  1431. .append(sourceLabel).append("</span>");
  1432. // 选项(如果有)- 也要解析公式
  1433. if (question.getOptions() != null && !question.getOptions().isEmpty()) {
  1434. card.append("<div class=\"options\">");
  1435. char optionLabel = 'A';
  1436. for (String option : question.getOptions()) {
  1437. String trimmedOption = option.trim();
  1438. // 检查选项是否已经以A. B. C. D.等开头
  1439. boolean hasPrefix = trimmedOption.length() >= 2 &&
  1440. Character.isLetter(trimmedOption.charAt(0)) &&
  1441. (trimmedOption.charAt(1) == '.' || trimmedOption.charAt(1) == '、' ||
  1442. trimmedOption.charAt(1) == ')' || trimmedOption.charAt(1) == ')');
  1443. // 处理选项中的公式和Markdown格式
  1444. String processedOption = renderMarkdownInline(option);
  1445. // 移除Markdown生成的外层p标签(因为我们自己添加了p标签)
  1446. processedOption = processedOption.replaceAll("^<p>", "").replaceAll("</p>\\s*$", "");
  1447. if (hasPrefix) {
  1448. // 已有前缀,直接显示
  1449. card.append("<p>").append(processedOption).append("</p>");
  1450. } else {
  1451. // 没有前缀,添加标签
  1452. card.append("<p>").append(optionLabel).append(". ").append(processedOption).append("</p>");
  1453. }
  1454. optionLabel++;
  1455. }
  1456. card.append("</div>");
  1457. }
  1458. // 答案区域
  1459. card.append("<div class=\"answer-section\">");
  1460. // 用户答案(如果是错误或薄弱题目)
  1461. if (statusClass.equals("wrong") || statusClass.equals("weak")) {
  1462. userAnswerRepository.findTopByQuestionIdOrderByAnsweredAtDesc(question.getId())
  1463. .ifPresent(answer -> {
  1464. String processedUserAnswer = processLatexFormulas(answer.getUserAnswer());
  1465. card.append("<p><span class=\"user-answer\">你的答案:</span>")
  1466. .append(processedUserAnswer).append("</p>");
  1467. });
  1468. }
  1469. // 正确答案(也要处理公式)
  1470. String processedCorrectAnswer = processLatexFormulas(question.getCorrectAnswer());
  1471. card.append("<p><span class=\"correct-answer\">正确答案:</span>").append(processedCorrectAnswer).append("</p>");
  1472. card.append("</div>");
  1473. // 解析
  1474. if (question.getExplanation() != null && !question.getExplanation().isEmpty()) {
  1475. card.append("<div class=\"explanation\">");
  1476. card.append("<div class=\"explanation-title\">解析</div>");
  1477. // 渲染解析中的Markdown(包含公式解析)
  1478. String explanationHtml = renderMarkdownInline(question.getExplanation());
  1479. card.append(explanationHtml);
  1480. card.append("</div>");
  1481. }
  1482. card.append("</div>");
  1483. return card.toString();
  1484. }
  1485. private static final Set<String> STRICT_TAGS = new HashSet<>(Arrays.asList(
  1486. "ul", "ol", "dl", "table", "thead", "tbody", "tfoot", "tr", "colgroup"));
  1487. private static final Set<String> ITEM_TAGS = new HashSet<>(Arrays.asList(
  1488. "li", "dt", "dd", "td", "th", "caption"));
  1489. private static final Set<String> FLOW_TAGS = new HashSet<>(Arrays.asList(
  1490. "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote",
  1491. "form", "section", "header", "footer", "article", "aside", "main",
  1492. "nav", "figure", "figcaption", "hr"));
  1493. /**
  1494. * 智能应用高亮到HTML内容,处理块级标签
  1495. * 避免将块级标签(如ul, div)包裹在span中,而是将span推入到内容层
  1496. */
  1497. private String applyHighlightToHtml(String html, String cssClass) {
  1498. if (html == null || html.isEmpty()) {
  1499. return "";
  1500. }
  1501. StringBuilder sb = new StringBuilder();
  1502. // 初始状态:开启高亮
  1503. boolean inSpan = true;
  1504. sb.append("<span class=\"").append(cssClass).append("\">");
  1505. // 正则匹配HTML标签:</?tagName...>
  1506. // 捕获组1:是否为结束标签 /
  1507. // 捕获组2:标签名
  1508. Pattern tagPattern = Pattern.compile("<(/)?([a-zA-Z0-9]+)[^>]*>", Pattern.CASE_INSENSITIVE);
  1509. java.util.regex.Matcher matcher = tagPattern.matcher(html);
  1510. int lastEnd = 0;
  1511. while (matcher.find()) {
  1512. // 添加标签前的文本
  1513. String text = html.substring(lastEnd, matcher.start());
  1514. sb.append(text);
  1515. String isEndTag = matcher.group(1); // "/" or null
  1516. String tagName = matcher.group(2).toLowerCase();
  1517. String fullTag = matcher.group(0);
  1518. boolean isStartTag = (isEndTag == null);
  1519. if (STRICT_TAGS.contains(tagName)) {
  1520. // 严格容器(ul, table等):不能包含文本,必须关闭span
  1521. if (isStartTag) {
  1522. // 开始标签:关闭当前span
  1523. if (inSpan) {
  1524. sb.append("</span>");
  1525. inSpan = false;
  1526. }
  1527. sb.append(fullTag);
  1528. } else {
  1529. // 结束标签:重新开启span(如果需要)
  1530. sb.append(fullTag);
  1531. if (!inSpan) {
  1532. sb.append("<span class=\"").append(cssClass).append("\">");
  1533. inSpan = true;
  1534. }
  1535. }
  1536. } else if (ITEM_TAGS.contains(tagName)) {
  1537. // 项目容器(li, td等):内容需要高亮
  1538. if (isStartTag) {
  1539. // 开始标签:先关闭(以防万一),添加标签,然后开启
  1540. if (inSpan) {
  1541. sb.append("</span>");
  1542. }
  1543. sb.append(fullTag);
  1544. sb.append("<span class=\"").append(cssClass).append("\">");
  1545. inSpan = true;
  1546. } else {
  1547. // 结束标签:关闭当前span
  1548. if (inSpan) {
  1549. sb.append("</span>");
  1550. inSpan = false;
  1551. }
  1552. sb.append(fullTag);
  1553. // 结束后通常紧跟空白或下一个项目开始,暂时不开启,由下一个显式内容或父级结束来处理
  1554. // 但为了保持连续性(如li之间的空白),我们可以选择开启
  1555. // 这里选择开启,以便捕获标签间的空白(虽然通常不可见)
  1556. // 或者更安全地:不开启,等待下一个显式内容?
  1557. // 根据之前的分析:</li>后通常是<li>或</ul>。
  1558. // 如果是<li>,它会处理开启。如果是</ul>,它会处理开启。
  1559. // 所以这里不开启是安全的,除了丢失空白的高亮(无所谓)。
  1560. // 但是!如果</li>后面有文本节点(不规范HTML),则不会高亮。
  1561. // 为了稳健性,我们还是开启吧,反正空白高亮看不见。
  1562. sb.append("<span class=\"").append(cssClass).append("\">");
  1563. inSpan = true;
  1564. }
  1565. } else if (FLOW_TAGS.contains(tagName)) {
  1566. // 流式容器(p, div等):内容需要高亮
  1567. if (isStartTag) {
  1568. // 开始标签:先关闭,添加标签,然后开启
  1569. if (inSpan) {
  1570. sb.append("</span>");
  1571. }
  1572. sb.append(fullTag);
  1573. // 对于自闭合标签如 <hr>,不需要开启
  1574. if (!fullTag.endsWith("/>") && !tagName.equals("hr")) {
  1575. sb.append("<span class=\"").append(cssClass).append("\">");
  1576. inSpan = true;
  1577. } else {
  1578. // 自闭合标签后,恢复开启状态(因为我们假设整个段落都要高亮)
  1579. sb.append("<span class=\"").append(cssClass).append("\">");
  1580. inSpan = true;
  1581. }
  1582. } else {
  1583. // 结束标签:关闭当前span,添加标签,然后重新开启
  1584. if (inSpan) {
  1585. sb.append("</span>");
  1586. }
  1587. sb.append(fullTag);
  1588. sb.append("<span class=\"").append(cssClass).append("\">");
  1589. inSpan = true;
  1590. }
  1591. } else {
  1592. // 内联标签或其他:直接添加,保持span状态
  1593. sb.append(fullTag);
  1594. }
  1595. lastEnd = matcher.end();
  1596. }
  1597. // 添加剩余文本
  1598. sb.append(html.substring(lastEnd));
  1599. // 结束时关闭span
  1600. if (inSpan) {
  1601. sb.append("</span>");
  1602. }
  1603. // 清理空span
  1604. String result = sb.toString();
  1605. String emptySpan = "<span class=\"" + cssClass + "\"></span>";
  1606. // 循环替换直到没有空span(处理嵌套产生的空span)
  1607. while (result.contains(emptySpan)) {
  1608. result = result.replace(emptySpan, "");
  1609. }
  1610. return result;
  1611. }
  1612. /**
  1613. * 渲染内联Markdown(用于题目和解析),同时处理数学公式
  1614. */
  1615. private String renderMarkdownInline(String text) {
  1616. if (text == null || text.isEmpty()) {
  1617. return "";
  1618. }
  1619. // 先处理数学公式,将其转换为HTML样式显示
  1620. String processedText = processLatexFormulas(text);
  1621. Node document = markdownParser.parse(processedText);
  1622. return htmlRenderer.render(document);
  1623. }
  1624. /**
  1625. * 处理LaTeX数学公式,将公式渲染为图片(Base64)嵌入HTML
  1626. * 支持多种格式:$$...$$ 块级公式,$...$ 行内公式,\[...\] 块级,\(...\) 行内
  1627. */
  1628. private String processLatexFormulas(String text) {
  1629. if (text == null) {
  1630. return "";
  1631. }
  1632. String result = text;
  1633. // 1. 处理块级公式 \[...\]
  1634. result = processFormulaPatternToImage(result, "\\[", "\\]", true);
  1635. // 2. 处理块级公式 $$...$$
  1636. result = processFormulaPatternToImage(result, "$$", "$$", true);
  1637. // 3. 处理行内公式 \(...\)
  1638. result = processFormulaPatternToImage(result, "\\(", "\\)", false);
  1639. // 4. 处理行内公式 $...$ (需要特别处理避免误匹配$$)
  1640. result = processDollarFormulasToImage(result);
  1641. return result;
  1642. }
  1643. /**
  1644. * 通用公式模式处理 - 渲染为图片
  1645. */
  1646. private String processFormulaPatternToImage(String text, String startDelim, String endDelim, boolean isBlock) {
  1647. StringBuilder result = new StringBuilder();
  1648. int i = 0;
  1649. while (i < text.length()) {
  1650. int start = text.indexOf(startDelim, i);
  1651. if (start == -1) {
  1652. result.append(text.substring(i));
  1653. break;
  1654. }
  1655. result.append(text, i, start);
  1656. int end = text.indexOf(endDelim, start + startDelim.length());
  1657. if (end == -1) {
  1658. result.append(text.substring(start));
  1659. break;
  1660. }
  1661. String formula = text.substring(start + startDelim.length(), end);
  1662. String renderedFormula = renderLatexToImage(formula.trim(), isBlock);
  1663. result.append(renderedFormula);
  1664. i = end + endDelim.length();
  1665. }
  1666. return result.toString();
  1667. }
  1668. /**
  1669. * 专门处理 $...$ 行内公式(避免误匹配 $$)- 渲染为图片
  1670. * 更严格的匹配规则:
  1671. * 1. $ 前后不能紧邻字母数字(避免误匹配如 $10 价格)
  1672. * 2. 公式内容必须包含LaTeX特征字符(如 \、^、_、{、})或是纯数学表达式
  1673. */
  1674. private String processDollarFormulasToImage(String text) {
  1675. StringBuilder result = new StringBuilder();
  1676. int i = 0;
  1677. while (i < text.length()) {
  1678. int inlineStart = text.indexOf("$", i);
  1679. if (inlineStart == -1) {
  1680. result.append(text.substring(i));
  1681. break;
  1682. }
  1683. // 检查是否是 $$ 开头(跳过,因为已处理过或不应处理)
  1684. if (inlineStart + 1 < text.length() && text.charAt(inlineStart + 1) == '$') {
  1685. result.append(text, i, inlineStart + 2);
  1686. i = inlineStart + 2;
  1687. continue;
  1688. }
  1689. // 检查 $ 前面是否紧邻字母数字(如果是,可能不是公式)
  1690. if (inlineStart > 0) {
  1691. char prevChar = text.charAt(inlineStart - 1);
  1692. if (Character.isLetterOrDigit(prevChar)) {
  1693. result.append(text, i, inlineStart + 1);
  1694. i = inlineStart + 1;
  1695. continue;
  1696. }
  1697. }
  1698. result.append(text, i, inlineStart);
  1699. // 查找配对的 $
  1700. int inlineEnd = text.indexOf("$", inlineStart + 1);
  1701. if (inlineEnd == -1) {
  1702. result.append(text.substring(inlineStart));
  1703. break;
  1704. }
  1705. // 检查结束符是否是 $$ 的一部分
  1706. if (inlineEnd + 1 < text.length() && text.charAt(inlineEnd + 1) == '$') {
  1707. result.append(text, inlineStart, inlineEnd + 2);
  1708. i = inlineEnd + 2;
  1709. continue;
  1710. }
  1711. String formula = text.substring(inlineStart + 1, inlineEnd);
  1712. // 公式内容不为空,且看起来像LaTeX公式才处理
  1713. if (!formula.trim().isEmpty() && looksLikeLatexFormula(formula)) {
  1714. String renderedFormula = renderLatexToImage(formula.trim(), false);
  1715. result.append(renderedFormula);
  1716. } else {
  1717. // 不像公式,保留原文
  1718. result.append("$").append(formula).append("$");
  1719. }
  1720. i = inlineEnd + 1;
  1721. }
  1722. return result.toString();
  1723. }
  1724. /**
  1725. * 判断内容是否看起来像LaTeX公式
  1726. * 包含LaTeX特征字符或是数学表达式
  1727. */
  1728. private boolean looksLikeLatexFormula(String content) {
  1729. if (content == null || content.trim().isEmpty()) {
  1730. return false;
  1731. }
  1732. // 包含LaTeX命令(以\开头)
  1733. if (content.contains("\\")) {
  1734. return true;
  1735. }
  1736. // 包含上下标
  1737. if (content.contains("^") || content.contains("_")) {
  1738. return true;
  1739. }
  1740. // 包含花括号(LaTeX分组)
  1741. if (content.contains("{") || content.contains("}")) {
  1742. return true;
  1743. }
  1744. // 包含数学运算符
  1745. if (content.contains("+") || content.contains("-") || content.contains("=") ||
  1746. content.contains("*") || content.contains("/") || content.contains("×") ||
  1747. content.contains("÷") || content.contains("≠") || content.contains("≤") ||
  1748. content.contains("≥") || content.contains("±")) {
  1749. return true;
  1750. }
  1751. // 包含希腊字母名称
  1752. if (content.matches(".*\\b(alpha|beta|gamma|delta|theta|pi|sigma|omega|lambda|mu)\\b.*")) {
  1753. return true;
  1754. }
  1755. // 纯数字和简单变量(如 x, y, a, b 等)
  1756. if (content.matches("^[a-zA-Z0-9\\s.,]+$") && content.length() <= 3) {
  1757. return true;
  1758. }
  1759. return false;
  1760. }
  1761. /**
  1762. * 将LaTeX公式渲染为Base64图片
  1763. *
  1764. * @param latex LaTeX公式文本
  1765. * @param isBlock 是否为块级公式(块级字体更大,居中显示)
  1766. * @return HTML img标签,包含Base64编码的图片
  1767. */
  1768. private String renderLatexToImage(String latex, boolean isBlock) {
  1769. if (latex == null || latex.trim().isEmpty()) {
  1770. return "";
  1771. }
  1772. try {
  1773. // 创建 TeXFormula
  1774. TeXFormula formula = new TeXFormula(latex);
  1775. // 设置字体大小:块级公式稍大,行内公式与正文匹配
  1776. float fontSize = isBlock ? 22f : 15f;
  1777. // 创建 TeXIcon
  1778. TeXIcon icon = formula.createTeXIcon(TeXConstants.STYLE_DISPLAY, fontSize);
  1779. icon.setInsets(new Insets(2, 2, 2, 2));
  1780. // 创建图片
  1781. BufferedImage image = new BufferedImage(
  1782. icon.getIconWidth(),
  1783. icon.getIconHeight(),
  1784. BufferedImage.TYPE_INT_ARGB);
  1785. Graphics2D g2 = image.createGraphics();
  1786. g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
  1787. g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
  1788. g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
  1789. // 设置白色背景
  1790. g2.setColor(Color.WHITE);
  1791. g2.fillRect(0, 0, icon.getIconWidth(), icon.getIconHeight());
  1792. // 绘制公式(黑色)
  1793. JLabel label = new JLabel();
  1794. label.setForeground(new Color(51, 51, 51)); // 深灰色 #333
  1795. icon.paintIcon(label, g2, 0, 0);
  1796. g2.dispose();
  1797. // 转换为 Base64
  1798. ByteArrayOutputStream baos = new ByteArrayOutputStream();
  1799. ImageIO.write(image, "PNG", baos);
  1800. String base64 = Base64.getEncoder().encodeToString(baos.toByteArray());
  1801. // 返回HTML img标签
  1802. if (isBlock) {
  1803. return "<div style=\"text-align: center; margin: 15px 0;\"><img src=\"data:image/png;base64," + base64
  1804. + "\" alt=\"formula\" style=\"max-width: 100%;\"/></div>";
  1805. } else {
  1806. // 行内公式:垂直居中,高度自适应,不限制最大高度
  1807. return "<img src=\"data:image/png;base64," + base64
  1808. + "\" alt=\"formula\" style=\"vertical-align: middle; height: auto;\"/>";
  1809. }
  1810. } catch (Exception e) {
  1811. log.warn("LaTeX公式渲染失败: {}, 错误: {}", latex, e.getMessage());
  1812. // 渲染失败时,返回带样式的源代码显示
  1813. String escapedLatex = escapeHtmlForFormula(latex);
  1814. if (isBlock) {
  1815. return "<div class=\"math\">" + escapedLatex + "</div>";
  1816. } else {
  1817. return "<span class=\"inline-math\">" + escapedLatex + "</span>";
  1818. }
  1819. }
  1820. }
  1821. /**
  1822. * 对公式内容进行HTML转义(保留部分符号的可读性)
  1823. */
  1824. private String escapeHtmlForFormula(String formula) {
  1825. if (formula == null) {
  1826. return "";
  1827. }
  1828. return formula.trim()
  1829. .replace("&", "&amp;")
  1830. .replace("<", "&lt;")
  1831. .replace(">", "&gt;")
  1832. .replace("\"", "&quot;");
  1833. }
  1834. /**
  1835. * HTML转义
  1836. */
  1837. private String escapeHtml(String text) {
  1838. if (text == null) {
  1839. return "";
  1840. }
  1841. return text.replace("&", "&amp;")
  1842. .replace("<", "&lt;")
  1843. .replace(">", "&gt;")
  1844. .replace("\"", "&quot;")
  1845. .replace("'", "&#39;")
  1846. .replace("\n", "<br/>");
  1847. }
  1848. }