@ccoalm/ccl-skills 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/classify_envelope.py +34 -3
  2. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_abort_leak_state_helpers.sh +148 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_classify_envelope.sh +28 -0
  4. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_parse_review_json.sh +7 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +13 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate_abort_leak.sh +271 -34
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-data-acquisition.md +3 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-perspective-research/references/public-disclosure-channels.md +2 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +12 -12
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +8 -0
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/eval-routing.md +7 -8
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +12 -0
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +3 -2
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +7 -6
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/recurring-anti-patterns-checklist.md +18 -0
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +47 -2
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +28 -29
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +165 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-health.rb +23 -10
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +303 -1
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +222 -91
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +12 -0
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +85 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +123 -0
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_dateless_host.sh +6 -1
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_gate_verdict_differential.sh +1 -1
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_round_attribution.sh +12 -12
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +455 -0
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_source_refuted.sh +20 -20
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_liveness_predicate_gate.sh +288 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +4 -4
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/deliverable-doc-genre-skeletons.md +133 -0
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/doc-charter-first.md +2 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/figure-and-table-craft.md +318 -0
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/AGENTS.md +46 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/doc-lint.py +246 -0
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/figure-lint.py +1092 -0
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/mutation_probe.sh +100 -0
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/test_figure_and_doc_lint.sh +375 -0
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/control.md +10 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/empty-header.md +6 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fake-header.md +13 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fenced-noise.md +14 -0
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-dangling.md +5 -0
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan-captioned.md +11 -0
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig-orphan.md +9 -0
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/fig.png +0 -0
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/imbalance.md +41 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/no-unit.md +8 -0
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/should-be-chart.md +11 -0
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/tables-only-clean.md +35 -0
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/unfilled.md +7 -0
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/doc/wide-table.md +5 -0
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/bad-viewbox.svg +9 -0
  56. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/blackmarker.svg +9 -0
  57. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/control.svg +12 -0
  58. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/crossings.svg +12 -0
  59. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/cvd-confusable.svg +9 -0
  60. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/decorative-line.svg +10 -0
  61. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-no-arrow.svg +11 -0
  62. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/edge-vague.svg +12 -0
  63. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-contract.json +21 -0
  64. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/figure-is-a-list.svg +12 -0
  65. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/flow-mixed.svg +13 -0
  66. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/low-contrast.svg +12 -0
  67. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/malformed.svg +1 -0
  68. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-aria.svg +9 -0
  69. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-group.svg +10 -0
  70. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-legend.svg +9 -0
  71. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-title.svg +12 -0
  72. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/no-viewbox.svg +9 -0
  73. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/offcontract-shape.svg +13 -0
  74. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/overflow.svg +13 -0
  75. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/transformed.svg +9 -0
  76. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/ungrouped-card.svg +12 -0
  77. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/scripts/tests/svg/unlabeled-edge.svg +13 -0
  78. package/dist/assets/release.json +276 -31
  79. package/package.json +1 -1
@@ -0,0 +1,1092 @@
1
+ #!/usr/bin/env python3
2
+ """figure-lint — SVG 图的起草期确定性检查。
3
+
4
+ 判据只取两类:
5
+ 1) 有外部权威依据的(WCAG 对比度、C4 记法、SVG 文本不换行的几何后果)
6
+ 2) 纯事实性的一致性检查(跨图比例/字号阶梯是否统一)
7
+
8
+ 不含任何"可读性阈值"类的拍脑袋数字。
9
+
10
+ 用法: figure-lint.py <dir-or-file>... [--json]
11
+ 退出码: 0 全过 / 1 有 ERROR / 2 只有 WARN
12
+ """
13
+ import sys, re, os, glob, json, math, collections
14
+ import xml.etree.ElementTree as ET
15
+
16
+ SVG = "{http://www.w3.org/2000/svg}"
17
+
18
+ # ---------- 颜色与对比度(WCAG 2.2 SC 1.4.3) ----------
19
+
20
+ def _lum(hexcolor):
21
+ h = hexcolor.lstrip('#')
22
+ if len(h) == 3:
23
+ h = ''.join(c * 2 for c in h)
24
+ r, g, b = [int(h[i:i+2], 16) / 255 for i in (0, 2, 4)]
25
+ f = lambda c: c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
26
+ return .2126 * f(r) + .7152 * f(g) + .0722 * f(b)
27
+
28
+ def contrast(fg, bg):
29
+ l1, l2 = sorted([_lum(fg), _lum(bg)], reverse=True)
30
+ return (l1 + .05) / (l2 + .05)
31
+
32
+ # ---------- 文本宽度估算 ----------
33
+ # CJK 全角 ≈ 1.0em,拉丁与数字 ≈ 0.55em,空格 ≈ 0.28em。
34
+ # 这是保守估算,不是精确排版;用于判「明显装不下」,不用于判「刚好放得下」。
35
+
36
+ def est_width(text, font_size):
37
+ w = 0.0
38
+ for ch in text:
39
+ o = ord(ch)
40
+ if 0x4E00 <= o <= 0x9FFF or 0x3000 <= o <= 0x303F or 0xFF00 <= o <= 0xFFEF:
41
+ w += 1.0
42
+ elif ch == ' ':
43
+ w += 0.28
44
+ else:
45
+ w += 0.55
46
+ return w * font_size
47
+
48
+ # ---------- CSS 解析 ----------
49
+
50
+ def parse_css(svg_text):
51
+ """返回 {class_name: {prop: value}}"""
52
+ out = {}
53
+ for block in re.findall(r'<style[^>]*>(.*?)</style>', svg_text, re.S):
54
+ for m in re.finditer(r'([^{}]+)\{([^}]*)\}', block):
55
+ sels, body = m.group(1), m.group(2)
56
+ props = {}
57
+ for decl in body.split(';'):
58
+ if ':' in decl:
59
+ k, v = decl.split(':', 1)
60
+ props[k.strip()] = v.strip()
61
+ for sel in sels.split(','):
62
+ sel = sel.strip()
63
+ if sel.startswith('.'):
64
+ out.setdefault(sel[1:], {}).update(props)
65
+ elif sel:
66
+ out.setdefault('%' + sel, {}).update(props) # 元素选择器
67
+ return out
68
+
69
+ def resolve(el, css, inherited=None):
70
+ """合并元素选择器、class、行内 style 与 presentation attribute。"""
71
+ props = dict(inherited or {})
72
+ tag = el.tag.replace(SVG, '')
73
+ props.update(css.get('%' + tag, {}))
74
+ for c in (el.get('class') or '').split():
75
+ props.update(css.get(c, {}))
76
+ for k in ('fill', 'font-size', 'font-weight', 'stroke'):
77
+ if el.get(k):
78
+ props[k] = el.get(k)
79
+ if el.get('style'):
80
+ for decl in el.get('style').split(';'):
81
+ if ':' in decl:
82
+ k, v = decl.split(':', 1)
83
+ props[k.strip()] = v.strip()
84
+ return props
85
+
86
+ UNIT_TO_PX = {'px': 1.0, 'pt': 96.0 / 72.0, 'pc': 16.0, 'in': 96.0,
87
+ 'cm': 96.0 / 2.54, 'mm': 9.6 / 2.54, '': 1.0}
88
+
89
+ def px(v, default=None, base=16.0, unsupported=None):
90
+ """把长度换算成用户单位。
91
+
92
+ 早先只取数字前缀:`1em` 当 1px、`12pt` 当 12px,
93
+ 会压掉 SVG-OVERFLOW、也会用错 WCAG 大字阈值。
94
+ """
95
+ if not v:
96
+ return default
97
+ m = re.match(r'^\s*(-?[\d.]+)\s*([a-z%]*)\s*$', str(v).strip(), re.I)
98
+ if not m:
99
+ if unsupported is not None:
100
+ unsupported.add(str(v))
101
+ return default
102
+ num, unit = float(m.group(1)), m.group(2).lower()
103
+ if unit in ('em', 'rem'):
104
+ return num * base
105
+ if unit in UNIT_TO_PX:
106
+ return num * UNIT_TO_PX[unit]
107
+ if unsupported is not None:
108
+ unsupported.add(str(v))
109
+ return default
110
+
111
+ # ---------- 几何:找文本压着的底色 ----------
112
+
113
+ def rects_of(root, css):
114
+ """收集矩形类底色形状,按文档序(后画的在上)。"""
115
+ out = []
116
+ def walk(node, inh):
117
+ for el in node:
118
+ p = resolve(el, css, inh)
119
+ tag = el.tag.replace(SVG, '')
120
+ if tag == 'rect':
121
+ try:
122
+ x = float(el.get('x', 0)); y = float(el.get('y', 0))
123
+ w = float(el.get('width', 0)); h = float(el.get('height', 0))
124
+ except ValueError:
125
+ x = y = w = h = 0
126
+ fill = p.get('fill')
127
+ if w > 0 and h > 0 and fill and fill.startswith('#'):
128
+ out.append((x, y, w, h, fill.upper()))
129
+ walk(el, p)
130
+ walk(root, {})
131
+ return out
132
+
133
+ def referenced_defs_colors(root, css):
134
+ """被 marker-*/use/fill=url() 引用到的 defs 定义确实会渲染,其颜色受契约约束。
135
+
136
+ 整体跳过 defs 是过度修正:箭头 marker 用契约外的颜色就查不出来,
137
+ 与「连线与箭头同样受配色契约约束」的声明矛盾。
138
+ """
139
+ used = set()
140
+ for el in root.iter():
141
+ p = resolve(el, css, {})
142
+ for key in ('marker-start', 'marker-end', 'marker-mid', 'fill', 'stroke'):
143
+ v = (p.get(key) or el.get(key) or '').strip()
144
+ m = re.match(r'url\(#([^)]+)\)', v)
145
+ if m:
146
+ used.add(m.group(1))
147
+ if el.tag == f'{SVG}use':
148
+ href = el.get('href') or el.get('{http://www.w3.org/1999/xlink}href') or ''
149
+ if href.startswith('#'):
150
+ used.add(href[1:])
151
+ seen = set()
152
+ for defs in root.iter(f'{SVG}defs'):
153
+ for node in defs:
154
+ if node.get('id') not in used:
155
+ continue
156
+ for el in [node] + list(node.iter()):
157
+ p = resolve(el, css, {})
158
+ for key in ('fill', 'stroke'):
159
+ v = (p.get(key) or '').strip()
160
+ if v.startswith('#'):
161
+ seen.add(v.upper())
162
+ tag = el.tag.replace(SVG, '')
163
+ # 同 all_paint_colors:fill 初始值是 black。被引用的 marker
164
+ # 若省略 fill,实际渲染成黑色——不算进来就能靠省略绕过契约。
165
+ if tag in ('path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline'):
166
+ if not (p.get('fill') or '').strip():
167
+ seen.add('#000000')
168
+ return seen
169
+
170
+ def all_paint_colors(root, css):
171
+ """全部渲染元素解析后的 fill 与 stroke 颜色。
172
+
173
+ 只收文本色会放过形状底色、连线与箭头——而 C4 要求的配色一致性
174
+ 正是针对这些承载语义的元素。
175
+ """
176
+ seen = set()
177
+ def walk(node, inh):
178
+ for el in node:
179
+ p = resolve(el, css, inh)
180
+ tag = el.tag.replace(SVG, '')
181
+ if tag not in ('defs', 'style', 'title', 'desc'):
182
+ for key in ('fill', 'stroke'):
183
+ v = (p.get(key) or '').strip()
184
+ if v.startswith('#'):
185
+ seen.add(v.upper())
186
+ # SVG 的 fill 初始值是 black:未声明 fill 的形状确实渲染成黑色。
187
+ # 不算进来,就能靠「省略 fill」绕过配色契约。
188
+ if tag in ('path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline', 'text'):
189
+ if not (p.get('fill') or '').strip():
190
+ seen.add('#000000')
191
+ if tag != 'defs':
192
+ walk(el, p)
193
+ walk(root, {})
194
+ return seen
195
+
196
+ def group_path_map(root):
197
+ """元素 -> 其祖先 <g> 链(用 id() 标识),用于判定「同一卡片是否同组」。"""
198
+ m = {}
199
+ def walk(node, chain):
200
+ for el in node:
201
+ c = chain + [id(el)] if el.tag == f'{SVG}g' else chain
202
+ m[id(el)] = c
203
+ walk(el, c)
204
+ walk(root, [])
205
+ return m
206
+
207
+ def local_card_under(x, y, rects, canvas_area):
208
+ """点 (x,y) 是否压在一块**局部**底板(卡片)上。
209
+
210
+ 整幅底板(覆盖 ≥95% 画布)算的是页面底色、不是卡片:它随主题一起换,
211
+ 压在它上面的文字就是「主题底色上的文字」。只有明显小于画布的矩形才是卡片,
212
+ 卡片底色不随主题换,拿全局主题底色去判它上面的文字会误报。
213
+ """
214
+ best = None; best_area = None
215
+ for (rx, ry, rw, rh, fill) in rects:
216
+ # fill="none"/transparent 的矩形只是描边框,不构成底色。
217
+ # 把它当卡片会让包住低对比文字的边框直接豁免掉 THEME-CONTRAST。
218
+ if not (isinstance(fill, str) and fill.startswith('#')):
219
+ continue
220
+ if rx <= x <= rx + rw and ry <= y <= ry + rh:
221
+ area = rw * rh
222
+ if best is None or area <= best_area:
223
+ best, best_area = fill, area
224
+ if best is None or best_area is None:
225
+ return None
226
+ if canvas_area and best_area >= canvas_area * 0.95:
227
+ return None
228
+ return best
229
+
230
+ def bg_under(x, y, rects, page_bg='#FFFFFF'):
231
+ """返回包含点 (x,y) 的最内层(最后绘制的最小)矩形填充色。
232
+
233
+ 找不到任何包含矩形时返回 page_bg 只在「确实没有底板」时正确。
234
+ 调用方必须先用 unresolvable_paint() 判断本图是否含无法解析的呈现
235
+ (transform、圆/多边形/路径底、渐变、rgb()/CSS 变量),
236
+ 否则会把无法解析当成白底,产生假绿或假红。
237
+ """
238
+ best = None; best_area = None
239
+ for (rx, ry, rw, rh, fill) in rects:
240
+ if rx <= x <= rx + rw and ry <= y <= ry + rh:
241
+ area = rw * rh
242
+ # 后画的优先;同为包含时取更小的(更内层)
243
+ if best is None or area <= best_area:
244
+ best, best_area = fill, area
245
+ return best or page_bg
246
+
247
+ def unresolvable_paint(root, css):
248
+ """本图是否含本检查器解析不了的底色呈现。返回原因列表。"""
249
+ reasons = set()
250
+ def _walk(node):
251
+ for el in node:
252
+ tag = el.tag.replace(SVG, '')
253
+ # 整个 defs 子树都不参与渲染(marker/gradient 定义在里面),
254
+ # 扁平遍历会把箭头 marker 的填充误当成"非矩形底色"。
255
+ if tag in ('defs', 'style', 'title', 'desc'):
256
+ continue
257
+ _inspect(el)
258
+ _walk(el)
259
+ def _inspect(el):
260
+ tag = el.tag.replace(SVG, '')
261
+ if el.get('transform'):
262
+ reasons.add('transform')
263
+ p = resolve(el, css, {})
264
+ for key in ('fill', 'stroke'):
265
+ v = (p.get(key) or '').strip().lower()
266
+ if not v or v in ('none', 'inherit'):
267
+ continue
268
+ if v.startswith('url('):
269
+ reasons.add('gradient/pattern')
270
+ elif v.startswith('var('):
271
+ reasons.add('css-variable')
272
+ elif v.startswith('rgb') or v.startswith('hsl'):
273
+ reasons.add('rgb()/hsl()')
274
+ elif not v.startswith('#'):
275
+ reasons.add('named-colour')
276
+ if tag in ('circle', 'ellipse', 'polygon') and (p.get('fill') or '').startswith('#'):
277
+ reasons.add('non-rect background')
278
+ if tag == 'path':
279
+ fillv = (p.get('fill') or '').strip().lower()
280
+ if fillv and fillv != 'none':
281
+ reasons.add('non-rect background')
282
+ _walk(root)
283
+ return sorted(reasons)
284
+
285
+ def container_width(x, y, rects, page_w):
286
+ """文本锚点所在的最小容器宽度;找不到则用画布宽。"""
287
+ best = None
288
+ for (rx, ry, rw, rh, _f) in rects:
289
+ if rx <= x <= rx + rw and ry <= y <= ry + rh:
290
+ if best is None or rw < best[0]:
291
+ best = (rw, rx)
292
+ return best if best else (page_w, 0.0)
293
+
294
+ # ---------- 路径中点(用于连线标签检测) ----------
295
+
296
+ def path_points(d):
297
+ """粗采样:取 d 里所有坐标对。"""
298
+ nums = [float(n) for n in re.findall(r'-?\d+\.?\d*', d)]
299
+ return [(nums[i], nums[i+1]) for i in range(0, len(nums) - 1, 2)]
300
+
301
+ def path_mid(d):
302
+ """按弧长取真中点。
303
+
304
+ 早先取 pts[len//2],对「M x1 y1 L x2 y2」这种单段路径等于取到终点,
305
+ 于是终点节点自己的文字被当成连线标签,未标注的连线普遍误判为通过。
306
+ """
307
+ pts = path_points(d)
308
+ if len(pts) < 2:
309
+ return pts[0] if pts else None
310
+ seg = []
311
+ total = 0.0
312
+ for a, b in zip(pts, pts[1:]):
313
+ L = math.hypot(b[0] - a[0], b[1] - a[1])
314
+ seg.append((a, b, L))
315
+ total += L
316
+ if total <= 0:
317
+ return pts[0]
318
+ half = total / 2
319
+ run = 0.0
320
+ for a, b, L in seg:
321
+ if run + L >= half:
322
+ t = (half - run) / L if L else 0.0
323
+ return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
324
+ run += L
325
+ return pts[-1]
326
+
327
+
328
+ # ---------- 边交叉与交叉角(Purchase 1997 / Huang-Eades-Hong 2014) ----------
329
+ # 只报数,不设阈:实证给出的是**排序**(交叉 > 弯折 > 对称),不是可量化门槛。
330
+ # 由它推出具体数字就变成了 references/figure-and-table-craft.md §9 删掉那四条的同类。
331
+
332
+ def _segments(d):
333
+ pts = path_points(d)
334
+ return list(zip(pts, pts[1:]))
335
+
336
+ def _seg_cross(a, b):
337
+ (x1, y1), (x2, y2) = a
338
+ (x3, y3), (x4, y4) = b
339
+ den = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3)
340
+ if abs(den) < 1e-9:
341
+ return None # 平行或共线,不计为交叉
342
+ t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / den
343
+ u = ((x3 - x1) * (y2 - y1) - (y3 - y1) * (x2 - x1)) / den
344
+ if not (0.0 < t < 1.0 and 0.0 < u < 1.0):
345
+ return None # 端点相接不算交叉
346
+ v1 = (x2 - x1, y2 - y1)
347
+ v2 = (x4 - x3, y4 - y3)
348
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
349
+ n1 = math.hypot(*v1); n2 = math.hypot(*v2)
350
+ if n1 == 0 or n2 == 0:
351
+ return None
352
+ ang = math.degrees(math.acos(max(-1.0, min(1.0, dot / (n1 * n2)))))
353
+ return min(ang, 180.0 - ang) # 取锐角
354
+
355
+ def crossing_stats(flows):
356
+ """返回 (交叉数, 最小交叉角) —— 只描述事实。"""
357
+ segs = []
358
+ for fl in flows:
359
+ segs.append(_segments(fl['d']))
360
+ count = 0
361
+ min_ang = None
362
+ for i in range(len(segs)):
363
+ for j in range(i + 1, len(segs)):
364
+ for sa in segs[i]:
365
+ for sb in segs[j]:
366
+ ang = _seg_cross(sa, sb)
367
+ if ang is None:
368
+ continue
369
+ count += 1
370
+ if min_ang is None or ang < min_ang:
371
+ min_ang = ang
372
+ return count, min_ang
373
+
374
+
375
+ # ---------- 色觉障碍:二色视模拟(Viénot 1999) ----------
376
+ # [外] 链路来自 Brettel/Viénot/Mollon 1997 与 Viénot/Brettel/Mollon 1999:
377
+ # sRGB → 去伽马 → linearRGB → LMS(Smith&Pokorny/Judd-Vos) → 沿缺陷轴投影到不变平面 → 回 sRGB。
378
+ # 投影的代表色取在**单侧二色视者双眼一致**的不变轴上(protan/deutan: 475nm 与 575nm;
379
+ # tritan: 485nm 与 660nm),所以模拟输出的亮度变化是算法的正确行为,
380
+ # **不是** "红色应该变暗" —— 那句说的是「红与黑难以区分」,是另一个量。
381
+ # 本目录曾用错误锚点(期望红色变暗)去验这个实现,差点判一个正确算法有 bug。
382
+ #
383
+ # [工] 判据本身:语义 token 两两在三类二色视下的最小距离。**只报数不设阈**——
384
+ # 参照点用实测给出(Okabe-Ito 八色 38,随机八色 10),是否可接受由人判断。
385
+
386
+ _LMS_FROM_RGB = ((0.17886, 0.43997, 0.03597),
387
+ (0.03380, 0.27515, 0.03621),
388
+ (0.00031, 0.00195, 0.01528))
389
+
390
+ def _inv3(m):
391
+ a, b, c = m[0]; d, e, f = m[1]; g, h, i = m[2]
392
+ det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
393
+ return ((( e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det),
394
+ (( f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det),
395
+ (( d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det))
396
+
397
+ _RGB_FROM_LMS = _inv3(_LMS_FROM_RGB)
398
+
399
+ def _srgb_to_lin(c):
400
+ c /= 255.0
401
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
402
+
403
+ def _lin_to_srgb(c):
404
+ c = max(0.0, min(1.0, c))
405
+ return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
406
+
407
+ def _mat3(m, v):
408
+ return [sum(m[r][k] * v[k] for k in range(3)) for r in range(3)]
409
+
410
+ def simulate_cvd(hexcol, kind):
411
+ """把一个 sRGB 十六进制色投影到指定二色视的可见色。"""
412
+ h = hexcol.lstrip('#')
413
+ if len(h) == 3:
414
+ h = ''.join(ch * 2 for ch in h)
415
+ lin = [_srgb_to_lin(int(h[i:i + 2], 16)) for i in (0, 2, 4)]
416
+ l, m_, s = _mat3(_LMS_FROM_RGB, lin)
417
+ if kind == 'protan':
418
+ l = 2.02344 * m_ - 2.52581 * s
419
+ elif kind == 'deutan':
420
+ m_ = 0.49421 * l + 1.24827 * s
421
+ elif kind == 'tritan':
422
+ s = -0.012491 * l + 0.072154 * m_
423
+ out = _mat3(_RGB_FROM_LMS, [l, m_, s])
424
+ return tuple(round(_lin_to_srgb(c) * 255) for c in out)
425
+
426
+ def cvd_min_distance(colors):
427
+ """返回 (最小距离, 色对, 缺陷类型);colors 为十六进制串序列。"""
428
+ cols = [c for c in colors if isinstance(c, str) and c.startswith('#') and len(c) in (4, 7)]
429
+ worst = None
430
+ for i in range(len(cols)):
431
+ for j in range(i + 1, len(cols)):
432
+ for kind in ('protan', 'deutan', 'tritan'):
433
+ a = simulate_cvd(cols[i], kind)
434
+ b = simulate_cvd(cols[j], kind)
435
+ dist = math.dist(a, b)
436
+ if worst is None or dist < worst[0]:
437
+ worst = (dist, (cols[i], cols[j]), kind)
438
+ return worst
439
+
440
+ # ---------- 版式契约 ----------
441
+
442
+ CONTRACT_NAME = 'figure-contract.json'
443
+
444
+ _CONTRACT_CACHE: dict = {}
445
+
446
+ def load_contract(start_dir):
447
+ """向上找 figure-contract.json。返回 (contract|None, path|None)。
448
+
449
+ 按目录缓存:多目录输入时每个文件各自解析最近的契约,
450
+ 不能用第一个文件那棵树的契约去套第二棵。
451
+ """
452
+ key = os.path.abspath(start_dir)
453
+ if key in _CONTRACT_CACHE:
454
+ return _CONTRACT_CACHE[key]
455
+ d = key
456
+ result = (None, None)
457
+ for _ in range(6):
458
+ p = os.path.join(d, CONTRACT_NAME)
459
+ if os.path.isfile(p):
460
+ try:
461
+ result = (validate_contract(json.load(open(p, encoding='utf8'))), p)
462
+ except Exception as e:
463
+ result = ({'__error__': str(e)}, p)
464
+ break
465
+ nd = os.path.dirname(d)
466
+ if nd == d:
467
+ break
468
+ d = nd
469
+ _CONTRACT_CACHE[key] = result
470
+ return result
471
+
472
+ REQUIRED_CONTRACT_FIELDS = ('canvas_ratios', 'font_scale', 'color_tokens')
473
+
474
+ def validate_contract(c):
475
+ """类型与完备性校验。
476
+
477
+ 字段全可选是个绕过口:放一个空的 figure-contract.json 就能让比例、字号、
478
+ 配色三项检查全部跳过,而闸仍报"有契约"。所以三项受检字段必须存在且非空。
479
+ """
480
+ if not isinstance(c, dict):
481
+ return {'__error__': 'contract root must be an object'}
482
+ errs = []
483
+ missing = [k for k in REQUIRED_CONTRACT_FIELDS if not c.get(k)]
484
+ if missing:
485
+ errs.append('missing or empty required field(s): ' + ', '.join(missing))
486
+ r = c.get('canvas_ratios')
487
+ if r is not None:
488
+ if not isinstance(r, list) or not r or not all(isinstance(x, (int, float)) for x in r):
489
+ errs.append('canvas_ratios must be a non-empty list of numbers')
490
+ f = c.get('font_scale')
491
+ if f is not None:
492
+ if not isinstance(f, list) or not f or not all(isinstance(x, (int, float)) for x in f):
493
+ errs.append('font_scale must be a non-empty list of numbers')
494
+ t = c.get('color_tokens')
495
+ if t is not None:
496
+ vals = t.values() if isinstance(t, dict) else (t if isinstance(t, list) else None)
497
+ if vals is None or not all(isinstance(x, str) for x in vals):
498
+ errs.append('color_tokens must be an object or list of colour strings')
499
+ sc = c.get('semantic_colors')
500
+ if sc is not None:
501
+ # 不校验类型时,数组/字符串会在下游 .values() 处直接崩;非法颜色会被静默丢弃成空集,
502
+ # 于是「没报 CVD」既可能是「距离没问题」也可能是「压根没测」——两者必须可区分。
503
+ if not isinstance(sc, dict) or not sc:
504
+ errs.append('semantic_colors must be a non-empty object mapping semantic name to colour')
505
+ else:
506
+ bad = [f'{k}={v!r}' for k, v in sc.items()
507
+ if not (isinstance(v, str) and re.fullmatch(r'#[0-9A-Fa-f]{6}', v))]
508
+ if bad:
509
+ errs.append('semantic_colors values must be #RRGGBB: ' + ', '.join(sorted(bad)))
510
+ else:
511
+ known = set()
512
+ if isinstance(t, dict):
513
+ known = {str(v).upper() for v in t.values() if isinstance(v, str)}
514
+ elif isinstance(t, list):
515
+ known = {str(v).upper() for v in t if isinstance(v, str)}
516
+ stray = sorted({v.upper() for v in sc.values()} - known)
517
+ if known and stray:
518
+ errs.append('semantic_colors must be a subset of color_tokens; not a token: '
519
+ + ', '.join(stray))
520
+ tol = c.get('ratio_tolerance')
521
+ if tol is not None and not isinstance(tol, (int, float)):
522
+ errs.append('ratio_tolerance must be a number')
523
+ th = c.get('themes')
524
+ if th is not None:
525
+ if not isinstance(th, dict) or not th:
526
+ errs.append('themes must be a non-empty object keyed by theme name')
527
+ else:
528
+ for name, vals in th.items():
529
+ if not isinstance(vals, dict):
530
+ errs.append(f'themes.{name} must be an object carrying a surface colour')
531
+ continue
532
+ sv = vals.get('surface')
533
+ # 只判真值会让 {"surface":"black"} 通过校验、随后因不是 # 开头被静默跳过——假绿。
534
+ if not (isinstance(sv, str) and re.fullmatch(r'#[0-9A-Fa-f]{6}', sv)):
535
+ errs.append(f'themes.{name}.surface must be a #RRGGBB colour, got {sv!r}')
536
+ if errs:
537
+ return {'__error__': '; '.join(errs)}
538
+ return c
539
+
540
+ def check_contract(stats, contract):
541
+ """把一张图的实测统计与契约比对。返回 findings 列表。
542
+
543
+ 契约不规定「几档」,只规定「哪几档」——一致性是外部要求(C4:
544
+ colour coding consistent within and across diagrams),
545
+ 具体取值是团队自选。偏离契约才是缺陷。
546
+ """
547
+ out = []
548
+ if not contract:
549
+ return out
550
+ ratios = contract.get('canvas_ratios')
551
+ if ratios and stats.get('ratio') is not None:
552
+ # 容差由契约声明,不由检查器内置。内置 0.02 是魔数,且会让
553
+ # 契约里没有的比例照样通过——与「一致性即契约符合性」矛盾。
554
+ tol = float(contract.get('ratio_tolerance', 0.0))
555
+ if not any(abs(stats['ratio'] - float(r)) <= tol for r in ratios):
556
+ suffix = '' if tol else '(契约未声明 ratio_tolerance,按精确匹配)'
557
+ out.append(('ERROR', 'CONTRACT-RATIO',
558
+ f"画布比例 {stats['ratio']:.6g} 不在契约允许集 {ratios}{suffix}"))
559
+ scale = contract.get('font_scale')
560
+ if scale:
561
+ allowed = {float(x) for x in scale}
562
+ stray = sorted(set(stats.get('sizes', [])) - allowed)
563
+ if stray:
564
+ out.append(('ERROR', 'CONTRACT-FONT-SCALE',
565
+ f"字号 {[f'{s:g}' for s in stray]} 不在契约阶梯 {sorted(allowed)} 内"))
566
+ tokens = contract.get('color_tokens')
567
+ if tokens:
568
+ allowed = {str(v).upper() for v in (tokens.values() if isinstance(tokens, dict) else tokens)}
569
+ # 取全部渲染元素的 fill 与 stroke,不只文本色:形状底色、连线与箭头
570
+ # 同样承载语义,只查文本色等于放过了大部分跨图配色不一致。
571
+ stray = sorted(set(stats.get('tokens', [])) - allowed)
572
+ if stray:
573
+ out.append(('ERROR', 'CONTRACT-COLOR-TOKEN',
574
+ f"颜色 {stray} 不在契约 token 表内"))
575
+ return out
576
+
577
+ # ---------- 主检查 ----------
578
+
579
+ def lint(path, contract_hint=None):
580
+ contract_hint = contract_hint or {}
581
+ findings = []
582
+ # 逐文件捕获:一个非 UTF-8 / 消失 / 不可读的文件不得中断整批,
583
+ # 否则 --json 输出被破坏、其余文件的检查结果一起消失。
584
+ try:
585
+ src = open(path, encoding='utf8').read()
586
+ except (UnicodeDecodeError, OSError) as e:
587
+ return ([{'level': 'ERROR', 'code': 'READ',
588
+ 'msg': f'无法读取: {type(e).__name__}: {e}'}], {})
589
+ def add(level, code, msg):
590
+ findings.append({'level': level, 'code': code, 'msg': msg})
591
+
592
+ try:
593
+ root = ET.fromstring(src)
594
+ except ET.ParseError as e:
595
+ add('ERROR', 'PARSE', f'SVG 解析失败: {e}')
596
+ return findings, {}
597
+
598
+ css = parse_css(src)
599
+ vb = root.get('viewBox')
600
+ if vb:
601
+ try:
602
+ p = [float(v) for v in vb.split()]
603
+ except ValueError:
604
+ p = []
605
+ if len(p) != 4 or not all(math.isfinite(v) for v in p) or p[2] <= 0 or p[3] <= 0:
606
+ add('ERROR', 'GEOMETRY', f'viewBox 非法(需四个有限数且宽高为正): "{vb}"')
607
+ p = [0.0, 0.0, 0.0, 0.0]
608
+ page_w, page_h = p[2], p[3]
609
+ else:
610
+ page_w = px(root.get('width'), 0) or 0
611
+ page_h = px(root.get('height'), 0) or 0
612
+ add('WARN', 'C4-VIEWBOX', '缺 viewBox,缩放行为不确定')
613
+ # 不预先舍入:先舍入再做「精确」比较等于引入未声明容差(16:9 的 1.7777… 会通过 [1.778])
614
+ ratio = (page_w / page_h) if page_h else None
615
+
616
+ rects = rects_of(root, css)
617
+ # 含 transform 等无法解析的呈现时,所有依赖几何的判据都不可信——
618
+ # 必须在第一个几何判据之前就算出来。
619
+ stats_tokens_for_cvd = sorted(all_paint_colors(root, css) | referenced_defs_colors(root, css))
620
+ paint_gaps = unresolvable_paint(root, css)
621
+ # 任何不可解析的呈现都让几何/底色判据不可信,不只 transform:
622
+ # 渐变、圆/多边形底、rgb()/CSS 变量同样会让「按白底算」得出假红或假绿。
623
+ geometry_unsafe = bool(paint_gaps)
624
+
625
+ # --- C4: 标题 ---
626
+ title_el = root.find(f'{SVG}title')
627
+ title = (title_el.text or '').strip() if title_el is not None else ''
628
+ if not title:
629
+ add('ERROR', 'C4-TITLE', '缺 <title>:C4 要求每张图有标题,说清图的类型与范围')
630
+
631
+ # --- 收集文本 ---
632
+ texts = []
633
+ def walk_text(node, inh):
634
+ for el in node:
635
+ p = resolve(el, css, inh)
636
+ if el.tag == f'{SVG}text':
637
+ content = ''.join(el.itertext()).strip()
638
+ content = ' '.join(content.split())
639
+ tspans = el.findall(f'{SVG}tspan')
640
+ try:
641
+ tx = float(el.get('x', 0)); ty = float(el.get('y', 0))
642
+ except ValueError:
643
+ tx = ty = 0.0
644
+ texts.append({
645
+ 'el': el,
646
+ 'text': content, 'x': tx, 'y': ty,
647
+ 'size': px(p.get('font-size'), 16.0),
648
+ 'fill': (p.get('fill') or '#000000').upper(),
649
+ 'bold': str(p.get('font-weight', '')).strip() in ('bold', '700', '800', '900'),
650
+ 'tspans': len(tspans),
651
+ 'anchor': el.get('text-anchor') or p.get('text-anchor') or 'start',
652
+ 'class': el.get('class') or '',
653
+ })
654
+ walk_text(el, p)
655
+ walk_text(root, {})
656
+
657
+ # --- C4: 图例 ---
658
+ # 判据一:显式关键字。判据二:存在 ≥3 个小色块,每个近旁有短文本。
659
+ has_kw = bool(re.search(r'图例|legend|Legend|图示说明', src))
660
+ swatches = [r for r in rects if 8 <= r[2] <= 40 and 8 <= r[3] <= 40]
661
+ paired = 0
662
+ for (rx, ry, rw, rh, _f) in swatches:
663
+ for t in texts:
664
+ if abs(t['y'] - (ry + rh / 2)) < rh and 0 < (t['x'] - (rx + rw)) < 160 and len(t['text']) <= 24:
665
+ paired += 1
666
+ break
667
+ if not has_kw and paired < 3:
668
+ add('WARN', 'C4-LEGEND',
669
+ f'未检出图例(关键字未命中;配对色块={paired}/需≥3)。'
670
+ f'C4「每张图都要有图例」是 [外],但本检测是 [工] 代理且**两个方向都会错**:'
671
+ f'两条目的合法图例会被拒,注释/desc/class 里出现 legend 字样又会放行——故非阻断,需人工确认')
672
+
673
+ # --- C4: 连线标签 ---
674
+ # 先识别全部连接线,再分别校验方向与标签。
675
+ # 早先只收「已经带 marker-end 的」——没有箭头的连线因此完全不进检查,
676
+ # 而「每条线单向」恰恰要求它必须有且只有一个方向箭头;双向线同样漏掉。
677
+ flows = []
678
+ def _markers(el, p):
679
+ return (
680
+ bool(p.get('marker-start') or el.get('marker-start')),
681
+ bool(p.get('marker-end') or el.get('marker-end')),
682
+ )
683
+ # 连线必须**显式声明**:class 命中契约声明的连线类名(默认 flow/edge/link/connector/arrow),
684
+ # 或自身带方向 marker。把「所有有描边的 path / 所有 line」都当连线会误挡分隔线、
685
+ # 网格线与线性图标——那是制造误报的判据,与本轮删掉那四条同类。
686
+ conn_classes = set(contract_hint.get('connector_classes') or
687
+ ('flow', 'edge', 'link', 'connector', 'arrow'))
688
+ def _declared_connector(el, p):
689
+ if set((el.get('class') or '').split()) & conn_classes:
690
+ return True
691
+ return any(_markers(el, p))
692
+ # 样式沿祖先继承:stroke 写在父 <g> 上的真实连线,用空继承解析会被漏掉。
693
+ def _walk_conn(node, inh):
694
+ for el in node:
695
+ p = resolve(el, css, inh)
696
+ tag = el.tag.replace(SVG, '')
697
+ if tag == 'path' and el.get('d') and _declared_connector(el, p):
698
+ flows.append({'d': el.get('d'), 'markers': _markers(el, p)})
699
+ elif tag == 'line' and _declared_connector(el, p):
700
+ flows.append({'d': f"M{el.get('x1',0)} {el.get('y1',0)} L{el.get('x2',0)} {el.get('y2',0)}",
701
+ 'markers': _markers(el, p)})
702
+ if tag != 'defs':
703
+ _walk_conn(el, p)
704
+ _walk_conn(root, {})
705
+ for fl in flows:
706
+ ms, me = fl['markers']
707
+ if ms and me:
708
+ add('ERROR', 'C4-EDGE-DIRECTION',
709
+ ' 连线同时带 marker-start 与 marker-end(双向):C4 要求每条线单向')
710
+ elif not ms and not me:
711
+ add('ERROR', 'C4-EDGE-DIRECTION',
712
+ '连线无方向箭头:C4 要求每条线单向且方向可读')
713
+
714
+ # Purchase 1997:削边交叉的收益远大于弯折与对称;正交对齐与出边夹角统计不显著。
715
+ # Huang/Eades/Hong 2014:不可避免的交叉,角度越大越好、不必直角。
716
+ # 两者给的都是排序不是阈值,所以这里只报数,交给人判断。
717
+ if len(flows) > 1 and not geometry_unsafe:
718
+ xn, xang = crossing_stats(flows)
719
+ if xn:
720
+ ang_txt = f',最小交叉角 {xang:.0f}°' if xang is not None else ''
721
+ add('WARN', 'GRAPH-CROSSINGS',
722
+ f'连线两两交叉 {xn} 处{ang_txt}。'
723
+ f'[外] 实证(Purchase 1997):削减边交叉的收益远大于减少弯折与提高对称,'
724
+ f'且不值得为对齐正交网格或加大出边夹角而牺牲它;'
725
+ f'交叉角越大越好但不必直角(Huang/Eades/Hong 2014)。'
726
+ f'**只报数不设阈**——实证给的是排序不是门槛,是否可接受由人判断')
727
+ unlabeled = 0
728
+ RADIUS = 90.0
729
+ for fl in flows:
730
+ d = fl['d']
731
+ mid = path_mid(d)
732
+ if not mid:
733
+ continue
734
+ found = False
735
+ for t in texts:
736
+ if not t['text'] or len(t['text']) > 30:
737
+ continue
738
+ # 节点框内的文字是节点名不是连线标签——早先不做这层排除,
739
+ # 于是终点节点的名字被当成了标签。
740
+ in_node = any(rx <= t['x'] <= rx + rw and ry <= t['y'] <= ry + rh
741
+ and rw < page_w * 0.95
742
+ for (rx, ry, rw, rh, _f) in rects)
743
+ if in_node:
744
+ continue
745
+ if math.hypot(t['x'] - mid[0], t['y'] - mid[1]) <= RADIUS:
746
+ found = True
747
+ break
748
+ if not found:
749
+ unlabeled += 1
750
+ if flows and unlabeled and not geometry_unsafe:
751
+ add('WARN', 'C4-EDGE-LABEL',
752
+ f'{unlabeled}/{len(flows)} 条连线在中点 {RADIUS:.0f}px 内找不到标签文本。'
753
+ f'C4「每条线带具体标签」是 [外],但本检测是 [工] 的邻近代理、两个方向都会错:'
754
+ f'中点附近的分区标题会掩盖未标注连线,标在别处的合法标签又会被拒——故非阻断;报出的是下界')
755
+
756
+ # 通用连线标签词
757
+ for t in texts:
758
+ if t['text'] in ('Uses', 'uses', '调用', '依赖', '使用', '关联'):
759
+ add('WARN', 'C4-EDGE-VAGUE', f'连线标签过于笼统: "{t["text"]}"')
760
+
761
+ # --- WCAG 1.4.3 对比度(底色按几何解析) ---
762
+ # 解析不了底色时明确报 UNSUPPORTED,不按白底判——按白底判会产生
763
+ # 假绿(深字压深底被判通过)或假红(浅字压浅底被判失败)。
764
+ if paint_gaps:
765
+ add('WARN', 'CONTRAST-UNSUPPORTED',
766
+ f'本图含本检查器无法解析底色/坐标的呈现({", ".join(paint_gaps)}):'
767
+ f'{"依赖几何的 ERROR 判据(对比度/溢出/分组/连线标签)已全部跳过," if geometry_unsafe else ""}'
768
+ f'结果不可信,需人工核对')
769
+ for t in texts:
770
+ if not t['text'] or not t['fill'].startswith('#'):
771
+ continue
772
+ bg = bg_under(t['x'], t['y'] - t['size'] * 0.35, rects)
773
+ large = t['size'] >= 24 or (t['size'] >= 18.66 and t['bold'])
774
+ need = 3.0 if large else 4.5
775
+ try:
776
+ c = contrast(t['fill'], bg)
777
+ except Exception:
778
+ continue
779
+ if c < need and not geometry_unsafe:
780
+ add('ERROR', 'WCAG-143',
781
+ f'对比度 {c:.2f} < {need}({t["size"]:g}px {t["fill"]} 压在 {bg} 上): "{t["text"][:24]}"')
782
+
783
+ # --- SVG 文本不换行 → 几何溢出 ---
784
+ for t in texts:
785
+ if not t['text'] or t['tspans'] > 0:
786
+ continue
787
+ w = est_width(t['text'], t['size'])
788
+ cw, cx = container_width(t['x'], t['y'] - t['size'] * 0.35, rects, page_w)
789
+ if t['anchor'] == 'middle':
790
+ avail = min(t['x'] - cx, cx + cw - t['x']) * 2
791
+ elif t['anchor'] == 'end':
792
+ avail = t['x'] - cx
793
+ else:
794
+ avail = cx + cw - t['x']
795
+ avail = max(avail, 0)
796
+ if avail > 0 and w > avail * 1.02 and not geometry_unsafe:
797
+ add('ERROR', 'SVG-OVERFLOW',
798
+ f'文本估算宽 {w:.0f}px > 可用 {avail:.0f}px 且未用 tspan 拆行(SVG <text> 默认不换行): "{t["text"][:28]}"')
799
+
800
+ # --- 分组(目标端 z 序重排防护) ---
801
+ # 早先只查「全图是否存在任意一个 <g>」——一个无关的组就能让整图通过,
802
+ # 而契约要求的是每张卡片(形状 + 其文字)自身成组。
803
+ gmap = group_path_map(root)
804
+ el_by_id = {}
805
+ def _index(node):
806
+ for el in node:
807
+ el_by_id[id(el)] = el
808
+ _index(el)
809
+ _index(root)
810
+ ungrouped_cards = 0
811
+ card_total = 0
812
+ def _is_card_rect(e):
813
+ try:
814
+ w = float(e.get('width', 0)); h = float(e.get('height', 0))
815
+ x = float(e.get('x', 0)); y = float(e.get('y', 0))
816
+ except ValueError:
817
+ return False
818
+ if w <= 0 or h <= 0 or w >= page_w * 0.95:
819
+ return False
820
+ return any(x <= t['x'] <= x + w and y <= t['y'] <= y + h for t in texts)
821
+ card_rects = [e for e in root.iter(f'{SVG}rect') if _is_card_rect(e)]
822
+ for el in root.iter(f'{SVG}rect'):
823
+ try:
824
+ rx = float(el.get('x', 0)); ry = float(el.get('y', 0))
825
+ rw = float(el.get('width', 0)); rh = float(el.get('height', 0))
826
+ except ValueError:
827
+ continue
828
+ if rw <= 0 or rh <= 0 or rw >= page_w * 0.95:
829
+ continue # 跳过整页底板
830
+ inside = [t for t in texts
831
+ if rx <= t['x'] <= rx + rw and ry <= t['y'] <= ry + rh]
832
+ if not inside:
833
+ continue
834
+ card_total += 1
835
+ rect_chain = gmap.get(id(el), [])
836
+ if not rect_chain:
837
+ ungrouped_cards += 1
838
+ continue
839
+ # 「祖先集合有交集」不够:把整张图包进一个顶层 <g>,每张卡片各自未分组
840
+ # 也会全部通过。判据是这张卡片有一个**专属**的最近公共组——
841
+ # 该组不能同时容纳其它卡片的形状。
842
+ bad_card = False
843
+ for t in inside:
844
+ tel = t.get('el')
845
+ text_chain = gmap.get(id(tel), [])
846
+ common = [g for g in rect_chain if g in text_chain]
847
+ if not common:
848
+ bad_card = True
849
+ break
850
+ nearest = common[-1] # 链尾 = 最近公共组
851
+ members = [r for r in card_rects
852
+ if nearest in gmap.get(id(r), [])]
853
+ if len(members) > 1:
854
+ bad_card = True # 该组不是这张卡片专属的
855
+ break
856
+ if bad_card:
857
+ ungrouped_cards += 1
858
+ if card_total and ungrouped_cards and not geometry_unsafe:
859
+ add('ERROR', 'GROUPING',
860
+ f'{ungrouped_cards}/{card_total} 张卡片的形状与其文字不在同一个 <g> 内:'
861
+ f'推送到会重排 z 序的目标端时,矩形可能盖住文字')
862
+ elif card_total == 0 and len(list(root.iter(f'{SVG}g'))) == 0:
863
+ add('ERROR', 'GROUPING', '全图零 <g> 分组:目标端重排 z 序时矩形可能盖住文字')
864
+
865
+ # --- 主题:语义色需在每个声明的底色上都达标 ---
866
+ # [工] 由 [外] WCAG 1.4.3 推出:契约声明了多个主题底色时,同一语义色要在每个底上算。
867
+ # 一张写死十六进制、没有主题维度的 token 表,在深色主题下整体失效——
868
+ # 设计系统实践称之为「token 系统坏了」:深色应当 swap token 值,而不是另写一套硬编码色。
869
+ themes = (contract_hint or {}).get('themes') or {}
870
+ if themes and geometry_unsafe:
871
+ # 底色解析不可信时(渐变、非矩形底板、transform、CSS 变量),
872
+ # local_card_under 对圆形/路径卡片一律返回 None,会把卡片上的文字
873
+ # 误按全局主题底色判——报出来的是假红。明说跳过,不静默。
874
+ add('WARN', 'THEME-UNASSESSED',
875
+ f'本图含无法解析的底色/坐标呈现,{len(themes)} 个声明主题的对比度全部未判定——'
876
+ f'不是「判过没问题」。需人工核对或把底板改成可解析的矩形')
877
+ elif themes:
878
+ for tname, tvals in themes.items():
879
+ bg = tvals.get('surface')
880
+ if not (isinstance(bg, str) and bg.startswith('#')):
881
+ continue
882
+ if bg.upper() == '#000000':
883
+ add('WARN', 'THEME-PURE-BLACK',
884
+ f'主题 {tname} 的底色是纯黑 #000000:建议 #0F0F0F–#1E1E1E 区间,纯黑缺少层次')
885
+ for t in texts:
886
+ if not t['text'] or not t['fill'].startswith('#'):
887
+ continue
888
+ # 只判直接压在主题底色上的文本。压在局部卡片上的文本,其实际底色
889
+ # 由卡片决定、随主题另行 swap,拿全局底色去判会误报
890
+ # (深底上的白卡片 + 深字,实际可读)。
891
+ card = local_card_under(t['x'], t['y'] - t['size'] * 0.35, rects,
892
+ (page_w * page_h) if (page_w and page_h) else 0)
893
+ if card is not None:
894
+ continue
895
+ large = t['size'] >= 24 or (t['size'] >= 18.66 and t['bold'])
896
+ need = 3.0 if large else 4.5
897
+ try:
898
+ c_ = contrast(t['fill'], bg)
899
+ except Exception:
900
+ continue
901
+ if c_ < need:
902
+ add('WARN', 'THEME-CONTRAST',
903
+ f'主题 {tname}:{t["size"]:g}px {t["fill"]} 压在 {bg} 上仅 {c_:.2f} < {need}'
904
+ f'——同一套写死的色在另一主题下失效,应按主题 swap token 值')
905
+ break
906
+
907
+ # --- 色觉障碍:语义色对是否仍可分([外] Viénot 1999 链路;[工] 只报数不设阈)---
908
+ # 只比**契约显式声明为语义状态**的色。早先比全部渲染色(含装饰、边框、底色),
909
+ # 等于假定任意两色都编码不同语义——那不成立。契约未声明语义色时不报。
910
+ sem_map = (contract_hint or {}).get('semantic_colors') or {}
911
+ sem = sorted({v.upper() for v in sem_map.values()
912
+ if isinstance(v, str) and re.fullmatch(r'#[0-9A-Fa-f]{6}', v)})
913
+ if len(sem) >= 2:
914
+ worst = cvd_min_distance(sem)
915
+ # 报的是**测量值与参照点**,不下「不可分」的判定——本检查器没有阈值依据。
916
+ if worst:
917
+ add('INFO', 'CVD-DISTANCE',
918
+ f'契约声明的语义色中,最接近的一对是 {worst[1][0]}/{worst[1][1]},'
919
+ f'在 {worst[2]} 下距离 {worst[0]:.0f}。**这是测量值不是判定**——'
920
+ f'参照点:Okabe-Ito 八色最小 38、随机八色 10。'
921
+ f'[外] 模拟链路来自 Brettel/Viénot/Mollon;本检查器无阈值依据,是否可接受由人判断'
922
+ f'(语义不应只靠颜色编码,可加形状/图标/文字冗余)')
923
+
924
+ # --- 重复出现的值应成为 token([工],来自设计系统实践:出现 >2 次的值就该是 token)---
925
+ # 这条比「偏离契约」更早一步:它能发现契约**该有而没有**的 token,
926
+ # 不必等人先把契约写全。
927
+ if not contract_hint.get('color_tokens'):
928
+ import collections as _c2
929
+ freq = _c2.Counter()
930
+ for m_ in re.finditer(r'(?:fill|stroke)\s*[:=]\s*"?(#[0-9A-Fa-f]{6})', src):
931
+ freq[m_.group(1).upper()] += 1
932
+ untokened = [c for c, n in freq.items() if n > 2]
933
+ if untokened:
934
+ add('WARN', 'VALUE-SHOULD-BE-TOKEN',
935
+ f'{len(untokened)} 个颜色各出现 >2 次却无契约 token 表:'
936
+ f'重复出现的值应当成为具名 token,否则每张图都要重新决定配色')
937
+
938
+ # --- 图注结构:figure/figcaption 与无障碍标签承载同一主张 ---
939
+ has_aria = bool(root.get('aria-label') or root.get('aria-labelledby'))
940
+ has_role = (root.get('role') == 'img')
941
+ if title and not (has_aria or has_role):
942
+ add('WARN', 'FIGURE-A11Y-STRUCTURE',
943
+ '有 <title> 但缺 role="img" 与 aria-label/aria-labelledby:'
944
+ '看不见图的读者拿不到图的主张。二者应承载同一句话')
945
+
946
+ # --- 图退化成并列框:有框无线 = 列表的图形版 ---
947
+ if card_total >= 3 and not flows:
948
+ add('WARN', 'FIGURE-IS-A-LIST',
949
+ f'{card_total} 个卡片、0 条连线:一组互不相连的标注框是**列表的图形版**,不是图。'
950
+ f'图的价值在于让读者看见机制——若没有关系要画,用列表或表更快')
951
+
952
+ # --- 流向一致:主方向混用会增加阅读成本 ---
953
+ if len(flows) >= 3 and not geometry_unsafe:
954
+ import collections as _c
955
+ dirs = _c.Counter()
956
+ for fl in flows:
957
+ ms, me = fl['markers']
958
+ if ms and me:
959
+ continue # 双向边没有单一方向,不计入分布
960
+ if not ms and not me:
961
+ continue # 两端都没箭头 = 无向连接;按 d 的书写顺序定向是凭空造方向
962
+ pts = path_points(fl['d'])
963
+ if len(pts) < 2:
964
+ continue
965
+ if ms and not me:
966
+ pts = pts[::-1] # marker-start 表示方向相反
967
+ dx = pts[-1][0] - pts[0][0]
968
+ dy = pts[-1][1] - pts[0][1]
969
+ if abs(dx) >= abs(dy):
970
+ dirs['L2R' if dx >= 0 else 'R2L'] += 1
971
+ else:
972
+ dirs['T2B' if dy >= 0 else 'B2T'] += 1
973
+ if dirs:
974
+ main, n = dirs.most_common(1)[0]
975
+ total = sum(dirs.values())
976
+ if n / total < 0.7:
977
+ add('WARN', 'FLOW-DIRECTION-MIXED',
978
+ f'连线主方向分布 {dict(dirs)}:主方向仅占 {n}/{total}。'
979
+ f'混用流向增加阅读成本;[工] 0.7 为本检查器的提示线,无外部阈值依据')
980
+
981
+
982
+ stats = {
983
+ 'ratio': ratio,
984
+ 'sizes': sorted({t['size'] for t in texts}),
985
+ 'tokens': sorted(all_paint_colors(root, css) | referenced_defs_colors(root, css)),
986
+ 'texts': len(texts),
987
+ 'flows': len(flows),
988
+ }
989
+ return findings, stats
990
+
991
+
992
+ def main(argv):
993
+ as_json = '--json' in argv
994
+ args = [a for a in argv[1:] if not a.startswith('--')]
995
+ files = []
996
+ for a in args:
997
+ if os.path.isdir(a):
998
+ files += sorted(glob.glob(os.path.join(a, '*.svg')))
999
+ else:
1000
+ files.append(a)
1001
+ if not files:
1002
+ print('用法: figure-lint.py <dir-or-file>... [--json]', file=sys.stderr)
1003
+ return 2
1004
+
1005
+ report = {}
1006
+ all_ratios = collections.Counter()
1007
+ all_sizes = set()
1008
+ for f in files:
1009
+ findings, stats = lint(f, (load_contract(os.path.dirname(os.path.abspath(f)))[0] or {}))
1010
+ report[f] = {'findings': findings, 'stats': stats}
1011
+ if stats.get('ratio'):
1012
+ all_ratios[stats['ratio']] += 1
1013
+ all_sizes |= set(stats.get('sizes', []))
1014
+
1015
+ # 一致性 = 契约符合性(C4: consistent within and across diagrams)
1016
+ # 逐文件解析各自最近的契约:跨两棵树检查时,不能用第一棵的契约套第二棵。
1017
+ cross = []
1018
+ per_file_contract = {f: load_contract(os.path.dirname(os.path.abspath(f))) for f in files}
1019
+ distinct = {cp for _c, cp in per_file_contract.values()}
1020
+ if len(distinct) > 1:
1021
+ for f, (c, cp) in per_file_contract.items():
1022
+ r = report[f]
1023
+ if c is None:
1024
+ r['findings'].append({'level': 'ERROR', 'code': 'CONTRACT-MISSING',
1025
+ 'msg': f'缺 {CONTRACT_NAME},一致性不可判定', 'line': None})
1026
+ elif '__error__' in c:
1027
+ r['findings'].append({'level': 'ERROR', 'code': 'CONTRACT-INVALID',
1028
+ 'msg': f'{CONTRACT_NAME} 无法使用: {c["__error__"]}', 'line': None})
1029
+ else:
1030
+ for lvl, code, msg in check_contract(r['stats'], c):
1031
+ r['findings'].append({'level': lvl, 'code': code, 'msg': msg, 'line': None})
1032
+ cross.append(f'逐文件契约: {sorted(str(x) for x in distinct)}')
1033
+ contract, cpath = None, None
1034
+ _skip_uniform = True
1035
+ else:
1036
+ _skip_uniform = False
1037
+ contract, cpath = per_file_contract[files[0]]
1038
+ if _skip_uniform:
1039
+ pass
1040
+ elif contract is None:
1041
+ cross.append(f'未找到 {CONTRACT_NAME}:一致性无法判定。'
1042
+ f'契约缺失本身是缺陷——没有冻结的版式,每轮内容变动都要重调几何')
1043
+ for r in report.values():
1044
+ r['findings'].append({'level': 'ERROR', 'code': 'CONTRACT-MISSING',
1045
+ 'msg': f'缺 {CONTRACT_NAME},一致性不可判定', 'line': None})
1046
+ elif '__error__' in contract:
1047
+ cross.append(f'{cpath} 解析失败: {contract["__error__"]}')
1048
+ for r in report.values():
1049
+ r['findings'].append({'level': 'ERROR', 'code': 'CONTRACT-INVALID',
1050
+ 'msg': f'{CONTRACT_NAME} 无法使用: {contract["__error__"]}',
1051
+ 'line': None})
1052
+ else:
1053
+ cross.append(f'契约: {cpath}')
1054
+ for f, r in report.items():
1055
+ for lvl, code, msg in check_contract(r['stats'], contract):
1056
+ r['findings'].append({'level': lvl, 'code': code, 'msg': msg, 'line': None})
1057
+
1058
+ n_err_all = sum(1 for r in report.values() for x in r['findings'] if x['level'] == 'ERROR')
1059
+ n_warn_all = sum(1 for r in report.values() for x in r['findings'] if x['level'] == 'WARN')
1060
+ if as_json:
1061
+ print(json.dumps({'files': report, 'cross': cross}, ensure_ascii=False, indent=1))
1062
+ return 1 if n_err_all else (2 if n_warn_all else 0) # INFO 不参与:它是测量不是判定
1063
+ else:
1064
+ n_err = n_warn = 0
1065
+ for f, r in report.items():
1066
+ errs = [x for x in r['findings'] if x['level'] == 'ERROR']
1067
+ warns = [x for x in r['findings'] if x['level'] == 'WARN']
1068
+ infos = [x for x in r['findings'] if x['level'] == 'INFO']
1069
+ # INFO 是「量出来的数」,没有阈值依据就不该让 CLI 变成非干净——
1070
+ # 否则「只报数不设阈」这句声明会被退出码当场推翻。
1071
+ n_err += len(errs); n_warn += len(warns)
1072
+ if not errs and not warns and not any(x['level'] == 'INFO' for x in r['findings']):
1073
+ print(f'✓ {os.path.basename(f)}')
1074
+ continue
1075
+ print(f'\n{os.path.basename(f)} [{len(errs)} ERROR / {len(warns)} WARN'
1076
+ + (f' / {len(infos)} INFO' if infos else '') + ']')
1077
+ seen = collections.Counter()
1078
+ for x in r['findings']:
1079
+ seen[x['code']] += 1
1080
+ if seen[x['code']] <= 3:
1081
+ print(f" {x['level']:5} {x['code']:16} {x['msg']}")
1082
+ for code, n in seen.items():
1083
+ if n > 3:
1084
+ print(f" ... {code:16} 另有 {n-3} 处")
1085
+ for c in cross:
1086
+ print(f'\nWARN CONSISTENCY-SIZES {c}')
1087
+ print(f'\n合计: {n_err} ERROR, {n_warn} WARN, {len(files)} 个文件')
1088
+ return 1 if n_err else (2 if n_warn else 0)
1089
+ return 0
1090
+
1091
+ if __name__ == '__main__':
1092
+ sys.exit(main(sys.argv))