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) + "" + 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;
}
/**
* 子节点按声明顺序展开。
*
* 有子组件时忽略 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