|
@@ -14,6 +14,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
import ch.qos.logback.core.read.ListAppender;
|
|
import ch.qos.logback.core.read.ListAppender;
|
|
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
|
import org.apache.pdfbox.text.PDFTextStripper;
|
|
import org.apache.pdfbox.text.PDFTextStripper;
|
|
|
|
|
+import org.apache.pdfbox.text.TextPosition;
|
|
|
import org.junit.jupiter.api.AfterAll;
|
|
import org.junit.jupiter.api.AfterAll;
|
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
import org.junit.jupiter.api.BeforeAll;
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.Test;
|
|
@@ -25,6 +26,8 @@ import java.nio.file.Files;
|
|
|
import java.nio.file.Path;
|
|
import java.nio.file.Path;
|
|
|
import java.text.Normalizer;
|
|
import java.text.Normalizer;
|
|
|
import java.time.Instant;
|
|
import java.time.Instant;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Comparator;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
@@ -268,6 +271,27 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateKeepsOutlookModuleOneCardsOnFirstPage() throws Exception {
|
|
|
|
|
+ ClasspathOutlookExamSprintReportRenderer renderer = new ClasspathOutlookExamSprintReportRenderer(OBJECT_MAPPER);
|
|
|
|
|
+ String html = renderer.render(
|
|
|
|
|
+ new UnmodeledReportContent(ReportType.OUTLOOK, samplePayloadWithComplex(true)),
|
|
|
|
|
+ Instant.parse("2026-01-03T08:00:00Z"));
|
|
|
|
|
+
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate(html);
|
|
|
|
|
+
|
|
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(1);
|
|
|
|
|
+ String firstPageText = normalizePdfText(pageText(document, 1));
|
|
|
|
|
+ assertThat(firstPageText)
|
|
|
|
|
+ .contains("考纲词汇掌握情况")
|
|
|
|
|
+ .contains("真题试卷词汇掌握情况")
|
|
|
|
|
+ .contains("常考词汇掌握情况")
|
|
|
|
|
+ .contains("词频区间掌握度");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@Test
|
|
@Test
|
|
|
void generateCreatesReadablePdfForAchievementReportTemplate() throws Exception {
|
|
void generateCreatesReadablePdfForAchievementReportTemplate() throws Exception {
|
|
|
ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
|
|
ClasspathAchievementExamSprintReportRenderer renderer = new ClasspathAchievementExamSprintReportRenderer();
|
|
@@ -290,6 +314,180 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateRepeatsReportHeaderAndFooterPageNumbers() throws Exception {
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
|
|
+ <html>
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <meta charset=\"UTF-8\"/>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ @page { size: A4; margin: 0; }
|
|
|
|
|
+ body { margin: 0; font-family: MiSans, ReportFont, sans-serif; }
|
|
|
|
|
+ .report-container { padding: 32px; }
|
|
|
|
|
+ .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
|
|
|
|
|
+ .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
|
|
|
|
|
+ .header-logo { width: 180px; }
|
|
|
|
|
+ .header-main { text-align: center; color: #68768a; }
|
|
|
|
|
+ .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
|
|
|
|
|
+ .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
|
|
|
|
|
+ .page { height: 1122px; page-break-after: always; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <div class=\"report-container\">
|
|
|
|
|
+ <header class=\"report-header\">
|
|
|
|
|
+ <div class=\"header-logo\"></div>
|
|
|
|
|
+ <div class=\"header-main\">
|
|
|
|
|
+ <div class=\"header-report-type\">个人学情报告</div>
|
|
|
|
|
+ <div class=\"header-student-name\">分页测试学生</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <section class=\"page\">第一页正文内容</section>
|
|
|
|
|
+ <section class=\"page\">第二页正文内容</section>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </body>
|
|
|
|
|
+ </html>
|
|
|
|
|
+ """);
|
|
|
|
|
+
|
|
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ int pageCount = document.getNumberOfPages();
|
|
|
|
|
+ assertThat(pageCount).isGreaterThanOrEqualTo(2);
|
|
|
|
|
+ String normalizedText = normalizePdfText(new PDFTextStripper().getText(document));
|
|
|
|
|
+ assertThat(countOccurrences(normalizedText, "个人学情报告")).isEqualTo(pageCount);
|
|
|
|
|
+ assertThat(countOccurrences(normalizedText, "分页测试学生")).isEqualTo(pageCount);
|
|
|
|
|
+ assertThat(normalizedText)
|
|
|
|
|
+ .contains("2026-05-1310:08:54")
|
|
|
|
|
+ .contains("1/" + pageCount)
|
|
|
|
|
+ .contains(pageCount + "/" + pageCount);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generateKeepsContinuedPageBodyAwayFromHeaderAndFooter() throws Exception {
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
|
|
+ <html>
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <meta charset=\"UTF-8\"/>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ @page { size: A4; margin: 0; }
|
|
|
|
|
+ body { margin: 0; font-family: MiSans, ReportFont, sans-serif; font-size: 14px; }
|
|
|
|
|
+ .report-container { padding: 32px; }
|
|
|
|
|
+ .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
|
|
|
|
|
+ .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
|
|
|
|
|
+ .header-logo { width: 180px; }
|
|
|
|
|
+ .header-main { text-align: center; color: #68768a; }
|
|
|
|
|
+ .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
|
|
|
|
|
+ .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
|
|
|
|
|
+ .filler { height: 620px; }
|
|
|
|
|
+ .large-card { height: 720px; border: 1px solid #eaeef5; page-break-inside: avoid; }
|
|
|
|
|
+ .bottom-marker { margin-top: 650px; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <div class=\"report-container\">
|
|
|
|
|
+ <header class=\"report-header\">
|
|
|
|
|
+ <div class=\"header-logo\"></div>
|
|
|
|
|
+ <div class=\"header-main\">
|
|
|
|
|
+ <div class=\"header-report-type\">个人学情报告</div>
|
|
|
|
|
+ <div class=\"header-student-name\">分页测试学生</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <section class=\"filler\">第一页正文内容</section>
|
|
|
|
|
+ <section class=\"large-card\">PAGE_TWO_BODY_START<div class=\"bottom-marker\">PAGE_TWO_BODY_END</div></section>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </body>
|
|
|
|
|
+ </html>
|
|
|
|
|
+ """);
|
|
|
|
|
+
|
|
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(2);
|
|
|
|
|
+ List<TextLine> pageTwoLines = textLines(pdfBytes, 2);
|
|
|
|
|
+
|
|
|
|
|
+ TextLine continuedBodyStart = findLineContaining(pageTwoLines, "PAGE_TWO_BODY_START");
|
|
|
|
|
+ TextLine continuedBodyEnd = findLineContaining(pageTwoLines, "PAGE_TWO_BODY_END");
|
|
|
|
|
+ float headerBottomY = pageTwoLines.stream()
|
|
|
|
|
+ .filter(line -> line.text.contains("个人学情报告")
|
|
|
|
|
+ || line.text.contains("分页测试学生")
|
|
|
|
|
+ || line.text.contains("2026-05-1310:08:54"))
|
|
|
|
|
+ .map(TextLine::bottomY)
|
|
|
|
|
+ .max(Float::compare)
|
|
|
|
|
+ .orElseThrow(() -> new AssertionError("Missing repeated report header in " + pageTwoLines));
|
|
|
|
|
+ TextLine footer = findLineContaining(pageTwoLines, "2/");
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(continuedBodyStart.y)
|
|
|
|
|
+ .as("continued page body should start below the repeated report header reserved area")
|
|
|
|
|
+ .isGreaterThanOrEqualTo(headerBottomY + 8f);
|
|
|
|
|
+ assertThat(footer.y - continuedBodyEnd.bottomY())
|
|
|
|
|
+ .as("continued page body should not collide with footer reserved area")
|
|
|
|
|
+ .isGreaterThanOrEqualTo(32f);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void generatePlacesBusinessInfoLeftAndPageNumbersRightInFooter() throws Exception {
|
|
|
|
|
+ byte[] pdfBytes = pdfGenerator.generate("""
|
|
|
|
|
+ <html>
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <meta charset=\"UTF-8\"/>
|
|
|
|
|
+ <style>
|
|
|
|
|
+ @page { size: A4; margin: 0; }
|
|
|
|
|
+ body { margin: 0; font-family: MiSans, ReportFont, sans-serif; font-size: 14px; }
|
|
|
|
|
+ .report-container { padding: 32px; }
|
|
|
|
|
+ .report-header { display: table; width: 100%; table-layout: fixed; border-bottom: 3px solid #111; margin-bottom: 28px; padding-bottom: 10px; }
|
|
|
|
|
+ .header-logo, .header-main, .header-generated-at { display: table-cell; vertical-align: top; }
|
|
|
|
|
+ .header-logo { width: 180px; }
|
|
|
|
|
+ .header-main { text-align: center; color: #68768a; }
|
|
|
|
|
+ .header-report-type, .header-student-name { font-size: 13px; line-height: 1.5; }
|
|
|
|
|
+ .header-generated-at { width: 260px; color: #68768a; font-size: 12px; line-height: 1.5; text-align: right; white-space: nowrap; }
|
|
|
|
|
+ .page { height: 1122px; page-break-after: always; }
|
|
|
|
|
+ </style>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <div class=\"report-container\">
|
|
|
|
|
+ <header class=\"report-header\">
|
|
|
|
|
+ <div class=\"header-logo\"></div>
|
|
|
|
|
+ <div class=\"header-main\">
|
|
|
|
|
+ <div class=\"header-report-type\">学习成果报告</div>
|
|
|
|
|
+ <div class=\"header-student-name\">分页测试学生</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class=\"header-generated-at\">2026-05-13 10:08:54</div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <section class=\"page\">第一页正文内容</section>
|
|
|
|
|
+ <section class=\"page\">第二页正文内容</section>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class=\"report-footer-business\">
|
|
|
|
|
+ <div class=\"report-footer-business-line\">张三 Tel:138987484</div>
|
|
|
|
|
+ <div class=\"report-footer-business-line\">浙江省杭州市</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </body>
|
|
|
|
|
+ </html>
|
|
|
|
|
+ """);
|
|
|
|
|
+
|
|
|
|
|
+ assertPdfHeader(pdfBytes);
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ assertThat(document.getNumberOfPages()).isGreaterThanOrEqualTo(2);
|
|
|
|
|
+ List<TextLine> pageTwoLines = textLines(pdfBytes, 2);
|
|
|
|
|
+ TextLine businessLine = findLineContaining(pageTwoLines, "张三Tel:138987484");
|
|
|
|
|
+ TextLine addressLine = findLineContaining(pageTwoLines, "浙江省杭州市");
|
|
|
|
|
+ TextLine pageNumberLine = findLineContaining(pageTwoLines, "2/");
|
|
|
|
|
+ float pageWidth = document.getPage(1).getMediaBox().getWidth();
|
|
|
|
|
+
|
|
|
|
|
+ assertThat(businessLine.x).isLessThan(pageWidth / 2f);
|
|
|
|
|
+ assertThat(addressLine.x).isLessThan(pageWidth / 2f);
|
|
|
|
|
+ assertThat(pageNumberLine.x).isGreaterThan(pageWidth / 2f);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private void assertPdfHeader(byte[] pdfBytes) {
|
|
private void assertPdfHeader(byte[] pdfBytes) {
|
|
|
assertThat(pdfBytes).isNotEmpty();
|
|
assertThat(pdfBytes).isNotEmpty();
|
|
|
assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
assertThat(new String(pdfBytes, 0, 4, StandardCharsets.ISO_8859_1)).isEqualTo("%PDF");
|
|
@@ -313,6 +511,74 @@ class PlaywrightExamSprintReportPdfGeneratorTest {
|
|
|
return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
|
|
return Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", "");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private String pageText(PDDocument document, int pageNumber) throws Exception {
|
|
|
|
|
+ PDFTextStripper stripper = new PDFTextStripper();
|
|
|
|
|
+ stripper.setStartPage(pageNumber);
|
|
|
|
|
+ stripper.setEndPage(pageNumber);
|
|
|
|
|
+ return stripper.getText(document);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int countOccurrences(String value, String needle) {
|
|
|
|
|
+ int count = 0;
|
|
|
|
|
+ int index = 0;
|
|
|
|
|
+ while ((index = value.indexOf(needle, index)) >= 0) {
|
|
|
|
|
+ count++;
|
|
|
|
|
+ index += needle.length();
|
|
|
|
|
+ }
|
|
|
|
|
+ return count;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<TextLine> textLines(byte[] pdfBytes, int pageNumber) throws Exception {
|
|
|
|
|
+ try (PDDocument document = PDDocument.load(pdfBytes)) {
|
|
|
|
|
+ TextLineStripper stripper = new TextLineStripper();
|
|
|
|
|
+ stripper.setSortByPosition(true);
|
|
|
|
|
+ stripper.setStartPage(pageNumber);
|
|
|
|
|
+ stripper.setEndPage(pageNumber);
|
|
|
|
|
+ stripper.getText(document);
|
|
|
|
|
+ return stripper.lines();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private TextLine findLineContaining(List<TextLine> lines, String text) {
|
|
|
|
|
+ return lines.stream()
|
|
|
|
|
+ .filter(line -> line.text.contains(text))
|
|
|
|
|
+ .findFirst()
|
|
|
|
|
+ .orElseThrow(() -> new AssertionError("Missing PDF text line: " + text + " in " + lines));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static final class TextLineStripper extends PDFTextStripper {
|
|
|
|
|
+
|
|
|
|
|
+ private final List<TextLine> lines = new ArrayList<>();
|
|
|
|
|
+
|
|
|
|
|
+ private TextLineStripper() throws java.io.IOException {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ protected void writeString(String text, List<TextPosition> textPositions) {
|
|
|
|
|
+ if (text == null || text.isBlank() || textPositions.isEmpty()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ float minY = Float.MAX_VALUE;
|
|
|
|
|
+ float maxY = Float.MIN_VALUE;
|
|
|
|
|
+ float minX = Float.MAX_VALUE;
|
|
|
|
|
+ for (TextPosition position : textPositions) {
|
|
|
|
|
+ minX = Math.min(minX, position.getXDirAdj());
|
|
|
|
|
+ minY = Math.min(minY, position.getYDirAdj());
|
|
|
|
|
+ maxY = Math.max(maxY, position.getYDirAdj() + position.getHeightDir());
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.add(new TextLine(Normalizer.normalize(text, Normalizer.Form.NFKC).replaceAll("\\s+", ""), minX, minY, maxY));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private List<TextLine> lines() {
|
|
|
|
|
+ return lines.stream()
|
|
|
|
|
+ .sorted(Comparator.comparing(TextLine::y))
|
|
|
|
|
+ .toList();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private record TextLine(String text, float x, float y, float bottomY) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private AchievementReportContent sampleAchievementContent() {
|
|
private AchievementReportContent sampleAchievementContent() {
|
|
|
return new AchievementReportContent(
|
|
return new AchievementReportContent(
|
|
|
"吴泓妤",
|
|
"吴泓妤",
|