Ver Fonte

fix(exam-sprint): 修复测试环境PDF图表乱码

金逸霄 há 2 semanas atrás
pai
commit
41f836eed3
8 ficheiros alterados com 185 adições e 12 exclusões
  1. 48 4
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/BundledOutlookReportFonts.java
  2. 1 1
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGenerator.java
  3. 3 1
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java
  4. 11 5
      abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRenderer.java
  5. 16 1
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/BundledOutlookReportFontsTest.java
  6. 58 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/pdf/OpenHtmlToPdfExamSprintReportPdfGeneratorTest.java
  7. 18 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilderTest.java
  8. 30 0
      abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/outlook/ClasspathOutlookExamSprintReportRendererTest.java

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

@@ -1,14 +1,17 @@
 package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 
+import java.awt.Font;
+import java.awt.FontFormatException;
+import java.awt.GraphicsEnvironment;
 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.Locale;
 import java.util.Map;
 import java.util.Objects;
 
@@ -16,6 +19,7 @@ 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 String AWT_FONT_PROBE_TEXT = "掌握率";
     private static final Map<String, BundledOutlookReportFonts> CACHE = new HashMap<>();
 
     private final List<Registration> registrations;
@@ -36,7 +40,10 @@ public final class BundledOutlookReportFonts {
             return cached;
         }
 
-        BundledOutlookReportFonts loaded = new BundledOutlookReportFonts(List.of(new Registration("MiSans", copyToTempFile(resourcePath))));
+        File copiedFont = copyToTempFile(resourcePath);
+        registerWithAwt(resourcePath, copiedFont);
+
+        BundledOutlookReportFonts loaded = new BundledOutlookReportFonts(List.of(new Registration("MiSans", copiedFont)));
         CACHE.put(resourcePath, loaded);
         return loaded;
     }
@@ -56,7 +63,7 @@ public final class BundledOutlookReportFonts {
     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);
+                throw new BundledFontMissingException("Bundled font resource not found on classpath: " + resourcePath);
             }
 
             Path tempDirectory = Files.createTempDirectory("bundled-outlook-report-font-");
@@ -74,7 +81,37 @@ public final class BundledOutlookReportFonts {
         }
     }
 
-    static final class BundledFontUnavailableException extends RuntimeException {
+    private static void registerWithAwt(String resourcePath, File fontFile) {
+        try {
+            Font font = Font.createFont(Font.TRUETYPE_FONT, fontFile);
+            String family = font.getFamily(Locale.ROOT);
+            boolean registered = GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(font);
+            if (!registered && !isAwtFontUsable(family)) {
+                throw new BundledFontUnavailableException(
+                        "Failed to register bundled font with Java AWT: " + resourcePath + " -> " + fontFile);
+            }
+            if (!isAwtFontUsable(family)) {
+                throw new BundledFontUnavailableException(
+                        "Bundled font registered with Java AWT but cannot display report CJK text: "
+                                + resourcePath + " -> " + fontFile + " (family: " + family + ")");
+            }
+        } catch (FontFormatException exception) {
+            throw new BundledFontUnavailableException(
+                    "Bundled font resource is not a valid TrueType font for Java AWT: " + resourcePath + " -> " + fontFile,
+                    exception);
+        } catch (IOException exception) {
+            throw new BundledFontUnavailableException(
+                    "Failed to load bundled font into Java AWT: " + resourcePath + " -> " + fontFile,
+                    exception);
+        }
+    }
+
+    private static boolean isAwtFontUsable(String family) {
+        return family != null && !family.isBlank()
+                && new Font(family, Font.PLAIN, 12).canDisplayUpTo(AWT_FONT_PROBE_TEXT) == -1;
+    }
+
+    static class BundledFontUnavailableException extends RuntimeException {
 
         BundledFontUnavailableException(String message) {
             super(message);
@@ -85,6 +122,13 @@ public final class BundledOutlookReportFonts {
         }
     }
 
+    static final class BundledFontMissingException extends BundledFontUnavailableException {
+
+        BundledFontMissingException(String message) {
+            super(message);
+        }
+    }
+
     public record Registration(String family, File file) {
         public Registration {
             Objects.requireNonNull(family, "family");

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

@@ -69,7 +69,7 @@ public class OpenHtmlToPdfExamSprintReportPdfGenerator implements ExamSprintRepo
         BundledOutlookReportFonts bundledFonts;
         try {
             bundledFonts = Objects.requireNonNull(bundledFontsSupplier.get(), "bundledFonts");
-        } catch (BundledOutlookReportFonts.BundledFontUnavailableException exception) {
+        } catch (BundledOutlookReportFonts.BundledFontMissingException exception) {
             return false;
         }
 

+ 3 - 1
abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilder.java

@@ -8,6 +8,7 @@ public class AchievementExamSprintReportSvgChartBuilder {
     private static final Pattern CSS_CLASS_PATTERN = Pattern.compile("^[A-Za-z0-9_ -]+$");
     private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
     private static final String DEFAULT_FILL_COLOR = "#ff7d00";
+    private static final String SVG_CJK_FONT_FAMILY = " font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
 
     public String comparisonBarChart(String cssClass,
                                      String ariaLabel,
@@ -33,7 +34,8 @@ public class AchievementExamSprintReportSvgChartBuilder {
 
         return new StringBuilder()
                 .append("<svg class='achievement-bar-chart ").append(safeCssClass(cssClass))
-                .append("' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='")
+                .append("'").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='")
                 .append(escape(ariaLabel)).append("'>")
                 .append(renderAxisGridAndTicks(maxValue, axisLeft, axisRight, axisTop, axisBottom))
                 .append("<rect class='chart-bar chart-bar-before' x='").append(beforeX)

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

@@ -23,6 +23,7 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
 
     private static final String TEMPLATE_RESOURCE = "templates/outlook-exam-sprint-report-template.html";
     private static final String DEFAULT_THEME_COLOR = "#448aff";
+    private static final String SVG_CJK_FONT_FAMILY = " font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
     private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$");
     private static final int CHART_AXIS_LEFT = 34;
     private static final int CHART_AXIS_TOP = 50;
@@ -73,7 +74,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>考纲词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
-                .append("<svg class='syllabus-donut-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
+                .append("<svg class='syllabus-donut-chart'").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220 220' role='img' aria-label='考纲词汇掌握情况'>")
                 .append("<circle class='chart-track' cx='110' cy='110' r='76' fill='none' stroke='#e8eef7' stroke-width='18'></circle>")
                 .append(renderProgressRing("donut-mastered-arc", "donut-mastered-full-circle", 110, 110, 76, masteredPercent, "#448aff"))
                 .append("<path class='donut-unmastered-arc' d='")
@@ -106,7 +108,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>真题试卷词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
-                .append("<svg class='past-paper-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
+                .append("<svg class='past-paper-column-chart'").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='真题试卷词汇掌握情况'>")
                 .append(renderChartAxes(320))
                 .append(renderYAxisTicks(axisMax, 250))
                 .append(renderXAxisTickMarks(112, 208))
@@ -140,7 +143,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<div class='card'>")
                 .append("<h3 class='card-title'>常考词汇掌握情况</h3>")
                 .append("<div class='chart-box'>")
-                .append("<svg class='high-frequency-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
+                .append("<svg class='high-frequency-column-chart'").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' role='img' aria-label='常考词汇掌握情况'>")
                 .append(renderChartAxes(320))
                 .append(renderYAxisTicks(axisMax, 20))
                 .append(renderXAxisTickMarks(112, 208))
@@ -174,7 +178,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
         builder.append("<div class='card'>")
                 .append("<h3 class='card-title'>词频区间掌握度</h3>")
                 .append("<div class='chart-box'>")
-                .append("<svg class='frequency-band-column-chart' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>")
+                .append("<svg class='frequency-band-column-chart'").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 220' role='img' aria-label='词频区间掌握度'>")
                 .append(renderChartAxes(360))
                 .append(renderYAxisTicks(axisMax, 50))
                 .append(renderXAxisTickMarks(97, 187, 277));
@@ -286,7 +291,8 @@ public class ClasspathOutlookExamSprintReportRenderer implements ExamSprintRepor
                 .append("<tr class='student-case-row'>")
                 .append("<td class='case-chart-cell'>")
                 .append("<div class='case-chart'>")
-                .append("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
+                .append("<svg").append(SVG_CJK_FONT_FAMILY)
+                .append(" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260' role='img' aria-label='上届学员提分案例图示'>")
                 .append("<circle cx='130' cy='130' r='86' fill='none' stroke='#ffe3c7' stroke-width='18'></circle>")
                 .append(renderProgressRing("case-progress-arc", "case-progress-full-circle", 130, 130, 86, progressPercent, "#ff7d00"))
                 .append("<text x='130' y='124' text-anchor='middle' fill='#8a5d36' font-size='26' font-weight='700'>")

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

@@ -2,7 +2,11 @@ package cn.yunzhixue.ability.center.examsprint.infrastructure.report.pdf;
 
 import org.junit.jupiter.api.Test;
 
+import java.awt.Font;
+import java.awt.GraphicsEnvironment;
 import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Locale;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -23,6 +27,17 @@ class BundledOutlookReportFontsTest {
         assertThat(registration.file().length()).isGreaterThan(0L);
     }
 
+    @Test
+    void loadRegistersBundledMiSansWithAwtGraphicsEnvironmentForBatikSvgText() {
+        BundledOutlookReportFonts.load();
+
+        String[] availableFontFamilies = GraphicsEnvironment.getLocalGraphicsEnvironment()
+                .getAvailableFontFamilyNames(Locale.ROOT);
+
+        assertThat(Arrays.asList(availableFontFamilies)).contains("MiSans VF");
+        assertThat(new Font("MiSans VF", Font.PLAIN, 24).canDisplayUpTo("掌握率")).isEqualTo(-1);
+    }
+
     @Test
     void loadRecreatesTempCopyWhenCachedFileIsRemoved() throws Exception {
         BundledOutlookReportFonts first = BundledOutlookReportFonts.load();
@@ -41,7 +56,7 @@ class BundledOutlookReportFontsTest {
     @Test
     void loadFailsWithClearMessageWhenClasspathResourceIsMissing() {
         assertThatThrownBy(() -> BundledOutlookReportFonts.load("/fonts/does-not-exist.ttf"))
-                .isInstanceOf(BundledOutlookReportFonts.BundledFontUnavailableException.class)
+                .isInstanceOf(BundledOutlookReportFonts.BundledFontMissingException.class)
                 .hasMessageContaining("/fonts/does-not-exist.ttf")
                 .hasMessageContaining("Bundled font resource not found on classpath");
     }

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

@@ -8,6 +8,7 @@ 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;
@@ -118,6 +119,38 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
         }
     }
 
+    @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);
+        }
+    }
+
     @Test
     void generateDoesNotHideUnexpectedBundledFontSupplierFailures() {
         OpenHtmlToPdfExamSprintReportPdfGenerator pdfGenerator = new OpenHtmlToPdfExamSprintReportPdfGenerator(
@@ -131,6 +164,31 @@ class OpenHtmlToPdfExamSprintReportPdfGeneratorTest {
                 .hasRootCauseMessage("broken supplier contract");
     }
 
+    @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");
+    }
+
+    @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+", "");
     }

+ 18 - 0
abilities/exam-sprint/infrastructure/src/test/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/AchievementExamSprintReportSvgChartBuilderTest.java

@@ -39,6 +39,24 @@ class AchievementExamSprintReportSvgChartBuilderTest {
                 .contains("fill='#448aff'");
     }
 
+    @Test
+    void comparisonBarChartDeclaresBatikCjkFontFamilyOnInlineSvg() {
+        AchievementExamSprintReportSvgChartBuilder builder = new AchievementExamSprintReportSvgChartBuilder();
+
+        String svg = builder.comparisonBarChart(
+                "vocabulary-growth-chart",
+                "词汇量对比",
+                "训练前",
+                2328,
+                "2328 词",
+                "训练后",
+                2347,
+                "2347 词",
+                "#448aff");
+
+        assertThat(svg).contains("<svg class='achievement-bar-chart vocabulary-growth-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"");
+    }
+
     private String extractRect(String svgOrHtml, String rectClass) {
         Matcher rectMatcher = SELF_CLOSING_RECT_PATTERN.matcher(svgOrHtml);
         String singleQuotedClass = "class='" + rectClass + "'";

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

@@ -10,6 +10,10 @@ import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -17,6 +21,8 @@ class ClasspathOutlookExamSprintReportRendererTest {
 
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final String TEMPLATE_PATH = "templates/outlook-exam-sprint-report-template.html";
+    private static final String SVG_CJK_FONT_FAMILY = "font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"";
+    private static final Pattern SVG_START_TAG_PATTERN = Pattern.compile("<svg\\b[^>]*>");
 
     @Test
     void renderBuildsOutlookHtmlAlignedWithDesignDraftDynamicStructure() throws Exception {
@@ -87,6 +93,21 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 .containsPattern("<text class='chart-axis-tick-label chart-axis-tick-label-y' x='26' y='54' text-anchor='end' fill='#7f8b97' font-size='11'>200</text>");
     }
 
+    @Test
+    void renderDeclaresBatikCjkFontFamilyOnEveryInlineSvg() throws Exception {
+        ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
+
+        String html = renderer.render(samplePayload(), Instant.parse("2026-01-03T08:00:00Z"));
+
+        assertThat(html)
+                .contains("<svg class='syllabus-donut-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
+                .contains("<svg class='past-paper-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
+                .contains("<svg class='high-frequency-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
+                .contains("<svg class='frequency-band-column-chart' font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\"")
+                .contains("<svg font-family=\"'MiSans VF', MiSans, ReportFont, sans-serif\" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 260 260'");
+        assertThat(svgStartTags(html)).hasSize(5).allSatisfy(svg -> assertThat(svg).contains(SVG_CJK_FONT_FAMILY));
+    }
+
     @Test
     void supportsOnlyOutlookReportType() {
         ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
@@ -322,6 +343,15 @@ class ClasspathOutlookExamSprintReportRendererTest {
                 """);
     }
 
+    private List<String> svgStartTags(String html) {
+        Matcher matcher = SVG_START_TAG_PATTERN.matcher(html);
+        List<String> svgStartTags = new ArrayList<>();
+        while (matcher.find()) {
+            svgStartTags.add(matcher.group());
+        }
+        return svgStartTags;
+    }
+
     private JsonNode payloadWithEscapingSamples() throws Exception {
         ObjectNode payload = samplePayload().deepCopy();