5 小时以前 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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
package cn.iocoder.yudao.module.qcreport.engine.render;
 
import cn.iocoder.yudao.module.qcreport.engine.Bindings;
import cn.iocoder.yudao.module.qcreport.engine.PageSetting;
import cn.iocoder.yudao.module.qcreport.engine.PageSizes;
import cn.iocoder.yudao.module.qcreport.engine.Paths;
import cn.iocoder.yudao.module.qcreport.engine.ResolvedPage;
import cn.iocoder.yudao.module.qcreport.engine.context.ReportContext;
import cn.iocoder.yudao.module.qcreport.engine.report.EvaluationOutcome;
import cn.iocoder.yudao.module.qcreport.engine.report.ReportEvaluator;
import cn.iocoder.yudao.module.qcreport.engine.rule.QualityRuleDefinition;
 
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
 
/**
 * 报告渲染引擎。
 * <p>
 * 输入「模板 Schema + 报告上下文」,输出可直接打印的 HTML 文档。
 * 渲染顺序固定为「先判定、后渲染」:PASS/FAIL 由 {@link ReportEvaluator} 算好写进上下文,
 * 模板里只做取值,因此同一份数据在任何模板下判定一致,模板也无法左右判定结果。
 * <p>
 * 这里不认识具体组件类型,只吃 GrapesJS 导出的项目数据(组件树 + 样式),
 * 只认三套协议:{@code data-qc-repeat} 重复行、{@code {{路径}}} 绑定、{@code schema.rules} 判定规则。
 * 与前端 {@code engine/render.ts} 一一对应,两边产出的 HTML 必须一致。
 */
public final class HtmlRenderer {
 
    /** 设计器标记,不进入产物 */
    private static final String QUALITY_TYPE_ATTR = "data-quality-type";
    private static final String QUALITY_PROP_PREFIX = "data-qc-";
    private static final String QUALITY_REPEAT_ATTR = "data-qc-repeat";
    private static final String QUALITY_REPEAT_ROW_ATTR = "data-qc-repeat-row";
    /** GrapesJS 的组件 id 换成 data 属性:重复行里 id 会重名,data 属性不会 */
    private static final String QUALITY_ID_ATTR = "data-qc-id";
 
    /** 自闭合标签,不能生成结束标签 */
    private static final Set<String> VOID_TAGS = Set.of(
            "br", "col", "hr", "img", "input", "link", "meta", "source", "track", "wbr");
 
    /**
     * GrapesJS 组件类型 → HTML 标签。
     * <p>
     * 项目数据只在标签与类型默认值不同时才写 tagName(例如 th、span、h2),
     * 表格结构(table/thead/tbody/row/cell)在数据里都只有 type,
     * 所以漏掉这张表就会把整张表格渲染成一堆 div。未收录的类型回退到 div,
     * 与 GrapesJS 基础组件的默认标签一致。
     */
    private static final Map<String, String> TYPE_TAGS = Map.of(
            "cell", "td",
            "row", "tr",
            "table", "table",
            "tbody", "tbody",
            "tfoot", "tfoot",
            "thead", "thead",
            "text", "div",
            "wrapper", "div");
 
    /** 渲染产物的基础排版。浏览器默认的表格与页边距会让报告走样,这里收敛成打印友好的基线 */
    private static final String BASE_CSS = """
            * { box-sizing: border-box; }
            body { margin: 0; color: #000; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 12px; line-height: 1.5; }
            table { border-collapse: collapse; width: 100%; }
            img { max-width: 100%; }
            tr, td, th { page-break-inside: avoid; }""";
 
    /** 承载外部地址的属性,值里出现可执行协议一律拦掉 */
    private static final Set<String> URL_ATTRIBUTES = Set.of(
            "href", "src", "xlink:href", "action", "formaction", "background", "poster");
 
    /** 可执行 / 可注入的 URL 协议 */
    private static final Pattern DANGEROUS_SCHEME =
            Pattern.compile("^\\s*(?:javascript|vbscript|data)\\s*:", Pattern.CASE_INSENSITIVE);
 
    /** data: 里只有图片是正当用途,其余(text/html 等)会变成注入面 */
    private static final Pattern DATA_IMAGE = Pattern.compile("^\\s*data:image/", Pattern.CASE_INSENSITIVE);
 
    /** GrapesJS 用 #组件id 选择器,这里改写成 data 属性选择器 */
    private static final Pattern ID_SELECTOR = Pattern.compile("#([\\w-]+)");
 
    private HtmlRenderer() {
    }
 
    /**
     * 渲染报告:判定 → 生成正文 → 拼样式 → 组装文档。
     *
     * @param grapes  GrapesJS 项目数据(模板 Schema 的 grapes 字段)
     * @param page    纸张与页边距配置
     * @param context 报告上下文
     * @param rules   判定规则,可为空
     */
    public static RenderOutcome render(Map<String, Object> grapes, PageSetting page,
                                       ReportContext context, List<QualityRuleDefinition> rules) {
        EvaluationOutcome evaluated = ReportEvaluator.evaluate(context, rules);
        RenderState state = new RenderState(evaluated.context().toScope(), "模板",
                new ArrayList<>(evaluated.errors()), new LinkedHashSet<>());
 
        RenderNode root = RenderNode.of(Paths.readPath(grapes, "pages[0].frames[0].component"));
        String body = root == null ? "" : renderNode(root, state);
        String css = buildCss(grapes == null ? null : grapes.get("styles"));
        String html = buildDocument(page, body, css, evaluated.context());
        return new RenderOutcome(html, body, evaluated.context(), state.errors);
    }
 
    /* ------------------------------ 节点展开 ------------------------------ */
 
    /** 渲染单个节点:文本节点与重复容器单独处理,其余按「标签 + 属性 + 子节点」展开 */
    private static String renderNode(RenderNode node, RenderState state) {
        // 文本节点只贡献文本,自身不产生标签
        if ("textnode".equals(node.type())) {
            return renderText(node.content() == null ? "" : node.content(), state);
        }
 
        String repeatPath = node.attribute(QUALITY_REPEAT_ATTR);
        if (repeatPath != null && !repeatPath.isEmpty()) {
            return renderRepeat(node, repeatPath, state);
        }
 
        String tag = resolveTag(node);
        String attributes = renderAttributes(node, state);
        String style = renderStyle(node);
        if (VOID_TAGS.contains(tag)) {
            return "<" + tag + attributes + style + ">";
        }
        return "<" + tag + attributes + style + ">" + renderChildren(node, state) + "</" + tag + ">";
    }
 
    /** 标签优先取数据里的 tagName,其次按组件类型推断 */
    private static String resolveTag(RenderNode node) {
        String tagName = node.tagName();
        if (tagName != null && !tagName.isEmpty()) {
            return tagName;
        }
        String type = node.type();
        String byType = type == null ? null : TYPE_TAGS.get(type);
        return byType == null ? "div" : byType;
    }
 
    /**
     * 子节点按声明顺序展开。
     * <p>
     * 有子组件时忽略 content——与 GrapesJS 一致:往一个带文本的组件里再拖入组件后,
     * 文本就让位给子组件,这里必须同规则,否则渲染产物会和设计器看到的不一样。
     */
    private static String renderChildren(RenderNode node, RenderState state) {
        List<RenderNode> children = node.children();
        if (!children.isEmpty()) {
            StringBuilder builder = new StringBuilder();
            for (RenderNode child : children) {
                builder.append(renderNode(child, state));
            }
            return builder.toString();
        }
        String content = node.content();
        return content == null ? "" : renderText(content, state);
    }
 
    /**
     * 重复容器:按数组路径展开行模板,其余子节点在原位置渲染一次。
     * <p>
     * 行模板上的 {@code data-qc-repeat-row} 声明了行内的循环变量名(如 item),
     * 行内容用 {@code {{item.xxx}}} 取值,与设计期在组件里约定的绑定路径一致。
     */
    private static String renderRepeat(RenderNode node, String path, RenderState state) {
        List<Object> rows = toRowList(Paths.readPath(state.scope, path));
        StringBuilder inner = new StringBuilder();
        for (RenderNode child : node.children()) {
            String rowVar = child.attribute(QUALITY_REPEAT_ROW_ATTR);
            if (rowVar == null || rowVar.isEmpty()) {
                inner.append(renderNode(child, state));
                continue;
            }
            for (int index = 0; index < rows.size(); index++) {
                Map<String, Object> rowScope = new LinkedHashMap<>(state.scope);
                rowScope.put(rowVar, rows.get(index));
                // index 由重复容器注入,行内可写 {{index}},从 1 开始
                rowScope.put("index", index + 1);
                inner.append(renderNode(child, state.withScope(rowScope, "第 " + (index + 1) + " 行")));
            }
        }
 
        String tag = resolveTag(node);
        return "<" + tag + renderAttributes(node, state) + renderStyle(node) + ">" + inner + "</" + tag + ">";
    }
 
    /** 非数组按单行处理,空值得到空表;比抛错更贴合「数据没填全」的报告场景 */
    private static List<Object> toRowList(Object value) {
        if (value instanceof List<?> list) {
            return new ArrayList<>(list);
        }
        if (value == null) {
            return List.of();
        }
        return List.of(value);
    }
 
    /* ------------------------------ 属性与样式 ------------------------------ */
 
    /**
     * 渲染节点属性。
     * <p>
     * 设计器标记(data-quality-type / data-qc-*)不进入产物;
     * 属性值里的 {@code {{path}}} 一并解析,图片地址、链接等也能绑定数据。
     * <p>
     * 比前端多一道闸:事件属性与可执行协议不进产物。产物是给人打印的文档,
     * on* 之类的属性没有正当用途,放行等于给模板开了个执行口子。
     */
    private static String renderAttributes(RenderNode node, RenderState state) {
        StringBuilder builder = new StringBuilder();
        for (Map.Entry<String, String> entry : node.attributes().entrySet()) {
            String name = entry.getKey();
            if (QUALITY_TYPE_ATTR.equals(name) || name.startsWith(QUALITY_PROP_PREFIX)) {
                continue;
            }
            String text = renderText(entry.getValue() == null ? "" : entry.getValue(), state);
            if (text.isEmpty()) {
                continue;
            }
            if (!isSafeAttribute(name, text, state)) {
                continue;
            }
            builder.append(' ').append("id".equals(name) ? QUALITY_ID_ATTR : name)
                    .append("=\"").append(escapeHtml(text)).append('"');
        }
        return builder.toString();
    }
 
    /** 属性名/值安全闸,拦下的写进问题清单,让模板作者看得见而不是悄悄少个属性 */
    private static boolean isSafeAttribute(String name, String value, RenderState state) {
        String lower = name.toLowerCase(Locale.ROOT);
        if (lower.length() > 2 && lower.startsWith("on")) {
            state.errors.add(state.where + "属性「" + name + "」是事件属性,报告产物不输出,已忽略");
            return false;
        }
        if (URL_ATTRIBUTES.contains(lower) && DANGEROUS_SCHEME.matcher(value).find()
                && !DATA_IMAGE.matcher(value).find()) {
            state.errors.add(state.where + "属性「" + name + "」的地址协议不安全,报告产物不输出,已忽略");
            return false;
        }
        return true;
    }
 
    /** 节点自身的行内样式 */
    private static String renderStyle(RenderNode node) {
        String style = cssStyle(node.style());
        return style.isEmpty() ? "" : " style=\"" + escapeHtml(style) + "\"";
    }
 
    /** 取文本里的绑定值,取不到的路径记进问题清单(未解析的 {{}} 不会留在产物里) */
    private static String renderText(String template, RenderState state) {
        return Bindings.resolveText(template, state.scope, path -> {
            if (state.reported.add(state.where + "|" + path)) {
                state.errors.add(state.where + "绑定「{{" + path + "}}」在当前数据中取不到值,已渲染为空");
            }
        });
    }
 
    /** 拼一段内联样式,过滤掉空值 */
    private static String cssStyle(Map<String, String> style) {
        List<String> parts = new ArrayList<>(style.size());
        for (Map.Entry<String, String> entry : style.entrySet()) {
            String value = entry.getValue();
            if (value == null || value.isEmpty()) {
                continue;
            }
            parts.add(entry.getKey() + ":" + value);
        }
        return String.join(";", parts);
    }
 
    /* ------------------------------ 样式与文档 ------------------------------ */
 
    /** 样式规则数组 → CSS 文本;GrapesJS 用 #组件id 选择器,这里改写成 data 属性选择器 */
    private static String buildCss(Object styles) {
        if (styles instanceof String text) {
            return text;
        }
        if (!(styles instanceof List<?> rules)) {
            return "";
        }
 
        List<String> blocks = new ArrayList<>();
        Map<String, List<String>> mediaBlocks = new LinkedHashMap<>();
        for (Object raw : rules) {
            if (!(raw instanceof Map<?, ?> rule)) {
                continue;
            }
            String selector = buildSelector(rule.get("selectors"));
            String body = cssStyle(textMap(rule.get("style")));
            if (selector.isEmpty() || body.isEmpty()) {
                continue;
            }
            String line = selector + " { " + body + " }";
            String mediaText = textOf(rule.get("mediaText")).trim();
            if (!mediaText.isEmpty()) {
                mediaBlocks.computeIfAbsent(mediaText, key -> new ArrayList<>()).add(line);
                continue;
            }
            blocks.add(line);
        }
 
        mediaBlocks.forEach((mediaText, lines) -> {
            String atRule = mediaText.startsWith("@") ? mediaText : "@media " + mediaText;
            blocks.add(atRule + " { " + String.join(" ", lines) + " }");
        });
        return String.join("\n", blocks);
    }
 
    private static String buildSelector(Object rawSelectors) {
        if (!(rawSelectors instanceof List<?> selectors)) {
            return "";
        }
        List<String> parts = new ArrayList<>(selectors.size());
        for (Object raw : selectors) {
            String selector = toSafeSelector(textOf(raw));
            if (!selector.isEmpty()) {
                parts.add(selector);
            }
        }
        return String.join(", ", parts);
    }
 
    /** #组件id → [data-qc-id="组件id"],重复行复制后样式仍然命中 */
    private static String toSafeSelector(String selector) {
        return ID_SELECTOR.matcher(selector).replaceAll("[" + QUALITY_ID_ATTR + "=\"$1\"]");
    }
 
    private static Map<String, String> textMap(Object raw) {
        Map<String, String> result = new LinkedHashMap<>();
        if (raw instanceof Map<?, ?> map) {
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                result.put(String.valueOf(entry.getKey()),
                        entry.getValue() == null ? null : String.valueOf(entry.getValue()));
            }
        }
        return result;
    }
 
    private static String textOf(Object value) {
        return value == null ? "" : String.valueOf(value);
    }
 
    /** 组装最终文档:纸张与页边距交给 @page,正文里不再重复留白,才能保证每页都有边距 */
    private static String buildDocument(PageSetting page, String body, String css, ReportContext context) {
        ResolvedPage resolved = PageSizes.resolve(page);
        String title = context.getReport().getReportName();
        if (title == null || title.isEmpty()) {
            title = context.getReport().getReportNo();
        }
        if (title == null || title.isEmpty()) {
            title = "质检报告";
        }
        return String.join("\n",
                "<!doctype html>",
                "<html lang=\"zh-CN\">",
                "<head>",
                "<meta charset=\"utf-8\" />",
                "<title>" + escapeHtml(title) + "</title>",
                "<style>",
                "@page { size: " + resolved.cssSize() + "; margin: " + resolved.cssMargin() + "; }",
                BASE_CSS,
                css,
                "</style>",
                "</head>",
                "<body>" + body + "</body>",
                "</html>");
    }
 
    /** 任何来自用户(设计器属性面板)的文本都必须先转义再拼进标签,否则渲染期就是注入点 */
    private static String escapeHtml(String value) {
        return value.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
 
    /** 一次渲染的行走状态:作用域 + 当前位置 + 问题清单,行内复制时共享后两者 */
    private static final class RenderState {
 
        private final Map<String, Object> scope;
        /** 当前渲染位置的描述,用于把问题定位到具体行 */
        private final String where;
        private final List<String> errors;
        /** 已上报的缺口,同一处只提示一次 */
        private final Set<String> reported;
 
        private RenderState(Map<String, Object> scope, String where,
                            List<String> errors, Set<String> reported) {
            this.scope = scope;
            this.where = where;
            this.errors = errors;
            this.reported = reported;
        }
 
        private RenderState withScope(Map<String, Object> scope, String where) {
            return new RenderState(scope, where, errors, reported);
        }
 
    }
 
}