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()); } }