8 小时以前 9bad721754fe8bbe2e5f459d0706e0fefac569f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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());
    }
 
}