
Criminal Case Visualization
- 1 installs
- 1 repo stars
- Updated August 4, 2026
- gcheng-001/legal-skills
Generates a full set of criminal-case charts (co-offending, sentencing, evidence matrix, fund flow, procedure) from the case-file review output.
About
A visualization skill that renders criminal-case charts tailored to co-offending, sentencing prediction, evidence matrices, and fund flows, inheriting a litigation-chart methodology. A Chinese criminal-defense lawyer uses it to produce courtroom-ready diagrams from review records.
- Criminal-specific charts built on litigation-chart plus html2png rendering
- Sourced from criminal-case-review output; must not replace original evidence images
Criminal Case Visualization by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,361 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gcheng-001/legal-skills --skill criminal-case-visualizationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | August 4, 2026 |
| Repository | gcheng-001/legal-skills ↗ |
What it does
Generates a full set of criminal-case charts (co-offending, sentencing, evidence matrix, fund flow, procedure) from the case-file review output.
Files
刑事案件可视化图表生成器
概述
基于诉讼可视化方法论,专门为刑事案件生成全套可视化图表。继承自 litigation-chart skill,但针对刑事案件的特殊需求(共同犯罪、量刑预测、证据矩阵、资金流向、程序流程等)做了定制化。
父级:criminal-case-os(刑事案件OS总控) 依赖:litigation-chart(通用可视化方法论)、html2png(渲染引擎) 数据源:criminal-case-review(刑事阅卷笔录)的输出产物
不得在通用 skill 中固化真实案件姓名、案号、当事人身份信息或联系方式;所有示例文件名必须使用占位符。
---
触发词
刑事可视化、刑事图表、刑事案件图表刑事辩护图表、辩护可视化criminal-case-visualization
TRIGGER when: 用户在刑事案件上下文中说"可视化""画图表""画图""图表"且与刑事案件相关
---
🎯 输出契约(最高纪律:任何案件都产出同一效果)
本 skill 的目标是确定性——换任何案件、任何当事人,输出风格、结构、清晰度、目录形态必须完全一致。下面是不可商量的契约,每次执行逐条照做,不得即兴发挥、不得改配色、不得换布局方式。
不可变参数表(照抄,禁止临时改动)
| 项目 | 固定值 | 理由/出处 |
|---|---|---|
| 风格 | Claude 橙白 · 方案A 极简克制 | 用户 2026-05-30 确认;受援人陶土橙是唯一焦点色 |
| 页面底色 | 暖白 #FAFAF9(禁止冷灰 `#F9F9F9`) | 规则6 |
| 受援人/己方 | 边框 #D97757/底 #FBEEE8 | 规则6 |
| 主犯 | 深暖灰边 #78716C/白底(禁止用红,退后不抢焦点) | 规则6 |
| 同案犯 | 中暖灰 #A8A29E/白底 | 规则6 |
| 无关方/其他被害人 | 浅暖灰 #D6D3D1/暖白底 | 规则6 |
| 功能色(仅证据/量刑) | 绿#10B981 黄#F59E0B 红#DC2626 | 规则6 |
| HTML 渲染宽度 | --width=1467 | 设计规格 |
| Mermaid 渲染清晰度 | `mmdc -s 4`(禁止 -s 2,会糊) | 规则7+模板铁律 |
| Mermaid subgraph 底 | init 里 clusterBkg:#FAFAF9(禁止默认鹅黄) | 模板铁律 |
| 验收 | 双门禁全 PASS + 肉眼复核,缺一不可 | 门禁段 |
标准作业流程(SOP,按序执行,不跳步)
1. 取数:从 criminal-case-review 阅卷笔录提取结构化数据(当事人/犯罪事实/证据/程序/金额)。法律事实只许原样引用,禁止编造任何姓名、金额、日期、法条、供述。 2. 分路:按"渲染路线决策"表给 8 张图分派路线——文字密集图(2/3/4/5/6)走 templates/light-card-table.html;节点连线图(1/7/8)走 templates/mermaid-graphs.md。 3. 出图:
- HTML 图:复制
light-card-table.html,替换{{占位符}},删除用不到的 section。配色变量已是方案A,不要改 `:root`。 - Mermaid 图:复制
mermaid-graphs.md对应模板,填数据,存chart1.mmd/chart7.mmd/chart8.mmd,用mmdc -i x.mmd -o x.png -b "#FFFFFF" -s 4渲染。 - HTML 通过相对路径
<img src="chart1.png">引用 Mermaid PNG。
4. 验收:两个门禁脚本都必须 PASS,再肉眼复核 PNG(文字清晰、无黑块、无重叠、无压字)。任一不过则修到过,不得交付。 5. 落盘(固定目录形态,保证每个案件长一样):
- 输出目录:
<案件目录>/可视化-YYYYMMDD/ - 主文件:
<案件名>-全套可视化图表.html - 配图:
chart1.png chart7.png chart8.png(与 HTML 同目录,否则图裂) - 预览:
<案件名>-全套可视化图表-预览.png - 只保留这一套终版;中间版、旧版生成完即删,不与终版并存(避免混淆)。
- 文件名禁用冒号等非法字符,日期用
YYYYMMDD短横线格式。
偏离本契约任何一条 = 输出不合格。若某案件数据缺失导致某张图无法生成,宁可省略该图并说明,也不得改风格凑数或编造数据。
---
⛔ 第一原则:渲染路线决策(动手前必读)
历史教训:本 skill 曾输出黑底卡片、时间轴文字重叠截断、连线压字。根因不是坐标算错,而是该用 HTML/Mermaid 自动布局的图被手写成了 SVG。 因此每张图开工前,先按下表定路线,默认不手写 SVG。
| 图表 | 内容性质 | 强制渲染路线 | 模板 |
|---|---|---|---|
| 时间轴、量刑预测、共同犯罪对比、受援人参与、证据矩阵 | 文字密集 | HTML 表格/卡片 | templates/light-card-table.html |
| 人物关系、资金流向、程序流程 | 节点连线 | Mermaid(mmdc 自动布局) | templates/mermaid-graphs.md |
| 需庭审反复精修的连线图 | — | 先出 Mermaid,再按需人工导入 draw.io 微调(draw.io 本机未装,需要时再装;它只精修,不自动布局) | — |
| 节点≤12、每框≤4行、文字短的极简图 | 极少 | 才允许手写 SVG | 见附录 |
draw.io 定位:最终庭审展示的可选人工精修出口,不参与自动生成。当前以 HTML 表格 + Mermaid 为自动出图主力。
黑底卡片的真实根因(务必记住)
图表4/5 整张变黑,不是有人填了黑色,而是:
.node rect { fill:#FFFFFF; filter: url(#shadow); } /* 对所有 svg 的 .node rect 生效 */但 <filter id="shadow"> 只定义在第一个 `<svg>` 里。其余 SVG 的 rect 引用了一个本节点内不存在的滤镜 → headless Chrome 把整个 rect 渲成黑色。
铁律:filter: url(#X)(无论写在 CSS 还是元素上)引用的滤镜,必须在用到它的每一个 `<svg>` 内部各自定义一份;跨 SVG 共享 defs 不生效。能不用 filter 阴影就别用(改用 CSS box-shadow 作用于 HTML 卡片)。此坑由 gate_dangling_filter_refs 自动拦截。
✅ 验收门禁(生成后必须全绿,否则不得交付)
# 门禁1:静态结构检查(黑底/裸rect/悬空filter/时间轴误用SVG/中文小字加粗 → ERROR)
python3 ~/.claude/skills/criminal-case-visualization/scripts/visual_layout_lint.py <file.html>
# 门禁2:渲染后像素采样(任何大面积黑带 → FAIL,覆盖一切未知机理)
python3 ~/.claude/skills/criminal-case-visualization/scripts/render_pixel_check.py <file.html>两个脚本都必须 PASS / 退出码0。出现 ERROR 必须修到通过,不得"批量忽略 WARN"或直接交付。门禁通过后仍需肉眼看一遍 PNG。
---
标准图表套装(刑事案件8张图)
刑事案件可视化标准套装包含以下8张图表,每张图有特定的用途和设计要点:
| 序号 | 图表名称 | 图表类型 | 核心用途 | 目标读者 |
|---|---|---|---|---|
| 1 | 人物关系图谱 | 关系图 | 梳理全部涉案人员、层级关系 | 内部+法官 |
| 2 | 案件事实时间轴 | 时间图 | 时间-事件-证据三栏对照 | 法官 |
| 3 | 受援人/当事人参与分析图 | 数据图 | 逐起分析参与程度、获利、同案犯对比 | 法官+内部 |
| 4 | 量刑预测对比图 | 数据图 | 多情景量刑计算、法律依据 | 内部+当事人 |
| 5 | 共同犯罪地位对比图 | 关系图+数据图 | 多维度对比各被告地位作用 | 法官 |
| 6 | 证据印证关系矩阵图 | 数据图 | 双轴矩阵展示证据印证/矛盾 | 法官 |
| 7 | 资金/财物流向图 | 流程图 | 完整资金闭环、矛盾点标注 | 法官+内部 |
| 8 | 案件程序流程图 | 流程图 | 诉讼阶段、期限审查、程序合法性 | 内部 |
---
核心工作流
输入:阅卷笔录(criminal-case-review 输出)
↓
Step 1:数据提取
├── 当事人信息(姓名、角色、持股、年龄等)
├── 犯罪事实(每起的时间、地点、参与人、金额)
├── 证据清单(供述、陈述、转账记录、文书)
├── 程序节点(受案、立案、抓获、取保、起诉、开庭)
└── 金额明细(分赃、转账、供述金额对比)
↓
Step 2:按"渲染路线决策"表选模板生成
├── 文字密集图 → 套 templates/light-card-table.html(浅色表格/卡片)
├── 节点连线图 → 套 templates/mermaid-graphs.md(mmdc 自动布局)
└── 统一浅色调色板、统一页脚(案号、日期)
↓
Step 3:双门禁验收(铁律)
├── visual_layout_lint.py → 必须 PASS
└── render_pixel_check.py → 必须 PASS(无黑带)
↓
Step 4:肉眼复核 PNG → 输出到案件目录文字密集内容的排版原则(通用)
- 刑事案件事实、证据、程序日期、法条依据属于文字密集内容,优先用 HTML 表格或卡片,不要塞进 SVG 方框。
- 长事实句必须先摘要成短标签,完整说明放到表格备注或图下说明中。
- 一个图只表达一个论点:人物关系、事实时间、证据矛盾、量刑计算不要混在同一张图。
- 需要全套 8 张图时,拆成多个 HTML section;不要为了"一屏展示"压缩字号或减少留白。
手写 SVG 的强制门槛(落到附录A前先过这关)
只有同时满足以下条件,才允许手写 SVG,否则一律走模板:
- 单张图节点 ≤ 12 个;每框正文 ≤ 4 行;每行中文不超过行宽公式上限;
- 图例放入 SVG 内部,不得用
position:absolute浮在图上; - 若用阴影
filter,必须在本 `<svg>` 内定义<filter>(见"黑底根因"); - 生成后必须过双门禁并肉眼复核。
---
附录A:手写 SVG 避坑清单(仅"极简图"路线适用)
⚠️ 仅当你按"渲染路线决策"表确认走手写 SVG(节点≤12、文字短)时,才需要逐条对照本清单。文字密集图和节点连线图请直接用模板,不要落到这里。这些规则是历史血泪教训,但绝大多数已被双门禁脚本自动检查——本清单仅作人工理解参考。
规则1:SVG text 必须显式设置 text-anchor
坑因:SVG <text> 默认 text-anchor="start"(左对齐)。如果 x 坐标设为方框中心,文字会从中心点向右延伸,导致溢出。
规则:
- 方框内居中文字:必须设置
text-anchor="middle" - 标签类文字(靠左排列):使用
text-anchor="start",但 x 从方框左边界+padding 开始 - CSS中
.node text { text-anchor: middle; }不会继承到.flow-node text或.process-box text,必须为每个容器类单独定义
检查方法:
grep -n 'class="flow-node' *.html → 确认 CSS 中有 .flow-node text { text-anchor: middle; }
grep -n 'class="process-box' *.html → 确认 CSS 中有 .process-box text { text-anchor: middle; }规则2:中文文字禁止 font-weight > 400
坑因:中文字体(如 PingFang SC)在 font-weight: 600 时笔画粘连,小字号下完全无法辨识。这与英文字体的"半粗体"效果完全不同。
规则:
- 所有中文字体:
font-weight: 400(normal)或 不设置 - 标题可用
font-weight: 600,但字号必须 >= 16px - SVG 中的
font-weight="600"属性优先级高于 CSS,必须同时在 HTML 中清除 - 特别注意:检查 CSS class 定义(如
.process-title)中是否内置了font-weight: 600
检查方法:
grep -n 'font-weight="600"' *.html → 逐条确认字号 >= 16px 或删除
grep -n 'font-weight: 600' style → 检查 CSS class 定义规则3:每行文字长度必须可控
坑因:SVG <text> 不支持自动换行。一行文字如果超出方框宽度,不会被截断而是直接溢出。
规则:
- 每行中文字符数 ≤ 方框宽度(px) ÷ 字号(px) × 0.9(留10%安全边距)
- 经验值:font-size:11px → 每行约22字;font-size:12px → 每行约20字
- 长文字必须拆分为多行
<text>元素 - 宁可多占垂直空间,也不要让文字溢出
公式:最大字符数 = (方框宽度 - 左右padding×2) ÷ (字号 × 1.1)
规则4:方框尺寸必须留足余量
坑因:方框太小会导致:文字被盖住、行间距不够、底部文字截断。
规则:
- 方框高度 = 标题行(25px) + 内容行数 × 行高(17px) + 上下padding(各15px)
- 方框宽度 = 最长文字行宽 × 1.15(留15%安全边距)
- 文字底部 y 坐标必须比方框底边 y 坐标小至少 5px
- 如果方框内文字较多,宁可增大方框或减小字号
规则5:position:absolute 元素可能盖住 SVG 内容
坑因:.legend 使用 position: absolute; bottom: 20px; right: 20px;,会浮在 SVG 上方,可能盖住右下角的内容。
规则:
- SVG 内最后一个元素的 y 坐标必须比
viewBox高度 - legend高度(约80px)小 - 或者将 legend 改为 SVG 内部元素(推荐)
- 或者给
.chart-body添加padding-bottom: 80px
规则6:颜色方案规范(Claude 橙白 · 方案A,2026-05-30 用户确认为默认)
设计原则:暖白底上浮白框,受援人陶土橙是唯一焦点色,主犯/同案用暖灰退后——不要让主犯的颜色与受援人争夺注意力。色值权威来源:templates/light-card-table.html 的 :root 与 templates/mermaid-graphs.md 的 classDef。
| 角色 | 边框色 | 底色 |
|---|---|---|
| 己方/受援人(焦点) | 陶土橙 #D97757 | 浅橙 #FBEEE8 |
| 主犯/组织者 | 深暖灰 #78716C(不用红,退后) | 白 #FFFFFF |
| 同案犯 | 中暖灰 #A8A29E | 白 #FFFFFF |
| 被害人/第三方/未参与 | 浅暖灰 #D6D3D1/#E7E5E4 | 暖白 #FAFAF9 |
| 页面背景 | — | 暖白 #FAFAF9 |
- 功能色(仅用于证据印证/量刑情景):有利绿
#10B981、存疑黄#F59E0B、矛盾红#DC2626。 - 禁止深色/黑色方框(已确认用户不喜欢)。禁止冷灰底 `#F9F9F9`,统一用暖白
#FAFAF9。 - Mermaid 必须在 init 覆盖
clusterBkg:#FAFAF9,否则 subgraph 会变默认鹅黄脏底。
规则7:必须执行双门禁验收
验收命令见上文"✅ 验收门禁"段。两个脚本(visual_layout_lint.py + render_pixel_check.py)都必须 PASS,再肉眼复核 PNG。对庭审展示图,宁可拆图,也不要缩小字号解决拥挤。
---
设计规格
标准尺寸
| 用途 | viewBox 尺寸 | 说明 |
|---|---|---|
| 关系图/数据图 | 1467 × 900 | 默认尺寸 |
| 时间轴 | HTML table | 不使用SVG,用HTML表格 |
| 矩阵图 | HTML table | 不使用SVG,用HTML表格 |
| 流程图 | 1467 × 650 | 横向流程 |
字号规范
| 用途 | 字号 | font-weight |
|---|---|---|
| 图表标题 | 16-20px | 600-700 |
| 模块标题 | 14-15px | 400 |
| 正文 | 11-12px | 400 |
| 标注/备注 | 10-11px | 400 |
| 标签 | 9-10px | 400 |
行高规范
- 同字号行间距:17px(font-size 11-12px 时)
- 不同区域间距:20-25px
- 方框内padding:15-20px
---
模板结构
生成的HTML文件采用以下结构:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>[案件名] - 全套可视化图表</title>
<style>
/* 通用样式:导航、容器、页脚 */
/* SVG节点样式:.node, .flow-node, .process-box */
/* 表格样式:.timeline-table, .matrix-table */
/* 标签样式:.evidence-tag */
/* 颜色变量 */
/* 打印样式 @media print */
</style>
</head>
<body>
<nav><!-- 图表导航目录 --></nav>
<section id="chart1"><!-- 人物关系图谱 --></section>
<section id="chart2"><!-- 案件事实时间轴 --></section>
<section id="chart3"><!-- 受援人参与分析图 --></section>
<section id="chart4"><!-- 量刑预测对比图 --></section>
<section id="chart5"><!-- 共同犯罪地位对比图 --></section>
<section id="chart6"><!-- 证据印证关系矩阵 --></section>
<section id="chart7"><!-- 资金流向图 --></section>
<section id="chart8"><!-- 案件程序流程图 --></section>
<footer><!-- 文档页脚 --></footer>
</body>
</html>---
修改机制
最小化原则
| 修改类型 | 处理方式 |
|---|---|
| 文字内容调整 | 只改目标文字,不动布局 |
| 文字溢出修复 | 优先缩短文字,其次增大方框 |
| 颜色调整 | 修改CSS变量或style属性 |
| 全局重做 | 从头执行工作流 |
修改后必须重新检查
每次修改后,重新执行"✅ 验收门禁"中的两个脚本,必须全部 PASS。
---
与刑事案件OS的集成
触发时机
1. 阅卷完成后:criminal-case-review 输出阅卷笔录后,可触发生成可视化图表 2. 会见准备前:生成图表辅助会见准备 3. 庭审准备阶段:生成/更新图表用于庭审展示 4. 用户手动触发:说"画图表""可视化"即触发
数据流向
criminal-case-review(阅卷笔录)
↓ 提取结构化数据
criminal-case-visualization(可视化图表,按"输出契约"SOP执行)
↓ 输出固定目录形态
案件目录/可视化-YYYYMMDD/
├── [案件名]-全套可视化图表.html
├── chart1.png / chart7.png / chart8.png
└── [案件名]-全套可视化图表-预览.png调度方式
从 criminal-case-os 总控菜单中选择"可视化图表"选项,或直接说"刑事可视化"触发。
---
参考文档与模板
| 文件 | 用途 |
|---|---|
templates/light-card-table.html | 首选:文字密集图浅色 HTML 表格/卡片成品骨架 |
templates/mermaid-graphs.md | 首选:关系/资金/程序流程 Mermaid 自动布局模板 |
scripts/visual_layout_lint.py | 门禁1:静态结构检查(黑底/裸rect/悬空filter/时间轴误用SVG/小字加粗) |
scripts/render_pixel_check.py | 门禁2:渲染后像素采样,拦截一切黑带 |
references/chart-templates.md | 8张图表逐张设计要点(数据提取规则) |
references/svg-pitfalls.md | 历史 SVG 踩坑记录(仅手写 SVG 时参考) |
litigation-chart/references/style-claude-orange.md | Claude Orange 风格 CSS 规范 |
---
依赖
- litigation-chart:通用可视化方法论和风格规范
- html2png:HTML→PNG 渲染引擎(已配 Playwright Chromium;门禁2 与肉眼验收都依赖它)
- mermaid-cli (mmdc):节点连线图自动布局渲染(本机已装 11.x)
- criminal-case-review:数据来源(阅卷笔录)
- scripts/visual_layout_lint.py + scripts/render_pixel_check.py:双验收门禁,生成后必须全 PASS
刑事案件8张图表详细模板规范
每张图表的具体设计要点、数据提取规则和常见陷阱。
---
图表1:人物关系图谱
类型:关系图(树形+网状混合) 用途:梳理全部涉案人员、层级关系 布局:三层结构 推荐渲染路线:Mermaid / Graphviz / draw.io 自动布局优先;人物超过 12 个时不要手写 SVG 坐标。
三层结构
1. 第一层(犯罪团伙内部):马锦武(组织者)→ 曾程炜(受援人)、马锦泽、徐才学、周浩等 2. 第二层(被害人):7名被害人,标注涉案金额、受援人是否参与 3. 第三层(关键证人):陈和者(资金中转)、另案处理人员、其他证人
数据提取
- 每个人物:姓名、角色(主犯/从犯/同案犯/被害人/证人)、持股比例、年龄、职务
- 关系连线:亲属(堂兄弟/表兄弟)、雇佣、商业伙伴、同事
- 受援人参与标注:参与案件数、具体案件、总获利
设计要点
- 橙色虚线标注受援人参与的犯罪指向线
- 右上角"受援人参与情况"汇总框
- 右下角图例(颜色说明)
---
图表2:案件事实时间轴
类型:HTML表格(非SVG) 用途:时间-事件-证据三栏对照 推荐渲染路线:HTML table。禁止改成 SVG 时间轴,除非每个节点只有短标签。
三栏结构
| 列 | 宽度 | 内容 |
|---|---|---|
| 时间 | 12% | 精确日期,部分到时分 |
| 关键事件 | 38% | 何人、何地、何事 |
| 证据支撑 | 50% | 证据类型标签 + 具体证据 |
行分类及背景色
- 犯罪事实行:浅红
#FEF2F2(受援人未参与)、浅橙#FFF7ED(受援人参与) - 程序节点行:浅蓝
#EFF6FF
证据标签样式
- 被告人供述:绿底
#DCFCE7 - 被害人陈述:蓝底
#DBEAFE - 微信转账:黄底
#FEF3C7 - 法律文书:紫底
#F3E8FF
重点标注
- 矛盾点用黄色 ⚠ 或红色 ❌ 标注
- 受援人参与用橙色 ★ 标注
---
图表3:受援人参与分析图
类型:SVG卡片式布局 用途:逐起分析参与程度、获利、同案犯对比 推荐渲染路线:HTML/CSS grid 卡片优先;只有摘要版才用 SVG。
每起案件3个卡片
1. 案件基本信息:时间、地点、被害人损失、参与人数 2. 受援人参与情况:报名/到场/谈判的 ✓/✗ 标注 3. 获利/分赃情况:供述金额 vs 转账金额 vs 同案犯分赃
综合分析框(底部)
- 左列"参与特征"、右列"从犯认定"
- 方框尺寸建议:640×140,留足余量
- 注意:底部可能被legend盖住,预留80px安全距离
证据矛盾标注
- ✓ 完全一致:绿色
- ⚠ 部分矛盾:黄色
- ❌ 重大矛盾:红色
---
图表4:量刑预测对比图
类型:SVG卡片式布局 用途:多情景量刑计算 推荐渲染路线:HTML/CSS grid 或 table 优先。量刑公式、法条依据和说明文字不得塞进小 SVG 方框。
布局结构
- 第一行:量刑基准计算(左)+ 量刑情节适用(右)
- 第二行:三种情景卡片并排
- 第三行:退赃金额(左)+ 缓刑条件(中)+ 策略建议(右)
- 第四行:免除处罚可能性分析(通栏)
三种情景
1. 检察院量刑建议(当前状态):红色边框 2. 认定从犯(未退赃):橙色边框 3. 全额退赃+谅解(最佳结果):绿色边框
设计陷阱
- 计算公式文字容易溢出,建议缩短:"18月×(1-40%-20%-20%)≈3.6月"
- 退赃金额明细建议垂直排列,每案占两行
---
图表5:共同犯罪地位对比图
类型:层级关系图 + 多维度数据卡片 用途:各被告地位、作用、持股、分赃全面对比 推荐渲染路线:上半部分用 Mermaid / Graphviz / draw.io;下半部分对比卡片用 HTML grid。
布局结构
1. 层级图(上半部分):马锦武(主犯)→ 其他被告(一层连线) 2. 多维度对比卡片(中部):4个并排卡片 3. 从犯认定依据(左下):详细认定依据,含供述引用 4. 量刑影响分析(右下):法律依据、指导意见、适用建议
多维度对比卡片陷阱
- 卡片宽度固定300-340px,文字容易溢出
- 建议缩写人名:"曾程炜、徐才学、伍伟其" → "曾、徐、伍"
- 底部结论行缩短:"⇒ 受援人获利最少,证明作用最小" → "⇒ 获利最少,作用最小"
从犯认定框陷阱
- 框内文字密度高,必须拆行
- 供述引用用引号标注,单独立行
- 字号建议:标题13px、正文11px
---
图表6:证据印证关系矩阵图
类型:HTML表格(非SVG) 用途:双轴矩阵展示证据印证/矛盾 推荐渲染路线:HTML table。禁止用 SVG 画矩阵。
双轴结构
- 横轴(表头):主观明知、客观行为、分赃金额、从犯地位、综合评价
- 纵轴(最左列):全部在案证据
交叉格颜色
- 绿色
#DCFCE7:✓ 印证 - 红色
#FEE2E2:❌ 矛盾 - 黄色
#FEF3C7:⚠ 存疑/部分 - 灰色
#F3F4F6:- 缺失
底部附加
- 综合评价行
- 质证策略建议(有利证据 vs 不利证据)
---
图表7:资金/财物流向图
类型:SVG流程图 用途:完整资金闭环路径 推荐渲染路线:draw.io 或 Graphviz 优先;多起案件拆成多条子图,不要在一张 SVG 内堆满所有资金链。
每起案件一条资金链
- 被害人 → 中转账户/组织者 → 各被告人
- 箭头标注金额和日期
- 受援人节点用橙色方框
- 矛盾点用红/绿底色标注框
右侧分析区域
- 资金流入汇总分析
- 资金流转特征分析
- 退赃退赔建议
设计陷阱
- 方框内文字必须 text-anchor: middle(这是最常见的坑)
- flow-node CSS 必须单独定义 text-anchor
- 矛盾标注框也要确保文字居中
---
图表8:案件程序流程图
类型:SVG横向流程图 用途:诉讼阶段、期限审查、程序合法性 推荐渲染路线:Mermaid / Graphviz / draw.io 优先;程序节点说明文字放到节点下方 HTML 注释区。
流程节点(横向排列)
- 每个节点:160×70 方框,内含标题、日期、备注
- 已完成:绿色边框
#10B981 - 当前阶段:橙色边框
#F97316 - 待完成:灰色虚线边框
#D1D5DB
设计陷阱(最多坑的一张图)
- process-title/date/duration 必须全部 text-anchor: middle
- font-weight 必须 400(不能加粗,中文会看不清)
- CSS class 定义中的 font-weight 和 HTML 属性中的 font-weight 都要清除
- 标题字号13px,日期11px,备注10px,都不加粗
下半部分
- 左:程序期限审查(侦查/审查起诉/审判)
- 右:关键文书汇总(按阶段分列)
- 底部通栏:程序要点提示 + 后续程序预测
SVG 渲染踩坑记录
⚠️ 本文档仅在走"手写 SVG"极简图路线时参考(见 SKILL.md 附录A)。默认路线是 HTML 模板 + Mermaid,不会落到这些坑。
黑底卡片的真实根因(跨 SVG 悬空filter引用)已在 SKILL.md"黑底卡片的真实根因"段单列,并由gate_dangling_filter_refs自动拦截。
日期:2026-05-28(历史记录,案例数据仅作示意)
---
坑1:SVG text 默认不居中(text-anchor 问题)
现象
方框内的文字从中心点向右延伸,导致文字溢出方框右边界。
根因
SVG <text> 元素默认 text-anchor="start"(左对齐)。当 x 坐标设为方框中心点时,文字会从中心开始向右绘制,而不是以中心为对称轴居中。
CSS 继承陷阱
/* 这样定义不会继承到其他容器类 */
.node text { text-anchor: middle; }
/* 以下容器类必须单独定义 */
.flow-node text { text-anchor: middle; } /* 资金流向图方框 */
.process-box text { text-anchor: middle; } /* 程序流程图方框 */.node text 选择器只会匹配 <g class="node"> 内的 <text>,不会匹配 <g class="flow-node"> 内的文字。注意:如果 class="process-box" 写在 <rect> 上,.process-box text 也匹配不到任何文字;流程节点必须使用 <g class="process-box"> 包住 rect + text,或给每个 <text> 直接写 text-anchor="middle"。
解决方案
为每个容器类型单独定义 text-anchor:
.node text,
.flow-node text,
.process-box text {
text-anchor: middle;
font-family: "PingFang SC", -apple-system, sans-serif;
}更稳妥的写法是在所有居中文本元素上直接写属性:
<text x="80" y="30" text-anchor="middle" font-size="12">审查起诉</text>---
坑2:中文字体 font-weight: 600 笔画粘连
现象
方框内的绿色/红色/橙色文字加粗后完全看不清,笔画糊在一起。
根因
中文字体(PingFang SC、Microsoft YaHei等)的字形复杂,笔画密度高。font-weight: 600 在英文字体中是"半粗体",但在中文字体中会导致笔画严重粘连,特别是字号 < 14px 时。
具体案例
<!-- ❌ 错误:小字号加粗,完全看不清 -->
<text font-size="11" font-weight="600" fill="#10B981">✓ 获利较少,无决定权</text>
<!-- ✅ 正确:小字号不加粗 -->
<text font-size="11" fill="#10B981">✓ 获利较少,无决定权</text>规则
| 字号范围 | 允许的 font-weight | 用途 |
|---|---|---|
| ≥ 16px | 400-600 | 图表标题、章节标题 |
| 12-15px | 400 | 模块标题、标签标题 |
| 10-11px | 400 | 正文、标注、备注 |
| ≤ 9px | 400 | 小标注 |
优先级陷阱
SVG 中的 font-weight="600" 属性优先级高于 CSS class。即使 CSS 中改了 .process-title { font-weight: 400; },如果 HTML 中有 font-weight="600" 属性,后者会覆盖 CSS。
必须同时清除两处: 1. CSS class 定义中的 font-weight 2. SVG 元素上的 font-weight="xxx" 属性
---
坑3:SVG text 不支持自动换行
现象
一行文字超出方框宽度,直接溢出到方框外面。
根因
SVG <text> 是单行元素,不像 HTML <div> 会自动换行。超长文字不会被截断。
解决方案
手动拆分为多个 <text> 元素:
<!-- ❌ 错误:一行太长 -->
<text x="20" y="55" font-size="12">
马锦武:犯意提起者("投标敲诈的主意是马锦武提出来的")
</text>
<!-- ✅ 正确:拆成两行 -->
<text x="20" y="45" font-size="11">
马锦武:犯意提起者
</text>
<text x="20" y="62" font-size="11">
("投标敲诈主意是马锦武提出的")
</text>行宽计算公式
最大字符数 = (方框宽度 - 左右padding × 2) ÷ (字号 × 1.1)经验值:
- font-size: 11px,方框宽 300px → 每行约 22 字
- font-size: 12px,方框宽 300px → 每行约 20 字
- font-size: 11px,方框宽 660px → 每行约 52 字
---
坑4:方框尺寸不足导致文字被盖住
现象
方框底部或侧面的文字被截断,看不到完整内容。
根因
方框的 <rect> 高度或宽度不足以容纳所有文字元素。
计算公式
方框最小高度 = 标题行(25px) + 内容行数 × 行高(17px) + 上下padding(各15px)
方框最小宽度 = 最长文字行 × 字号 × 1.1 + 左右padding(各20px)具体案例
综合分析框从 560×120 增大到 640×140:
<!-- ❌ 太小 -->
<rect x="-280" y="-60" width="560" height="120"/>
<!-- ✅ 增大 -->
<rect x="-320" y="-70" width="640" height="140"/>---
坑5:position:absolute legend 盖住 SVG 内容
现象
图表右下角的文字被图例(legend)遮盖。
根因
.legend 使用 position: absolute; bottom: 20px; right: 20px;,浮在 SVG 上方。
解决方案
1. 推荐:将 legend 改为 SVG 内部元素(用 <g> 包裹) 2. 备选:SVG 内最后一个元素的 y 坐标预留 legend 空间(约 80px) 3. 备选:给 .chart-body 添加 padding-bottom: 100px;
---
坑6:多维度对比卡片文字溢出
现象
4个并排的对比卡片(维度1-4)中的文字溢出卡片边界。
根因
卡片宽度(300px)固定,但文字长度不一致。某些行的文字过长。
解决方案
1. 缩短文字("曾程炜、徐才学、伍伟其:3起" → "曾、徐、伍:3起") 2. 减小字体(12px → 11px) 3. 减小 padding(20px → 15px) 4. 底部结论行进一步缩短
---
坑7:程序流程图方框文字不居中重叠
现象
流程图方框内的标题、日期、备注三行文字不居中,向右偏移导致重叠。
根因
.process-title、.process-date、.process-duration 三个 CSS class 都没有定义 text-anchor,默认左对齐。而 HTML 中 x 坐标设为方框中心(80),导致文字从中心向右延伸。
解决方案
在 CSS 中统一添加 text-anchor: middle:
.process-title { text-anchor: middle; }
.process-date { text-anchor: middle; }
.process-duration { text-anchor: middle; }---
坑8:复杂图不能靠手写坐标硬撑
现象
同一张图里节点很多、文字很多、连线很多,第一次生成看似完整,但实际打开 PNG 后会出现文字重叠、方框压住文字、图例盖住右下角内容。
根因
模型手写 SVG 坐标时没有真实执行浏览器排版,也不会可靠测量中文文本宽高。规则写得再细,也只能降低概率,不能替代自动布局和渲染验收。
解决方案
1. 时间轴、矩阵、量刑计算等文字密集图,改用 HTML table/grid。 2. 关系图、流程图、资金流向图,优先用 Mermaid、Graphviz 或 draw.io 自动布局。 3. 需要最终庭审展示和人工微调时,优先输出 draw.io / Excalidraw,而不是不可编辑的手写 SVG。 4. 手写 SVG 只用于节点少、文字短的图,并运行:
python3 ~/.claude/skills/criminal-case-visualization/scripts/visual_layout_lint.py <file.html>---
通用排查命令
# 先跑本 skill 的布局检查
python3 ~/.claude/skills/criminal-case-visualization/scripts/visual_layout_lint.py <file.html>
# 检查所有 font-weight="600" 的地方
grep -n 'font-weight="600"' *.html
# 检查 CSS 中是否有 font-weight: 600
grep -n 'font-weight: 600' *.html
# 检查哪些容器类没有 text-anchor 定义
grep -n 'text-anchor' *.html
# 检查 viewBox 尺寸是否合理
grep -n 'viewBox' *.html#!/usr/bin/env python3
"""Render-then-sample gate for criminal-case visualization output.
Static SVG analysis cannot catch every way a card turns black (missing fill,
dangling filter refs, CSS that never matched, renderer quirks). This is the
final backstop: render the HTML to PNG with the project's html2png, then sample
pixels. Large near-black regions = a black card slipped through = FAIL.
Usage:
python3 render_pixel_check.py <input.html> [--keep-png] [--png=out.png]
Exit codes: 0 PASS, 1 FAIL (black region found), 2 setup/render error.
Rationale (rule 6): the user has confirmed they dislike dark/black boxes. A
chart that renders with a black card face is a hard failure, not a warning.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
HTML2PNG = Path.home() / ".claude/skills/html2png/scripts/html2png.js"
# A pixel is "near-black" below this perceived luminance (0..255).
NEAR_BLACK_LUM = 60
# Fail if a single horizontal band (row group) is >= this fraction near-black,
# OR the whole image exceeds the global fraction. Bands catch one black card
# even when the rest of the long page is white.
BAND_FRACTION = 0.45
GLOBAL_FRACTION = 0.06
BAND_ROWS = 40 # downsampled rows per band
def render(html: Path, png: Path, width: int) -> None:
if not HTML2PNG.exists():
raise FileNotFoundError(f"html2png not found at {HTML2PNG}")
subprocess.run(
["node", str(HTML2PNG), str(html), str(png), f"--width={width}"],
check=True,
capture_output=True,
text=True,
)
def analyze(png: Path) -> tuple[bool, list[str]]:
try:
from PIL import Image
except ImportError:
return False, ["Pillow not installed; cannot pixel-check. pip install Pillow"]
im = Image.open(png).convert("RGB")
w, h = im.size
# Downsample for speed; keep aspect. Width 200 is plenty to spot a card.
small_w = 200
small_h = max(1, int(h * small_w / w))
sm = im.resize((small_w, small_h))
px = list(sm.getdata()) # noqa: deprecation tolerated; small image
def lum(p: tuple[int, int, int]) -> float:
return 0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]
near_black = [lum(p) < NEAR_BLACK_LUM for p in px]
total = len(px)
global_frac = sum(near_black) / total if total else 0.0
msgs: list[str] = []
failed = False
if global_frac >= GLOBAL_FRACTION:
failed = True
msgs.append(
f"global near-black coverage {global_frac*100:.1f}% "
f">= {GLOBAL_FRACTION*100:.0f}% (rule 6: no dark/black cards)"
)
# Banded scan: find the worst horizontal band.
rows = small_h
band_px = small_w * min(BAND_ROWS, rows)
worst = 0.0
worst_y = 0
for top in range(0, rows, BAND_ROWS):
bottom = min(rows, top + BAND_ROWS)
cnt = 0
for y in range(top, bottom):
base = y * small_w
cnt += sum(near_black[base: base + small_w])
frac = cnt / (small_w * (bottom - top))
if frac > worst:
worst, worst_y = frac, top
if worst >= BAND_FRACTION:
failed = True
# Map band back to approximate original-y for the human.
approx_y = int(worst_y / rows * h)
msgs.append(
f"horizontal band near y={approx_y}px is {worst*100:.0f}% near-black "
f">= {BAND_FRACTION*100:.0f}% — looks like a black card. Open the PNG."
)
if not failed:
msgs.append(
f"OK: global near-black {global_frac*100:.1f}%, worst band {worst*100:.0f}%"
)
return (not failed), msgs
def main() -> int:
ap = argparse.ArgumentParser(description="Render HTML and fail on black cards")
ap.add_argument("html", type=Path)
ap.add_argument("--png", type=Path, default=None)
ap.add_argument("--width", type=int, default=1080)
ap.add_argument("--keep-png", action="store_true")
args = ap.parse_args()
if not args.html.exists():
print(f"ERROR: file not found: {args.html}")
return 2
tmp = None
if args.png:
png = args.png
else:
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
png = Path(tmp.name)
tmp.close()
try:
render(args.html, png, args.width)
except subprocess.CalledProcessError as exc:
print(f"ERROR: render failed: {exc.stderr[:400]}")
return 2
except FileNotFoundError as exc:
print(f"ERROR: {exc}")
return 2
ok, msgs = analyze(png)
for m in msgs:
prefix = "PASS" if ok and m.startswith("OK") else ("ERROR" if not ok else "INFO")
print(f"{prefix}: {m}")
if not args.keep_png and not args.png and png.exists():
png.unlink()
elif args.keep_png or args.png:
print(f"INFO: PNG saved at {png}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Lightweight layout lint for criminal-case visualization HTML/SVG files.
The checker is intentionally approximate. It catches common failures before
human review: missing viewBox, small bold Chinese text, text overflow outside a
nearby rect, and obvious text-to-text collisions.
"""
from __future__ import annotations
import argparse
import html
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
SVG_RE = re.compile(r"<svg\b[\s\S]*?</svg>", re.IGNORECASE)
TAG_NS_RE = re.compile(r"^\{.*\}")
# --- Hard-gate config (2026-05-30) ---------------------------------------
# These checks turn long-standing "wall rules" into machine-enforced ERRORs.
# Reason: prior outputs violated every documented rule (black cards, SVG
# timelines, blurred bold Chinese) because nothing failed the build.
# Allow-list of light fills the skill explicitly sanctions (see SKILL rule 6
# + chart-templates background colors). Anything darker that backs a sizeable
# rect/cell is treated as a forbidden dark card.
LIGHT_FILL_ALLOW = {
"#ffffff", "#fff", "#f9f9f9", "#f9fafb", "#f3f4f6", "#f8fafc",
"#fef2f2", "#fee2e2", "#fff7ed", "#ffedd5", "#fef3c7", "#fde68a",
"#eff6ff", "#dbeafe", "#dcfce7", "#bbf7d0", "#f3e8ff", "#ede9fe",
"#e5e7eb", "#d1d5db", "#fafafa", "#f1f5f9", "#ecfdf5", "#fff1f2",
}
# Color-word fallbacks that are unambiguously dark.
DARK_COLOR_WORDS = {"black", "#000", "#000000"}
# Sections whose content is text-dense and MUST be HTML table/grid, never SVG.
# Matched against the nearest preceding heading/section marker text.
TABLE_ONLY_KEYWORDS = ("时间轴", "时间图", "事实时间", "证据印证", "证据矩阵", "矩阵")
HEX_RE = re.compile(r"#[0-9a-fA-F]{3,6}\b")
def hex_luminance(hex_color: str) -> float | None:
"""Perceived luminance 0..255 for a #rgb / #rrggbb string, else None."""
h = hex_color.strip().lower().lstrip("#")
if len(h) == 3:
h = "".join( c * 2 for c in h)
if len(h) != 6:
return None
try:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
except ValueError:
return None
return 0.299 * r + 0.587 * g + 0.114 * b
def is_dark_fill(value: str | None) -> bool:
"""True when a fill/background value is darker than the light allow-list."""
if not value:
return False
v = value.strip().lower()
if v in DARK_COLOR_WORDS:
return True
m = HEX_RE.search(v)
if not m:
return False
hexv = m.group(0).lower()
if hexv in LIGHT_FILL_ALLOW:
return False
lum = hex_luminance(hexv)
# Threshold ~110: #1F2937 (lum~38) and #374151 (lum~62) are caught;
# mid grays like #6B7280 (lum~113) used for *strokes/text* pass here and
# are only flagged when they actually back a large rect (see caller).
return lum is not None and lum < 110
@dataclass
class Box:
x1: float
y1: float
x2: float
y2: float
@property
def width(self) -> float:
return max(0.0, self.x2 - self.x1)
@property
def height(self) -> float:
return max(0.0, self.y2 - self.y1)
def contains(self, other: "Box", pad: float = 0.0) -> bool:
return (
other.x1 >= self.x1 - pad
and other.y1 >= self.y1 - pad
and other.x2 <= self.x2 + pad
and other.y2 <= self.y2 + pad
)
def intersects(self, other: "Box") -> bool:
return not (
self.x2 <= other.x1
or other.x2 <= self.x1
or self.y2 <= other.y1
or other.y2 <= self.y1
)
def intersection_area(self, other: "Box") -> float:
if not self.intersects(other):
return 0.0
return max(0.0, min(self.x2, other.x2) - max(self.x1, other.x1)) * max(
0.0, min(self.y2, other.y2) - max(self.y1, other.y1)
)
@dataclass
class TextItem:
text: str
box: Box
font_size: float
font_weight: int
anchor: str
line_no: int
def strip_ns(tag: str) -> str:
return TAG_NS_RE.sub("", tag)
def parse_float(value: str | None, default: float = 0.0) -> float:
if not value:
return default
match = re.search(r"-?\d+(?:\.\d+)?", value)
if not match:
return default
return float(match.group(0))
def parse_weight(value: str | None) -> int:
if not value:
return 400
value = value.strip().lower()
if value in {"normal", "regular"}:
return 400
if value in {"bold", "bolder"}:
return 700
match = re.search(r"\d+", value)
return int(match.group(0)) if match else 400
def style_map(style: str | None) -> dict[str, str]:
result: dict[str, str] = {}
if not style:
return result
for part in style.split(";"):
if ":" in part:
key, value = part.split(":", 1)
result[key.strip()] = value.strip()
return result
def attr_or_style(elem: ET.Element, name: str, default: str | None = None) -> str | None:
if name in elem.attrib:
return elem.attrib[name]
return style_map(elem.attrib.get("style")).get(name, default)
def text_content(elem: ET.Element) -> str:
text = "".join(elem.itertext())
return html.unescape(re.sub(r"\s+", " ", text)).strip()
def estimated_text_width(text: str, font_size: float) -> float:
width = 0.0
for ch in text:
code = ord(ch)
if ch.isspace():
width += 0.35
elif 0x4E00 <= code <= 0x9FFF or 0x3000 <= code <= 0x303F or 0xFF00 <= code <= 0xFFEF:
width += 1.0
elif ch in "MW@#%":
width += 0.85
else:
width += 0.55
return width * font_size
def line_number(source: str, needle: str) -> int:
idx = source.find(needle)
if idx < 0:
return 0
return source.count("\n", 0, idx) + 1
def parse_svg(source: str, index: int) -> tuple[list[Box], list[TextItem], list[str], list[str]]:
warnings: list[str] = []
errors: list[str] = []
rects: list[Box] = []
texts: list[TextItem] = []
try:
root = ET.fromstring(source)
except ET.ParseError as exc:
return rects, texts, warnings, [f"svg[{index}] XML parse error: {exc}"]
if not root.attrib.get("viewBox"):
warnings.append(f"svg[{index}] missing viewBox")
for elem in root.iter():
tag = strip_ns(elem.tag)
if tag == "rect":
x = parse_float(elem.attrib.get("x"))
y = parse_float(elem.attrib.get("y"))
w = parse_float(elem.attrib.get("width"))
h = parse_float(elem.attrib.get("height"))
if w > 0 and h > 0:
rects.append(Box(x, y, x + w, y + h))
elif tag == "text":
text = text_content(elem)
if not text:
continue
x = parse_float(elem.attrib.get("x"))
y = parse_float(elem.attrib.get("y"))
font_size = parse_float(attr_or_style(elem, "font-size"), 12.0)
weight = parse_weight(attr_or_style(elem, "font-weight"))
anchor = attr_or_style(elem, "text-anchor", "start") or "start"
width = estimated_text_width(text, font_size)
height = font_size * 1.25
if anchor == "middle":
x1 = x - width / 2
elif anchor == "end":
x1 = x - width
else:
x1 = x
y1 = y - font_size
item = TextItem(
text=text,
box=Box(x1, y1, x1 + width, y1 + height),
font_size=font_size,
font_weight=weight,
anchor=anchor,
line_no=line_number(source, text),
)
texts.append(item)
if font_size < 16 and weight > 400:
warnings.append(
f"svg[{index}] small bold text may blur: line {item.line_no}, "
f"font-size={font_size:g}, font-weight={weight}, text={text[:30]!r}"
)
return rects, texts, warnings, errors
def nearest_container(text: TextItem, rects: list[Box]) -> Box | None:
cx = (text.box.x1 + text.box.x2) / 2
cy = (text.box.y1 + text.box.y2) / 2
candidates = [r for r in rects if r.x1 - 2 <= cx <= r.x2 + 2 and r.y1 - 8 <= cy <= r.y2 + 8]
if not candidates:
return None
return min(candidates, key=lambda r: r.width * r.height)
def lint_layout(rects: list[Box], texts: list[TextItem], index: int) -> tuple[list[str], list[str]]:
warnings: list[str] = []
errors: list[str] = []
for item in texts:
container = nearest_container(item, rects)
if container and not container.contains(item.box, pad=4):
errors.append(
f"svg[{index}] text likely overflows containing box: line {item.line_no}, "
f"text={item.text[:40]!r}"
)
for i, a in enumerate(texts):
for b in texts[i + 1 :]:
if abs(a.box.y1 - b.box.y1) < max(a.font_size, b.font_size) * 0.35:
continue
area = a.box.intersection_area(b.box)
if area <= 0:
continue
smaller = max(1.0, min(a.box.width * a.box.height, b.box.width * b.box.height))
if area / smaller > 0.18:
errors.append(
f"svg[{index}] text boxes overlap: line {a.line_no} {a.text[:24]!r} / "
f"line {b.line_no} {b.text[:24]!r}"
)
return warnings, errors
def gate_dark_cards(raw: str) -> list[str]:
"""ERROR on dark-filled SVG rects / dark-background CSS used as card faces.
Skill rule 6 forbids dark/black boxes (user-confirmed dislike). Prior
outputs used fill="#1F2937" on large rects for charts 4/5.
"""
errors: list[str] = []
# 1) SVG <rect ... fill="#1F2937"> with non-trivial size.
for m in re.finditer(r"<rect\b[^>]*>", raw, re.IGNORECASE):
tag = m.group(0)
fill = None
fm = re.search(r'fill\s*[:=]\s*["\']?\s*(#[0-9a-fA-F]{3,6}|black)', tag)
if fm:
fill = fm.group(1)
if not is_dark_fill(fill):
continue
w = parse_float(re.search(r'width\s*=\s*["\']?([\d.]+)', tag).group(1)) if re.search(r'width\s*=', tag) else 0.0
h = parse_float(re.search(r'height\s*=\s*["\']?([\d.]+)', tag).group(1)) if re.search(r'height\s*=', tag) else 0.0
if w * h >= 2500: # ~50x50 and up = a card face, not a thin accent bar
errors.append(
f"forbidden dark card: <rect fill={fill}> size {w:g}x{h:g} "
f"at line {line_number(raw, tag[:40])} (rule 6: no dark/black boxes)"
)
# 1b) Bare <rect> with no fill attribute AND no class to back it via CSS.
# SVG defaults such a rect to BLACK fill -> renders as a black card. This
# is the real root cause of charts 4/5 black boxes (fill was never set;
# only `.node rect{fill:#FFF}` existed, so non-.node cards went black).
# A class only "backs" a rect's fill if its CSS body sets `fill:` to a
# real color (not `none`, and `stroke:` does NOT count — that was the
# charts 4/5 trap: `.node rect { stroke: ... }` with no fill -> black).
def _has_fill(body: str) -> bool:
fm = re.search(r'(?<!-)fill\s*:\s*([^;}\s]+)', body)
return bool(fm) and fm.group(1).strip().lower() not in {"none", "transparent"}
css_rect_classes: set[str] = set()
for cm in re.finditer(r'\.([\w-]+(?:\.[\w-]+)*)\s+rect\s*\{([^}]*)\}', raw):
if _has_fill(cm.group(2)):
# ".node.neutral" -> {"node","neutral"}; match if rect has ALL? No —
# CSS compound needs all; but keep permissive: any token present.
css_rect_classes.update(cm.group(1).split("."))
if re.search(r'(^|[^.\w])rect\s*\{([^}]*)\}', raw):
for cm in re.finditer(r'(^|[^.\w])rect\s*\{([^}]*)\}', raw):
if _has_fill(cm.group(2)):
css_rect_classes.add("__bare_rect__")
break
for m in re.finditer(r'<rect\b[^>]*?>', raw, re.DOTALL):
tag = m.group(0)
if re.search(r'fill\s*[:=]', tag):
continue # explicit fill (attr or inline style) — handled above
cls_m = re.search(r'class\s*=\s*["\']([^"\']*)["\']', tag)
classes = set(cls_m.group(1).split()) if cls_m else set()
backed = bool(classes & css_rect_classes) or "__bare_rect__" in css_rect_classes
# also check parent <g class="..."> within 200 chars before the rect
if not backed:
ctx = raw[max(0, m.start() - 200):m.start()]
for gm in re.finditer(r'<g\b[^>]*class\s*=\s*["\']([^"\']*)["\']', ctx):
if set(gm.group(1).split()) & css_rect_classes:
backed = True
w = parse_float(re.search(r'width\s*=\s*["\']?([\d.]+)', tag).group(1)) if re.search(r'width\s*=', tag) else 0.0
h = parse_float(re.search(r'height\s*=\s*["\']?([\d.]+)', tag).group(1)) if re.search(r'height\s*=', tag) else 0.0
if not backed and w * h >= 2500:
errors.append(
f"bare <rect> with no fill (renders BLACK): size {w:g}x{h:g} "
f"at line {line_number(raw, tag[:50])} — set fill or a CSS-backed class "
f"(rule 6 root cause: SVG default fill is black)"
)
# 2) CSS background: dark on card-like classes.
for m in re.finditer(r'background[a-z-]*\s*:\s*(#[0-9a-fA-F]{3,6}|black)', raw, re.IGNORECASE):
val = m.group(1)
if is_dark_fill(val):
errors.append(
f"forbidden dark background in CSS: {val} at line "
f"{raw.count(chr(10), 0, m.start()) + 1} (rule 6: no dark/black boxes)"
)
return errors
def gate_dangling_filter_refs(raw: str) -> list[str]:
"""ERROR when a CSS/element filter references a #id not defined in the SAME svg.
THE charts 4/5 black-card root cause: `.node rect { filter: url(#shadow); }`
applies to every svg, but `<filter id="shadow">` was defined only inside
svg[1]. In every other svg the rects reference a missing filter, and
headless Chrome renders them as solid BLACK. Pure fill/grep checks miss this.
"""
errors: list[str] = []
# Collect filter ids referenced from CSS (apply to ALL svgs that match).
css_blocks = re.findall(r"<style\b[^>]*>([\s\S]*?)</style>", raw, re.IGNORECASE)
css_text = "\n".join(css_blocks)
css_filter_ids = set(re.findall(r"filter\s*:\s*url\(#([\w-]+)\)", css_text))
# Does CSS target rect / .node rect etc.? If so, every svg with such rects
# needs the id defined locally.
css_targets_rect = bool(re.search(r"rect\s*\{[^}]*filter\s*:\s*url\(#", css_text))
sources = SVG_RE.findall(raw)
for idx, svg in enumerate(sources, start=1):
local_ids = set(re.findall(r'<filter\b[^>]*id\s*=\s*["\']([\w-]+)["\']', svg))
# inline filter="url(#x)" usages inside this svg
inline_refs = set(re.findall(r'filter\s*=\s*["\']url\(#([\w-]+)\)["\']', svg))
style_refs = set(re.findall(r'filter\s*:\s*url\(#([\w-]+)\)', svg))
refs = inline_refs | style_refs
# If CSS applies a filter to rects and this svg has <rect>, the css id
# must resolve locally too.
if css_targets_rect and re.search(r"<rect\b", svg):
refs |= css_filter_ids
missing = {r for r in refs if r and r not in local_ids}
for mid in sorted(missing):
errors.append(
f"svg[{idx}] references filter #{mid} not defined in this <svg> "
f"(renders BLACK in Chrome). Define <filter id=\"{mid}\"> inside "
f"EACH svg that uses it, or drop the filter. (charts 4/5 root cause)"
)
return errors
def gate_table_only_sections(raw: str) -> list[str]:
"""ERROR when a text-dense section (timeline/matrix) contains an SVG.
These MUST be HTML table/grid. Prior output put the timeline in SVG and the
cards overlapped + text was clipped.
"""
errors: list[str] = []
# Split into <section ...> blocks; fall back to heading-delimited chunks.
blocks = re.split(r'(?=<section\b)', raw, flags=re.IGNORECASE)
if len(blocks) <= 1:
blocks = re.split(r'(?=<h[1-3]\b)', raw, flags=re.IGNORECASE)
for blk in blocks:
head = blk[:400]
if not any(kw in head for kw in TABLE_ONLY_KEYWORDS):
continue
if re.search(r"<svg\b", blk, re.IGNORECASE):
label = next((kw for kw in TABLE_ONLY_KEYWORDS if kw in head), "?")
errors.append(
f"text-dense section '{label}' uses <svg>; must be HTML table/grid "
f"(2026-05-29 routing rule + charts 2/6 spec)"
)
return errors
def gate_bold_chinese(raw: str) -> list[str]:
"""ERROR on small bold Chinese in SVG text (strokes merge, illegible).
Complements the per-SVG WARN: this scans the whole file including CSS
classes so a .process-title{font-weight:600} hidden in <style> still fails.
"""
errors: list[str] = []
han = re.compile(r"[一-鿿]")
# SVG text elements carrying font-weight >=600 attribute.
for m in re.finditer(r"<text\b[^>]*>(.*?)</text>", raw, re.IGNORECASE | re.DOTALL):
tag, inner = m.group(0), m.group(1)
if not han.search(inner):
continue
wm = re.search(r'font-weight\s*[:=]\s*["\']?\s*(\d{3}|bold|bolder)', tag)
if not wm:
continue
weight = parse_weight(wm.group(1))
size = parse_float(attr_value(tag, "font-size"), 12.0)
if weight > 400 and size < 16:
errors.append(
f"small bold Chinese in SVG: weight={weight} size={size:g} "
f"text={inner.strip()[:24]!r} at line {line_number(raw, tag[:40])} "
f"(rule 2: Chinese font-weight must be 400 under 16px)"
)
return errors
def attr_value(tag: str, name: str) -> str | None:
m = re.search(rf'{name}\s*[:=]\s*["\']?\s*([^"\';\s>]+)', tag)
return m.group(1) if m else None
def gate_palette_contract(raw: str) -> list[str]:
"""ERROR when output deviates from the locked Claude-orange Scheme-A contract.
The skill's whole point is determinism: every case must look identical.
Two regressions this catches:
1. cold-gray page background #F9F9F9 (contract mandates warm white #FAFAF9)
2. Mermaid graphs missing clusterBkg override -> default mustard subgraph
"""
errors: list[str] = []
# 1) Cold-gray page background. Match `--c-page` var or body background.
for m in re.finditer(r'--c-page\s*:\s*(#[0-9a-fA-F]{3,6})', raw):
if m.group(1).lower() in {"#f9f9f9", "#f9fafb", "#f3f4f6"}:
errors.append(
f"cold-gray page background {m.group(1)} at line "
f"{raw.count(chr(10), 0, m.start()) + 1}; contract requires warm white #FAFAF9 (rule 6)"
)
# 2) Mermaid init blocks must set clusterBkg to avoid default mustard.
# Only check files that actually contain mermaid init directives.
for m in re.finditer(r"%%\{init:.*?\}%%", raw, re.DOTALL):
block = m.group(0)
if "subgraph" in raw and "clusterBkg" not in block:
errors.append(
"Mermaid init present but missing clusterBkg override; "
"subgraph will render default mustard. Add clusterBkg:'#FAFAF9' (模板铁律)"
)
break
return errors
def lint_file(path: Path) -> int:
raw = path.read_text(encoding="utf-8", errors="replace")
sources = SVG_RE.findall(raw)
if not sources and "<svg" in raw:
sources = [raw]
warnings: list[str] = []
errors: list[str] = []
if not sources:
warnings.append("no inline SVG found; HTML/CSS layout still requires PNG visual review")
if "position:absolute" in raw or "position: absolute" in raw:
warnings.append("absolute-positioned element found; confirm it does not cover the diagram")
# --- Hard gates: documented rules, now build-failing -------------------
errors.extend(gate_dark_cards(raw))
errors.extend(gate_dangling_filter_refs(raw))
errors.extend(gate_table_only_sections(raw))
errors.extend(gate_bold_chinese(raw))
errors.extend(gate_palette_contract(raw))
for idx, svg in enumerate(sources, start=1):
rects, texts, svg_warnings, svg_errors = parse_svg(svg, idx)
warnings.extend(svg_warnings)
errors.extend(svg_errors)
if svg_errors:
continue
layout_warnings, layout_errors = lint_layout(rects, texts, idx)
warnings.extend(layout_warnings)
errors.extend(layout_errors)
for msg in warnings:
print(f"WARN: {msg}")
for msg in errors:
print(f"ERROR: {msg}")
if errors:
print(f"FAIL: {len(errors)} error(s), {len(warnings)} warning(s)")
return 1
print(f"PASS: {len(warnings)} warning(s)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Lint SVG/HTML diagram layout")
parser.add_argument("file", type=Path, help="HTML or SVG file to check")
args = parser.parse_args()
if not args.file.exists():
print(f"ERROR: file not found: {args.file}")
return 2
return lint_file(args.file)
if __name__ == "__main__":
sys.exit(main())
<!DOCTYPE html>
<!--
刑事案件可视化 · 浅色 HTML 表格/卡片标准模板
用途:文字密集型图表(时间轴、量刑预测、共同犯罪对比、受援人参与、证据矩阵)
原则:纯 HTML+CSS,浏览器自动换行/撑高,绝不手写 SVG 坐标。
禁止:黑底卡片、深色方框、SVG <filter url(#id)> 跨节点引用。
套用方法:复制本文件 → 替换 {{占位符}} → 删除用不到的 section →
node ~/.claude/skills/html2png/scripts/html2png.js 本文件 out.png --width=1467
python3 ~/.claude/skills/criminal-case-visualization/scripts/render_pixel_check.py 本文件
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>{{案件名}} - 可视化图表</title>
<style>
:root {
/* —— 调色板(Claude 橙白 · 方案A 极简克制,2026-05-30 定为默认)—— */
/* 设计原则:暖白底上浮白框,受援人陶土橙点亮全场,其他安静退后。 */
--c-page: #FAFAF9; /* 页面底:暖白(非冷灰) */
--c-card: #FFFFFF; /* 卡片底(永远浅色,禁止改深) */
--c-ink: #1C1917; /* 主文字(仅文字用,不可做卡片底) */
--c-sub: #78716C; /* 辅助文字(暖灰) */
--c-line: #E7E5E4; /* 分隔线(暖灰) */
--c-our: #D97757; /* 己方/受援人:Claude 陶土橙(唯一焦点色) */
--c-leader: #78716C; /* 主犯/组织者:深暖灰描边(退后,不与橙争焦点) */
--c-co: #A8A29E; /* 同案犯:中暖灰 */
--c-third: #D6D3D1; /* 第三方/被害人:浅暖灰 */
--c-ok: #10B981; /* 印证/有利 绿(功能色保留) */
--c-warn: #F59E0B; /* 存疑 黄 */
--c-bad: #DC2626; /* 矛盾/不利 红 */
/* 浅功能底(表格格子专用,全部浅色) */
--b-ok: #DCFCE7; --b-warn: #FEF3C7; --b-bad: #FEE2E2;
--b-info: #DBEAFE; --b-our: #FBEEE8; --b-mute: #F5F5F4;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--c-page);
font-family: "PingFang SC", -apple-system, "Microsoft YaHei", sans-serif;
color: var(--c-ink); font-weight: 400; line-height: 1.6;
}
nav {
position: sticky; top: 0; background: var(--c-card);
border-bottom: 1px solid var(--c-line); padding: 12px 24px;
display: flex; gap: 18px; flex-wrap: wrap; font-size: 13px; z-index: 10;
}
nav a { color: var(--c-sub); text-decoration: none; }
nav a:hover { color: var(--c-our); }
.chart-section { max-width: 1467px; margin: 0 auto; padding: 32px 24px 8px; }
.chart-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.chart-subtitle { font-size: 13px; color: var(--c-sub); margin: 0 0 20px; }
/* —— 通用卡片 —— */
.card {
background: var(--c-card); border: 2px solid var(--c-third);
border-radius: 8px; padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.card.our { border-color: var(--c-our); }
.card.leader { border-color: var(--c-leader); }
.card.co { border-color: var(--c-co); }
.card.ok { border-color: var(--c-ok); }
.card.bad { border-color: var(--c-bad); }
.card h3 { margin: 0 0 8px; font-size: 14px; font-weight: 600; }
.card .meta { font-size: 12px; color: var(--c-sub); }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-4 { grid-template-columns: repeat(4, 1fr); }
/* —— 表格(时间轴 / 证据矩阵 通用)—— */
table { width: 100%; border-collapse: collapse; background: var(--c-card);
border-radius: 8px; overflow: hidden; font-size: 13px; }
th, td { border: 1px solid var(--c-line); padding: 10px 12px;
vertical-align: top; text-align: left; }
th { background: var(--b-mute); font-weight: 600; }
/* 行/格 浅色底(全部浅色,杜绝黑块) */
.row-our { background: var(--b-our); }
.row-info { background: var(--b-info); }
.cell-ok { background: var(--b-ok); }
.cell-warn { background: var(--b-warn); }
.cell-bad { background: var(--b-bad); }
.cell-mute { background: var(--b-mute); color: var(--c-sub); }
/* —— 标签 —— */
.tag { display: inline-block; font-size: 11px; padding: 1px 7px;
border-radius: 4px; margin: 1px 2px; }
.tag-ok { background: var(--b-ok); color: #166534; }
.tag-info { background: var(--b-info); color: #1E40AF; }
.tag-warn { background: var(--b-warn); color: #92400E; }
.tag-bad { background: var(--b-bad); color: #991B1B; }
.tag-our { background: var(--b-our); color: #9A3412; }
/* —— 量刑/数据 行 —— */
.kv { display: grid; grid-template-columns: 92px 1fr; gap: 4px 10px;
font-size: 13px; }
.kv dt { color: var(--c-sub); }
.kv dd { margin: 0; }
.accent-ok { color: var(--c-ok); }
.accent-bad { color: var(--c-bad); }
.accent-our { color: var(--c-our); }
/* —— 图例(流内放置,不用 absolute,避免遮挡)—— */
.legend { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px;
color: var(--c-sub); margin-top: 14px; }
.legend span::before { content: "—"; margin-right: 5px; font-weight: 700; }
.lg-our::before { color: var(--c-our); }
.lg-co::before { color: var(--c-co); }
.lg-third::before { color: var(--c-third); }
footer { max-width: 1467px; margin: 24px auto; padding: 16px 24px;
border-top: 1px solid var(--c-line); font-size: 12px;
color: var(--c-sub); }
@media print {
nav { display: none; }
.chart-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
<nav>
<a href="#chart2">2. 事实时间轴</a>
<a href="#chart3">3. 受援人参与</a>
<a href="#chart4">4. 量刑预测</a>
<a href="#chart5">5. 共同犯罪地位</a>
<a href="#chart6">6. 证据矩阵</a>
</nav>
<!-- ============ 图表2:案件事实时间轴(HTML 表格,禁用 SVG)============ -->
<section id="chart2" class="chart-section">
<h2 class="chart-title">{{案件名}} — 案件事实时间轴</h2>
<p class="chart-subtitle">{{案号}}|时间—事件—证据三栏对照</p>
<table>
<colgroup><col style="width:12%"><col style="width:38%"><col style="width:50%"></colgroup>
<thead><tr><th>时间</th><th>关键事件</th><th>证据支撑</th></tr></thead>
<tbody>
<!-- 受援人参与的犯罪事实行加 class="row-our" -->
<tr class="row-our">
<td>{{日期}}</td>
<td>{{何人何地何事}} <span class="tag tag-our">★ 受援人参与</span></td>
<td><span class="tag tag-ok">被告人供述</span><span class="tag tag-info">被害人陈述</span> {{具体证据}}</td>
</tr>
<tr>
<td>{{日期}}</td>
<td>{{事件}}</td>
<td><span class="tag tag-warn">微信转账</span> {{证据}} <span class="accent-bad">❌ {{矛盾点}}</span></td>
</tr>
<!-- 程序节点行加 class="row-info" -->
<tr class="row-info">
<td>{{日期}}</td>
<td>{{程序节点:抓获/起诉/开庭}}</td>
<td><span class="tag tag-info">法律文书</span> {{文书}}</td>
</tr>
</tbody>
</table>
</section>
<!-- ============ 图表3:受援人参与分析(卡片 grid)============ -->
<section id="chart3" class="chart-section">
<h2 class="chart-title">受援人参与情况分析</h2>
<p class="chart-subtitle">逐起分析参与程度、获利与同案犯对比</p>
<div class="grid grid-3">
<div class="card our">
<h3>第一起:{{案名}}</h3>
<div class="kv">
<dt>时间地点</dt><dd>{{时间地点}}</dd>
<dt>被害损失</dt><dd>{{金额}}</dd>
<dt>参与情况</dt><dd>{{报名✓ 到场✗ 谈判✗}}</dd>
<dt>获利</dt><dd class="accent-our">{{获利}}</dd>
</div>
</div>
<!-- 复制卡片… -->
</div>
<div class="grid grid-2" style="margin-top:16px">
<div class="card">
<h3>受援人整体参与</h3>
<div class="kv">
<dt>参与案件</dt><dd>{{n}}起</dd>
<dt>总获利</dt><dd>{{金额区间}}</dd>
<dt>参与程度</dt><dd>{{低|多起未到场|未主导}}</dd>
</div>
</div>
<div class="card our">
<h3>从犯地位认定依据</h3>
<ol style="margin:0;padding-left:18px;font-size:13px">
<li>组织地位:{{主犯认定}}</li>
<li>分工情况:{{受援人分工}}</li>
<li>分赃方式:{{无决定权/由主犯分配}}</li>
<li>获利较少:{{金额}}</li>
</ol>
<p class="accent-our" style="margin:8px 0 0">⇒ 建议认定为从犯,从轻处罚</p>
</div>
</div>
</section>
<!-- ============ 图表4:量刑预测对比(卡片 grid,浅色)============ -->
<section id="chart4" class="chart-section">
<h2 class="chart-title">量刑预测对比</h2>
<p class="chart-subtitle">不同情节下的量刑预测、计算过程及法律依据</p>
<div class="grid grid-2">
<div class="card">
<h3>量刑基准计算</h3>
<div class="kv">
<dt>犯罪数额</dt><dd>{{数额}}(属"数额较大")</dd>
<dt>法定刑期</dt><dd>{{刑法第X条}}</dd>
<dt>量刑起点</dt><dd>{{起点}}</dd>
<dt>基准刑</dt><dd class="accent-ok">{{基准刑}}</dd>
</div>
<p class="meta" style="margin:8px 0 0">法律依据:{{司法解释条文}}</p>
</div>
<div class="card">
<h3>量刑情节适用</h3>
<div class="kv">
<dt>从轻情节</dt><dd class="accent-ok">{{自首/坦白/退赃/认罪认罚}}</dd>
<dt>从重情节</dt><dd class="accent-bad">{{有则列,无则"无"}}</dd>
<dt>待完成</dt><dd>{{退赃/谅解}}</dd>
</div>
</div>
</div>
<div class="grid grid-3" style="margin-top:16px">
<div class="card bad">
<h3>情景一:检察院建议</h3>
<p class="kv"><dt>量刑幅度</dt><dd class="accent-bad">{{区间}}</dd></p>
</div>
<div class="card our">
<h3>情景二:认定从犯</h3>
<p class="kv"><dt>量刑幅度</dt><dd class="accent-our">{{区间}}</dd></p>
</div>
<div class="card ok">
<h3>情景三:全额退赃+谅解</h3>
<p class="kv"><dt>量刑幅度</dt><dd class="accent-ok">{{区间,可缓刑}}</dd></p>
</div>
</div>
</section>
<!-- ============ 图表5:共同犯罪地位对比(表格)============ -->
<section id="chart5" class="chart-section">
<h2 class="chart-title">共同犯罪地位对比</h2>
<p class="chart-subtitle">各被告地位、作用、分赃多维度对比</p>
<table>
<thead><tr><th>维度</th><th>{{主犯}}</th><th>{{同案犯A}}</th><th class="row-our">{{受援人}}</th></tr></thead>
<tbody>
<tr><th>地位作用</th><td>组织指挥</td><td>{{}}</td><td class="row-our">{{次要}}</td></tr>
<tr><th>参与案件</th><td>{{全部}}</td><td>{{}}</td><td class="row-our">{{n起}}</td></tr>
<tr><th>分赃</th><td>{{主导分配}}</td><td>{{}}</td><td class="row-our accent-our">{{最少}}</td></tr>
<tr><th>主从认定</th><td><span class="tag tag-bad">主犯</span></td><td>{{}}</td><td class="row-our"><span class="tag tag-our">从犯</span></td></tr>
</tbody>
</table>
</section>
<!-- ============ 图表6:证据印证关系矩阵(表格)============ -->
<section id="chart6" class="chart-section">
<h2 class="chart-title">证据印证关系矩阵</h2>
<p class="chart-subtitle">双轴矩阵展示证据印证/矛盾</p>
<table>
<thead><tr><th>证据\待证</th><th>主观明知</th><th>客观行为</th><th>分赃金额</th><th>从犯地位</th></tr></thead>
<tbody>
<tr>
<th>{{被告人供述}}</th>
<td class="cell-ok">✓ {{}}</td>
<td class="cell-ok">✓ {{}}</td>
<td class="cell-bad">❌ {{矛盾}}</td>
<td class="cell-ok">✓ {{}}</td>
</tr>
<tr>
<th>{{微信转账记录}}</th>
<td class="cell-mute">- 缺失</td>
<td class="cell-warn">⚠ {{部分}}</td>
<td class="cell-bad">❌ {{矛盾}}</td>
<td class="cell-mute">-</td>
</tr>
</tbody>
</table>
<div class="legend" style="margin-top:14px">
<span class="tag tag-ok">✓ 印证</span>
<span class="tag tag-bad">❌ 矛盾</span>
<span class="tag tag-warn">⚠ 存疑</span>
<span class="tag cell-mute" style="padding:1px 7px">- 缺失</span>
</div>
</section>
<footer>{{案号}}|生成时间:{{日期}}</footer>
</body>
</html>
Mermaid 节点连线图模板(关系图 / 资金流向 / 程序流程)
节点连线类图表用 Mermaid 自动布局,不手写 SVG 坐标。
本机已装 mmdc(mermaid-cli 11.x),渲染命令见末尾。配色 = Claude 橙白 · 方案A 极简克制(2026-05-30 定为默认):暖白底,受援人陶土橙是唯一焦点色,主犯/同案用暖灰退后。禁止深色/黑色填充。禁止 Mermaid 默认鹅黄 subgraph 底(必须在 init 里覆盖 clusterBkg)。---
必备 init 头(每张图第一行,统一暖白主题)
%%{init: {'theme':'base','themeVariables':{'fontFamily':'PingFang SC, sans-serif','fontSize':'15px','clusterBkg':'#FAFAF9','clusterBorder':'#E7E5E4','edgeLabelBackground':'#FAFAF9','lineColor':'#A8A29E'}}}%%clusterBkg:#FAFAF9 是关键——不覆盖就会变成默认鹅黄脏底(历史踩坑)。必备 classDef 尾(每张图末尾统一)
classDef leader fill:#FFFFFF,stroke:#78716C,stroke-width:2px,color:#1C1917;
classDef our fill:#FBEEE8,stroke:#D97757,stroke-width:2.5px,color:#7C2D12;
classDef co fill:#FFFFFF,stroke:#D6D3D1,stroke-width:1.5px,color:#44403C;
classDef mute fill:#FAFAF9,stroke:#E7E5E4,stroke-width:1px,color:#A8A29E;
classDef vour fill:#FBEEE8,stroke:#D97757,stroke-width:1.5px,color:#7C2D12;角色→class 对照:受援人/己方 our(陶土橙,唯一焦点);主犯/组织者 leader(白底深灰粗边,退后不抢橙);同案犯 co(白底中灰);无关方/未参与/其他被害人 mute(暖白浅灰,最弱);受援人参与的被害人 vour(浅橙描边)。进度类(图8)用 done/current/todo,见下。
---
图表1:人物关系图谱
%%{init: {'theme':'base','themeVariables':{'fontFamily':'PingFang SC, sans-serif','fontSize':'15px','clusterBkg':'#FAFAF9','clusterBorder':'#E7E5E4','edgeLabelBackground':'#FAFAF9','lineColor':'#A8A29E'}}}%%
flowchart TB
subgraph 团伙[" 犯罪团伙内部 "]
direction TB
M["{{主犯}}<br/>主犯·组织指挥<br/>持股40%"]:::leader
XJ["{{未参与股东}}<br/>股东30%·未参与"]:::mute
O["{{受援人}}<br/>受援人·从犯<br/>持股15%·员工"]:::our
X["{{同案犯}}<br/>同案·持股15%"]:::co
M -->|雇佣指挥| O
M -->|雇佣指挥| X
end
subgraph 受害[" 被害人(涉案共{{总额}}) "]
direction LR
V1["{{参与被害人}}<br/>{{金额}}"]:::vour
V4["{{其他被害人}}<br/>{{金额}}"]:::mute
end
O -.参与.-> V1
classDef leader fill:#FFFFFF,stroke:#78716C,stroke-width:2px,color:#1C1917;
classDef our fill:#FBEEE8,stroke:#D97757,stroke-width:2.5px,color:#7C2D12;
classDef co fill:#FFFFFF,stroke:#D6D3D1,stroke-width:1.5px,color:#44403C;
classDef mute fill:#FAFAF9,stroke:#E7E5E4,stroke-width:1px,color:#A8A29E;
classDef vour fill:#FBEEE8,stroke:#D97757,stroke-width:1.5px,color:#7C2D12;要点:受援人参与的指向线用虚线 -.参与.->(Mermaid 自动避让,不会压字)。
---
图表7:资金/财物流向图
%%{init: {'theme':'base','themeVariables':{'fontFamily':'PingFang SC, sans-serif','fontSize':'15px','clusterBkg':'#FAFAF9','clusterBorder':'#E7E5E4','edgeLabelBackground':'#FAFAF9','lineColor':'#A8A29E'}}}%%
flowchart LR
subgraph S1[" 第一案 {{被害人}} {{金额}} "]
direction LR
A1["{{被害人}}<br/>被害人"]:::mute --{{金额}}--> A2["{{中转}}"]:::co
A2 --> A3["{{组织者}}<br/>分赃"]:::leader
A3 -.{{金额}}.-> A5["{{受援人}}<br/>{{矛盾标注}}"]:::our
end
classDef leader fill:#FFFFFF,stroke:#78716C,stroke-width:2px,color:#1C1917;
classDef our fill:#FBEEE8,stroke:#D97757,stroke-width:2.5px,color:#7C2D12;
classDef co fill:#FFFFFF,stroke:#D6D3D1,stroke-width:1.5px,color:#44403C;
classDef mute fill:#FAFAF9,stroke:#E7E5E4,stroke-width:1px,color:#A8A29E;多起案件 → 每起一个 subgraph 子图,不要全堆进一张。矛盾点写进受援人节点文字(如"供述900·差600"),不另设红块。
---
图表8:案件程序流程图
进度三态用专用 classDef:已完成浅橙底、当前阶段实心橙、待定暖白灰。
%%{init: {'theme':'base','themeVariables':{'fontFamily':'PingFang SC, sans-serif','fontSize':'15px','edgeLabelBackground':'#FFFFFF','lineColor':'#A8A29E'}}}%%
flowchart LR
A["受案<br/>{{日期}}"]:::done
B["立案<br/>{{日期}}"]:::done
C["抓获/取保<br/>{{日期}}"]:::done
D["审查起诉<br/>{{日期}}"]:::done
E["提起公诉<br/>{{日期}}"]:::current
F["开庭<br/>待定"]:::todo
G["判决<br/>待定"]:::todo
A --> B --> C --> D --> E --> F --> G
classDef done fill:#FBEEE8,stroke:#D97757,stroke-width:1.5px,color:#7C2D12;
classDef current fill:#D97757,stroke:#B45309,stroke-width:2.5px,color:#FFFFFF;
classDef todo fill:#FAFAF9,stroke:#E7E5E4,stroke-width:1.5px,color:#A8A29E;节点只放短标题+日期;期限审查、文书清单等长文字放到图下 HTML 说明区(用 light-card-table 的卡片),不塞进节点。
---
渲染命令
# 单图:mmdc 读 .mmd 出 PNG(白底,scale=4 保证中文清晰)
mmdc -i chart1.mmd -o chart1.png -b "#FFFFFF" -s 4
# 批量
for f in chart*.mmd; do mmdc -i "$f" -o "${f%.mmd}.png" -b "#FFFFFF" -s 4; done清晰度铁律:scale 必须用 `-s 4`,不要用 2。 历史踩坑:-s 2 渲出的图嵌进 1467px 宽的 HTML 后,中文笔画发糊(尤其横向流程图节点被压扁时)。-s 4 输出约 3136px 宽,缩放到显示宽度后仍锐利。横向流程图(图8)即使整体很扁也照用横排 + -s 4,不要为清晰度改竖排(竖排会拉成瘦长条,不适合嵌入)。
渲染后把 PNG 与引用它的 HTML 放同一目录,再跑 render_pixel_check.py 或肉眼确认无黑块、无压字、文字清晰。Mermaid 自动布局已基本杜绝重叠,但节点文字过长仍可能撑大画布——超长文字务必用 <br/> 主动换行。