Procházet zdrojové kódy

Merge branch 'fix/outlook-report-misans' of jyx/dcjxb.microservice into master

金逸霄 před 2 týdny
rodič
revize
fd6009f853

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

@@ -0,0 +1,94 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+public final class BundledOutlookReportFonts {
+
+    private static final String DEFAULT_RESOURCE_PATH = "/fonts/MiSans-VF.ttf";
+    private static final String TEMP_FILE_NAME = "MiSans-VF.ttf";
+    private static final Map<String, BundledOutlookReportFonts> CACHE = new HashMap<>();
+
+    private final List<Registration> registrations;
+
+    private BundledOutlookReportFonts(List<Registration> registrations) {
+        this.registrations = List.copyOf(registrations);
+    }
+
+    public static synchronized BundledOutlookReportFonts load() {
+        return load(DEFAULT_RESOURCE_PATH);
+    }
+
+    public static synchronized BundledOutlookReportFonts load(String resourcePath) {
+        Objects.requireNonNull(resourcePath, "resourcePath");
+
+        BundledOutlookReportFonts cached = CACHE.get(resourcePath);
+        if (cached != null && cached.isHealthy()) {
+            return cached;
+        }
+
+        BundledOutlookReportFonts loaded = new BundledOutlookReportFonts(List.of(new Registration("MiSans", copyToTempFile(resourcePath))));
+        CACHE.put(resourcePath, loaded);
+        return loaded;
+    }
+
+    public List<Registration> registrations() {
+        return registrations;
+    }
+
+    private boolean isHealthy() {
+        return registrations.size() == 1 && isHealthy(registrations.get(0).file());
+    }
+
+    private static boolean isHealthy(File file) {
+        return file.exists() && file.isFile() && file.canRead() && file.length() > 0L;
+    }
+
+    private static File copyToTempFile(String resourcePath) {
+        try (InputStream inputStream = BundledOutlookReportFonts.class.getResourceAsStream(resourcePath)) {
+            if (inputStream == null) {
+                throw new BundledFontUnavailableException("Bundled font resource not found on classpath: " + resourcePath);
+            }
+
+            Path tempDirectory = Files.createTempDirectory("bundled-outlook-report-font-");
+            Path tempFile = tempDirectory.resolve(TEMP_FILE_NAME);
+            Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING);
+            File copiedFile = tempFile.toFile();
+            tempDirectory.toFile().deleteOnExit();
+            copiedFile.deleteOnExit();
+            if (!isHealthy(copiedFile)) {
+                throw new BundledFontUnavailableException("Bundled font copy is not readable: " + resourcePath + " -> " + tempFile);
+            }
+            return copiedFile;
+        } catch (IOException exception) {
+            throw new BundledFontUnavailableException("Failed to copy bundled font resource: " + resourcePath, exception);
+        }
+    }
+
+    static final class BundledFontUnavailableException extends RuntimeException {
+
+        BundledFontUnavailableException(String message) {
+            super(message);
+        }
+
+        BundledFontUnavailableException(String message, Throwable cause) {
+            super(message, cause);
+        }
+    }
+
+    public record Registration(String family, File file) {
+        public Registration {
+            Objects.requireNonNull(family, "family");
+            Objects.requireNonNull(file, "file");
+        }
+    }
+}

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

@@ -10,6 +10,8 @@ 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;
 
 @Component
 public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintReportPdfGenerator {
@@ -23,6 +25,20 @@ public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintRepo
             "/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()) {
@@ -42,7 +58,36 @@ public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintRepo
     }
 
     private void registerAvailableFont(PdfRendererBuilder builder) {
-        for (String candidate : FONT_CANDIDATES) {
+        if (registerBundledFonts(builder)) {
+            return;
+        }
+
+        registerSystemFont(builder);
+    }
+
+    private boolean registerBundledFonts(PdfRendererBuilder builder) {
+        BundledOutlookReportFonts bundledFonts;
+        try {
+            bundledFonts = Objects.requireNonNull(bundledFontsSupplier.get(), "bundledFonts");
+        } catch (BundledOutlookReportFonts.BundledFontUnavailableException 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");
@@ -50,4 +95,8 @@ public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintRepo
             }
         }
     }
+
+    private boolean isReadableFile(File file) {
+        return file.exists() && file.isFile() && file.canRead();
+    }
 }

binární
abilities/exam-sprint/infrastructure/src/main/resources/fonts/MiSans-VF.ttf


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

@@ -14,9 +14,9 @@
             background-color: #f5f7fa;
             padding: 0;
             color: #333;
-            font-family: ReportFont, "Microsoft YaHei", sans-serif;
+            font-family: MiSans, ReportFont, sans-serif;
             font-size: 14px;
-            line-height: 1.6;
+            line-height: 1.72;
         }
 
         .report-container {
@@ -29,16 +29,20 @@
         }
 
         h1.report-title {
+            font-family: MiSans, ReportFont, sans-serif;
             text-align: center;
-            font-size: 26px;
+            font-size: 28px;
+            font-weight: 600;
             color: #2b4c8a;
             margin: 0 0 8px;
         }
 
         p.report-subtitle {
+            font-family: MiSans, ReportFont, sans-serif;
             text-align: center;
             margin: 0 0 24px;
             color: #68768a;
+            line-height: 1.72;
         }
 
         .section {
@@ -46,12 +50,13 @@
         }
 
         .section-title {
+            font-family: MiSans, ReportFont, sans-serif;
             font-size: 20px;
             color: #2b4c8a;
             border-left: 6px solid #ff7d00;
             padding-left: 12px;
             margin: 0 0 14px;
-            font-weight: 700;
+            font-weight: 600;
         }
 
         .analysis-table {
@@ -87,9 +92,10 @@
         }
 
         .card-title {
+            font-family: MiSans, ReportFont, sans-serif;
             font-size: 16px;
             color: #2b4c8a;
-            font-weight: 700;
+            font-weight: 600;
             margin: 0 0 10px;
         }
 
@@ -108,8 +114,10 @@
         }
 
         .data-text {
+            font-family: MiSans, ReportFont, sans-serif;
             margin: 6px 0;
             color: #3d4a5d;
+            line-height: 1.72;
         }
 
         .highlight,
@@ -161,8 +169,9 @@
         }
 
         .freq-header {
+            font-family: MiSans, ReportFont, sans-serif;
             font-size: 18px;
-            font-weight: 700;
+            font-weight: 600;
             color: #303a49;
             margin-bottom: 10px;
         }
@@ -193,14 +202,18 @@
         }
 
         .freq-data {
+            font-family: MiSans, ReportFont, sans-serif;
             color: #4a5568;
             font-size: 13px;
+            line-height: 1.72;
         }
 
         .text-desc {
+            font-family: MiSans, ReportFont, sans-serif;
             font-size: 14px;
             color: #4a5568;
             margin: 10px 0;
+            line-height: 1.72;
         }
 
         .suggest-note {

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

@@ -0,0 +1,48 @@
+package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Files;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class BundledOutlookReportFontsTest {
+
+    @Test
+    void loadReturnsCachedInstanceWithOneMiSansRegistrationWhenHealthy() {
+        BundledOutlookReportFonts first = BundledOutlookReportFonts.load();
+        BundledOutlookReportFonts second = BundledOutlookReportFonts.load();
+
+        assertThat(second).isSameAs(first);
+        assertThat(first.registrations()).hasSize(1);
+        BundledOutlookReportFonts.Registration registration = first.registrations().get(0);
+        assertThat(registration.family()).isEqualTo("MiSans");
+        assertThat(registration.file().getName()).isEqualTo("MiSans-VF.ttf");
+        assertThat(registration.file()).exists().isFile().canRead();
+        assertThat(registration.file().length()).isGreaterThan(0L);
+    }
+
+    @Test
+    void loadRecreatesTempCopyWhenCachedFileIsRemoved() throws Exception {
+        BundledOutlookReportFonts first = BundledOutlookReportFonts.load();
+        BundledOutlookReportFonts.Registration firstRegistration = first.registrations().get(0);
+        Files.delete(firstRegistration.file().toPath());
+
+        BundledOutlookReportFonts second = BundledOutlookReportFonts.load();
+        BundledOutlookReportFonts.Registration secondRegistration = second.registrations().get(0);
+
+        assertThat(secondRegistration.family()).isEqualTo("MiSans");
+        assertThat(secondRegistration.file().getName()).isEqualTo("MiSans-VF.ttf");
+        assertThat(secondRegistration.file()).exists().isFile().canRead();
+        assertThat(secondRegistration.file().length()).isGreaterThan(0L);
+    }
+
+    @Test
+    void loadFailsWithClearMessageWhenClasspathResourceIsMissing() {
+        assertThatThrownBy(() -> BundledOutlookReportFonts.load("/fonts/does-not-exist.ttf"))
+                .isInstanceOf(BundledOutlookReportFonts.BundledFontUnavailableException.class)
+                .hasMessageContaining("/fonts/does-not-exist.ttf")
+                .hasMessageContaining("Bundled font resource not found on classpath");
+    }
+}

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

@@ -10,9 +10,12 @@ import org.junit.jupiter.api.Test;
 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 {
 
@@ -54,6 +57,53 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    @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分");
+        }
+    }
+
+    @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");
+    }
+
+    private String normalizePdfText(String text) {
+        return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
+    }
+
     private JsonNode samplePayload() throws Exception {
         return OBJECT_MAPPER.readTree("""
                 {

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

@@ -19,9 +19,10 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .contains("background-color: #f5f7fa")
                 .contains("padding: 0")
                 .contains("color: #333")
-                .contains("font-family: ReportFont, \"Microsoft YaHei\", sans-serif")
-                .contains("ReportFont")
-                .contains("\"Microsoft YaHei\"")
+                .contains("font-family: MiSans, ReportFont, sans-serif")
+                .contains("font-size: 14px")
+                .contains("line-height: 1.72")
+                .doesNotContain("Microsoft YaHei")
                 .contains(".report-container")
                 .contains("max-width: 1200px")
                 .contains("margin: 0 auto")
@@ -29,8 +30,14 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .contains("padding: 32px")
                 .contains("border-radius: 12px")
                 .contains("border: 1px solid #e7edf5")
+                .contains("h1.report-title")
+                .contains("font-size: 28px")
+                .containsPattern("h1\\.report-title\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*28px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
+                .contains("p.report-subtitle")
+                .containsPattern("p\\.report-subtitle\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
                 .contains(".section")
                 .contains(".section-title")
+                .containsPattern("\\.section-title\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*20px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
                 .contains("border-left: 6px solid #ff7d00")
                 .contains(".analysis-table")
                 .contains(".analysis-row")
@@ -38,6 +45,7 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .containsPattern("\\.analysis-table\\s*\\{[^}]*width\\s*:\\s*100%\\s*;[^}]*table-layout\\s*:\\s*fixed\\s*;[^}]*}")
                 .containsPattern("\\.analysis-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
                 .containsPattern("\\.card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
+                .containsPattern("\\.card-title\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*16px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
                 .contains(".frequency-table")
                 .contains(".frequency-row")
                 .contains(".frequency-cell")
@@ -45,9 +53,11 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
                 .containsPattern("\\.frequency-row\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
                 .containsPattern("\\.freq-card\\s*\\{[^}]*page-break-inside\\s*:\\s*avoid\\s*;[^}]*}")
                 .contains(".freq-header")
-                .containsPattern("\\.freq-header\\s*\\{[^}]*font-size\\s*:\\s*18px\\s*;[^}]*}")
+                .containsPattern("\\.freq-header\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*font-size\\s*:\\s*18px\\s*;[^}]*font-weight\\s*:\\s*600\\s*;[^}]*}")
+                .containsPattern("\\.data-text\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
                 .contains(".text-desc")
-                .containsPattern("\\.text-desc\\s*\\{[^}]*color\\s*:\\s*#4a5568\\s*;[^}]*}")
+                .containsPattern("\\.text-desc\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
+                .containsPattern("\\.freq-data\\s*\\{[^}]*font-family\\s*:\\s*MiSans, ReportFont, sans-serif\\s*;[^}]*line-height\\s*:\\s*1.72\\s*;[^}]*}")
                 .contains(".student-case")
                 .contains(".student-case-table")
                 .contains(".student-case-row")
@@ -98,9 +108,12 @@ class OutlookExamSprintReportTemplateCompatibilityTest {
         assertThat(normalizedTemplate)
                 .containsPattern("@page\\s*\\{[^}]*size\\s*:\\s*A4\\s*;[^}]*margin\\s*:\\s*0\\s*;[^}]*}")
                 .contains("h1.report-title")
-                .contains("font-size: 26px")
+                .contains("font-family: MiSans, ReportFont, sans-serif")
+                .contains("font-size: 28px")
+                .contains("font-weight: 600")
                 .contains("color: #2b4c8a")
                 .contains("p.report-subtitle")
+                .contains("line-height: 1.72")
                 .contains("text-align: center");
     }