ソースを参照

Merge branch 'feat/切换Playwright生成PDF' of jyx/dcjxb.microservice into master

金逸霄 1 週間 前
コミット
c0513aaeb9

+ 31 - 30
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportWarmupResourceIntegrationTest.java

@@ -4,7 +4,7 @@ import cn.yunzhixue.ability.center.examsprint.contracts.report.AchievementExamSp
 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.pdf.OpenHtmlToPdfExamSprintReportPdfGenerator;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightExamSprintReportPdfGenerator;
 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;
@@ -26,39 +26,40 @@ class ExamSprintReportWarmupResourceIntegrationTest {
     /** 覆盖运行时 warmup JSON 资源场景,当使用真实渲染器和 PDF 生成器时,两个报告都应产出非空 HTML 与 PDF。 */
     @Test
     void warmupResourcesRenderHtmlAndGeneratePdfForBothReports() throws Exception {
-        OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator();
+        try (PlaywrightExamSprintReportPdfGenerator pdfGenerator = new PlaywrightExamSprintReportPdfGenerator()) {
 
-        JsonNode outlookPayload = readResource(ExamSprintReportWarmupRunner.OUTLOOK_WARMUP_RESOURCE);
-        ClasspathOutlookExamSprintReportRenderer outlookRenderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
-        String outlookHtml = outlookRenderer.render(
-                new UnmodeledReportContent(ReportType.OUTLOOK, outlookPayload),
-                GENERATED_AT);
-        byte[] outlookPdf = pdfGenerator.generate(outlookHtml);
+            JsonNode outlookPayload = readResource(ExamSprintReportWarmupRunner.OUTLOOK_WARMUP_RESOURCE);
+            ClasspathOutlookExamSprintReportRenderer outlookRenderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+            String outlookHtml = outlookRenderer.render(
+                    new UnmodeledReportContent(ReportType.OUTLOOK, outlookPayload),
+                    GENERATED_AT);
+            byte[] outlookPdf = pdfGenerator.generate(outlookHtml);
 
-        assertThat(outlookPayload.path("StudentName").asText()).isEqualTo("20260318测试");
-        assertThat(outlookHtml)
-                .contains("高考英语临考词汇突击潜力展望报告")
-                .contains("模块一:个人学情分析")
-                .contains("模块二:科学备考建议");
-        assertPdfBytes(outlookPdf);
+            assertThat(outlookPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+            assertThat(outlookHtml)
+                    .contains("高考英语临考词汇突击潜力展望报告")
+                    .contains("模块一:个人学情分析")
+                    .contains("模块二:科学备考建议");
+            assertPdfBytes(outlookPdf);
 
-        JsonNode achievementPayload = readResource(ExamSprintReportWarmupRunner.ACHIEVEMENT_WARMUP_RESOURCE);
-        AchievementExamSprintReportPayload reportPayload = OBJECT_MAPPER.treeToValue(
-                achievementPayload,
-                AchievementExamSprintReportPayload.class);
-        AchievementReportContent achievementContent = AchievementReportContentMapper.toDomainContent(reportPayload);
-        ClasspathAchievementExamSprintReportRenderer achievementRenderer = new ClasspathAchievementExamSprintReportRenderer();
-        String achievementHtml = achievementRenderer.render(achievementContent, GENERATED_AT);
-        byte[] achievementPdf = pdfGenerator.generate(achievementHtml);
+            JsonNode achievementPayload = readResource(ExamSprintReportWarmupRunner.ACHIEVEMENT_WARMUP_RESOURCE);
+            AchievementExamSprintReportPayload reportPayload = OBJECT_MAPPER.treeToValue(
+                    achievementPayload,
+                    AchievementExamSprintReportPayload.class);
+            AchievementReportContent achievementContent = AchievementReportContentMapper.toDomainContent(reportPayload);
+            ClasspathAchievementExamSprintReportRenderer achievementRenderer = new ClasspathAchievementExamSprintReportRenderer();
+            String achievementHtml = achievementRenderer.render(achievementContent, GENERATED_AT);
+            byte[] achievementPdf = pdfGenerator.generate(achievementHtml);
 
-        assertThat(achievementContent.studentName()).isEqualTo("20260318测试");
-        assertThat(achievementContent.reportTitle()).isEqualTo("初中英语临考突击学习成果报告");
-        assertThat(achievementHtml)
-                .contains("初中英语临考突击学习成果报告")
-                .contains("模块一:词汇量对比")
-                .contains("模块二:试卷熟词量对比")
-                .contains("模块三:实考生词命中状况");
-        assertPdfBytes(achievementPdf);
+            assertThat(achievementContent.studentName()).isEqualTo("20260318测试");
+            assertThat(achievementContent.reportTitle()).isEqualTo("初中英语临考突击学习成果报告");
+            assertThat(achievementHtml)
+                    .contains("初中英语临考突击学习成果报告")
+                    .contains("模块一:词汇量对比")
+                    .contains("模块二:试卷熟词量对比")
+                    .contains("模块三:实考生词命中状况");
+            assertPdfBytes(achievementPdf);
+        }
     }
 
     private JsonNode readResource(String resourcePath) throws Exception {

+ 5 - 0
abilities/exam-sprint/infrastructure/pom.xml

@@ -44,6 +44,11 @@
             <artifactId>openhtmltopdf-svg-support</artifactId>
             <version>1.0.10</version>
         </dependency>
+        <dependency>
+            <groupId>com.microsoft.playwright</groupId>
+            <artifactId>playwright</artifactId>
+            <version>1.58.0</version>
+        </dependency>
         <dependency>
             <groupId>com.azure</groupId>
             <artifactId>azure-storage-blob</artifactId>

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

@@ -3,7 +3,6 @@ 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 org.springframework.stereotype.Component;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -13,7 +12,6 @@ import java.util.List;
 import java.util.Objects;
 import java.util.function.Supplier;
 
-@Component
 public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator {
 
     private static final List<String> FONT_CANDIDATES = List.of(

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

@@ -0,0 +1,210 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
+import com.microsoft.playwright.Browser;
+import com.microsoft.playwright.BrowserContext;
+import com.microsoft.playwright.BrowserType;
+import com.microsoft.playwright.Page;
+import com.microsoft.playwright.Playwright;
+import com.microsoft.playwright.options.Margin;
+import com.microsoft.playwright.options.Media;
+import com.microsoft.playwright.options.WaitUntilState;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.stereotype.Component;
+
+import java.io.File;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+@Component
+public class PlaywrightExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator, DisposableBean, AutoCloseable {
+
+    private static final double DEFAULT_LAUNCH_TIMEOUT_MILLIS = 30_000;
+    private static final double DEFAULT_RENDER_TIMEOUT_MILLIS = 30_000;
+    private static final List<String> REPORT_FONT_FAMILIES = List.of("MiSans", "MiSans VF", "ReportFont");
+    private static final String FONT_STYLE_MARKER = "data-playwright-pdf-fonts";
+
+    private final Object playwrightLock = new Object();
+    private final double launchTimeoutMillis;
+    private final double renderTimeoutMillis;
+    private final String bundledFontStyle;
+    private Playwright playwright;
+    private Browser browser;
+    private boolean closed;
+
+    public PlaywrightExamSprintReportPdfGenerator() {
+        this(BundledOutlookReportFonts::load, DEFAULT_LAUNCH_TIMEOUT_MILLIS, DEFAULT_RENDER_TIMEOUT_MILLIS);
+    }
+
+    PlaywrightExamSprintReportPdfGenerator(
+            Supplier<BundledOutlookReportFonts> bundledFontsSupplier,
+            double launchTimeoutMillis,
+            double renderTimeoutMillis) {
+        this.launchTimeoutMillis = launchTimeoutMillis;
+        this.renderTimeoutMillis = renderTimeoutMillis;
+        this.bundledFontStyle = resolveBundledFontStyle(Objects.requireNonNull(bundledFontsSupplier, "bundledFontsSupplier"));
+    }
+
+    @Override
+    public byte[] generate(String htmlContent) {
+        Objects.requireNonNull(htmlContent, "htmlContent");
+
+        // Playwright Java objects are not safe for unsynchronized concurrent use; keep the first
+        // Playwright implementation intentionally single-worker until throughput requirements justify a pool.
+        synchronized (playwrightLock) {
+            ensureOpen();
+            initializeBrowserIfNecessary();
+            BrowserContext context = null;
+            try {
+                context = browser.newContext(new Browser.NewContextOptions().setLocale("zh-CN"));
+                context.setDefaultTimeout(renderTimeoutMillis);
+                context.setDefaultNavigationTimeout(renderTimeoutMillis);
+                Page page = context.newPage();
+                page.emulateMedia(new Page.EmulateMediaOptions().setMedia(Media.PRINT));
+                page.setContent(withBundledFonts(htmlContent), new Page.SetContentOptions()
+                        .setWaitUntil(WaitUntilState.LOAD)
+                        .setTimeout(renderTimeoutMillis));
+                page.evaluate("() => document.fonts ? document.fonts.ready.then(() => true) : true");
+                return page.pdf(new Page.PdfOptions()
+                        .setFormat("A4")
+                        .setPrintBackground(true)
+                        .setPreferCSSPageSize(true)
+                        .setMargin(new Margin()
+                                .setTop("0")
+                                .setRight("0")
+                                .setBottom("0")
+                                .setLeft("0")));
+            } catch (Exception exception) {
+                throw new IllegalStateException("Failed to generate PDF", exception);
+            } finally {
+                closeQuietly(context);
+            }
+        }
+    }
+
+    @Override
+    public void destroy() {
+        synchronized (playwrightLock) {
+            if (closed) {
+                return;
+            }
+            RuntimeException closeFailure = null;
+            try {
+                if (browser != null) {
+                    browser.close();
+                }
+            } catch (RuntimeException exception) {
+                closeFailure = exception;
+            }
+            try {
+                if (playwright != null) {
+                    playwright.close();
+                }
+            } catch (RuntimeException exception) {
+                if (closeFailure == null) {
+                    closeFailure = exception;
+                } else {
+                    closeFailure.addSuppressed(exception);
+                }
+            }
+            closed = true;
+            if (closeFailure != null) {
+                throw closeFailure;
+            }
+        }
+    }
+
+    @Override
+    public void close() {
+        destroy();
+    }
+
+    private void ensureOpen() {
+        if (closed) {
+            throw new IllegalStateException("PDF generator is closed");
+        }
+    }
+
+    private void initializeBrowserIfNecessary() {
+        if (browser != null) {
+            return;
+        }
+
+        Playwright initializedPlaywright = null;
+        Browser initializedBrowser = null;
+        try {
+            initializedPlaywright = Playwright.create();
+            initializedBrowser = initializedPlaywright.chromium().launch(new BrowserType.LaunchOptions()
+                    .setHeadless(true)
+                    .setTimeout(launchTimeoutMillis));
+            playwright = initializedPlaywright;
+            browser = initializedBrowser;
+        } catch (RuntimeException exception) {
+            closeQuietly(initializedBrowser);
+            closeQuietly(initializedPlaywright);
+            throw new IllegalStateException("Failed to initialize Playwright PDF generator", exception);
+        }
+    }
+
+    private String withBundledFonts(String htmlContent) {
+        if (bundledFontStyle.isBlank() || htmlContent.contains(FONT_STYLE_MARKER)) {
+            return htmlContent;
+        }
+
+        String styleElement = "<style " + FONT_STYLE_MARKER + ">\n" + bundledFontStyle + "\n</style>";
+        String lowerCaseHtml = htmlContent.toLowerCase(Locale.ROOT);
+        int headEndIndex = lowerCaseHtml.indexOf("</head>");
+        if (headEndIndex < 0) {
+            return styleElement + htmlContent;
+        }
+        return htmlContent.substring(0, headEndIndex) + styleElement + htmlContent.substring(headEndIndex);
+    }
+
+    private static String resolveBundledFontStyle(Supplier<BundledOutlookReportFonts> bundledFontsSupplier) {
+        BundledOutlookReportFonts bundledFonts;
+        try {
+            bundledFonts = Objects.requireNonNull(bundledFontsSupplier.get(), "bundledFonts");
+        } catch (BundledOutlookReportFonts.BundledFontMissingException exception) {
+            return "";
+        }
+
+        if (bundledFonts.registrations().isEmpty()) {
+            return "";
+        }
+
+        File fontFile = bundledFonts.registrations().get(0).file();
+        if (!fontFile.exists() || !fontFile.isFile() || !fontFile.canRead()) {
+            throw new IllegalStateException("Bundled font file is not readable: " + fontFile);
+        }
+
+        String fontUri = fontFile.toURI().toString();
+        return REPORT_FONT_FAMILIES.stream()
+                .map(family -> fontFace(family, fontUri))
+                .collect(Collectors.joining("\n"));
+    }
+
+    private static String fontFace(String family, String fontUri) {
+        return """
+                @font-face {
+                    font-family: '%s';
+                    src: url('%s') format('truetype');
+                    font-weight: 100 900;
+                    font-style: normal;
+                    font-display: swap;
+                }
+                """.formatted(family, fontUri);
+    }
+
+    private static void closeQuietly(AutoCloseable closeable) {
+        if (closeable == null) {
+            return;
+        }
+        try {
+            closeable.close();
+        } catch (Exception ignored) {
+        }
+    }
+}

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

@@ -0,0 +1,229 @@
+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.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+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.assertThatCode;
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class PlaywrightExamSprintReportPdfGeneratorTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    private PlaywrightExamSprintReportPdfGenerator pdfGenerator;
+
+    @BeforeAll
+    void createPdfGenerator() {
+        pdfGenerator = new PlaywrightExamSprintReportPdfGenerator();
+    }
+
+    @AfterAll
+    void closePdfGenerator() {
+        if (pdfGenerator != null) {
+            pdfGenerator.close();
+        }
+    }
+
+    @Test
+    void constructorDoesNotLaunchChromiumBeforeFirstGenerate() {
+        assertThatCode(() -> {
+            try (PlaywrightExamSprintReportPdfGenerator generator = new PlaywrightExamSprintReportPdfGenerator(
+                    BundledOutlookReportFonts::load,
+                    1,
+                    1)) {
+                // Constructor should not require a browser launch; Chromium is initialized lazily by generate().
+            }
+        }).doesNotThrowAnyException();
+    }
+
+    @Test
+    void generateCreatesPdfWithExtractableChineseText() throws Exception {
+        byte[] pdfBytes = pdfGenerator.generate("""
+                <html>
+                <head>
+                    <meta charset=\"UTF-8\"/>
+                    <style>
+                        body { font-family: MiSans, ReportFont, sans-serif; }
+                    </style>
+                </head>
+                <body>
+                    <p>Playwright 测试:临考冲刺 +19分</p>
+                </body>
+                </html>
+                """);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
+                    .contains("Playwright测试:临考冲刺+19分");
+        }
+    }
+
+    @Test
+    void generateRendersInlineSvgChineseText() throws Exception {
+        byte[] pdfBytes = pdfGenerator.generate("""
+                <html>
+                <head>
+                    <meta charset=\"UTF-8\"/>
+                </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>
+                """);
+
+        assertPdfHeader(pdfBytes);
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isEqualTo(1);
+        }
+    }
+
+    @Test
+    void generateCreatesReadablePdfForOutlookReportTemplate() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+        String html = renderer.render(
+                new UnmodeledReportContent(ReportType.OUTLOOK, samplePayloadWithComplex(false)),
+                Instant.parse("2026-01-03T08:00:00Z"));
+
+        byte[] pdfBytes = pdfGenerator.generate(html);
+
+        assertPdfHeader(pdfBytes);
+        Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "playwright-outlook-report-demo.pdf");
+        Files.deleteIfExists(previewPdfPath);
+        writePreviewPdf(previewPdfPath, pdfBytes);
+        assertThat(previewPdfPath).exists().isRegularFile();
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
+                    .contains("高考英语临考词汇突击潜力展望报告")
+                    .contains("模块一:个人学情分析")
+                    .contains("模块二:科学备考建议");
+        }
+    }
+
+    @Test
+    void generateCreatesReadablePdfForAchievementReportTemplate() throws Exception {
+        ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
+        String html = renderer.render(sampleAchievementContent(), Instant.parse("2026-04-25T08:00:00Z"));
+
+        byte[] pdfBytes = pdfGenerator.generate(html);
+
+        assertPdfHeader(pdfBytes);
+        Path previewPdfPath = Path.of(System.getProperty("user.dir"), "target", "playwright-achievement-report-demo.pdf");
+        Files.deleteIfExists(previewPdfPath);
+        writePreviewPdf(previewPdfPath, pdfBytes);
+        assertThat(previewPdfPath).exists().isRegularFile();
+        try (PDDocument document = PDDocument.load(pdfBytes)) {
+            assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
+            assertThat(normalizePdfText(new PDFTextStripper().getText(document)))
+                    .contains("高考英语临考突击学习成果报告")
+                    .contains("模块一:词汇量对比")
+                    .contains("模块二:试卷熟词量对比")
+                    .contains("模块三:实考生词命中状况");
+        }
+    }
+
+    private void assertPdfHeader(byte[] pdfBytes) {
+        assertThat(pdfBytes).isNotEmpty();
+        assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
+    }
+
+    private void writePreviewPdf(Path previewPdfPath, byte[] pdfBytes) throws Exception {
+        Files.createDirectories(previewPdfPath.getParent());
+        Files.write(previewPdfPath, pdfBytes);
+    }
+
+    private String normalizePdfText(String text) {
+        return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
+    }
+
+    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;
+    }
+}

+ 18 - 0
ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/examsprint/configuration/ExamSprintReportRuntimeConfigurationTest.java

@@ -6,11 +6,13 @@ import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfG
 import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
 import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
+import cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf.PlaywrightExamSprintReportPdfGenerator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.test.context.runner.ApplicationContextRunner;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
 
 import java.time.Clock;
 import java.time.Instant;
@@ -42,6 +44,22 @@ class ExamSprintReportRuntimeConfigurationTest {
                         .hasBean("examSprintReportExecutor"));
     }
 
+    @Test
+    void defaultPdfGeneratorComponentRegistersPlaywrightImplementationWithoutLaunchingChromium() {
+        new ApplicationContextRunner()
+                .withUserConfiguration(PlaywrightPdfGeneratorConfiguration.class)
+                .run(context -> {
+                    assertThat(context).hasSingleBean(ExamSprintReportPdfGenerator.class);
+                    assertThat(context.getBean(ExamSprintReportPdfGenerator.class))
+                            .isInstanceOf(PlaywrightExamSprintReportPdfGenerator.class);
+                });
+    }
+
+    @Configuration
+    @Import(PlaywrightExamSprintReportPdfGenerator.class)
+    static class PlaywrightPdfGeneratorConfiguration {
+    }
+
     @Configuration
     static class WarmupRunnerCollaboratorsConfiguration {
 

+ 118 - 0
docs/superpowers/plans/2026-05-07-playwright-pdf-generator.md

@@ -0,0 +1,118 @@
+# Playwright PDF Generator Implementation Plan
+
+> **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.
+
+**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.
+
+**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.
+
+---
+
+## 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.
+- 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
+
+**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:
+
+```xml
+<dependency>
+    <groupId>com.microsoft.playwright</groupId>
+    <artifactId>playwright</artifactId>
+    <version>1.58.0</version>
+</dependency>
+```
+
+- [ ] Remove `import org.springframework.stereotype.Component;` and `@Component` from `OpenHtmlToPdfExamSprintReportPdfGenerator`.
+
+- [ ] Run compile for the infrastructure module:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -am -DskipTests compile
+```
+
+Expected: compile succeeds or fails only because the new Playwright implementation has not been added yet if this task is executed independently.
+
+### Task 2: Implement Playwright PDF Generator
+
+**Files:**
+- Create: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGenerator.java`
+
+- [ ] Create `PlaywrightExamSprintReportPdfGenerator` with these behaviors:
+  - Implements `ExamSprintReportPdfGenerator`.
+  - Is annotated with `@Component`.
+  - Lazily creates one headless Chromium `Browser` during the first `generate()` call so Spring context startup does not require Chromium to launch.
+  - Serializes Playwright calls using a private lock.
+  - Creates and closes a fresh `BrowserContext` per PDF.
+  - Injects bundled MiSans as `MiSans`, `MiSans VF`, and `ReportFont` through a `file:` URL `@font-face` block when the bundled font is available.
+  - Waits for `document.fonts.ready` before `page.pdf()`.
+  - Uses A4, print media, CSS page size preference, and background printing.
+  - Wraps generation failures in `IllegalStateException("Failed to generate PDF", exception)`.
+  - Closes browser and Playwright from `destroy()`/`close()`.
+
+### Task 3: Add Playwright Generator Tests
+
+**Files:**
+- Create: `abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/PlaywrightExamSprintReportPdfGeneratorTest.java`
+
+- [ ] Add tests that create one generator for the class and close it after all tests.
+- [ ] Verify simple Chinese HTML returns bytes whose first four ISO-8859-1 chars are `%PDF`.
+- [ ] Verify inline SVG Chinese text renders without failure.
+- [ ] Verify full Outlook and Achievement templates generate readable PDFs with at least one page.
+
+### Task 4: Update Warmup Resource Integration Test
+
+**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`.
+- [ ] Close the generator at the end of the test using try-with-resources.
+
+### Task 5: Verification
+
+**Files:**
+- No source file changes.
+
+- [ ] If Chromium is not installed for Playwright, run this command before PDF tests:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure exec:java -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"
+```
+
+- [ ] Run targeted infrastructure tests:
+
+```bash
+mvn -pl abilities/exam-sprint/infrastructure -am -Dtest=PlaywrightExamSprintReportPdfGeneratorTest,OpenHtmlToPdfExamSprintReportPdfGeneratorTest test
+```
+
+- [ ] Run the application warmup resource integration test:
+
+```bash
+mvn -pl abilities/exam-sprint/application -am -Dtest=ExamSprintReportWarmupResourceIntegrationTest test
+```
+
+- [ ] Run runtime configuration test to ensure the default Spring wiring still accepts a single PDF generator bean:
+
+```bash
+mvn -pl ability-center-runtime -am -Dtest=ExamSprintReportRuntimeConfigurationTest test
+```
+
+## 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.
+- 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.