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;
/**
* 打印参数换算。
*
* 这里守的是一条很容易悄悄坏掉的约束:**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 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 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 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 size(PdfPrintOptions options) {
return List.of(options.widthCss(), options.heightCss());
}
private static List margins(PdfPrintOptions options) {
return List.of(options.marginTopCss(), options.marginRightCss(),
options.marginBottomCss(), options.marginLeftCss());
}
}