Просмотр исходного кода

Merge branch 'feat/p0-outlook-report-shell' of jyx/dcjxb.microservice into master

金逸霄 3 недель назад
Родитель
Сommit
dced1ae228

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

@@ -2,7 +2,7 @@
 <html lang="zh-CN">
 <head>
     <meta charset="UTF-8"/>
-    <title>{{reportVersionLabel}}</title>
+    <title>高考英语临考词汇突击潜力展望报告</title>
     <style>
         @page {
             size: A4;
@@ -15,14 +15,11 @@
             font-size: 12px;
             line-height: 1.65;
             margin: 0;
-            background: #f5f7fa;
+            background: #ffffff;
         }
 
         .report-container {
-            background: #ffffff;
-            border: 1px solid #e8eef7;
-            border-radius: 12px;
-            padding: 22px 24px 20px;
+            padding: 8px 10px 6px;
         }
 
         .report-title {
@@ -30,97 +27,178 @@
             font-size: 28px;
             color: #2b4c8a;
             font-weight: 700;
-            margin-bottom: 8px;
+            margin-bottom: 6px;
         }
 
         .report-subtitle {
             text-align: center;
             font-size: 14px;
             color: #5f6b7a;
-            margin-bottom: 20px;
+            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;
         }
 
-        .meta-grid,
-        .content-grid,
-        .plan-grid {
+        .module-body {
+            margin-top: 10px;
+        }
+
+        .report-intro-meta {
             font-size: 0;
-            margin-bottom: 18px;
+            margin-bottom: 10px;
         }
 
-        .meta-item,
-        .summary-card,
-        .content-card,
-        .plan-card,
-        .case-card {
+        .report-intro-meta span {
+            display: inline-block;
+            font-size: 12px;
+            color: #516173;
+            margin-right: 16px;
+        }
+
+        .report-intro-summary,
+        .report-intro-insight {
+            font-size: 13px;
+            color: #444;
+            margin: 4px 0 0;
+        }
+
+        .module-one-row {
+            font-size: 0;
+            margin-bottom: 14px;
+            page-break-inside: avoid;
+        }
+
+        .module-one-row-second {
+            margin-bottom: 0;
+        }
+
+        .content-card {
             background: #fafbfc;
             border: 1px solid #e4ebf5;
             border-radius: 14px;
             padding: 16px;
         }
 
-        .meta-item,
-        .content-card,
-        .plan-card {
+        .content-card {
             display: inline-block;
             width: 47.2%;
             vertical-align: top;
-            margin: 0 2.8% 14px 0;
+            margin: 0 2.8% 0 0;
             box-sizing: border-box;
             font-size: 12px;
         }
 
-        .meta-grid .meta-item:nth-child(2n),
-        .content-grid .content-card:nth-child(2n),
-        .plan-grid .plan-card:nth-child(2n) {
+        .module-one-row .content-card:last-child {
             margin-right: 0;
         }
 
-        .meta-label {
-            color: #6d7a8a;
-            font-size: 11px;
-            margin-bottom: 4px;
+        .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;
         }
 
-        .meta-value {
+        .card-title {
+            font-size: 16px;
             color: #2b4c8a;
             font-weight: 700;
+            margin-bottom: 8px;
         }
 
-        .summary-card {
-            background: #edf3fc;
-            border-color: #d7e3f7;
-            margin-bottom: 20px;
+        .chart-shell {
+            text-align: center;
+            margin: 8px 0 12px;
         }
 
-        .section-title {
-            font-size: 20px;
-            color: #2b4c8a;
-            border-left: 6px solid #ff7d00;
-            padding-left: 12px;
-            margin: 24px 0 14px;
+        .syllabus-donut-chart,
+        .past-paper-column-chart,
+        .high-frequency-column-chart,
+        .frequency-band-column-chart {
+            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;
         }
 
-        .summary-card,
-        .case-card {
+        .chart-caption {
             font-size: 12px;
+            fill: #6d7a8a;
         }
 
-        .card-title,
-        .plan-title {
-            font-size: 16px;
-            color: #2b4c8a;
-            font-weight: 700;
-            margin-bottom: 8px;
+        .syllabus-donut-chart {
+            display: block;
         }
 
-        .chip-group {
-            margin-top: 10px;
-            font-size: 0;
+        .past-paper-column-chart {
+            display: block;
+        }
+
+        .high-frequency-column-chart,
+        .frequency-band-column-chart {
+            display: block;
+        }
+
+        .donut-label-text {
+            font-size: 12px;
+            fill: #444;
+        }
+
+        .chart-axis {
+            stroke: #9aa6b2;
         }
 
-        .chip,
-        .tag {
+        .chart-gridline {
+            stroke: #d7e0ec;
+        }
+
+        .data-text {
+            font-size: 14px;
+            line-height: 1.8;
+            color: #444;
+        }
+
+        .chart-note {
+            font-size: 13px;
+            color: #516173;
+        }
+
+        .badge {
             display: inline-block;
             background: #fff1e7;
             color: #ff7d00;
@@ -128,82 +206,292 @@
             padding: 2px 10px;
             font-size: 11px;
             font-weight: 700;
-            margin: 0 8px 8px 0;
+            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;
+        }
+
+        .study-suggestion-intro h3 {
+            font-size: 16px;
+            color: #2b4c8a;
+            margin: 0 0 8px;
+        }
+
+        .study-suggestion-intro p {
+            font-size: 14px;
+            color: #555;
+            margin: 0 0 16px;
+        }
+
+        .study-frequency-grid {
+            font-size: 0;
+            margin: 20px 0 16px;
+        }
+
+        .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;
+        }
+
+        .study-frequency-grid .study-frequency-card:last-child {
+            margin-right: 0;
+        }
+
+        .study-frequency-card.active {
+            background: #e3f2fd;
+            border-color: #2196f3;
+        }
+
+        .study-frequency-header {
+            font-size: 18px;
+            font-weight: 700;
+            color: #333;
+            margin-bottom: 8px;
+        }
+
+        .crown {
+            font-size: 15px;
+            margin-left: 8px;
+        }
+
+        .study-frequency-progress {
+            background: #e0e0e0;
+            height: 8px;
+            border-radius: 4px;
+            margin: 12px 0 10px;
+            overflow: hidden;
+        }
+
+        .study-frequency-progress-fill {
+            background: #2196f3;
+            display: block;
+            height: 8px;
+            border-radius: 4px;
+        }
+
+        .study-frequency-data {
+            font-size: 14px;
+            color: #333;
+        }
+
+        .study-frequency-data .win-rate {
+            color: #2196f3;
+        }
+
+        .study-strategy-note {
+            margin-top: 15px;
+            padding: 12px 20px;
+            background: #fff8e1;
+            border-left: 4px solid #ffc107;
+            border-radius: 4px;
+            font-size: 15px;
+            color: #e65100;
+        }
+
+        .study-strategy-label {
+            font-weight: 700;
+            margin-right: 10px;
+        }
+
+        .study-stage-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;
+        }
+
+        .study-stage-item h4 {
+            font-size: 16px;
+            color: #2b4c8a;
+            margin: 0 0 8px;
+        }
+
+        .study-stage-item p {
+            font-size: 14px;
+            color: #555;
+            line-height: 1.7;
+            margin: 0;
+        }
+
+        .case-study-shell {
+            background: #fff7ed;
+            border: 1px solid #f3d7bb;
+            border-radius: 14px;
+            padding: 18px;
+            font-size: 0;
+            page-break-inside: avoid;
+        }
+
+        .module-three-section .case-study-shell {
+            page-break-inside: avoid;
+        }
+
+        .case-study-visual,
+        .case-info {
+            display: inline-block;
+            vertical-align: top;
+            box-sizing: border-box;
         }
 
-        .frequency-list {
+        .case-study-visual {
+            width: 34%;
+            margin-right: 4%;
+            position: relative;
+        }
+
+        .case-info {
+            width: 62%;
             font-size: 12px;
         }
 
-        .frequency-item,
-        .plan-cadence {
-            color: #516173;
+        .case-study-visual-chart {
+            display: block;
+            width: 100%;
+            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;
         }
 
-        ul {
-            margin: 8px 0 0 18px;
-            padding: 0;
+        .case-info-group:last-child {
+            margin-bottom: 0;
         }
 
-        p {
-            margin: 6px 0;
+        .case-info-label {
+            color: #516173;
+            font-weight: 700;
         }
 
-        .plan-grid {
-            margin-bottom: 10px;
+        .case-info-value {
+            color: #444;
+        }
+
+        .highlight,
+        .score-gain {
+            color: #ff7d00;
+            font-weight: 700;
+        }
+
+        .score-gain {
+            font-size: 18px;
         }
     </style>
 </head>
 <body>
 <div class="report-container">
-    <div class="report-title">{{reportVersionLabel}}</div>
-    <div class="report-subtitle">高考英语临考词汇突击潜力展望报告</div>
+    <div class="report-title">高考英语临考词汇突击潜力展望报告</div>
+    <div class="report-subtitle">科学规划 · 精准提分 · 短期见效</div>
+    {{reportIntroShell}}
 
-    <div class="meta-grid">
-        <div class="meta-item">
-            <div class="meta-label">学生姓名</div>
-            <div class="meta-value">{{learnerName}}</div>
-        </div>
-        <div class="meta-item">
-            <div class="meta-label">目标考试</div>
-            <div class="meta-value">{{targetExamName}}</div>
+    <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>
-        <div class="meta-item">
-            <div class="meta-label">冲刺周期</div>
-            <div class="meta-value">{{sprintPeriodLabel}}</div>
-        </div>
-        <div class="meta-item">
-            <div class="meta-label">生成信息</div>
-            <div class="meta-value">{{authorName}} · {{generatedAt}}</div>
-        </div>
-    </div>
-
-    <div class="summary-card">
-        <p><strong>学情摘要:</strong>{{summary}}</p>
-        <p><strong>当前阶段:</strong>{{currentStage}}</p>
-        <p><strong>关键洞察:</strong>{{keyInsight}}</p>
-        <p><strong>备考就绪度:</strong>{{readinessScore}}%</p>
     </div>
 
-    <div class="section-title">模块一:个人学情分析</div>
-    <div class="content-grid">
-        <div class="content-card">{{syllabusMasteryProfile}}</div>
-        <div class="content-card">{{pastPaperVocabularyProfile}}</div>
-        <div class="content-card">{{highFrequencyVocabularyProfile}}</div>
-        <div class="content-card">
-            <div class="card-title">词频区间掌握度</div>
-            {{vocabularyFrequencyBands}}
+    <div class='module-section module-two-section'>
+        <div class="section-title">模块二:科学备考建议</div>
+        <div class='module-body module-two-body'>
+            {{studySuggestionSection}}
         </div>
     </div>
 
-    <div class="section-title">模块二:科学备考建议</div>
-    <div class="plan-grid">
-        {{sprintPlanOptions}}
+    <div class='module-section module-three-section'>
+        <div class="section-title">模块三:上届学员提分案例</div>
+        <div class='module-body module-three-body'>
+            {{scoreImprovementCaseStudy}}
+        </div>
     </div>
-
-    <div class="section-title">模块三:诊断案例</div>
-    {{diagnosticCaseStudy}}
 </div>
 </body>
 </html>

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

@@ -4,9 +4,11 @@ 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.time.Instant;
 
@@ -15,6 +17,20 @@ 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 {
@@ -29,74 +45,419 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
 
         try (PDDocument document = PDDocument.load(pdfBytes)) {
             String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
-            assertThat(normalizedText).contains("2026词汇展望报告");
+            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("7天提分冲刺");
+            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);
+
+        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")
+                    .contains("模块一:个人学情分析")
+                    .contains("考纲词汇掌握情况")
+                    .contains("真题试卷词汇掌握情况");
+        }
+    }
+
+    @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("""
                 {
                   "reportMetadata": {
-                    "reportVersionLabel": "2026 词汇展望报告",
                     "learnerName": "李同学",
                     "targetExamName": "春季高考英语",
-                    "sprintPeriodLabel": "30 天考前冲刺",
-                    "authorName": "Ability Bot"
+                    "sprintPeriodLabel": "30 天考前冲刺"
                   },
                   "readinessOverview": {
                     "summary": "基础较稳,具备短期冲刺提分空间。",
-                    "currentStage": "冲刺提升期",
-                    "keyInsight": "高频与常考词群是提分关键。",
-                    "readinessScore": 72
+                    "keyInsight": "核心观察:高频与常考词群是提分关键。"
                   },
-                  "syllabusMasteryProfile": {
-                    "masteryPercent": 78,
-                    "diagnosis": "考纲词覆盖较好。",
-                    "recommendation": "保持滚动复习。",
-                    "dimensionScores": [
-                      {"label": "识记", "score": 82},
-                      {"label": "应用", "score": 74}
-                    ]
+                  "syllabusMasteryChart": {
+                    "totalWordCount": 4200,
+                    "masteredWordCount": 2701,
+                    "unmasteredWordCount": 1499,
+                    "masteryPercent": 64,
+                    "recommendation": "优先补齐高考核心场景词。"
                   },
-                  "pastPaperVocabularyProfile": {
-                    "masteredWordCount": 420,
-                    "totalWordCount": 600,
-                    "masteryPercent": 70,
-                    "diagnosis": "真题词汇还需查漏补缺。",
-                    "recommendation": "优先扫清近三年高频词。",
-                    "sampleWords": ["abandon", "adapt", "assume"]
+                  "pastPaperVocabularyChart": {
+                    "totalWordCount": 961,
+                    "unknownWordCountBeforeSprint": 847,
+                    "unknownWordCountAfterSprint": 716,
+                    "projectedScoreGainLabel": "预计提分5-15分",
+                    "recommendation": "先压降真题生词占比。"
                   },
-                  "highFrequencyVocabularyProfile": {
-                    "masteredWordCount": 320,
-                    "totalWordCount": 400,
-                    "masteryPercent": 80,
-                    "diagnosis": "常考词汇掌握情况良好。",
-                    "recommendation": "继续稳固高频词群。",
-                    "sampleWords": ["benefit", "capacity", "decline"]
+                  "highFrequencyVocabularyChart": {
+                    "basicCorePercent": 62,
+                    "highScorePercent": 41,
+                    "highlightLabel": "拉分词是提分核心突破项"
                   },
-                  "vocabularyFrequencyBands": [
-                    {"bandLabel": "高频词", "masteryPercent": 80, "targetPercent": 90},
-                    {"bandLabel": "中频词", "masteryPercent": 68, "targetPercent": 80},
-                    {"bandLabel": "低频词", "masteryPercent": 45, "targetPercent": 60}
-                  ],
-                  "sprintPlanOptions": [
-                    {
-                      "planName": "7 天提分冲刺",
-                      "cadenceLabel": "7 天",
-                      "tagLabel": "推荐",
-                      "focus": "高频词与真题词回收",
-                      "actionItems": ["晨读高频词", "午间错词复现", "晚间真题套练"],
-                      "expectedOutcome": "稳定拿下基础词汇题"
+                  "vocabularyFrequencyBandChart": {
+                    "bars": [
+                      {"bandLabel": "高频词", "currentValue": 188.6, "priorityLabel": "优先学习", "themeColor": "#448aff"},
+                      {"bandLabel": "中频词", "currentValue": 154.5, "priorityLabel": "重点突破", "themeColor": "#4caf50"},
+                      {"bandLabel": "低频词", "currentValue": 70.4, "priorityLabel": "酌情学习", "themeColor": "#ff9800"}
+                    ]
+                  },
+                  "studySuggestionSection": {
+                    "cadenceCards": [
+                      {"cadencePerWeek": 1, "scoreGainLabel": "+5分", "winRatePercent": 38, "recommended": false},
+                      {"cadencePerWeek": 2, "scoreGainLabel": "+10分", "winRatePercent": 55, "recommended": false},
+                      {"cadencePerWeek": 3, "scoreGainLabel": "+10分", "winRatePercent": 72, "recommended": true},
+                      {"cadencePerWeek": 5, "scoreGainLabel": "+20分", "winRatePercent": 88, "recommended": false}
+                    ],
+                    "strategyProjection": {
+                      "recommendedCadenceLabel": "3套",
+                      "projectedScoreGainLabel": "15+10分",
+                      "overallWinRatePercent": 72
+                    },
+                    "halfMonthSprintAdvice": {
+                      "description": "按词频优先级记忆,不浪费时间;只攻克高频/中频核心词,2周15小时速记500-800必考词,快速缩小生词缺口。"
+                    },
+                    "halfHourReviewAdvice": {
+                      "description": "只复习已标记的核心词汇,不学新词;使用专属《压轴词》速记手册,保持记忆热度,考场直接见效。"
                     }
-                  ],
-                  "diagnosticCaseStudy": {
-                    "title": "上届学员案例",
-                    "context": "基础一般但执行力强。",
-                    "diagnosis": "高频词重复错误较多。",
-                    "strategy": "连续 10 天高频词闭环复习。",
-                    "keyTakeaway": "短周期高频复现可快速提分。"
+                  },
+                  "scoreImprovementCaseStudy": {
+                    "headline": "真实提分 · 效果可复制",
+                    "learnerName": "王雷宇",
+                    "studyPeriodLabel": "考前3天短期突击",
+                    "memorizedWordCount": 705,
+                    "examHitWordCount": 237,
+                    "hitRatePercent": 33.8,
+                    "baselineScoreLabel": "70分以下",
+                    "finalScore": 89,
+                    "scoreGain": 19
                   }
                 }
                 """);

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

@@ -11,18 +11,103 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
 
     @Test
     void templateDoesNotUseOpenHtmlToPdfUnsupportedLayoutDeclarations() throws Exception {
-        String template = new String(
+        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*:.*");
+    }
+
+    @Test
+    void templateDefinesTaskOneIntroRhythmAndUnifiedModuleShellSelectors() 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}}");
+    }
+
+    @Test
+    void templateDefinesPdfSafeCaseStudyShellStyles() throws Exception {
+        String template = normalizeWhitespace(loadTemplate());
+
+        assertThat(template)
+                .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");
+    }
+
+    @Test
+    void templateKeepsA4PageSizeWhileRemovingReportLevelOuterShell() 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");
+    }
+
+    private String loadTemplate() throws Exception {
+        return new String(
                 new ClassPathResource("templates/outlook-exam-sprint-report-template.html")
                         .getInputStream()
                         .readAllBytes(),
                 StandardCharsets.UTF_8);
+    }
 
-        assertThat(template)
-                .doesNotContain("display: grid")
-                .doesNotContain("grid-template-columns")
-                .doesNotContain("gap:")
-                .doesNotContain("display: flex")
-                .doesNotContain("flex-wrap")
-                .doesNotContain("flex-direction");
+    private String normalizeWhitespace(String value) {
+        return value.replaceAll("\\s+", " ").trim();
     }
 }