Jelajahi Sumber

Merge branch 'feature/outlook-report-visual-alignment' of jyx/dcjxb.microservice into master

金逸霄 2 minggu lalu
induk
melakukan
ec6ff426be

+ 219 - 139
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java

@@ -13,16 +13,17 @@ import java.io.InputStream;
 import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
-import java.time.ZoneOffset;
-import java.time.format.DateTimeFormatter;
 import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
+import java.util.regex.Pattern;
 
 @Component
 public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintReportRenderer {
 
-    private static final DateTimeFormatter DATE_TIME_FORMATTER =
-            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm 'UTC'").withZone(ZoneOffset.UTC);
+    private static final String TEMPLATE_RESOURCE = "templates/outlook-exam-sprint-report-template.html";
+    private static final String DEFAULT_THEME_COLOR = "#448aff";
+    private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
 
     private final ObjectMapper objectMapper;
 
@@ -40,7 +41,6 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         try {
             OutlookExamSprintReportPayload reportPayload = objectMapper.treeToValue(payload, OutlookExamSprintReportPayload.class);
             return loadTemplate()
-                    .replace("{{reportIntroShell}}", renderReportIntroShell(reportPayload, generatedAt))
                     .replace("{{syllabusMasteryChart}}", renderSyllabusMasteryChart(reportPayload.syllabusMasteryChart()))
                     .replace("{{pastPaperVocabularyChart}}", renderPastPaperVocabularyChart(reportPayload.pastPaperVocabularyChart()))
                     .replace("{{highFrequencyVocabularyChart}}", renderHighFrequencyVocabularyChart(reportPayload.highFrequencyVocabularyChart()))
@@ -55,83 +55,73 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
     }
 
     private String loadTemplate() throws IOException {
-        try (InputStream inputStream = new ClassPathResource("templates/outlook-exam-sprint-report-template.html").getInputStream()) {
+        try (InputStream inputStream = new ClassPathResource(TEMPLATE_RESOURCE).getInputStream()) {
             return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
         }
     }
 
-    private String renderReportIntroShell(OutlookExamSprintReportPayload payload, Instant generatedAt) {
-        OutlookExamSprintReportPayload.ReportMetadata metadata = payload.reportMetadata();
-        OutlookExamSprintReportPayload.ReadinessOverview readinessOverview = payload.readinessOverview();
-
-        return new StringBuilder()
-                .append("<div class='report-intro-shell'>")
-                .append("<div class='report-intro-meta'>")
-                .append("<span>报告版本:").append(escape(metadata.reportVersionLabel())).append("</span>")
-                .append("<span>学生:").append(escape(metadata.learnerName())).append("</span>")
-                .append("<span>目标考试:").append(escape(metadata.targetExamName())).append("</span>")
-                .append("<span>冲刺周期:").append(escape(metadata.sprintPeriodLabel())).append("</span>")
-                .append("<span>作者:").append(escape(metadata.authorName())).append("</span>")
-                .append("<span>生成时间:").append(escape(DATE_TIME_FORMATTER.format(generatedAt))).append("</span>")
-                .append("</div>")
-                .append("<p class='report-intro-summary'>").append(escape(readinessOverview.summary())).append("</p>")
-                .append("<p class='report-intro-insight'>核心观察:").append(escape(readinessOverview.keyInsight())).append("</p>")
-                .append("<p class='report-intro-summary'>当前阶段:").append(escape(readinessOverview.currentStage())).append(" · 备考得分 ")
-                .append(readinessOverview.readinessScore()).append("</p>")
-                .append("</div>")
-                .toString();
-    }
-
     private String renderSyllabusMasteryChart(OutlookExamSprintReportPayload.SyllabusMasteryChart chart) {
-        int masteredArcPercent = Math.max(0, Math.min(100, chart.masteryPercent()));
-        int unmasteredArcPercent = 100 - masteredArcPercent;
+        double masteredPercent = percentage(chart.masteredWordCount(), chart.totalWordCount());
+        double unmasteredPercent = percentage(chart.unmasteredWordCount(), chart.totalWordCount());
+        double endAngle = -90 + (Math.max(0d, Math.min(100d, masteredPercent)) * 3.6);
 
         return new StringBuilder()
-                .append("<div class='card-title'>考纲词汇掌握情况</div>")
-                .append("<div class='chart-shell'>")
+                .append("<div class='card'>")
+                .append("<h3 class='card-title'>考纲词汇掌握情况</h3>")
+                .append("<div class='chart-box'>")
                 .append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
-                .append("<circle class='chart-track' cx='110' cy='110' r='76'></circle>")
-                .append("<path class='donut-mastered-arc' d='")
-                .append(describeArc(110, 110, 76, -90, -90 + (masteredArcPercent * 3.6)))
-                .append("' stroke='#448aff' stroke-width='18' fill='none' stroke-linecap='round'/>")
+                .append("<circle class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'></circle>")
+                .append(renderProgressRing("donut-mastered-arc", "donut-mastered-full-circle", 110, 110, 76, masteredPercent, "#448aff"))
                 .append("<path class='donut-unmastered-arc' d='")
-                .append(describeArc(110, 110, 76, -90 + (masteredArcPercent * 3.6), 270))
+                .append(describeArc(110, 110, 76, endAngle, 270))
                 .append("' stroke='#e8eef7' stroke-width='18' fill='none' stroke-linecap='round'/>")
-                .append("<text class='chart-percent' x='110' y='106' text-anchor='middle'>")
-                .append(chart.masteryPercent()).append("%</text>")
-                .append("<text class='chart-caption' x='110' y='131' text-anchor='middle'>")
-                .append(escape(chart.summaryLabel())).append("</text>")
+                .append("<text class='chart-percent' x='110' y='106' text-anchor='middle' fill='#2b4c8a' font-size='28' font-weight='700'>")
+                .append(formatTwoDecimals(masteredPercent)).append("%</text>")
+                .append("<text class='chart-caption' x='110' y='131' text-anchor='middle' fill='#5f6b7a' font-size='14'>掌握率</text>")
                 .append("</svg>")
                 .append("</div>")
                 .append("<div class='data-text'>")
-                .append("已掌握 ").append(chart.masteredWordCount()).append(" / ").append(chart.totalWordCount())
-                .append(" 词,未掌握 ").append(chart.unmasteredWordCount()).append(" 词。</div>")
-                .append("<p class='chart-note'>").append(escape(chart.recommendation())).append("</p>")
+                .append("考纲总量:<span class='highlight'>").append(chart.totalWordCount())
+                .append("词</span> | 已掌握:<span class='highlight'>").append(chart.masteredWordCount())
+                .append("词(").append(formatTwoDecimals(masteredPercent)).append("%)</span><br/>")
+                .append("未掌握:<span class='highlight'>").append(chart.unmasteredWordCount())
+                .append("词(").append(formatTwoDecimals(unmasteredPercent)).append("%)</span>")
+                .append("</div>")
+                .append("</div>")
                 .toString();
     }
 
     private String renderPastPaperVocabularyChart(OutlookExamSprintReportPayload.PastPaperVocabularyChart chart) {
-        int totalHeight = barHeight(chart.totalWordCount(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
-        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint()));
+        int maxValue = Math.max(chart.totalWordCount(), chart.unknownWordCountBeforeSprint());
+        int totalHeight = barHeight(chart.totalWordCount(), maxValue);
+        int unknownHeight = barHeight(chart.unknownWordCountBeforeSprint(), maxValue);
+        double beforePercent = percentage(chart.unknownWordCountBeforeSprint(), chart.totalWordCount());
+        double afterPercent = percentage(chart.unknownWordCountAfterSprint(), chart.totalWordCount());
 
         return new StringBuilder()
-                .append("<div class='card-title'>真题试卷词汇掌握情况</div>")
-                .append("<div class='chart-shell'>")
+                .append("<div class='card'>")
+                .append("<h3 class='card-title'>真题试卷词汇掌握情况</h3>")
+                .append("<div class='chart-box'>")
                 .append("<svg class='past-paper-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
-                .append(renderChartAxes(320, 220))
+                .append(renderChartAxes(320))
                 .append("<rect class='chart-column total-column' x='84' y='").append(180 - totalHeight)
                 .append("' width='56' height='").append(totalHeight).append("' rx='8' ry='8' fill='#448aff'/>")
                 .append("<rect class='chart-column unknown-column' x='180' y='").append(180 - unknownHeight)
                 .append("' width='56' height='").append(unknownHeight).append("' rx='8' ry='8' fill='#ff9800'/>")
                 .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>总词量</text>")
-                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>未知词</text>")
+                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>生词量</text>")
                 .append("</svg>")
                 .append("</div>")
-                .append("<div class='data-text'>总词量 ").append(chart.totalWordCount())
-                .append(" 词,冲刺前未知 ").append(chart.unknownWordCountBeforeSprint())
-                .append(" 词,冲刺后未知 ").append(chart.unknownWordCountAfterSprint()).append(" 词。</div>")
-                .append("<p class='chart-note'>").append(escape(chart.projectedScoreGainLabel())).append(" · ")
-                .append(escape(chart.recommendation())).append("</p>")
+                .append("<div class='data-text'>")
+                .append("真题总词:").append(chart.totalWordCount()).append("词 | 生词量:")
+                .append(chart.unknownWordCountBeforeSprint()).append("词(")
+                .append(formatTwoDecimals(beforePercent)).append("%)<br/>")
+                .append("冲刺后生词:").append(chart.unknownWordCountAfterSprint())
+                .append("词,生词占比降至").append(formatTwoDecimals(afterPercent)).append("%,")
+                .append(escape(chart.projectedScoreGainLabel()))
+                .append("</div>")
+                .append("<p class='data-text'>").append(escape(chart.recommendation())).append("</p>")
+                .append("</div>")
                 .toString();
     }
 
@@ -140,145 +130,184 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         int highScoreHeight = barHeight(chart.highScorePercent(), 100);
 
         return new StringBuilder()
-                .append("<div class='card-title'>常考词汇掌握情况</div>")
-                .append("<div class='chart-shell'>")
+                .append("<div class='card'>")
+                .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
+                .append("<div class='chart-box'>")
                 .append("<svg class='high-frequency-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
-                .append(renderChartAxes(320, 220))
+                .append(renderChartAxes(320))
                 .append("<rect class='chart-column basic-core-column' x='84' y='").append(180 - basicHeight)
                 .append("' width='56' height='").append(basicHeight).append("' rx='8' ry='8' fill='#42a5f5'/>")
                 .append("<rect class='chart-column high-score-column' x='180' y='").append(180 - highScoreHeight)
                 .append("' width='56' height='").append(highScoreHeight).append("' rx='8' ry='8' fill='#66bb6a'/>")
-                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础核心</text>")
-                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>拉分词</text>")
+                .append("<text class='chart-caption' x='112' y='198' text-anchor='middle'>基础必会词</text>")
+                .append("<text class='chart-caption' x='208' y='198' text-anchor='middle'>高分拉分词</text>")
                 .append("</svg>")
                 .append("</div>")
-                .append("<div class='data-text'>基础核心 ").append(chart.basicCorePercent()).append("% ,高分词 ")
-                .append(chart.highScorePercent()).append("%。</div>")
-                .append("<p class='chart-note'>").append(escape(chart.highlightLabel())).append("</p>")
+                .append("<div class='data-text'>基础必会词:掌握率").append(chart.basicCorePercent())
+                .append("%<br/>高分拉分词:掌握率").append(chart.highScorePercent()).append("%</div>")
+                .append("<p class='data-text'><span class='highlight'>")
+                .append(escape(chart.highlightLabel()))
+                .append("</span></p>")
+                .append("</div>")
                 .toString();
     }
 
     private String renderVocabularyFrequencyBandChart(OutlookExamSprintReportPayload.VocabularyFrequencyBandChart chart) {
         List<OutlookExamSprintReportPayload.VocabularyFrequencyBar> bars = chart.bars();
-        int highest = bars.stream().mapToInt(bar -> (int) Math.ceil(bar.currentValue())).max().orElse(1);
+        double highest = bars.stream()
+                .mapToDouble(OutlookExamSprintReportPayload.VocabularyFrequencyBar::currentValue)
+                .max()
+                .orElse(1d);
+        int maxScaled = (int) Math.ceil(highest * 10d);
 
         StringBuilder builder = new StringBuilder();
-        builder.append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>");
-        builder.append(renderChartAxes(360, 220));
+        builder.append("<div class='card'>")
+                .append("<h3 class='card-title'>词频区间掌握度</h3>")
+                .append("<div class='chart-box'>")
+                .append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>")
+                .append(renderChartAxes(360));
 
         int[] xPositions = {70, 160, 250};
         String[] columnClasses = {"high-band-column", "mid-band-column", "low-band-column"};
         for (int index = 0; index < Math.min(3, bars.size()); index++) {
             OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
-            int height = barHeight((int) Math.round(bar.currentValue()), highest);
+            int height = barHeight((int) Math.round(bar.currentValue() * 10d), maxScaled);
             int x = xPositions[index];
+
             builder.append("<rect class='chart-column ").append(columnClasses[index]).append("' x='")
                     .append(x).append("' y='").append(180 - height).append("' width='54' height='")
-                    .append(height).append("' rx='8' ry='8' fill='").append(escape(bar.themeColor())).append("'/>")
+                    .append(height).append("' rx='8' ry='8' fill='").append(safeColor(bar.themeColor())).append("'/>")
                     .append("<text class='donut-label-text' x='").append(x + 27).append("' y='198' text-anchor='middle'>")
                     .append(escape(bar.bandLabel())).append("</text>")
                     .append("<text class='chart-caption' x='").append(x + 27).append("' y='20' text-anchor='middle'>")
                     .append(escape(bar.priorityLabel())).append("</text>");
         }
 
-        builder.append("</svg>");
+        builder.append("</svg>")
+                .append("</div>")
+                .append("<div class='data-text'>");
+
+        for (int index = 0; index < Math.min(3, bars.size()); index++) {
+            OutlookExamSprintReportPayload.VocabularyFrequencyBar bar = bars.get(index);
+            if (index > 0) {
+                builder.append("<br/>");
+            }
+            builder.append(escape(bar.bandLabel())).append(":")
+                    .append(formatOneDecimal(bar.currentValue()))
+                    .append("(")
+                    .append(escape(bar.priorityLabel()))
+                    .append(")");
+        }
+
+        builder.append("</div>")
+                .append("</div>");
         return builder.toString();
     }
 
     private String renderStudySuggestionSection(OutlookExamSprintReportPayload.FrequencyPlan frequencyPlan) {
         StringBuilder builder = new StringBuilder();
-        builder.append("<div class='study-suggestion-shell'>")
-                .append("<div class='study-suggestion-intro'>")
-                .append("<h3>").append(escape(frequencyPlan.recommendationTitle())).append("</h3>")
-                .append("<p>").append(escape(frequencyPlan.recommendationSummary())).append("</p>")
+        builder.append("<div class='suggest-item'>")
+                .append("<h4>🎯 练习学案频率与提分规划</h4>")
+                .append("<p class='text-desc'>")
+                .append(escape(frequencyPlan.recommendationSummary()))
+                .append("</p>")
                 .append("</div>")
-                .append("<div class='study-frequency-grid'>");
-
-        for (OutlookExamSprintReportPayload.FrequencyPlanCard card : frequencyPlan.cards()) {
-            builder.append("<div class='study-frequency-card")
-                    .append(card.recommended() ? " active" : "")
-                    .append("'>")
-                    .append("<div class='study-frequency-header'>")
-                    .append(escape(card.cadencePerWeek() + "套/周"));
-            if (card.emphasisIcon() != null && !card.emphasisIcon().isBlank()) {
-                builder.append("<span class='crown'>").append(escape(card.emphasisIcon())).append("</span>");
+                .append("<table class='frequency-table' role='presentation'><tr class='frequency-row'>");
+
+        for (int index = 0; index < frequencyPlan.cards().size(); index++) {
+            OutlookExamSprintReportPayload.FrequencyPlanCard card = frequencyPlan.cards().get(index);
+            int columnNumber = index + 1;
+            builder.append("<td class='frequency-cell frequency-cell-").append(columnNumber).append("'>")
+                    .append("<div class='freq-card card-").append(columnNumber);
+            if (card.recommended()) {
+                builder.append(" active");
             }
-            builder.append("</div>")
-                    .append("<div class='study-frequency-progress'><span class='study-frequency-progress-fill' style='width:")
-                    .append(card.winRatePercent()).append("%'></span></div>")
-                    .append("<div class='study-frequency-data'>")
-                    .append("<span class='win-rate'>").append(escape(card.scoreGainLabel())).append("</span> · ")
-                    .append(card.winRatePercent()).append("%")
-                    .append("</div>");
-
-            if (card.badgeLabel() != null && !card.badgeLabel().isBlank()) {
-                builder.append("<div class='badge'>").append(escape(card.badgeLabel())).append("</div>");
+            builder.append("'>")
+                    .append("<div class='freq-header'>")
+                    .append(card.cadencePerWeek()).append("套/周");
+
+            if (card.recommended()) {
+                String badgeLabel = card.badgeLabel() == null || card.badgeLabel().isBlank() ? "推荐" : card.badgeLabel();
+                builder.append(" <span class='badge'>").append(escape(badgeLabel)).append("</span>");
+            } else if (card.cadencePerWeek() == 5) {
+                builder.append(" <span class='crown'>★</span>");
             }
 
-            builder.append("</div>");
+            builder.append("</div>")
+                    .append("<div class='freq-progress'><span style='width:")
+                    .append(card.winRatePercent()).append("%'></span></div>")
+                    .append("<div class='freq-data'>提升 <strong>")
+                    .append(escape(card.scoreGainLabel()))
+                    .append("</strong> · 胜率 <strong>")
+                    .append(card.winRatePercent())
+                    .append("%</strong></div>")
+                    .append("</div>")
+                    .append("</td>");
         }
 
-        builder.append("</div>")
-                .append("<div class='study-strategy-note'><span class='study-strategy-label'>")
+        builder.append("</tr></table>")
+                .append("<div class='suggest-note'><strong>")
                 .append(escape(frequencyPlan.recommendationTitle()))
-                .append("</span>")
+                .append(":</strong>")
                 .append(escape(frequencyPlan.recommendationSummary()))
                 .append("</div>")
-                .append("<div class='study-stage-box'>");
+                .append("<div class='suggest-box'>");
 
         for (OutlookExamSprintReportPayload.PhaseSuggestion suggestion : frequencyPlan.phaseSuggestions()) {
-            builder.append("<div class='study-stage-item'>")
+            builder.append("<div class='suggest-item'>")
                     .append("<h4>").append(escape(suggestion.title())).append("</h4>")
                     .append("<p>").append(escape(suggestion.description())).append("</p>")
                     .append("</div>");
         }
 
-        builder.append("</div></div>");
+        builder.append("</div>");
         return builder.toString();
     }
 
     private String renderScoreImprovementCaseStudy(OutlookExamSprintReportPayload.ScoreImprovementCaseStudy caseStudy) {
+        double progressPercent = Math.max(0d, Math.min(100d, caseStudy.hitRatePercent()));
+        double endAngle = -90 + (progressPercent * 3.6);
+
         return new StringBuilder()
-                .append("<div class='case-study-shell'>")
-                .append("<div class='case-study-visual'>")
-                .append("<svg class='case-study-visual-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
-                .append("<circle cx='130' cy='130' r='92' fill='#fff3e5' stroke='#f3d7bb' stroke-width='2'/>")
-                .append("<path d='M52 176 L104 124 L136 146 L204 82' fill='none' stroke='#ff7d00' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/>")
-                .append("<circle cx='52' cy='176' r='8' fill='#ff7d00'/>")
-                .append("<circle cx='104' cy='124' r='8' fill='#ff7d00'/>")
-                .append("<circle cx='136' cy='146' r='8' fill='#ff7d00'/>")
-                .append("<circle cx='204' cy='82' r='8' fill='#ff7d00'/>")
-                .append("<text x='130' y='214' text-anchor='middle' fill='#8a5d36' font-size='14'>")
-                .append(escape(caseStudy.baselineScoreLabel())).append(" → ").append(caseStudy.finalScore()).append("分</text>")
+                .append("<div class='student-case'>")
+                .append("<table class='student-case-table' role='presentation'>")
+                .append("<tr class='student-case-row'>")
+                .append("<td class='case-chart-cell'>")
+                .append("<div class='case-chart'>")
+                .append("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
+                .append("<circle cx='130' cy='130' r='86' fill='none' stroke='#ffe3c7' stroke-width='18'></circle>")
+                .append(renderProgressRing("case-progress-arc", "case-progress-full-circle", 130, 130, 86, progressPercent, "#ff7d00"))
+                .append("<text x='130' y='124' text-anchor='middle' fill='#8a5d36' font-size='26' font-weight='700'>")
+                .append(formatOneDecimal(progressPercent)).append("%</text>")
+                .append("<text x='130' y='152' text-anchor='middle' fill='#8a5d36' font-size='12'>命中率</text>")
                 .append("</svg>")
-                .append("<div class='case-hit-rate-badge'><span class='case-hit-rate-value'>")
-                .append(caseStudy.hitRatePercent()).append("%</span><span class='case-hit-rate-label'>命中率</span></div>")
                 .append("</div>")
+                .append("</td>")
+                .append("<td class='case-info-cell'>")
                 .append("<div class='case-info'>")
                 .append("<h3>").append(escape(caseStudy.headline())).append("</h3>")
-                .append(caseInfoSection("背景", caseStudy.studyPeriodLabel()))
-                .append(caseInfoSection("学生", caseStudy.learnerName()))
-                .append(caseInfoSection("记忆词汇", caseStudy.memorizedWordCount() + "词"))
-                .append(caseInfoSection("高考命中", caseStudy.examHitWordCount() + "词"))
-                .append(caseInfoSection("结果", caseStudy.baselineScoreLabel() + " · " + caseStudy.finalScore() + "分 · 提升分数:+" + caseStudy.scoreGain() + "分"))
+                .append("<p><strong>学员:</strong>").append(escape(caseStudy.learnerName())).append("</p>")
+                .append("<p><strong>阶段:</strong>").append(escape(caseStudy.studyPeriodLabel())).append("</p>")
+                .append("<p><strong>记忆词汇:</strong>").append(caseStudy.memorizedWordCount())
+                .append("词 | 高考命中:").append(caseStudy.examHitWordCount())
+                .append("词 | 命中率:").append(formatOneDecimal(progressPercent)).append("%</p>")
+                .append("<p><strong>提升分数:</strong><span class='highlight score-gain'>+")
+                .append(caseStudy.scoreGain()).append("分</span></p>")
+                .append("<p>").append(escape(caseStudy.baselineScoreLabel())).append(" → ")
+                .append(caseStudy.finalScore()).append("分</p>")
                 .append("</div>")
+                .append("</td>")
+                .append("</tr>")
+                .append("</table>")
                 .append("</div>")
                 .toString();
     }
 
-    private StringBuilder caseInfoSection(String label, String value) {
-        return new StringBuilder()
-                .append("<div class='case-info-section'>")
-                .append("<div class='case-info-group'><span class='case-info-label'>")
-                .append(escape(label)).append(":</span><span class='case-info-value'>")
-                .append(escape(value)).append("</span></div>")
-                .append("</div>");
-    }
-
-    private String renderChartAxes(int width, int height) {
+    private String renderChartAxes(int width) {
         return new StringBuilder()
                 .append("<line class='chart-axis' x1='34' y1='18' x2='34' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
-                .append("<line class='chart-axis' x1='34' y1='180' x2='").append(width - 24).append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
+                .append("<line class='chart-axis' x1='34' y1='180' x2='").append(width - 24)
+                .append("' y2='180' stroke='#9aa6b2' stroke-width='1'/>")
                 .toString();
     }
 
@@ -290,6 +319,13 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         return Math.max(16, (int) Math.round(ratio * 130));
     }
 
+    private double percentage(int numerator, int denominator) {
+        if (denominator <= 0) {
+            return 0d;
+        }
+        return (numerator * 100d) / denominator;
+    }
+
     private String describeArc(double centerX, double centerY, double radius, double startAngle, double endAngle) {
         double startRadians = Math.toRadians(startAngle);
         double endRadians = Math.toRadians(endAngle);
@@ -297,20 +333,53 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         double startY = centerY + (radius * Math.sin(startRadians));
         double endX = centerX + (radius * Math.cos(endRadians));
         double endY = centerY + (radius * Math.sin(endRadians));
-        double largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
+        int largeArcFlag = Math.abs(endAngle - startAngle) <= 180 ? 0 : 1;
+        return new StringBuilder()
+                .append("M ").append(formatTwoDecimals(startX)).append(' ')
+                .append(formatTwoDecimals(startY)).append(' ')
+                .append("A ").append(formatTwoDecimals(radius)).append(' ')
+                .append(formatTwoDecimals(radius)).append(" 0 ")
+                .append(largeArcFlag).append(" 1 ")
+                .append(formatTwoDecimals(endX)).append(' ')
+                .append(formatTwoDecimals(endY))
+                .toString();
+    }
+
+    private String renderProgressRing(String arcClass,
+                                      String fullCircleClass,
+                                      double centerX,
+                                      double centerY,
+                                      double radius,
+                                      double percent,
+                                      String strokeColor) {
+        double boundedPercent = Math.max(0d, Math.min(100d, percent));
+        if (boundedPercent >= 100d) {
+            return new StringBuilder()
+                    .append("<circle class='").append(fullCircleClass).append("' cx='")
+                    .append(formatTwoDecimals(centerX)).append("' cy='")
+                    .append(formatTwoDecimals(centerY)).append("' r='")
+                    .append(formatTwoDecimals(radius)).append("' fill='none' stroke='")
+                    .append(strokeColor).append("' stroke-width='18'></circle>")
+                    .toString();
+        }
+        if (boundedPercent <= 0d) {
+            return "";
+        }
+        double endAngle = -90 + (boundedPercent * 3.6);
         return new StringBuilder()
-                .append("M ").append(formatDouble(startX)).append(' ')
-                .append(formatDouble(startY)).append(' ')
-                .append("A ").append(formatDouble(radius)).append(' ')
-                .append(formatDouble(radius)).append(" 0 ")
-                .append((int) largeArcFlag).append(" 1 ")
-                .append(formatDouble(endX)).append(' ')
-                .append(formatDouble(endY))
+                .append("<path class='").append(arcClass).append("' d='")
+                .append(describeArc(centerX, centerY, radius, -90, endAngle))
+                .append("' stroke='").append(strokeColor)
+                .append("' stroke-width='18' fill='none' stroke-linecap='round'/>")
                 .toString();
     }
 
-    private String formatDouble(double value) {
-        return String.format(java.util.Locale.ROOT, "%.2f", value);
+    private String formatOneDecimal(double value) {
+        return String.format(Locale.ROOT, "%.1f", value);
+    }
+
+    private String formatTwoDecimals(double value) {
+        return String.format(Locale.ROOT, "%.2f", value);
     }
 
     private String escape(String value) {
@@ -323,4 +392,15 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .replace("\"", "&quot;")
                 .replace("'", "&#39;");
     }
+
+    private String safeColor(String value) {
+        if (value == null) {
+            return DEFAULT_THEME_COLOR;
+        }
+        String trimmed = value.trim();
+        if (HEX_COLOR_PATTERN.matcher(trimmed).matches()) {
+            return trimmed;
+        }
+        return DEFAULT_THEME_COLOR;
+    }
 }

+ 169 - 363
abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html

@@ -10,487 +10,293 @@
         }
 
         body {
-            font-family: ReportFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
-            color: #2f3542;
-            font-size: 12px;
-            line-height: 1.65;
             margin: 0;
-            background: #ffffff;
+            background-color: #f5f7fa;
+            padding: 30px 20px;
+            color: #333;
+            font-family: ReportFont, "Microsoft YaHei", sans-serif;
+            font-size: 14px;
+            line-height: 1.6;
         }
 
         .report-container {
-            padding: 8px 10px 6px;
+            max-width: 1200px;
+            margin: 0 auto;
+            background: #fff;
+            padding: 40px;
+            border-radius: 12px;
+            border: 1px solid #e7edf5;
         }
 
-        .report-title {
+        h1.report-title {
             text-align: center;
-            font-size: 28px;
+            font-size: 26px;
             color: #2b4c8a;
-            font-weight: 700;
-            margin-bottom: 6px;
+            margin: 0 0 8px;
         }
 
-        .report-subtitle {
+        p.report-subtitle {
             text-align: center;
-            font-size: 14px;
-            color: #5f6b7a;
-            margin-bottom: 12px;
-        }
-
-        .report-intro-shell {
-            background: #f7f9fc;
-            border: 1px solid #e4ebf5;
-            border-radius: 12px;
-            padding: 14px 18px;
-            margin-bottom: 14px;
-        }
-
-        .module-section {
-            margin-top: 22px;
-        }
-
-        .module-one-section {
-            margin-top: 0;
-        }
-
-        .module-body {
-            margin-top: 10px;
+            margin: 0 0 24px;
+            color: #68768a;
         }
 
-        .report-intro-meta {
-            font-size: 0;
-            margin-bottom: 10px;
+        .section {
+            margin-top: 26px;
         }
 
-        .report-intro-meta span {
-            display: inline-block;
-            font-size: 12px;
-            color: #516173;
-            margin-right: 16px;
+        .section-title {
+            font-size: 20px;
+            color: #2b4c8a;
+            border-left: 6px solid #ff7d00;
+            padding-left: 12px;
+            margin: 0 0 14px;
+            font-weight: 700;
         }
 
-        .report-intro-summary,
-        .report-intro-insight {
-            font-size: 13px;
-            color: #444;
-            margin: 4px 0 0;
+        .analysis-table {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: separate;
+            border-spacing: 0 16px;
         }
 
-        .module-one-row {
-            font-size: 0;
-            margin-bottom: 14px;
+        .analysis-row {
             page-break-inside: avoid;
         }
 
-        .module-one-row-second {
-            margin-bottom: 0;
-        }
-
-        .content-card {
-            background: #fafbfc;
-            border: 1px solid #e4ebf5;
-            border-radius: 14px;
-            padding: 16px;
+        .analysis-cell {
+            width: 50%;
+            vertical-align: top;
         }
 
-        .content-card {
-            display: inline-block;
-            width: 47.2%;
-            vertical-align: top;
-            margin: 0 2.8% 0 0;
-            box-sizing: border-box;
-            font-size: 12px;
+        .analysis-cell-left {
+            padding-right: 10px;
         }
 
-        .module-one-row .content-card:last-child {
-            margin-right: 0;
+        .analysis-cell-right {
+            padding-left: 10px;
         }
 
-        .section-title {
-            font-size: 20px;
-            color: #2b4c8a;
-            border-left: 6px solid #ff7d00;
-            padding-left: 12px;
-            margin: 24px 0 14px;
-            font-weight: 700;
-            page-break-after: avoid;
+        .card {
+            background: #fff;
+            border: 1px solid #e7edf5;
+            border-radius: 12px;
+            padding: 16px;
+            page-break-inside: avoid;
         }
 
         .card-title {
             font-size: 16px;
             color: #2b4c8a;
             font-weight: 700;
-            margin-bottom: 8px;
+            margin: 0 0 10px;
         }
 
-        .chart-shell {
+        .chart-box {
+            height: 190px;
+            margin-bottom: 10px;
+            border-radius: 10px;
+            background: #f8fbff;
             text-align: center;
-            margin: 8px 0 12px;
         }
 
-        .syllabus-donut-chart,
-        .past-paper-column-chart,
-        .high-frequency-column-chart,
-        .frequency-band-column-chart {
+        .chart-box svg {
             width: 100%;
-            height: 220px;
-        }
-
-        .chart-track {
-            fill: none;
-            stroke: #e8eef7;
-            stroke-width: 18;
-        }
-
-        .chart-mastered {
-            fill: none;
-            stroke: #448aff;
-            stroke-width: 18;
-            transform: rotate(-90deg);
-            transform-origin: 110px 110px;
-        }
-
-        .chart-percent {
-            font-size: 24px;
-            fill: #2b4c8a;
-            font-weight: 700;
-        }
-
-        .chart-caption {
-            font-size: 12px;
-            fill: #6d7a8a;
-        }
-
-        .syllabus-donut-chart {
-            display: block;
-        }
-
-        .past-paper-column-chart {
-            display: block;
-        }
-
-        .high-frequency-column-chart,
-        .frequency-band-column-chart {
+            height: 100%;
             display: block;
         }
 
-        .donut-label-text {
-            font-size: 12px;
-            fill: #444;
-        }
-
-        .chart-axis {
-            stroke: #9aa6b2;
-        }
-
-        .chart-gridline {
-            stroke: #d7e0ec;
-        }
-
         .data-text {
-            font-size: 14px;
-            line-height: 1.8;
-            color: #444;
-        }
-
-        .chart-note {
-            font-size: 13px;
-            color: #516173;
+            margin: 6px 0;
+            color: #3d4a5d;
         }
 
-        .badge {
-            display: inline-block;
-            background: #fff1e7;
+        .highlight,
+        .score-gain {
             color: #ff7d00;
-            border-radius: 999px;
-            padding: 2px 10px;
-            font-size: 11px;
             font-weight: 700;
-            margin-left: 8px;
-        }
-
-        ul {
-            margin: 8px 0 0 18px;
-            padding: 0;
-        }
-
-        p {
-            margin: 6px 0;
         }
 
-        .study-suggestion-shell {
-            margin-bottom: 18px;
-        }
-
-        .module-two-section .study-frequency-grid {
-            page-break-before: avoid;
+        .frequency-table {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: separate;
+            border-spacing: 0;
+            margin-bottom: 16px;
         }
 
-        .study-suggestion-intro h3 {
-            font-size: 16px;
-            color: #2b4c8a;
-            margin: 0 0 8px;
+        .frequency-row {
+            page-break-inside: avoid;
         }
 
-        .study-suggestion-intro p {
-            font-size: 14px;
-            color: #555;
-            margin: 0 0 16px;
+        .frequency-cell {
+            width: 25%;
+            vertical-align: top;
         }
 
-        .study-frequency-grid {
-            font-size: 0;
-            margin: 20px 0 16px;
+        .frequency-cell-1,
+        .frequency-cell-2,
+        .frequency-cell-3 {
+            padding-right: 7px;
         }
 
-        .study-frequency-card {
-            display: inline-block;
-            width: 23%;
-            margin-right: 2.66%;
-            vertical-align: bottom;
-            position: relative;
-            background: #f8f9fa;
-            border: 2px solid #e0e0e0;
-            border-radius: 8px;
-            padding: 15px;
-            box-sizing: border-box;
+        .frequency-cell-2,
+        .frequency-cell-3,
+        .frequency-cell-4 {
+            padding-left: 7px;
         }
 
-        .study-frequency-grid .study-frequency-card:last-child {
-            margin-right: 0;
+        .freq-card {
+            background: #f8f9fb;
+            border: 1px solid #e3e9f2;
+            border-radius: 10px;
+            padding: 14px 12px;
+            page-break-inside: avoid;
         }
 
-        .study-frequency-card.active {
-            background: #e3f2fd;
-            border-color: #2196f3;
+        .freq-card.active {
+            background: #eaf4ff;
+            border-color: #6ba7ff;
         }
 
-        .study-frequency-header {
+        .freq-header {
             font-size: 18px;
             font-weight: 700;
-            color: #333;
-            margin-bottom: 8px;
+            color: #303a49;
+            margin-bottom: 10px;
         }
 
-        .crown {
-            font-size: 15px;
-            margin-left: 8px;
+        .badge {
+            display: inline-block;
+            margin-left: 6px;
+            padding: 2px 8px;
+            border-radius: 999px;
+            background: #fff1e7;
+            color: #ff7d00;
+            font-size: 12px;
+            vertical-align: middle;
         }
 
-        .study-frequency-progress {
-            background: #e0e0e0;
+        .freq-progress {
+            background: #dfe6ef;
+            border-radius: 999px;
             height: 8px;
-            border-radius: 4px;
-            margin: 12px 0 10px;
             overflow: hidden;
+            margin-bottom: 10px;
         }
 
-        .study-frequency-progress-fill {
-            background: #2196f3;
+        .freq-progress > span {
             display: block;
-            height: 8px;
-            border-radius: 4px;
-        }
-
-        .study-frequency-data {
-            font-size: 14px;
-            color: #333;
+            height: 100%;
+            background: #2c8cff;
         }
 
-        .study-frequency-data .win-rate {
-            color: #2196f3;
+        .freq-data {
+            color: #4a5568;
+            font-size: 13px;
         }
 
-        .study-strategy-note {
-            margin-top: 15px;
-            padding: 12px 20px;
-            background: #fff8e1;
-            border-left: 4px solid #ffc107;
-            border-radius: 4px;
-            font-size: 15px;
-            color: #e65100;
+        .text-desc {
+            font-size: 14px;
+            color: #4a5568;
+            margin: 10px 0;
         }
 
-        .study-strategy-label {
-            font-weight: 700;
-            margin-right: 10px;
+        .suggest-note {
+            background: #fff8e7;
+            border-left: 4px solid #ffb13d;
+            border-radius: 8px;
+            padding: 10px 12px;
+            color: #7b5a2f;
+            margin-bottom: 12px;
         }
 
-        .study-stage-box {
+        .suggest-box {
             background: #edf3fc;
             border-radius: 10px;
-            padding: 25px 30px;
-            margin-top: 20px;
-        }
-
-        .study-stage-item {
-            margin-bottom: 20px;
-        }
-
-        .study-stage-item:last-child {
-            margin-bottom: 0;
+            padding: 14px;
         }
 
-        .study-stage-item h4 {
-            font-size: 16px;
-            color: #2b4c8a;
-            margin: 0 0 8px;
+        .suggest-item + .suggest-item {
+            margin-top: 10px;
         }
 
-        .study-stage-item p {
-            font-size: 14px;
-            color: #555;
-            line-height: 1.7;
-            margin: 0;
+        .suggest-item p {
+            margin: 4px 0 0;
+            color: #4a5568;
         }
 
-        .case-study-shell {
+        .student-case {
             background: #fff7ed;
-            border: 1px solid #f3d7bb;
-            border-radius: 14px;
-            padding: 18px;
-            font-size: 0;
+            border: 1px solid #f5dcc2;
+            border-radius: 12px;
+            padding: 16px;
             page-break-inside: avoid;
         }
 
-        .module-three-section .case-study-shell {
-            page-break-inside: avoid;
+        .student-case-table {
+            width: 100%;
+            table-layout: fixed;
+            border-collapse: collapse;
         }
 
-        .case-study-visual,
-        .case-info {
-            display: inline-block;
-            vertical-align: top;
-            box-sizing: border-box;
+        .student-case-row {
+            page-break-inside: avoid;
         }
 
-        .case-study-visual {
-            width: 34%;
-            margin-right: 4%;
-            position: relative;
+        .case-chart-cell {
+            width: 320px;
+            vertical-align: middle;
+            padding-right: 20px;
         }
 
-        .case-info {
-            width: 62%;
-            font-size: 12px;
+        .case-info-cell {
+            vertical-align: middle;
         }
 
-        .case-study-visual-chart {
-            display: block;
-            width: 100%;
+        .case-chart svg {
+            width: 260px;
             height: 260px;
-        }
-
-        .case-hit-rate-badge {
-            position: absolute;
-            top: 14px;
-            right: 8px;
-            background: #ffffff;
-            border: 1px solid #ffd7bf;
-            border-radius: 999px;
-            padding: 8px 12px;
-            text-align: center;
-        }
-
-        .case-hit-rate-value {
-            display: block;
-            color: #e07a1a;
-            font-size: 16px;
-            font-weight: 700;
-        }
-
-        .case-hit-rate-label {
             display: block;
-            color: #8a5d36;
-            font-size: 11px;
-        }
-
-        .case-info h3 {
-            font-size: 18px;
-            color: #2b4c8a;
-            margin: 0 0 10px;
         }
 
-        .case-info-section {
-            margin-bottom: 12px;
-            background: #fffaf4;
-            border: 1px solid #f3deca;
-            border-radius: 12px;
-            padding: 10px 12px;
-        }
-
-        .case-info-section:last-child {
-            margin-bottom: 0;
-        }
-
-        .case-info-section.result-section {
-            background: #fff3e5;
-            border-color: #ffd7bf;
-        }
-
-        .case-info-group {
-            margin-bottom: 8px;
-        }
-
-        .case-info-group:last-child {
-            margin-bottom: 0;
-        }
-
-        .case-info-label {
-            color: #516173;
-            font-weight: 700;
-        }
-
-        .case-info-value {
-            color: #444;
-        }
-
-        .highlight,
-        .score-gain {
-            color: #ff7d00;
-            font-weight: 700;
+        .case-info {
+            width: 100%;
         }
 
-        .score-gain {
-            font-size: 18px;
+        .case-info p {
+            margin: 0 0 8px;
         }
     </style>
 </head>
 <body>
 <div class="report-container">
-    <div class="report-title">高考英语临考词汇突击潜力展望报告</div>
-    <div class="report-subtitle">科学规划 · 精准提分 · 短期见效</div>
-    {{reportIntroShell}}
-
-    <div class='module-section module-one-section'>
-        <div class="section-title">模块一:个人学情分析</div>
-        <div class='module-body module-one-body'>
-            <div class="module-one-row module-one-row-first">
-                <div class="content-card">{{syllabusMasteryChart}}</div>
-                <div class="content-card">{{pastPaperVocabularyChart}}</div>
-            </div>
-            <div class="module-one-row module-one-row-second">
-                <div class="content-card">{{highFrequencyVocabularyChart}}</div>
-                <div class="content-card">
-                    <div class="card-title">词频区间掌握度</div>
-                    {{vocabularyFrequencyBandChart}}
-                </div>
-            </div>
-        </div>
+    <h1 class="report-title">高考英语临考词汇突击潜力展望报告</h1>
+    <p class="report-subtitle">科学规划 · 精准提分 · 短期见效</p>
+
+    <div class="section">
+        <h2 class="section-title">模块一:个人学情分析</h2>
+        <table class="analysis-table" role="presentation">
+            <tr class="analysis-row">
+                <td class="analysis-cell analysis-cell-left">{{syllabusMasteryChart}}</td>
+                <td class="analysis-cell analysis-cell-right">{{pastPaperVocabularyChart}}</td>
+            </tr>
+            <tr class="analysis-row">
+                <td class="analysis-cell analysis-cell-left">{{highFrequencyVocabularyChart}}</td>
+                <td class="analysis-cell analysis-cell-right">{{vocabularyFrequencyBandChart}}</td>
+            </tr>
+        </table>
     </div>
 
-    <div class='module-section module-two-section'>
-        <div class="section-title">模块二:科学备考建议</div>
-        <div class='module-body module-two-body'>
-            {{studySuggestionSection}}
-        </div>
+    <div class="section">
+        <h2 class="section-title">模块二:科学备考建议</h2>
+        {{studySuggestionSection}}
     </div>
 
-    <div class='module-section module-three-section'>
-        <div class="section-title">模块三:上届学员提分案例</div>
-        <div class='module-body module-three-body'>
-            {{scoreImprovementCaseStudy}}
-        </div>
+    <div class="section">
+        <h2 class="section-title">模块三:上届学员提分案例</h2>
+        {{scoreImprovementCaseStudy}}
     </div>
 </div>
 </body>

+ 24 - 360
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java

@@ -4,12 +4,12 @@ import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.ou
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.rendering.PDFRenderer;
 import org.apache.pdfbox.text.PDFTextStripper;
 import org.junit.jupiter.api.Test;
 
-import java.awt.image.BufferedImage;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -17,23 +17,9 @@ import static org.assertj.core.api.Assertions.assertThat;
 class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
-    private static final int MODULE_ONE_PAGE_INDEX = 0;
-    private static final double MODULE_ONE_FIRST_ROW_HEIGHT_RATIO = 0.33;
-    private static final long FIRST_ROW_CHART_DIFFERENCE_THRESHOLD = 15_000L;
-    private static final double MODULE_ONE_LOWER_HALF_START_RATIO = 0.20;
-    private static final double MODULE_ONE_LOWER_HALF_END_RATIO = 0.70;
-    private static final long LOWER_HALF_CHART_DIFFERENCE_THRESHOLD = 2_000L;
-    private static final double SECOND_CARD_AREA_START_X_RATIO = 0.50;
-    private static final double SECOND_CARD_AREA_END_X_RATIO = 0.90;
-    private static final long SECOND_CARD_COLUMN_DIFFERENCE_THRESHOLD = 3_500L;
-    private static final double MODULE_THREE_START_X_RATIO = 0.00;
-    private static final double MODULE_THREE_END_X_RATIO = 1.00;
-    private static final double MODULE_THREE_START_Y_RATIO = 0.00;
-    private static final double MODULE_THREE_END_Y_RATIO = 1.00;
-    private static final long MODULE_THREE_VISUAL_DIFFERENCE_THRESHOLD = 8_000L;
 
     @Test
-    void generateCreatesPdfWithExtractableOutlookKeyText() throws Exception {
+    void generateCreatesPdfSmokeWithExtractableOutlookKeyText() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
 
@@ -43,354 +29,31 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         assertThat(pdfBytes).isNotEmpty();
         assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
 
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
-            assertThat(normalizedText).contains("高考英语临考词汇突击潜力展望报告");
-            assertThat(normalizedText).contains("模块一:个人学情分析");
-            assertThat(normalizedText).contains("模块二:科学备考建议");
-            assertThat(normalizedText).contains("模块三:上届学员提分案例");
-            assertThat(normalizedText).contains("学生:李同学");
-            assertThat(normalizedText).contains("目标考试:春季高考英语");
-            assertThat(normalizedText).contains("冲刺周期:30天考前冲刺");
-            assertThat(normalizedText).contains("基础较稳,具备短期冲刺提分空间。");
-            assertThat(normalizedText).contains("核心观察:高频与常考词群是提分关键。");
-            assertThat(normalizedText).contains("常考词汇掌握情况");
-            assertThat(normalizedText).contains("1套/周");
-            assertThat(normalizedText).contains("3套/周");
-            assertThat(normalizedText).containsAnyOf("💡建议策略", "建议策略");
-            assertThat(normalizedText).containsAnyOf("考前半个月核心突击期", "考前半个月·核心突击期");
-            assertThat(normalizedText).containsAnyOf("考前半小时临阵巩固期", "考前半小时·临阵巩固期");
-            assertThat(normalizedText).containsAnyOf("拉分词是提分核心突破项", "预计提分5-15分");
-            assertThat(normalizedText).containsAnyOf("真实提分·效果可复制", "真实提分效果可复制");
-            assertThat(normalizedText).contains("记忆词汇:705词");
-            assertThat(normalizedText).contains("高考命中:237词");
-            assertThat(normalizedText).containsAnyOf("提升分数:+19分", "提升分数:19分", "+19分");
-        }
-    }
-
-    @Test
-    void generatePlacesModuleOneTitleAndFirstRowOnTheFirstPage() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        byte[] pdfBytes = pdfGenerator.generate(html);
+        Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "outlook-report-demo.pdf");
+        Files.createDirectories(previewPdfPath.getParent());
+        Files.write(previewPdfPath, pdfBytes);
+        assertThat(previewPdfPath).exists().isRegularFile();
 
         try (PDDocument document = PDDocument.load(pdfBytes)) {
-            PDFTextStripper textStripper = new PDFTextStripper();
-            textStripper.setStartPage(1);
-            textStripper.setEndPage(1);
-            String firstPageText = textStripper.getText(document).replaceAll("\\s+", "");
-
-            assertThat(firstPageText)
-                    .as("module-one title and first-row content should appear on the first PDF page")
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
+            assertThat(normalizedText)
+                    .contains("高考英语临考词汇突击潜力展望报告")
                     .contains("模块一:个人学情分析")
-                    .contains("考纲词汇掌握情况")
-                    .contains("真题试卷词汇掌握情况");
+                    .contains("模块二:科学备考建议")
+                    .contains("模块三:上届学员提分案例")
+                    .contains("词频区间掌握度")
+                    .containsAnyOf("练习学案频率与提分规划", "建议策略")
+                    .contains("王雷宇")
+                    .contains("705词")
+                    .contains("237词")
+                    .contains("+19分")
+                    .containsAnyOf("高频词:188.6(优先学习)", "高频词188.6优先学习")
+                    .containsAnyOf("中频词:154.5(重点突破)", "中频词154.5重点突破")
+                    .containsAnyOf("低频词:70.4(酌情学习)", "低频词70.4酌情学习");
         }
     }
 
-    @Test
-    void generateKeepsModuleTwoTitleAndFirstCoreContentOnSamePage() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        byte[] pdfBytes = pdfGenerator.generate(html);
-
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            PDFTextStripper textStripper = new PDFTextStripper();
-            boolean foundSharedPage = false;
-
-            for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
-                textStripper.setStartPage(pageIndex + 1);
-                textStripper.setEndPage(pageIndex + 1);
-                String pageText = textStripper.getText(document).replaceAll("\\s+", "");
-                boolean hasModuleTwoFirstCoreContent = pageText.contains("💡建议策略")
-                        || pageText.contains("建议策略")
-                        || pageText.contains("1套/周");
-
-                if (pageText.contains("模块二:科学备考建议") && hasModuleTwoFirstCoreContent) {
-                    foundSharedPage = true;
-                    break;
-                }
-            }
-
-            assertThat(foundSharedPage)
-                    .as("module-two title and first core content should appear on the same PDF page")
-                    .isTrue();
-        }
-    }
-
-    @Test
-    void generateRendersFirstRowModuleOneChartsDifferentlyFromEmptySvgShells() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        String emptyFirstRowHtml = stripFirstRowModuleOneDataShapes(html);
-        byte[] chartPdfBytes = pdfGenerator.generate(html);
-        byte[] emptyFirstRowPdfBytes = pdfGenerator.generate(emptyFirstRowHtml);
-
-        try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
-             PDDocument emptyDocument = PDDocument.load(emptyFirstRowPdfBytes)) {
-            BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
-            BufferedImage emptyPage = new PDFRenderer(emptyDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
-
-            long differentPixels = countDifferentPixelsInRectangle(chartPage, emptyPage, 0.0, 1.0, 0.0, 1.0);
-
-            assertThat(differentPixels)
-                    .as("first-row module-one charts should visibly affect rendered PDF pixels")
-                    .isGreaterThan(FIRST_ROW_CHART_DIFFERENCE_THRESHOLD);
-        }
-    }
-
-    @Test
-    void generateRendersPastPaperColumnsDifferentlyFromColumnlessScaffolding() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        String withoutColumnShapesHtml = stripPastPaperColumnShapes(html);
-        byte[] chartPdfBytes = pdfGenerator.generate(html);
-        byte[] noColumnsPdfBytes = pdfGenerator.generate(withoutColumnShapesHtml);
-
-        try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
-             PDDocument noColumnsDocument = PDDocument.load(noColumnsPdfBytes)) {
-            BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
-            BufferedImage noColumnsPage = new PDFRenderer(noColumnsDocument).renderImageWithDPI(MODULE_ONE_PAGE_INDEX, 144);
-
-            long differentPixels = countDifferentPixelsInRectangle(chartPage, noColumnsPage, 0.0, 1.0, 0.0, 1.0);
-
-            assertThat(differentPixels)
-                    .as("past-paper data columns should visibly affect rendered PDF pixels")
-                    .isGreaterThan(SECOND_CARD_COLUMN_DIFFERENCE_THRESHOLD);
-        }
-    }
-
-    @Test
-    void generateRendersLowerHalfModuleOneChartsDifferentlyFromColumnlessScaffolding() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        String strippedHtml = stripLowerHalfModuleOneColumnShapes(html);
-        byte[] chartPdfBytes = pdfGenerator.generate(html);
-        byte[] strippedPdfBytes = pdfGenerator.generate(strippedHtml);
-
-        try (PDDocument chartDocument = PDDocument.load(chartPdfBytes);
-             PDDocument strippedDocument = PDDocument.load(strippedPdfBytes)) {
-            int chartModuleOnePageIndex = findPageIndexContainingText(chartDocument, "词频区间掌握度");
-            int strippedModuleOnePageIndex = findPageIndexContainingText(strippedDocument, "词频区间掌握度");
-
-            assertThat(strippedModuleOnePageIndex)
-                    .as("module-one lower-half content should stay on the same page after stripping column shapes")
-                    .isEqualTo(chartModuleOnePageIndex);
-
-            BufferedImage chartPage = new PDFRenderer(chartDocument).renderImageWithDPI(chartModuleOnePageIndex, 144);
-            BufferedImage strippedPage = new PDFRenderer(strippedDocument).renderImageWithDPI(strippedModuleOnePageIndex, 144);
-
-            long differentPixels = countDifferentPixelsInRectangle(chartPage, strippedPage, 0.0, 1.0, 0.0, 1.0);
-
-            assertThat(differentPixels)
-                    .as("lower-half module-one charts should visibly affect rendered PDF pixels")
-                    .isGreaterThan(LOWER_HALF_CHART_DIFFERENCE_THRESHOLD);
-        }
-    }
-
-    @Test
-    void generateRendersModuleThreeVisualDifferentlyFromVisualFreeShell() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
-        String strippedHtml = stripModuleThreeVisual(html);
-        byte[] visualPdfBytes = pdfGenerator.generate(html);
-        byte[] strippedPdfBytes = pdfGenerator.generate(strippedHtml);
-
-        try (PDDocument visualDocument = PDDocument.load(visualPdfBytes);
-             PDDocument strippedDocument = PDDocument.load(strippedPdfBytes)) {
-            int visualModuleThreePageIndex = findPageIndexContainingText(visualDocument, "模块三:上届学员提分案例");
-            int strippedModuleThreePageIndex = findPageIndexContainingText(strippedDocument, "模块三:上届学员提分案例");
-
-            assertThat(strippedModuleThreePageIndex)
-                    .as("module-three title should stay on the same page after stripping visual shell")
-                    .isEqualTo(visualModuleThreePageIndex);
-
-            BufferedImage visualPage = new PDFRenderer(visualDocument).renderImageWithDPI(visualModuleThreePageIndex, 144);
-            BufferedImage strippedPage = new PDFRenderer(strippedDocument).renderImageWithDPI(strippedModuleThreePageIndex, 144);
-
-            long differentPixels = countDifferentPixelsInRectangle(
-                    visualPage,
-                    strippedPage,
-                    MODULE_THREE_START_X_RATIO,
-                    MODULE_THREE_END_X_RATIO,
-                    MODULE_THREE_START_Y_RATIO,
-                    MODULE_THREE_END_Y_RATIO);
-
-            assertThat(differentPixels)
-                    .as("module-three visual should visibly affect rendered PDF pixels")
-                    .isGreaterThan(MODULE_THREE_VISUAL_DIFFERENCE_THRESHOLD);
-        }
-    }
-
-    private long countDifferentPixelsInTopArea(BufferedImage left, BufferedImage right, double heightRatio) {
-        assertThat(left.getWidth()).isEqualTo(right.getWidth());
-        assertThat(left.getHeight()).isEqualTo(right.getHeight());
-
-        int compareHeight = (int) Math.round(left.getHeight() * heightRatio);
-        long differentPixels = 0L;
-        for (int y = 0; y < compareHeight; y++) {
-            for (int x = 0; x < left.getWidth(); x++) {
-                if (left.getRGB(x, y) != right.getRGB(x, y)) {
-                    differentPixels++;
-                }
-            }
-        }
-        return differentPixels;
-    }
-
-    private int findPageIndexContainingText(PDDocument document, String expectedText) throws Exception {
-        PDFTextStripper textStripper = new PDFTextStripper();
-        String normalizedExpectedText = expectedText.replaceAll("\\s+", "");
-
-        for (int pageIndex = 0; pageIndex < document.getNumberOfPages(); pageIndex++) {
-            textStripper.setStartPage(pageIndex + 1);
-            textStripper.setEndPage(pageIndex + 1);
-            String pageText = textStripper.getText(document).replaceAll("\\s+", "");
-            if (pageText.contains(normalizedExpectedText)) {
-                return pageIndex;
-            }
-        }
-
-        throw new AssertionError("expected PDF page containing text: " + expectedText);
-    }
-
-    private long countDifferentPixelsInArea(BufferedImage left,
-                                            BufferedImage right,
-                                            double startXRatio,
-                                            double endXRatio,
-                                            double heightRatio) {
-        assertThat(left.getWidth()).isEqualTo(right.getWidth());
-        assertThat(left.getHeight()).isEqualTo(right.getHeight());
-
-        int startX = (int) Math.round(left.getWidth() * startXRatio);
-        int endX = (int) Math.round(left.getWidth() * endXRatio);
-        int compareHeight = (int) Math.round(left.getHeight() * heightRatio);
-        long differentPixels = 0L;
-        for (int y = 0; y < compareHeight; y++) {
-            for (int x = startX; x < endX; x++) {
-                if (left.getRGB(x, y) != right.getRGB(x, y)) {
-                    differentPixels++;
-                }
-            }
-        }
-        return differentPixels;
-    }
-
-    private long countDifferentPixelsInVerticalBand(BufferedImage left,
-                                                    BufferedImage right,
-                                                    double startYRatio,
-                                                    double endYRatio) {
-        assertThat(left.getWidth()).isEqualTo(right.getWidth());
-        assertThat(left.getHeight()).isEqualTo(right.getHeight());
-
-        int startY = (int) Math.round(left.getHeight() * startYRatio);
-        int endY = (int) Math.round(left.getHeight() * endYRatio);
-        long differentPixels = 0L;
-        for (int y = startY; y < endY; y++) {
-            for (int x = 0; x < left.getWidth(); x++) {
-                if (left.getRGB(x, y) != right.getRGB(x, y)) {
-                    differentPixels++;
-                }
-            }
-        }
-        return differentPixels;
-    }
-
-    private long countDifferentPixelsInRectangle(BufferedImage left,
-                                                 BufferedImage right,
-                                                 double startXRatio,
-                                                 double endXRatio,
-                                                 double startYRatio,
-                                                 double endYRatio) {
-        assertThat(left.getWidth()).isEqualTo(right.getWidth());
-        assertThat(left.getHeight()).isEqualTo(right.getHeight());
-
-        int startX = (int) Math.round(left.getWidth() * startXRatio);
-        int endX = (int) Math.round(left.getWidth() * endXRatio);
-        int startY = (int) Math.round(left.getHeight() * startYRatio);
-        int endY = (int) Math.round(left.getHeight() * endYRatio);
-        long differentPixels = 0L;
-        for (int y = startY; y < endY; y++) {
-            for (int x = startX; x < endX; x++) {
-                if (left.getRGB(x, y) != right.getRGB(x, y)) {
-                    differentPixels++;
-                }
-            }
-        }
-        return differentPixels;
-    }
-
-    private String stripFirstRowModuleOneDataShapes(String html) {
-        String stripped = stripRequiredFragment(html, "<path class='donut-mastered-arc'", "donut mastered arc");
-        stripped = stripRequiredFragment(stripped, "<path class='donut-unmastered-arc'", "donut unmastered arc");
-        stripped = stripRequiredFragment(stripped, "<rect class='chart-column total-column'", "total column");
-        return stripRequiredFragment(stripped, "<rect class='chart-column unknown-column'", "unknown column");
-    }
-
-    private String stripPastPaperColumnShapes(String html) {
-        String stripped = stripRequiredFragment(html, "<rect class='chart-column total-column'", "total column");
-        return stripRequiredFragment(stripped, "<rect class='chart-column unknown-column'", "unknown column");
-    }
-
-    private String stripLowerHalfModuleOneColumnShapes(String html) {
-        String stripped = stripRequiredFragment(html, "<rect class='chart-column basic-core-column'", "basic core column");
-        stripped = stripRequiredFragment(stripped, "<rect class='chart-column high-score-column'", "high score column");
-        stripped = stripRequiredFragment(stripped, "<rect class='chart-column high-band-column'", "high band column");
-        stripped = stripRequiredFragment(stripped, "<rect class='chart-column mid-band-column'", "mid band column");
-        return stripRequiredFragment(stripped, "<rect class='chart-column low-band-column'", "low band column");
-    }
-
-    private String stripModuleThreeVisual(String html) {
-        String stripped = stripRequiredContainerFragment(html, "<svg class='case-study-visual-chart'", "</svg>", "module three visual chart");
-        return stripRequiredContainerFragment(stripped, "<div class='case-hit-rate-badge'", "</div>", "module three hit-rate badge");
-    }
-
-    private String stripRequiredFragment(String html, String startToken, String description) {
-        assertThat(html)
-                .as("expected rendered HTML to include %s markup", description)
-                .contains(startToken);
-
-        String strippedHtml = html.replaceFirst(java.util.regex.Pattern.quote(startToken) + "[^>]*/>", "");
-
-        assertThat(strippedHtml)
-                .as("expected stripping %s markup to remove SVG fragment", description)
-                .isNotEqualTo(html);
-
-        return strippedHtml;
-    }
-
-    private String stripRequiredContainerFragment(String html, String startToken, String endToken, String description) {
-        assertThat(html)
-                .as("expected rendered HTML to include %s markup", description)
-                .contains(startToken);
-
-        int startIndex = html.indexOf(startToken);
-        int endIndex = html.indexOf(endToken, startIndex);
-
-        assertThat(startIndex).as("expected start token for %s", description).isNotNegative();
-        assertThat(endIndex).as("expected end token for %s", description).isGreaterThan(startIndex);
-
-        String strippedHtml = html.substring(0, startIndex) + html.substring(endIndex + endToken.length());
-
-        assertThat(strippedHtml)
-                .as("expected stripping %s markup to remove HTML fragment", description)
-                .isNotEqualTo(html);
-
-        return strippedHtml;
-    }
-
     private JsonNode samplePayload() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {
@@ -404,7 +67,8 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                   "readinessOverview": {
                     "summary": "基础较稳,具备短期冲刺提分空间。",
                     "currentStage": "冲刺提升期",
-                    "keyInsight": "核心观察:高频与常考词群是提分关键。"
+                    "keyInsight": "核心观察:高频与常考词群是提分关键。",
+                    "readinessScore": 72
                   },
                   "syllabusMasteryChart": {
                     "totalWordCount": 4200,

+ 152 - 4
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

@@ -3,6 +3,7 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.o
 import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.junit.jupiter.api.Test;
 
 import java.io.ByteArrayInputStream;
@@ -18,14 +19,58 @@ class ClasspathOutlookExamSprintReportRendererTest {
     private static final String TEMPLATE_PATH = "templates/outlook-exam-sprint-report-template.html";
 
     @Test
-    void renderBuildsOutlookHtmlFromClasspathTemplate() throws Exception {
+    void renderBuildsOutlookHtmlAlignedWithDesignDraftDynamicStructure() throws Exception {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
 
         String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
 
-        assertThat(html).contains("2026 词汇展望报告");
-        assertThat(html).contains("常考词汇掌握情况");
-        assertThat(html).contains("7 天提分冲刺");
+        assertThat(html)
+                .contains("class=\"analysis-table\"")
+                .contains("class=\"analysis-row\"")
+                .contains("class=\"analysis-cell analysis-cell-left\"")
+                .contains("class=\"analysis-cell analysis-cell-right\"")
+                .contains("class='card")
+                .contains("class='chart-box'")
+                .contains("class='data-text'")
+                .contains("<table class='frequency-table' role='presentation'>")
+                .contains("<tr class='frequency-row'>")
+                .contains("<td class='frequency-cell frequency-cell-1'>")
+                .contains("<td class='frequency-cell frequency-cell-2'>")
+                .contains("<td class='frequency-cell frequency-cell-3'>")
+                .contains("<td class='frequency-cell frequency-cell-4'>")
+                .contains("class='freq-card card-3 active'")
+                .contains("class='freq-header'>3套/周 <span class='badge'>推荐</span>")
+                .contains("class='freq-header'>5套/周 <span class='crown'>★</span>")
+                .contains("class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'")
+                .doesNotContain("class='freq-title'>")
+                .doesNotContain("1套/周 <span class='badge'>")
+                .doesNotContain("2套/周 <span class='badge'>")
+                .doesNotContain("5套/周 <span class='badge'>")
+                .doesNotContain("④")
+                .contains("胜率 <strong>72%</strong>")
+                .contains("class='suggest-box'")
+                .contains("考前半个月·核心突击期")
+                .contains("考前半小时·临阵巩固期")
+                .contains("class='student-case'")
+                .contains("<table class='student-case-table' role='presentation'>")
+                .contains("<tr class='student-case-row'>")
+                .contains("<td class='case-chart-cell'>")
+                .contains("<td class='case-info-cell'>")
+                .contains("class='case-chart'")
+                .contains("class='case-info'")
+                .contains("王雷宇")
+                .contains("705词 | 高考命中:237词 | 命中率:33.8%")
+                .contains("+19分")
+                .contains("考纲总量:<span class='highlight'>4200词</span>")
+                .contains("已掌握:<span class='highlight'>2701词(64.31%)</span>")
+                .contains("未掌握:<span class='highlight'>1499词(35.69%)</span>")
+                .contains("真题总词:961词 | 生词量:847词(88.14%)")
+                .contains("生词占比降至74.51%")
+                .contains("基础必会词:掌握率62%")
+                .contains("高分拉分词:掌握率41%")
+                .contains("高频词:188.6(优先学习)")
+                .contains("中频词:154.5(重点突破)")
+                .contains("低频词:70.4(酌情学习)");
     }
 
     @Test
@@ -63,6 +108,65 @@ class ClasspathOutlookExamSprintReportRendererTest {
         assertThat(trackingClassLoader.inputStream.closed).isTrue();
     }
 
+    @Test
+    void renderEscapesHtmlSensitiveCharactersAndScriptPayloads() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        JsonNode payload = payloadWithEscapingSamples();
+        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("李&lt;同学&gt;")
+                .contains("春季&amp;高考英语")
+                .contains("Ability &quot;Bot&quot;")
+                .contains("O&#39;Brien")
+                .contains("&lt;script&gt;alert(1)&lt;/script&gt;")
+                .doesNotContain("<script>alert(1)</script>")
+                .doesNotContain("<script>");
+    }
+
+    @Test
+    void renderFallsBackToDefaultThemeColorWhenPayloadColorIsInvalid() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        JsonNode payload = payloadWithInvalidThemeColors();
+        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .containsPattern("class='chart-column high-band-column'[^>]*fill='#448aff'")
+                .containsPattern("class='chart-column mid-band-column'[^>]*fill='#448aff'")
+                .containsPattern("class='chart-column low-band-column'[^>]*fill='#448aff'")
+                .doesNotContain("onload=")
+                .doesNotContain("javascript:alert(1)");
+    }
+
+    @Test
+    void renderUsesCircleForSyllabusAndCaseProgressWhenPercentReachesHundred() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        JsonNode payload = payloadWithProgressPercent(100, 100.0);
+        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("class='donut-mastered-full-circle'")
+                .contains("class='case-progress-full-circle'")
+                .doesNotContain("class='donut-mastered-arc'")
+                .doesNotContain("class='case-progress-arc'");
+    }
+
+    @Test
+    void renderSkipsProgressArcWhenSyllabusAndCasePercentAreZeroOrBelow() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        JsonNode payload = payloadWithProgressPercent(0, 0.0);
+        String html = renderer.render(payload, Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .doesNotContain("class='donut-mastered-arc'")
+                .doesNotContain("class='case-progress-arc'")
+                .contains("class='donut-unmastered-arc'");
+    }
+
     private static final class TrackingObjectMapper extends ObjectMapper {
         private boolean treeToValueCalled;
 
@@ -203,4 +307,48 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 }
                 """);
     }
+
+    private JsonNode payloadWithEscapingSamples() throws Exception {
+        ObjectNode payload = samplePayload().deepCopy();
+
+        ObjectNode frequencyPlan = (ObjectNode) payload.get("frequencyPlan");
+        ObjectNode firstPhaseSuggestion = (ObjectNode) frequencyPlan.withArray("phaseSuggestions").get(0);
+        firstPhaseSuggestion.put("title", "春季&高考英语");
+        frequencyPlan.put("recommendationSummary", "<script>alert(1)</script>");
+
+        ObjectNode scoreImprovementCaseStudy = (ObjectNode) payload.get("scoreImprovementCaseStudy");
+        scoreImprovementCaseStudy.put("learnerName", "李<同学>");
+        scoreImprovementCaseStudy.put("headline", "Ability \"Bot\"");
+        scoreImprovementCaseStudy.put("baselineScoreLabel", "O'Brien");
+
+        return payload;
+    }
+
+    private JsonNode payloadWithInvalidThemeColors() throws Exception {
+        ObjectNode payload = samplePayload().deepCopy();
+        payload.with("vocabularyFrequencyBandChart").withArray("bars").forEach(node -> {
+            ObjectNode bar = (ObjectNode) node;
+            if ("高频词".equals(bar.path("bandLabel").asText())) {
+                bar.put("themeColor", "\" onload=\"alert(1)");
+                return;
+            }
+            if ("中频词".equals(bar.path("bandLabel").asText())) {
+                bar.put("themeColor", "url(javascript:alert(1))");
+                return;
+            }
+            bar.put("themeColor", "#12");
+        });
+        return payload;
+    }
+
+    private JsonNode payloadWithProgressPercent(int masteryPercent, double hitRatePercent) throws Exception {
+        ObjectNode payload = samplePayload().deepCopy();
+        ObjectNode syllabusMasteryChart = (ObjectNode) payload.get("syllabusMasteryChart");
+        syllabusMasteryChart.put("totalWordCount", 100);
+        syllabusMasteryChart.put("masteredWordCount", Math.max(0, Math.min(100, masteryPercent)));
+        syllabusMasteryChart.put("unmasteredWordCount", 100 - Math.max(0, Math.min(100, masteryPercent)));
+        syllabusMasteryChart.put("masteryPercent", masteryPercent);
+        ((ObjectNode) payload.get("scoreImprovementCaseStudy")).put("hitRatePercent", hitRatePercent);
+        return payload;
+    }
 }

+ 75 - 71
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java

@@ -3,6 +3,7 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.o
 import org.junit.jupiter.api.Test;
 import org.springframework.core.io.ClassPathResource;
 
+import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -10,101 +11,104 @@ import static org.assertj.core.api.Assertions.assertThat;
 class OutlookExamSprintReportTemplateCompatibilityTest {
 
     @Test
-    void templateDoesNotUseOpenHtmlToPdfUnsupportedLayoutDeclarations() throws Exception {
+    void templateMatchesDesignDraftWithPdfSafeLayoutStyles() throws Exception {
         String normalizedTemplate = normalizeWhitespace(loadTemplate());
 
         assertThat(normalizedTemplate)
-                .contains(".content-card")
-                .contains(".syllabus-donut-chart")
-                .containsPattern("\\.past-paper-(?:bar|column)-chart")
-                .contains(".high-frequency-column-chart")
-                .contains(".frequency-band-column-chart")
-                .contains(".study-suggestion-shell")
-                .contains(".case-study-shell")
-                .contains(".study-frequency-card")
-                .contains(".study-strategy-note")
-                .contains(".study-stage-box")
-                .contains(".case-study-visual-chart")
-                .contains(".case-hit-rate-badge")
-                .contains(".case-hit-rate-value")
-                .contains(".case-hit-rate-label")
-                .contains(".case-info-section")
-                .contains(".case-info-group")
-                .contains(".case-info-label")
-                .contains(".case-info-value")
-                .contains("{{studySuggestionSection}}")
-                .doesNotMatch(".*display\\s*:\\s*grid.*")
-                .doesNotMatch(".*display\\s*:\\s*flex.*")
-                .doesNotMatch(".*(?:^|[^-])gap\\s*:.*")
-                .doesNotMatch(".*row-gap\\s*:.*")
-                .doesNotMatch(".*column-gap\\s*:.*")
-                .doesNotMatch(".*grid-template-columns\\s*:.*")
-                .doesNotMatch(".*flex-wrap\\s*:.*")
-                .doesNotMatch(".*flex-direction\\s*:.*");
+                .contains("body")
+                .contains("background-color: #f5f7fa")
+                .contains("padding: 30px 20px")
+                .contains("color: #333")
+                .contains("font-family: ReportFont, \"Microsoft YaHei\", sans-serif")
+                .contains("ReportFont")
+                .contains("\"Microsoft YaHei\"")
+                .contains(".report-container")
+                .contains("max-width: 1200px")
+                .contains("margin: 0 auto")
+                .contains("background: #fff")
+                .contains("padding: 40px")
+                .contains("border-radius: 12px")
+                .contains("border: 1px solid #e7edf5")
+                .contains(".section")
+                .contains(".section-title")
+                .contains("border-left: 6px solid #ff7d00")
+                .contains(".analysis-table")
+                .contains(".analysis-row")
+                .contains(".analysis-cell")
+                .containsPattern("\\.analysis-table\\s*\\{[^}]*width\\s*:\\s*100%\\s*;[^}]*table-layout\\s*:\\s*fixed\\s*;[^}]*}")
+                .containsPattern("\\.analysis-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .containsPattern("\\.card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .contains(".frequency-table")
+                .contains(".frequency-row")
+                .contains(".frequency-cell")
+                .containsPattern("\\.frequency-table\\s*\\{[^}]*width\\s*:\\s*100%\\s*;[^}]*table-layout\\s*:\\s*fixed\\s*;[^}]*}")
+                .containsPattern("\\.frequency-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .containsPattern("\\.freq-card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .contains(".freq-header")
+                .containsPattern("\\.freq-header\\s*\\{[^}]*font-size\\s*:\\s*18px\\s*;[^}]*}")
+                .contains(".text-desc")
+                .containsPattern("\\.text-desc\\s*\\{[^}]*color\\s*:\\s*#4a5568\\s*;[^}]*}")
+                .contains(".student-case")
+                .contains(".student-case-table")
+                .contains(".student-case-row")
+                .contains(".case-chart-cell")
+                .contains(".case-info-cell")
+                .containsPattern("\\.student-case\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .doesNotContainPattern("\\.analysis-grid\\s*\\{[^}]*display\\s*:\\s*grid\\s*;")
+                .doesNotContainPattern("\\.frequency-grid\\s*\\{[^}]*display\\s*:\\s*flex\\s*;")
+                .doesNotContainPattern("\\.student-case\\s*\\{[^}]*display\\s*:\\s*flex\\s*;")
+                .doesNotContainPattern("\\.section\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;");
     }
 
     @Test
-    void templateDefinesTaskOneIntroRhythmAndUnifiedModuleShellSelectors() throws Exception {
+    void templateUsesH2ForAllModuleSectionTitles() throws Exception {
         String normalizedTemplate = normalizeWhitespace(loadTemplate());
 
         assertThat(normalizedTemplate)
-                .contains(".report-intro-shell")
-                .contains(".report-intro-meta")
-                .contains(".report-intro-summary")
-                .contains(".report-intro-insight")
-                .contains(".module-section")
-                .containsPattern("\\.module-one-section\\s*\\{[^}]*margin-top\\s*:\\s*0\\s*;[^}]*}")
-                .contains(".module-body")
-                .contains(".module-one-row")
-                .contains("module-one-row module-one-row-first")
-                .contains("module-one-row module-one-row-second")
-                .containsPattern("<div class='module-body module-one-body'> <div class=\"module-one-row module-one-row-first\">")
-                .containsPattern("<div class=\"module-one-row module-one-row-second\">")
-                .doesNotContain("class=\"content-grid\"")
-                .doesNotContain("class='content-grid'")
-                .contains("{{reportIntroShell}}");
+                .contains("<h2 class=\"section-title\">模块一:个人学情分析</h2>")
+                .contains("<h2 class=\"section-title\">模块二:科学备考建议</h2>")
+                .contains("<h2 class=\"section-title\">模块三:上届学员提分案例</h2>")
+                .doesNotContain("<div class=\"section-title\">");
     }
 
     @Test
-    void templateDefinesPdfSafeCaseStudyShellStyles() throws Exception {
-        String template = normalizeWhitespace(loadTemplate());
+    void templateContainsRequiredRendererSlotsForDynamicSections() throws Exception {
+        String normalizedTemplate = normalizeWhitespace(loadTemplate());
 
-        assertThat(template)
+        assertThat(normalizedTemplate)
+                .contains("{{syllabusMasteryChart}}")
+                .contains("{{pastPaperVocabularyChart}}")
+                .contains("{{highFrequencyVocabularyChart}}")
+                .contains("{{vocabularyFrequencyBandChart}}")
                 .contains("{{scoreImprovementCaseStudy}}")
-                .containsPattern("\\.case-study-shell\\s*\\{[^}]*font-size\\s*:\\s*0\\s*;[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
-                .doesNotMatch("(?is).*(?:^|[^a-z-])break-inside\\s*:.*")
-                .contains(".case-study-visual, .case-info { display: inline-block; vertical-align: top; box-sizing: border-box; }")
-                .contains(".case-study-visual { width: 34%; margin-right: 4%; position: relative; }")
-                .contains(".case-info { width: 62%; font-size: 12px; }")
-                .contains(".case-study-visual-chart { display: block; width: 100%; height: 260px; }")
-                .contains(".case-hit-rate-badge { position: absolute; top: 14px; right: 8px; background: #ffffff; border: 1px solid #ffd7bf; border-radius: 999px; padding: 8px 12px; text-align: center; }")
-                .contains(".case-hit-rate-value { display: block; color: #e07a1a; font-size: 16px; font-weight: 700; }")
-                .contains(".case-hit-rate-label { display: block; color: #8a5d36; font-size: 11px; }")
-                .contains(".case-info-section { margin-bottom: 12px; background: #fffaf4; border: 1px solid #f3deca; border-radius: 12px; padding: 10px 12px; }")
-                .contains(".case-info-group { margin-bottom: 8px; }")
-                .contains(".highlight,")
-                .contains(".score-gain");
+                .contains("{{studySuggestionSection}}")
+                .contains("<table class=\"analysis-table\" role=\"presentation\">")
+                .contains("<tr class=\"analysis-row\">")
+                .contains("<td class=\"analysis-cell analysis-cell-left\">{{syllabusMasteryChart}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-right\">{{pastPaperVocabularyChart}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-left\">{{highFrequencyVocabularyChart}}</td>")
+                .contains("<td class=\"analysis-cell analysis-cell-right\">{{vocabularyFrequencyBandChart}}</td>")
+                .doesNotContain("{{reportIntroShell}}");
     }
 
     @Test
-    void templateKeepsA4PageSizeWhileRemovingReportLevelOuterShell() throws Exception {
+    void templateKeepsA4PageSizeAndReportTitleHierarchyFromDesignDraft() throws Exception {
         String normalizedTemplate = normalizeWhitespace(loadTemplate());
 
         assertThat(normalizedTemplate)
                 .containsPattern("@page\\s*\\{[^}]*size\\s*:\\s*A4\\s*;[^}]*}")
-                .contains(".report-container")
-                .doesNotContainPattern("\\.report-container\\s*\\{[^}]*background\\s*:\\s*#ffffff")
-                .doesNotContainPattern("\\.report-container\\s*\\{[^}]*border\\s*:\\s*1px\\s+solid\\s+#e8eef7")
-                .doesNotContainPattern("\\.report-container\\s*\\{[^}]*border-radius\\s*:\\s*12px")
-                .doesNotContainPattern("body\\s*\\{[^}]*background\\s*:\\s*#f5f7fa");
+                .contains("h1.report-title")
+                .contains("font-size: 26px")
+                .contains("color: #2b4c8a")
+                .contains("p.report-subtitle")
+                .contains("text-align: center");
     }
 
     private String loadTemplate() throws Exception {
-        return new String(
-                new ClassPathResource("templates/outlook-exam-sprint-report-template.html")
-                        .getInputStream()
-                        .readAllBytes(),
-                StandardCharsets.UTF_8);
+        ClassPathResource resource = new ClassPathResource("templates/outlook-exam-sprint-report-template.html");
+        try (InputStream inputStream = resource.getInputStream()) {
+            return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+        }
     }
 
     private String normalizeWhitespace(String value) {