Ver Fonte

chore(临考突击报告): 清理旧 PDF 引擎遗留实现

金逸霄 há 6 dias atrás
pai
commit
0236119f93
15 ficheiros alterados com 111 adições e 511 exclusões
  1. 6 10
      abilities/exam-sprint/infrastructure/pom.xml
  2. 0 100
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java
  3. 13 5
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java
  4. 0 336
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  5. 41 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java
  6. 2 2
      docs/plans/2026-04-13-outlook-report-module-one-charts.md
  7. 7 7
      docs/plans/2026-04-13-outlook-report-style-alignment.md
  8. 1 1
      docs/plans/2026-04-29-outlook-payload-contract.md
  9. 13 13
      docs/plans/2026-04-29-playwright-print-css.md
  10. 5 5
      docs/plans/2026-04-30-achievement-report-mastery-hit-rate.md
  11. 8 13
      docs/superpowers/plans/2026-04-20-ability-center-naming-migration.md
  12. 2 2
      docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md
  13. 9 13
      docs/superpowers/plans/2026-05-07-playwright-pdf-generator.md
  14. 1 1
      docs/superpowers/specs/2026-04-20-ability-center-naming-design.md
  15. 3 3
      docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md

+ 6 - 10
abilities/exam-sprint/infrastructure/pom.xml

@@ -34,16 +34,6 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-autoconfigure</artifactId>
         </dependency>
-        <dependency>
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-pdfbox</artifactId>
-            <version>1.0.10</version>
-        </dependency>
-        <dependency>
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-svg-support</artifactId>
-            <version>1.0.10</version>
-        </dependency>
         <dependency>
             <groupId>com.microsoft.playwright</groupId>
             <artifactId>playwright</artifactId>
@@ -63,6 +53,12 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+            <version>2.0.24</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>

+ 0 - 100
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java

@@ -1,100 +0,0 @@
-package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
-
-import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
-import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
-import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
-
-import java.io.ByteArrayOutputStream;
-import java.io.File;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.util.List;
-import java.util.Objects;
-import java.util.function.Supplier;
-
-public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator {
-
-    private static final List<String> FONT_CANDIDATES = List.of(
-            "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
-            "/Library/Fonts/Arial Unicode.ttf",
-            "/usr/share/fonts/truetype/noto/NotoSansSC-Regular.ttf",
-            "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
-            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
-            "/System/Library/Fonts/Hiragino Sans GB.ttc",
-            "/System/Library/Fonts/Supplemental/Songti.ttc");
-
-    private final Supplier<BundledOutlookReportFonts> bundledFontsSupplier;
-    private final List<String> fontCandidates;
-
-    public OpenHtmlToPdfExamSprintReportPdfGenerator() {
-        this(BundledOutlookReportFonts::load, FONT_CANDIDATES);
-    }
-
-    OpenHtmlToPdfExamSprintReportPdfGenerator(
-            Supplier<BundledOutlookReportFonts> bundledFontsSupplier,
-            List<String> fontCandidates) {
-        this.bundledFontsSupplier = Objects.requireNonNull(bundledFontsSupplier, "bundledFontsSupplier");
-        this.fontCandidates = List.copyOf(Objects.requireNonNull(fontCandidates, "fontCandidates"));
-    }
-
-    @Override
-    public byte[] generate(String htmlContent) {
-        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-            PdfRendererBuilder builder = new PdfRendererBuilder();
-            builder.useFastMode();
-            builder.withHtmlContent(htmlContent, null);
-            builder.useSVGDrawer(new BatikSVGDrawer());
-            registerAvailableFont(builder);
-            builder.toStream(outputStream);
-            builder.run();
-            return outputStream.toByteArray();
-        } catch (IOException exception) {
-            throw new UncheckedIOException("Failed to generate PDF", exception);
-        } catch (Exception exception) {
-            throw new IllegalStateException("Failed to generate PDF", exception);
-        }
-    }
-
-    private void registerAvailableFont(PdfRendererBuilder builder) {
-        if (registerBundledFonts(builder)) {
-            return;
-        }
-
-        registerSystemFont(builder);
-    }
-
-    private boolean registerBundledFonts(PdfRendererBuilder builder) {
-        BundledOutlookReportFonts bundledFonts;
-        try {
-            bundledFonts = Objects.requireNonNull(bundledFontsSupplier.get(), "bundledFonts");
-        } catch (BundledOutlookReportFonts.BundledFontMissingException exception) {
-            return false;
-        }
-
-        boolean registered = false;
-        for (BundledOutlookReportFonts.Registration registration : bundledFonts.registrations()) {
-            File fontFile = registration.file();
-            if (!isReadableFile(fontFile)) {
-                throw new IllegalStateException("Bundled font file is not readable: " + fontFile);
-            }
-            builder.useFont(fontFile, registration.family());
-            builder.useFont(fontFile, "ReportFont");
-            registered = true;
-        }
-        return registered;
-    }
-
-    private void registerSystemFont(PdfRendererBuilder builder) {
-        for (String candidate : fontCandidates) {
-            File fontFile = new File(candidate);
-            if (fontFile.exists()) {
-                builder.useFont(fontFile, "ReportFont");
-                return;
-            }
-        }
-    }
-
-    private boolean isReadableFile(File file) {
-        return file.exists() && file.isFile() && file.canRead();
-    }
-}

+ 13 - 5
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java

@@ -137,9 +137,7 @@ public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportP
         Browser initializedBrowser = null;
         try {
             initializedPlaywright = Playwright.create();
-            initializedBrowser = initializedPlaywright.chromium().launch(new BrowserType.LaunchOptions()
-                    .setHeadless(true)
-                    .setTimeout(launchTimeoutMillis));
+            initializedBrowser = initializedPlaywright.chromium().launch(createLaunchOptions(launchTimeoutMillis));
             playwright = initializedPlaywright;
             browser = initializedBrowser;
         } catch (RuntimeException exception) {
@@ -149,7 +147,13 @@ public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportP
         }
     }
 
-    private String withBundledFonts(String htmlContent) {
+    static BrowserType.LaunchOptions createLaunchOptions(double launchTimeoutMillis) {
+        return new BrowserType.LaunchOptions()
+                .setHeadless(true)
+                .setTimeout(launchTimeoutMillis);
+    }
+
+    String withBundledFonts(String htmlContent) {
         if (bundledFontStyle.isBlank() || htmlContent.contains(FONT_STYLE_MARKER)) {
             return htmlContent;
         }
@@ -195,7 +199,11 @@ public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportP
                     font-style: normal;
                     font-display: swap;
                 }
-                """.formatted(family, fontUri);
+                """.formatted(escapeCssString(family), escapeCssString(fontUri));
+    }
+
+    private static String escapeCssString(String value) {
+        return value.replace("\\", "\\\\").replace("'", "\\'");
     }
 
     private static void closeQuietly(AutoCloseable closeable) {

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

@@ -1,336 +0,0 @@
-package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
-
-import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
-import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
-import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
-import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.achievement.ClasspathAchievementExamSprintReportRenderer;
-import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.outlook.ClasspathOutlookExamSprintReportRenderer;
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.text.PDFTextStripper;
-import org.junit.jupiter.api.Test;
-
-import java.awt.Font;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.text.Normalizer;
-import java.time.Instant;
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
-
-    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
-
-    /**
-     * 覆盖官方上游词汇 payload 渲染后的 Outlook HTML 生成 PDF 时,应产出可解析 PDF 并包含关键静态与计算文本。
-     */
-    @Test
-    void generateCreatesPdfSmokeWithExtractableOutlookKeyText() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(unmodeledOutlookContent(samplePayloadWithComplex(true)), Instant.parse("2026-01-03T08:00:00Z"));
-        byte[] pdfBytes = pdfGenerator.generate(html);
-
-        assertThat(pdfBytes).isNotEmpty();
-        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
-
-        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)) {
-            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
-            String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
-            assertThat(normalizedText)
-                    .contains("高考英语临考词汇突击潜力展望报告")
-                    .contains("模块一:个人学情分析")
-                    .contains("模块二:科学备考建议")
-                    .contains("模块三:上届学员提分案例")
-                    .contains("词频区间掌握度")
-                    .containsAnyOf("练习学案频率与提分规划", "建议策略")
-                    .contains("王雷宇")
-                    .contains("705词")
-                    .contains("237词")
-                    .contains("+19分")
-                    .containsAnyOf("高频词:56.7%(优先学习)", "高频词56.7%优先学习")
-                    .containsAnyOf("中频词:47.5%(重点突破)", "中频词47.5%重点突破")
-                    .containsAnyOf("低频词:30.0%(酌情学习)", "低频词30.0%酌情学习");
-        }
-    }
-
-    /**
-     * 覆盖官方上游词汇 payload 在 Complex=false 时生成 Outlook PDF,应隐藏模块三案例文本。
-     */
-    @Test
-    void generateCreatesPdfSmokeWithoutOutlookModuleThreeWhenComplexIsFalse() throws Exception {
-        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(unmodeledOutlookContent(samplePayloadWithComplex(false)), Instant.parse("2026-01-03T08:00:00Z"));
-        byte[] pdfBytes = pdfGenerator.generate(html);
-
-        assertThat(pdfBytes).isNotEmpty();
-        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
-
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
-            String normalizedText = new PDFTextStripper().getText(document).replaceAll("\\s+", "");
-            assertThat(normalizedText)
-                    .contains("高考英语临考词汇突击潜力展望报告")
-                    .contains("模块一:个人学情分析")
-                    .contains("模块二:科学备考建议")
-                    .contains("词频区间掌握度")
-                    .doesNotContain("模块三:上届学员提分案例")
-                    .doesNotContain("王雷宇")
-                    .doesNotContain("+19分");
-        }
-    }
-
-    /**
-     * 覆盖学习成果报告 HTML 生成 PDF 时,应产出可解析 PDF 并包含成果报告关键文本。
-     */
-    @Test
-    void generateCreatesPdfSmokeWithExtractableAchievementKeyText() throws Exception {
-        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
-
-        String html = renderer.render(sampleAchievementContent(), Instant.parse("2026-04-25T08:00:00Z"));
-        byte[] pdfBytes = pdfGenerator.generate(html);
-
-        assertThat(pdfBytes).isNotEmpty();
-        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
-
-        Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "achievement-report-demo.pdf");
-        Files.createDirectories(previewPdfPath.getParent());
-        Files.write(previewPdfPath, pdfBytes);
-        assertThat(previewPdfPath).exists().isRegularFile();
-
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
-            String normalizedText = normalizePdfText(new PDFTextStripper().getText(document));
-            assertThat(normalizedText)
-                    .contains("高考英语临考突击学习成果报告")
-                    .contains("模块一:词汇量对比")
-                    .contains("模块二:试卷熟词量对比")
-                    .contains("模块三:实考生词命中状况")
-                    .contains("词汇量提升(个)")
-                    .contains("试卷掌握度命中率")
-                    .contains("21.1%")
-                    .contains("0.48倍")
-                    .contains("高考词汇量")
-                    .contains("3500词")
-                    .contains("掌握率")
-                    .contains("66.51%")
-                    .contains("67.06%")
-                    .contains("试卷标题")
-                    .contains("2024真题")
-                    .contains("861词")
-                    .contains("75.49%")
-                    .contains("75.96%")
-                    .contains("207个")
-                    .contains("203个")
-                    .contains("4个")
-                    .containsAnyOf("number", "bear", "popular", "importance")
-                    .doesNotContain("真题生词命中率")
-                    .doesNotContain("1.93%");
-        }
-    }
-
-    /**
-     * 覆盖系统字体候选为空时,PDF 生成器应使用随包 MiSans 字体输出可抽取中文文本。
-     */
-    @Test
-    void generateUsesBundledMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
-                BundledOutlookReportFonts::load,
-                List.of());
-
-        byte[] pdfBytes = pdfGenerator.generate("""
-                <html>
-                <head>
-                    <meta charset=\"UTF-8\"/>
-                    <style>
-                        body { font-family: MiSans; }
-                    </style>
-                </head>
-                <body>
-                    <p>MiSans 测试:临考冲刺 +19分</p>
-                </body>
-                </html>
-                """);
-
-        assertThat(pdfBytes).isNotEmpty();
-        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
-
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
-            assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
-                    .contains("MiSans测试:临考冲刺+19分");
-        }
-    }
-
-    /**
-     * 覆盖系统字体候选为空且 HTML 包含内联 SVG 中文时,AWT 注册的随包 MiSans 应支持 SVG 文本渲染。
-     */
-    @Test
-    void generateRendersInlineSvgChineseTextWithAwtRegisteredMiSansWhenSystemFontCandidatesAreEmpty() throws Exception {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
-                BundledOutlookReportFonts::load,
-                List.of());
-
-        BundledOutlookReportFonts.load();
-        assertThat(new Font("MiSans VF", Font.PLAIN, 24).canDisplayUpTo("掌握率")).isEqualTo(-1);
-
-        byte[] pdfBytes = pdfGenerator.generate("""
-                <html>
-                <head>
-                    <meta charset=\"UTF-8\"/>
-                    <style>
-                        body { font-family: sans-serif; }
-                    </style>
-                </head>
-                <body>
-                    <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 80' width='220' height='80'
-                         font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\">
-                        <text x='20' y='48' font-size='24'>掌握率</text>
-                    </svg>
-                </body>
-                </html>
-                """);
-
-        assertThat(pdfBytes).isNotEmpty();
-        try (PDDocument document = PDDocument.load(pdfBytes)) {
-            assertThat(document.getNumberOfPages()).isEqualTo(1);
-        }
-    }
-
-    /**
-     * 覆盖随包字体 supplier 抛出非预期异常时,PDF 生成器应保留根因并向调用方报告生成失败。
-     */
-    @Test
-    void generateDoesNotHideUnexpectedBundledFontSupplierFailures() {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
-                () -> { throw new IllegalArgumentException("broken supplier contract"); },
-                List.of("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"));
-
-        assertThatThrownBy(() -> pdfGenerator.generate("<html><body>测试</body></html>"))
-                .isInstanceOf(IllegalStateException.class)
-                .hasMessageContaining("Failed to generate PDF")
-                .hasRootCauseInstanceOf(IllegalArgumentException.class)
-                .hasRootCauseMessage("broken supplier contract");
-    }
-
-    /**
-     * 覆盖随包字体资源缺失时,PDF 生成器应回退系统字体并仍能生成 ASCII PDF。
-     */
-    @Test
-    void generateFallsBackToSystemFontsWhenBundledFontResourceIsMissing() {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
-                () -> { throw new BundledOutlookReportFonts.BundledFontMissingException("missing bundled font"); },
-                List.of());
-
-        byte[] pdfBytes = pdfGenerator.generate("<html><body>ASCII fallback smoke</body></html>");
-
-        assertThat(pdfBytes).isNotEmpty();
-        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
-    }
-
-    /**
-     * 覆盖随包字体注册失败时,PDF 生成器不应静默回退而应报告生成失败。
-     */
-    @Test
-    void generateDoesNotFallbackWhenBundledFontRegistrationFails() {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
-                () -> { throw new BundledOutlookReportFonts.BundledFontUnavailableException("invalid bundled font"); },
-                List.of());
-
-        assertThatThrownBy(() -> pdfGenerator.generate("<html><body>测试</body></html>"))
-                .isInstanceOf(IllegalStateException.class)
-                .hasMessageContaining("Failed to generate PDF")
-                .hasRootCauseInstanceOf(BundledOutlookReportFonts.BundledFontUnavailableException.class)
-                .hasRootCauseMessage("invalid bundled font");
-    }
-
-    private String normalizePdfText(String text) {
-        return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
-    }
-
-    private UnmodeledReportContent unmodeledOutlookContent(JsonNode payload) {
-        return new UnmodeledReportContent(ReportType.OUTLOOK, payload);
-    }
-
-    private AchievementReportContent sampleAchievementContent() {
-        return new AchievementReportContent(
-                "吴泓妤",
-                "高考英语临考突击学习成果报告",
-                "2024真题 · 两周专项训练 · 真实提分效果",
-                "恭喜完成两周考前突击专项训练",
-                "基于2024英语真题试卷 · 真实学习效果分析",
-                new AchievementReportContent.SummaryMetrics(
-                        "+19",
-                        "+4",
-                        "21.1%",
-                        "0.48"),
-                new AchievementReportContent.Comparison(2328.0, 2347.0, "2328", "2347", "+19"),
-                new AchievementReportContent.Comparison(650.0, 654.0, "650", "654", "+4"),
-                new AchievementReportContent.StageVocabularySummary("高考", "3500", "66.51", "67.06", "+0.55"),
-                new AchievementReportContent.TestPaperVocabularySummary("2024真题", "861", "207", "203", "75.49", "75.96", "+0.62"),
-                new AchievementReportContent.ExamUnknownWordsHitStatus(
-                        "21.1%",
-                        "0.48",
-                        "207",
-                        "203",
-                        "4",
-                        List.of("number", "bear", "popular", "importance")));
-    }
-
-    private JsonNode samplePayload() throws Exception {
-        return OBJECT_MAPPER.readTree("""
-                {
-                  "StudentName": "20260318测试",
-                  "StudentStage": 2,
-                  "StageName": "初中",
-                  "StageVocabulary": 10,
-                  "StudentVocabulary": 4,
-                  "StageExaminName": "中考",
-                  "StageImportant": 3,
-                  "StudentWordsLatest": [
-                    {"WordId": 1, "MeanId": 1, "WordSpell": "w1", "WordFrequency": 1, "Mastery": 1.0, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 2, "MeanId": 1, "WordSpell": "w2", "WordFrequency": 2, "Mastery": 0.5, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 3, "MeanId": 1, "WordSpell": "w3", "WordFrequency": 3, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 4, "MeanId": 1, "WordSpell": "w4", "WordFrequency": 4, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 5, "MeanId": 1, "WordSpell": "w5", "WordFrequency": 5, "Mastery": 0.6, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 6, "MeanId": 1, "WordSpell": "w6", "WordFrequency": 6, "Mastery": 0.8, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 7, "MeanId": 1, "WordSpell": "w7", "WordFrequency": 7, "Mastery": 0.1, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 8, "MeanId": 1, "WordSpell": "w8", "WordFrequency": 8, "Mastery": 0.2, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 9, "MeanId": 1, "WordSpell": "w9", "WordFrequency": 9, "Mastery": 0.3, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"},
-                    {"WordId": 10, "MeanId": 1, "WordSpell": "w10", "WordFrequency": 10, "Mastery": 0.4, "ReviewTimes": 1, "Reliability": 2, "CreateTime": "2026-03-18T15:28:25.5813874+08:00"}
-                  ],
-                  "MastedWordCount": 4,
-                  "UnMastedWordCount": 6,
-                  "ExamineStrangeWordCount": 3,
-                  "TestPaperWordIdArray": [1, 2, 3, 4, 5],
-                  "TestPaperTitle": "文章2.jpg",
-                  "TestPaperUnMasterWordCount": 3,
-                  "TestPaperMastedWordCount": 2,
-                  "TestPaperWordCount": 5,
-                  "Complex": false
-                }
-                """);
-    }
-
-    private JsonNode samplePayloadWithComplex(boolean complex) throws Exception {
-        ObjectNode payload = (ObjectNode) samplePayload();
-        payload.put("Complex", complex);
-        return payload;
-    }
-
-}

+ 41 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java

@@ -8,6 +8,7 @@ import cn.yunzhixue.ability.center.examsprint.infrastructure.report.rendering.ou
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.microsoft.playwright.BrowserType;
 import org.apache.pdfbox.pdmodel.PDDocument;
 import org.apache.pdfbox.text.PDFTextStripper;
 import org.junit.jupiter.api.AfterAll;
@@ -56,6 +57,46 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
         }).doesNotThrowAnyException();
     }
 
+    @Test
+    void chromiumLaunchUsesDefaultSandboxOptions() {
+        BrowserType.LaunchOptions launchOptions = PlaywrightExamSprintReportPdfGenerator.createLaunchOptions(123);
+
+        assertThat(launchOptions.args).isNull();
+    }
+
+    @Test
+    void withBundledFontsInjectsMiSansAliasesBeforeHeadClose() {
+        String html = "<html><head><meta charset=\"UTF-8\"/></head><body>测试</body></html>";
+
+        try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                BundledOutlookReportFonts::load,
+                1,
+                1)) {
+            String htmlWithFonts = generator.withBundledFonts(html);
+
+            assertThat(htmlWithFonts)
+                    .contains("<style data-playwright-pdf-fonts>")
+                    .contains("@font-face")
+                    .contains("font-family: 'MiSans'")
+                    .contains("font-family: 'MiSans VF'")
+                    .contains("font-family: 'ReportFont'")
+                    .contains("src: url('file:")
+                    .containsSubsequence("<head>", "<style data-playwright-pdf-fonts>", "</head>");
+        }
+    }
+
+    @Test
+    void withBundledFontsKeepsOriginalHtmlWhenBundledFontIsMissing() {
+        String html = "<html><body>fallback</body></html>";
+
+        try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                () -> { throw new BundledOutlookReportFonts.BundledFontMissingException("missing bundled font"); },
+                1,
+                1)) {
+            assertThat(generator.withBundledFonts(html)).isEqualTo(html);
+        }
+    }
+
     @Test
     void generateCreatesPdfWithExtractableChineseText() throws Exception {
         byte[] pdfBytes = pdfGenerator.generate("""

+ 2 - 2
docs/plans/2026-04-13-outlook-report-module-one-charts.md

@@ -4,9 +4,9 @@
 
 **Goal:** Rebuild module one of the Outlook PDF report into a reference-style 2x2 chart dashboard, making three cards data-faithful with the current DTO and keeping the common-vocabulary card as an explicitly approximate visual.
 
-**Architecture:** Keep the existing template + server-side renderer + openhtmltopdf pipeline. Replace module-one chart fragments with print-safe inline SVG charts that resemble the reference screenshot: a donut chart, a total-vs-unknown bar chart, an approximate dual-bar common-vocabulary card, and a vertical frequency chart. Avoid browser JS and keep the request DTO unchanged.
+**Architecture:** Keep the existing template + server-side renderer + Playwright pipeline. Replace module-one chart fragments with print-safe inline SVG charts that resemble the reference screenshot: a donut chart, a total-vs-unknown bar chart, an approximate dual-bar common-vocabulary card, and a vertical frequency chart. Avoid browser JS and keep the request DTO unchanged.
 
-**Tech Stack:** Java 17+, Spring component rendering, openhtmltopdf 1.0.10, inline SVG, JUnit 5 / AssertJ, Maven.
+**Tech Stack:** Java 17+, Spring component rendering, Playwright Java, inline SVG, JUnit 5 / AssertJ, Maven.
 
 ---
 

+ 7 - 7
docs/plans/2026-04-13-outlook-report-style-alignment.md

@@ -2,11 +2,11 @@
 
 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
 
-**Goal:** Rebuild the Outlook PDF report so its visual hierarchy, module naming, and card/chart presentation are highly similar to the provided reference HTML while keeping the current request payload and openhtmltopdf rendering pipeline.
+**Goal:** Rebuild the Outlook PDF report so its visual hierarchy, module naming, and card/chart presentation are highly similar to the provided reference HTML while keeping the current request payload and Playwright rendering pipeline.
 
-**Architecture:** Keep the existing async report flow and server-side HTML-to-PDF conversion. Replace the current template with a print-safe layout that mirrors the reference page structure, render charts as server-generated inline SVG or static HTML fragments instead of browser JavaScript, and stabilize typography through explicit font handling that works in openhtmltopdf.
+**Architecture:** Keep the existing async report flow and server-side HTML-to-PDF conversion. Replace the current template with a print-safe layout that mirrors the reference page structure, render charts as server-generated inline SVG or static HTML fragments instead of browser JavaScript, and stabilize typography through explicit font handling that works with Playwright/Chromium PDF output.
 
-**Tech Stack:** Java 17+, Spring components, openhtmltopdf 1.0.10, server-side HTML template rendering, JUnit 5 / AssertJ, Maven multi-module build.
+**Tech Stack:** Java 17+, Spring components, Playwright Java, server-side HTML template rendering, JUnit 5 / AssertJ, Maven multi-module build.
 
 ---
 
@@ -211,7 +211,7 @@ Refactor `OutlookReportSvgChartBuilder` so it produces print-safe chart fragment
 - Simple vertical/horizontal bar chart markup for exam/common/frequency comparisons
 - Frequency recommendation cards with inline percent fills rendered as CSS backgrounds or inline SVG, avoiding JS
 
-Prefer inline SVG when it materially improves fidelity and remains deterministic in openhtmltopdf. Keep all text escaped.
+Prefer inline SVG when it materially improves fidelity and remains deterministic in Playwright-generated PDFs. Keep all text escaped.
 
 **Step 4: Run tests to verify they pass**
 
@@ -235,7 +235,7 @@ git commit -m "feat: add print-safe reference-style outlook charts"
 ### Task 5: Stabilize font behavior and verify PDF rendering output
 
 **Files:**
-- Modify: `ability-integration/src/main/java/cn/yunzhixue/microservice/ability/report/outlook/integration/OpenHtmlToPdfOutlookReportPdfGenerator.java`
+- Modify: `ability-integration/src/main/java/cn/yunzhixue/microservice/ability/report/outlook/integration/PlaywrightOutlookReportPdfGenerator.java`
 - Create if approved/available: `ability-integration/src/main/resources/fonts/<chosen-cjk-font-file>`
 - Test: `ability-integration/src/test/java/cn/yunzhixue/microservice/ability/report/outlook/integration/ClasspathOutlookReportHtmlRendererTest.java`
 
@@ -279,7 +279,7 @@ If practical, also run a manual local smoke flow to regenerate one PDF and inspe
 Only if the user explicitly requests commits later:
 
 ```bash
-git add ability-integration/src/main/java/cn/yunzhixue/microservice/ability/report/outlook/integration/OpenHtmlToPdfOutlookReportPdfGenerator.java ability-integration/src/main/resources/fonts
+git add ability-integration/src/main/java/cn/yunzhixue/microservice/ability/report/outlook/integration/PlaywrightOutlookReportPdfGenerator.java ability-integration/src/main/resources/fonts
 git commit -m "fix: stabilize outlook pdf font rendering"
 ```
 
@@ -328,6 +328,6 @@ git commit -m "feat: restyle outlook pdf report to match reference"
 
 - The reference HTML contains ECharts and browser JS. Do **not** port those scripts directly.
 - Keep the current request DTO unchanged for this “A: high similarity” scope.
-- Prefer deterministic server-rendered markup over clever CSS unsupported by `openhtmltopdf`.
+- Prefer deterministic server-rendered markup over clever CSS that makes Playwright PDF output unstable.
 - When unsure between visual fidelity and PDF stability, choose the more stable print-safe implementation.
 - Before claiming success, attach evidence from test output and at least one rendered PDF.

+ 1 - 1
docs/plans/2026-04-29-outlook-payload-contract.md

@@ -106,7 +106,7 @@ Expected: PASS after test/helper updates.
 **Files:**
 - Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
 - Modify tests: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
-- Modify tests: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+- Modify tests: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
 
 **Step 1: Update renderer tests first**
 

+ 13 - 13
docs/plans/2026-04-29-openhtmltopdf-print-css.md → docs/plans/2026-04-29-playwright-print-css.md

@@ -1,16 +1,16 @@
-# OpenHTMLToPDF Print-Friendly CSS Implementation Plan
+# Playwright Print-Friendly CSS Implementation Plan
 
 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
 
-**Goal:** 将考试冲刺报告模板中的现代浏览器 CSS 降级为 OpenHTMLToPDF 兼容的 print-friendly CSS,避免已知 CSS parser warning,并保持 PDF 文本与版式结构稳定。
+**Goal:** 将考试冲刺报告模板中的 CSS 收敛为 Playwright/Chromium PDF 稳定的 print-friendly CSS,并保持 PDF 文本与版式结构稳定。
 
-**Architecture:** 只修改资源模板与模板兼容性测试,不改 PDF 生成器行为。使用现有 `OutlookExamSprintReportTemplateCompatibilityTest` 作为 CSS 合约测试,先新增“不包含 OpenHTMLToPDF 不支持属性/选择器”的失败断言,再调整 `outlook-exam-sprint-report-template.html`。
+**Architecture:** 只修改资源模板与模板兼容性测试,不改 PDF 生成器行为。使用现有 `OutlookExamSprintReportTemplateCompatibilityTest` 作为 CSS 合约测试,先新增“不包含不稳定打印属性/选择器”的失败断言,再调整 `outlook-exam-sprint-report-template.html`。
 
-**Tech Stack:** Java 17, Maven, JUnit 5, AssertJ, Spring `ClassPathResource`, OpenHTMLToPDF, HTML/CSS template resources.
+**Tech Stack:** Java 17, Maven, JUnit 5, AssertJ, Spring `ClassPathResource`, Playwright Java, HTML/CSS template resources.
 
 ---
 
-### Task 1: Outlook 模板 CSS 兼容 OpenHTMLToPDF
+### Task 1: Outlook 模板 CSS 兼容 Playwright PDF 输出
 
 **Files:**
 - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/OutlookExamSprintReportTemplateCompatibilityTest.java`
@@ -43,33 +43,33 @@ Run:
 mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=OutlookExamSprintReportTemplateCompatibilityTest test
 ```
 
-Expected: FAIL because current template still contains `-webkit-print-color-adjust`, `print-color-adjust`, `box-shadow`, `display: flex`, `flex-direction`, `:nth-of-type`, and `break-before: page`.
+Expected: FAIL if the current template still contains these print-unstable constructs: `-webkit-print-color-adjust`, `print-color-adjust`, `box-shadow`, `display: flex`, `flex-direction`, `:nth-of-type`, and `break-before: page`.
 
 **Step 3: Write minimal implementation**
 
 In `outlook-exam-sprint-report-template.html`:
 
-1. Remove unsupported print color controls from `body`:
+1. Remove print color controls from `body` if they make Playwright PDF output unstable:
 
 ```css
 -webkit-print-color-adjust: exact;
 print-color-adjust: exact;
 ```
 
-2. Remove unsupported shadow from `.report-container`:
+2. Remove shadow from `.report-container` if it makes Playwright PDF output unstable:
 
 ```css
 box-shadow: 0 2px 15px rgba(0, 0, 0, 0.06);
 ```
 
-3. Remove unsupported flex declarations from `.card` while keeping existing block layout and sizing:
+3. Remove flex declarations from `.card` while keeping existing block layout and sizing if the PDF layout is more stable without flex:
 
 ```css
 display: flex;
 flex-direction: column;
 ```
 
-4. Replace the unsupported print selector with a direct class on the second module section. Change HTML:
+4. Replace the print selector with a direct class on the second module section if the selector makes PDF layout verification brittle. Change HTML:
 
 ```html
 <div class="section">
@@ -97,7 +97,7 @@ with:
 }
 ```
 
-5. Remove unsupported modern pagination declaration and keep legacy print pagination:
+5. Remove modern pagination declaration if Playwright PDF output is more stable with the legacy print pagination rule:
 
 ```css
 break-before: page;
@@ -125,10 +125,10 @@ Expected: PASS.
 Run:
 
 ```bash
-mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=OpenHtmlToPdfExamSprintReportPdfGeneratorTest#generateCreatesPdfSmokeWithExtractableOutlookKeyText test
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=PlaywrightExamSprintReportPdfGeneratorTest#generateCreatesReadablePdfForOutlookReportTemplate test
 ```
 
-Expected: PASS. The prior OpenHTMLToPDF CSS parser warnings for `-webkit-print-color-adjust`, `print-color-adjust`, `box-shadow`, `flex`, `flex-direction`, `nth-of-type`, and `Value page` should no longer appear for the Outlook PDF smoke test.
+Expected: PASS. The Outlook PDF smoke test should render without relying on unstable print CSS constructs.
 
 **Step 6: Do not commit unless explicitly requested**
 

+ 5 - 5
docs/plans/2026-04-30-achievement-report-mastery-hit-rate.md

@@ -64,7 +64,7 @@ Expected: PASS.
 **Files:**
 - Modify: `abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html`
 - Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
-- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+- Test: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
 
 **Step 1: Write the failing tests**
 
@@ -76,7 +76,7 @@ Expected: PASS.
 Run:
 
 ```bash
-mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,PlaywrightExamSprintReportPdfGeneratorTest test
 ```
 
 Expected: FAIL because the template still shows the old label and existing fixture-derived values still render `1.93%`.
@@ -91,7 +91,7 @@ Expected: FAIL because the template still shows the old label and existing fixtu
 Run:
 
 ```bash
-mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest,PlaywrightExamSprintReportPdfGeneratorTest test
 ```
 
 Expected: PASS.
@@ -110,7 +110,7 @@ Expected: PASS.
 Run:
 
 ```bash
-mvn -pl abilities/exam-sprint/infrastructure,abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest,ExamSprintReportApplicationServiceTest,ClasspathAchievementExamSprintReportRendererTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+mvn -pl abilities/exam-sprint/infrastructure,abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest,ExamSprintReportApplicationServiceTest,ClasspathAchievementExamSprintReportRendererTest,PlaywrightExamSprintReportPdfGeneratorTest test
 ```
 
 Expected: PASS with zero failures.
@@ -120,7 +120,7 @@ Expected: PASS with zero failures.
 Run:
 
 ```bash
-git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
+git diff -- abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java abilities/exam-sprint/infrastructure/src/main/resources/templates/achievement-exam-sprint-report-template.html abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java
 ```
 
 Expected: Only the derived-metric logic, renamed labels, and directly related test updates appear.

+ 8 - 13
docs/superpowers/plans/2026-04-20-ability-center-naming-migration.md

@@ -6,7 +6,7 @@
 
 **Architecture:** Execute the migration in vertical slices that keep the reactor green: first rename the top-level modules and add the new DDD module shells, then add final-vocabulary kernel/contracts/runtime adapter code, then implement the new application/domain/infrastructure stack under the final package root, and finally delete the legacy `OutlookReportTask...` code in one cleanup pass. Use one stable public resource model (`/api/exam-sprint/reports`) and one stable domain aggregate (`ExamSprintReport`), with `OUTLOOK`/`ACHIEVEMENT` represented as `reportType` rather than top-level module or path names.
 
-**Tech Stack:** Java 17, Maven multi-module build, Spring Boot 3.3, Spring Web, Spring Validation, Spring Scheduling/Async, Jackson, OpenHTMLToPDF, Azure Blob Storage, JUnit 5, MockMvc.
+**Tech Stack:** Java 17, Maven multi-module build, Spring Boot 3.3, Spring Web, Spring Validation, Spring Scheduling/Async, Jackson, Playwright Java, Azure Blob Storage, JUnit 5, MockMvc.
 
 ---
 
@@ -87,8 +87,8 @@ ability-center/
   - In-memory storage implementation with local download URL generation.
 - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java`
   - Azure Blob storage implementation.
-- `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java`
-  - PDF implementation using OpenHTMLToPDF.
+- `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java`
+  - PDF implementation using Playwright.
 - `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
   - First report type renderer.
 - `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`
@@ -323,14 +323,9 @@ Create `abilities/exam-sprint/infrastructure/pom.xml`:
             <artifactId>spring-boot-autoconfigure</artifactId>
         </dependency>
         <dependency>
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-pdfbox</artifactId>
-            <version>1.0.10</version>
-        </dependency>
-        <dependency>
-            <groupId>com.openhtmltopdf</groupId>
-            <artifactId>openhtmltopdf-svg-support</artifactId>
-            <version>1.0.10</version>
+            <groupId>com.microsoft.playwright</groupId>
+            <artifactId>playwright</artifactId>
+            <version>1.58.0</version>
         </dependency>
         <dependency>
             <groupId>com.azure</groupId>
@@ -1349,7 +1344,7 @@ git commit -m "refactor: add exam sprint report aggregate and application servic
 - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/repository/InMemoryExamSprintReportRepository.java`
 - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/InMemoryExamSprintReportStorage.java`
 - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/storage/AzureBlobExamSprintReportStorage.java`
-- Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java`
+- Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java`
 - Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
 - Create: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
 - Create: `abilities/exam-sprint/infrastructure/src/main/resources/templates/outlook-exam-sprint-report-template.html`
@@ -1662,7 +1657,7 @@ blobClient.setMetadata(Map.of(
         "expiresAt", expiresAt.toString()));
 ```
 
-Create `OpenHtmlToPdfExamSprintReportPdfGenerator.java` by copying the current OpenHTMLToPDF implementation and renaming the class/interface imports to the final package tree.
+Create `PlaywrightExamSprintReportPdfGenerator.java` by using Playwright Chromium to render the final HTML into PDF bytes under the final package tree.
 
 Create `ClasspathOutlookExamSprintReportRenderer.java`:
 

+ 2 - 2
docs/superpowers/plans/2026-04-28-ddd-naming-governance-jsonnode-payload-loop.md

@@ -140,7 +140,7 @@ Reasons:
   - Convert sample JSON to domain content in the test boundary and call `render(AchievementReportContent, Instant)`.
 - `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
   - Wrap sample `JsonNode` as `UnmodeledReportContent` before rendering.
-- `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+- `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
   - Wrap sample content when rendering before PDF generation.
 - `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/ExamSprintArchitectureTest.java`
   - Remove the Jackson allowlist and make domain -> Jackson a hard rule.
@@ -826,7 +826,7 @@ Expected: PASS. Tests should now distinguish domain content assertions from publ
 - Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java`
 - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRendererTest.java`
 - Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java`
-- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java`
+- Modify: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
 
 - [ ] **Step 1: Run infrastructure tests to expose port signature failures**
 

+ 9 - 13
docs/superpowers/plans/2026-05-07-playwright-pdf-generator.md

@@ -2,31 +2,29 @@
 
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 
-**Goal:** Replace the default exam-sprint PDF generator with Playwright Java while keeping the openhtmltopdf implementation and dependencies available for comparison.
+**Goal:** Replace the default exam-sprint PDF generator with Playwright Java as the sole supported PDF generation engine.
 
-**Architecture:** Keep `ExamSprintReportPdfGenerator` unchanged. Add a Spring-managed `PlaywrightExamSprintReportPdfGenerator` in the infrastructure PDF package, remove component auto-registration from `OpenHtmlToPdfExamSprintReportPdfGenerator`, and let application/runtime code continue consuming the domain interface. The first version reuses one Chromium browser and serializes Playwright calls to avoid unsafe concurrent access.
+**Architecture:** Keep `ExamSprintReportPdfGenerator` unchanged. Add a Spring-managed `PlaywrightExamSprintReportPdfGenerator` in the infrastructure PDF package and let application/runtime code continue consuming the domain interface. The first version reuses one Chromium browser and serializes Playwright calls to avoid unsafe concurrent access.
 
-**Tech Stack:** Java 17, Spring Boot 3.3.5, Maven, Playwright Java, Chromium, JUnit 5, AssertJ, PDFBox test utilities already present transitively through openhtmltopdf tests.
+**Tech Stack:** Java 17, Spring Boot 3.3.5, Maven, Playwright Java, Chromium, JUnit 5, AssertJ, PDFBox test utilities.
 
 ---
 
 ## File Structure
 
-- Modify `abilities/exam-sprint/infrastructure/pom.xml`: add `com.microsoft.playwright:playwright` and keep existing `com.openhtmltopdf` dependencies.
-- Modify `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java`: remove `@Component` so the legacy implementation is not the default Spring bean.
+- Modify `abilities/exam-sprint/infrastructure/pom.xml`: add `com.microsoft.playwright:playwright` and keep PDF test utilities explicit.
 - Create `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java`: default PDF generator using Playwright Chromium.
 - Create `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`: targeted Playwright generator coverage.
 - Modify `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupResourceIntegrationTest.java`: exercise Playwright in the existing warmup resource integration path.
 
 ## Tasks
 
-### Task 1: Add Playwright Dependency and Default Bean Switch
+### Task 1: Add Playwright Dependency
 
 **Files:**
 - Modify: `abilities/exam-sprint/infrastructure/pom.xml`
-- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java`
 
-- [ ] Add this dependency after the existing `openhtmltopdf-svg-support` dependency:
+- [ ] Add this dependency to the infrastructure module:
 
 ```xml
 <dependency>
@@ -36,8 +34,6 @@
 </dependency>
 ```
 
-- [ ] Remove `import org.springframework.stereotype.Component;` and `@Component` from `OpenHtmlToPdfExamSprintReportPdfGenerator`.
-
 - [ ] Run compile for the infrastructure module:
 
 ```bash
@@ -78,7 +74,7 @@ Expected: compile succeeds or fails only because the new Playwright implementati
 **Files:**
 - Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupResourceIntegrationTest.java`
 
-- [ ] Replace direct construction of `OpenHtmlToPdfExamSprintReportPdfGenerator` with `PlaywrightExamSprintReportPdfGenerator`.
+- [ ] Use direct construction of `PlaywrightExamSprintReportPdfGenerator` in the warmup resource integration test.
 - [ ] Close the generator at the end of the test using try-with-resources.
 
 ### Task 5: Verification
@@ -95,7 +91,7 @@ mvn -pl abilities/exam-sprint/infrastructure exec:java -Dexec.mainClass=com.micr
 - [ ] Run targeted infrastructure tests:
 
 ```bash
-mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=PlaywrightExamSprintReportPdfGeneratorTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=PlaywrightExamSprintReportPdfGeneratorTest test
 ```
 
 - [ ] Run the application warmup resource integration test:
@@ -112,7 +108,7 @@ mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportRuntimeConfigurationTe
 
 ## Self-Review
 
-- Spec coverage: The plan directly implements default Playwright switching, keeps openhtmltopdf code and dependencies, and validates both the new generator and warmup resource path.
+- Spec coverage: The plan directly implements default Playwright switching and validates both the new generator and warmup resource path.
 - Placeholder scan: No task relies on TBD/TODO wording; all files and commands are explicit.
 - Type consistency: The plan keeps `ExamSprintReportPdfGenerator.generate(String): byte[]` unchanged and introduces only infrastructure-level Playwright classes.
 - Commit policy: No commit step is included because this session has not received an explicit request to create a git commit.

+ 1 - 1
docs/superpowers/specs/2026-04-20-ability-center-naming-design.md

@@ -283,7 +283,7 @@ Recommended target names:
 - `AzureBlobExamSprintReportStorage`
 - `InMemoryExamSprintReportStorage`
 - `ExamSprintReportPdfGenerator`
-- `OpenHtmlToPdfExamSprintReportPdfGenerator`
+- `PlaywrightExamSprintReportPdfGenerator`
 - `ExamSprintReportRenderer`
 - `ClasspathOutlookExamSprintReportRenderer`
 - `ClasspathAchievementExamSprintReportRenderer`

+ 3 - 3
docs/superpowers/specs/2026-04-27-ddd-naming-governance-design.md

@@ -64,7 +64,7 @@ The `application` module owns use-case orchestration and outbound port definitio
 
 ### 4. Infrastructure names implementation technology
 
-The `infrastructure` module may use technical names because its job is to adapt concrete technology. Prefixes such as `AzureBlob`, `OpenHtmlToPdf`, `Classpath`, and `InMemory` are appropriate there.
+The `infrastructure` module may use technical names because its job is to adapt concrete technology. Prefixes such as `AzureBlob`, `Playwright`, `Classpath`, and `InMemory` are appropriate there.
 
 ### 5. Governance is incremental
 
@@ -237,7 +237,7 @@ AzureBlobReportFileStorage
 InMemoryExamSprintReportRepository
 ClasspathOutlookReportDocumentRenderer
 ClasspathAchievementReportDocumentRenderer
-OpenHtmlToPdfDocumentGenerator
+PlaywrightDocumentGenerator
 ExamSprintReportInfrastructureConfiguration
 ```
 
@@ -245,7 +245,7 @@ Allowed technology prefixes:
 
 ```text
 AzureBlob
-OpenHtmlToPdf
+Playwright
 Classpath
 InMemory
 Jdbc