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; /** * 报告渲染引擎。 *

* 输入「模板 Schema + 报告上下文」,输出可直接打印的 HTML 文档。 * 渲染顺序固定为「先判定、后渲染」:PASS/FAIL 由 {@link ReportEvaluator} 算好写进上下文, * 模板里只做取值,因此同一份数据在任何模板下判定一致,模板也无法左右判定结果。 *

* 这里不认识具体组件类型,只吃 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 VOID_TAGS = Set.of( "br", "col", "hr", "img", "input", "link", "meta", "source", "track", "wbr"); /** * GrapesJS 组件类型 → HTML 标签。 *

* 项目数据只在标签与类型默认值不同时才写 tagName(例如 th、span、h2), * 表格结构(table/thead/tbody/row/cell)在数据里都只有 type, * 所以漏掉这张表就会把整张表格渲染成一堆 div。未收录的类型回退到 div, * 与 GrapesJS 基础组件的默认标签一致。 */ private static final Map 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 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 grapes, PageSetting page, ReportContext context, List 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) + ""; } /** 标签优先取数据里的 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; } /** * 子节点按声明顺序展开。 *

* 有子组件时忽略 content——与 GrapesJS 一致:往一个带文本的组件里再拖入组件后, * 文本就让位给子组件,这里必须同规则,否则渲染产物会和设计器看到的不一样。 */ private static String renderChildren(RenderNode node, RenderState state) { List 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); } /** * 重复容器:按数组路径展开行模板,其余子节点在原位置渲染一次。 *

* 行模板上的 {@code data-qc-repeat-row} 声明了行内的循环变量名(如 item), * 行内容用 {@code {{item.xxx}}} 取值,与设计期在组件里约定的绑定路径一致。 */ private static String renderRepeat(RenderNode node, String path, RenderState state) { List 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 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 + ""; } /** 非数组按单行处理,空值得到空表;比抛错更贴合「数据没填全」的报告场景 */ private static List toRowList(Object value) { if (value instanceof List list) { return new ArrayList<>(list); } if (value == null) { return List.of(); } return List.of(value); } /* ------------------------------ 属性与样式 ------------------------------ */ /** * 渲染节点属性。 *

* 设计器标记(data-quality-type / data-qc-*)不进入产物; * 属性值里的 {@code {{path}}} 一并解析,图片地址、链接等也能绑定数据。 *

* 比前端多一道闸:事件属性与可执行协议不进产物。产物是给人打印的文档, * on* 之类的属性没有正当用途,放行等于给模板开了个执行口子。 */ private static String renderAttributes(RenderNode node, RenderState state) { StringBuilder builder = new StringBuilder(); for (Map.Entry 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 style) { List parts = new ArrayList<>(style.size()); for (Map.Entry 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 blocks = new ArrayList<>(); Map> 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 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 textMap(Object raw) { Map 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", "", "", "", "", "" + escapeHtml(title) + "", "", "", "" + body + "", ""); } /** 任何来自用户(设计器属性面板)的文本都必须先转义再拼进标签,否则渲染期就是注入点 */ private static String escapeHtml(String value) { return value.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'"); } /** 一次渲染的行走状态:作用域 + 当前位置 + 问题清单,行内复制时共享后两者 */ private static final class RenderState { private final Map scope; /** 当前渲染位置的描述,用于把问题定位到具体行 */ private final String where; private final List errors; /** 已上报的缺口,同一处只提示一次 */ private final Set reported; private RenderState(Map scope, String where, List errors, Set reported) { this.scope = scope; this.where = where; this.errors = errors; this.reported = reported; } private RenderState withScope(Map scope, String where) { return new RenderState(scope, where, errors, reported); } } }