package cn.iocoder.yudao.module.qcreport.service.render;
|
|
import cn.iocoder.yudao.module.qcreport.engine.PageMargin;
|
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
|
import cn.iocoder.yudao.module.qcreport.engine.PageSizes;
|
import cn.iocoder.yudao.module.qcreport.engine.ResolvedPage;
|
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.Test;
|
|
import java.util.Arrays;
|
import java.util.List;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
/**
|
* 打印参数换算。
|
* <p>
|
* 这里守的是一条很容易悄悄坏掉的约束:**PDF 的纸面几何必须与 HTML 里那行 @page 逐字相同**。
|
* 两者一旦漂移,表现不是报错,而是「页面预览正常、打出来裁掉一截」——
|
* 这种偏差肉眼很难在几十个项目里发现,所以用同源断言把它钉死在单测里。
|
*/
|
class PdfPrintOptionsTest {
|
|
@Test
|
@DisplayName("常见纸张与方向换算成 mm")
|
void sizes() {
|
assertEquals(List.of("210mm", "297mm"), size(PdfPrintOptions.of(a4(), true)));
|
assertEquals(List.of("420mm", "297mm"), size(PdfPrintOptions.of(a3Landscape(), true)));
|
assertEquals(List.of("148mm", "210mm"), size(PdfPrintOptions.of(a5(), true)));
|
}
|
|
@Test
|
@DisplayName("Letter 的 215.9mm 不被收成整数")
|
void letterKeepsDecimal() {
|
PageSetting page = new PageSetting("Letter", "portrait", PageMargin.none());
|
assertEquals(List.of("215.9mm", "279.4mm"), size(PdfPrintOptions.of(page, true)));
|
}
|
|
@Test
|
@DisplayName("未配纸张时回退到默认 A4 纵向")
|
void fallbackToDefault() {
|
assertEquals(List.of("210mm", "297mm"),
|
size(PdfPrintOptions.of(PageSetting.defaultSetting(), true)));
|
}
|
|
@Test
|
@DisplayName("未配页边距时四周都是默认 10mm,配了就原样透传")
|
void margins() {
|
assertEquals(List.of("10mm", "10mm", "10mm", "10mm"), margins(PdfPrintOptions.of(a4(), true)));
|
|
PageSetting custom = new PageSetting("A4", "portrait", new PageMargin(5.5, 20.0, 12.0, 20.5));
|
assertEquals(List.of("5.5mm", "20mm", "12mm", "20.5mm"),
|
margins(PdfPrintOptions.of(custom, true)));
|
|
// 只配了一边,其余仍落到默认值:与前端 margin?.top ?? DEFAULT 的语义一致
|
PageSetting partial = new PageSetting("A4", "portrait", new PageMargin(25.0, null, null, null));
|
assertEquals(List.of("25mm", "10mm", "10mm", "10mm"), margins(PdfPrintOptions.of(partial, true)));
|
}
|
|
@Test
|
@DisplayName("纸面几何与 @page 同源:宽度+高度与四边距必须逐字等于 ResolvedPage 的输出")
|
void geometryMatchesCssPage() {
|
List<PageSetting> cases = List.of(
|
PageSetting.defaultSetting(), a4(), a3Landscape(), a5(),
|
new PageSetting("Letter", "landscape", new PageMargin(3.0, 4.5, 5.0, 6.0)),
|
new PageSetting("A4", "portrait", new PageMargin(0.0, 0.0, 0.0, 0.0)));
|
for (PageSetting page : cases) {
|
PdfPrintOptions options = PdfPrintOptions.of(page, true);
|
ResolvedPage resolved = PageSizes.resolve(page);
|
assertEquals(resolved.cssSize(), options.widthCss() + " " + options.heightCss(),
|
"纸面宽高与 @page 的 size 不一致,page=" + page);
|
assertEquals(resolved.cssMargin(), options.marginTopCss() + " " + options.marginRightCss()
|
+ " " + options.marginBottomCss() + " " + options.marginLeftCss(),
|
"页边距与 @page 的 margin 不一致,page=" + page);
|
}
|
}
|
|
@Test
|
@DisplayName("页脚模板:开启时带页码占位符,关闭时为空串")
|
void footerTemplate() {
|
String withPageNumber = PdfPrintOptions.of(a4(), true).footerTemplate();
|
assertTrue(withPageNumber.contains("pageNumber"), "页脚缺少当前页码占位符");
|
assertTrue(withPageNumber.contains("totalPages"), "页脚缺少总页数占位符");
|
assertTrue(withPageNumber.contains("font-size"), "页脚没带字号,浏览器会按 0 号字处理,等于看不见");
|
|
assertEquals("", PdfPrintOptions.of(a4(), false).footerTemplate());
|
}
|
|
@Test
|
@DisplayName("页眉模板非空:留空会让 Chromium 补上它自带的「打印日期 + 文档标题」")
|
void headerTemplateMustNotBeEmpty() {
|
String header = PdfPrintOptions.HEADER_TEMPLATE;
|
assertTrue(header != null && !header.isBlank(),
|
"页眉模板为空/null 时 Chromium 会打印自带的日期与标题,报告上会多出没人要的时间戳");
|
assertFalse(header.contains("class=\"date\""), "页眉里不该出现 Chromium 的日期占位类");
|
assertFalse(header.contains("class=\"title\""), "页眉里不该出现 Chromium 的标题占位类");
|
}
|
|
@Test
|
@DisplayName("打印加固样式:跨页重复表头 + 打印背景色")
|
void printCss() {
|
String printCss = PdfPrintOptions.of(a4(), true).printCss();
|
assertTrue(printCss.contains("table-header-group"), "缺少跨页重复表头,长表格第二页会没有表头");
|
assertTrue(printCss.contains("break-inside"), "缺少禁止行内断页,一行会从中间劈开");
|
assertTrue(printCss.contains("print-color-adjust"), "缺少打印背景色,判定结果的底色打不出来");
|
assertFalse(printCss.contains("@page"), "打印样式里不该再写 @page:纸面几何的唯一权威是 HTML 里那一行");
|
}
|
|
@Test
|
@DisplayName("长表允许跨页:break-inside:avoid 只加在行/单元格/图片上,绝不能加在整张表上")
|
void longTableMayBreakAcrossPages() {
|
List<String> guarded = selectorsDeclaring(PdfPrintOptions.of(a4(), true).printCss(), "break-inside");
|
assertFalse(guarded.contains("table"),
|
"整张表禁止断页会把放不下的长表整体推到下一页,第 1 页只剩页眉与大片空白;"
|
+ "跨页要靠 thead 每页重复表头 + 行内不断页,不能靠把整张表搬走");
|
assertTrue(guarded.contains("tr"), "行内禁止断页不能一并去掉,否则一行会从中间劈开");
|
assertTrue(guarded.contains("thead") || PdfPrintOptions.of(a4(), true).printCss()
|
.contains("table-header-group"),
|
"表头必须在每页重复,否则第 2 页起的表格没有列名");
|
}
|
|
/** 取出声明了指定属性的那条规则的「选择器列表」;没有这样的规则时返回空列表。 */
|
private static List<String> selectorsDeclaring(String css, String property) {
|
for (String rule : css.split("}")) {
|
int brace = rule.indexOf('{');
|
if (brace < 0 || !rule.substring(brace + 1).contains(property + ":")) {
|
continue;
|
}
|
return Arrays.stream(rule.substring(0, brace).split(","))
|
.map(String::trim).filter(selector -> !selector.isEmpty()).toList();
|
}
|
return List.of();
|
}
|
|
private static PageSetting a4() {
|
return new PageSetting("A4", "portrait", PageMargin.none());
|
}
|
|
private static PageSetting a3Landscape() {
|
return new PageSetting("A3", "landscape", PageMargin.none());
|
}
|
|
private static PageSetting a5() {
|
return new PageSetting("A5", "portrait", PageMargin.none());
|
}
|
|
private static List<String> size(PdfPrintOptions options) {
|
return List.of(options.widthCss(), options.heightCss());
|
}
|
|
private static List<String> margins(PdfPrintOptions options) {
|
return List.of(options.marginTopCss(), options.marginRightCss(),
|
options.marginBottomCss(), options.marginLeftCss());
|
}
|
|
}
|