graphitect 0.2.0__py3-none-any.whl

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 (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,836 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
6
+ import {
7
+ DESKTOP_READABILITY_VIEWPORT,
8
+ DESKTOP_READER_DIAGRAM_WIDTH,
9
+ MIN_PROJECTED_NODE_TEXT_PX,
10
+ projectedNodeTextPx,
11
+ } from '../renderers/shared/desktop-readability.mjs';
12
+
13
+ const input = process.argv[2];
14
+
15
+ if (!input || input === '-h' || input === '--help') {
16
+ console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
17
+ process.exit(input ? 0 : 2);
18
+ }
19
+
20
+ const htmlPath = path.resolve(input);
21
+ let html;
22
+ try {
23
+ html = fs.readFileSync(htmlPath, 'utf8');
24
+ } catch (err) {
25
+ console.error(JSON.stringify({
26
+ ok: false,
27
+ file: htmlPath,
28
+ checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
29
+ }, null, 2));
30
+ process.exit(1);
31
+ }
32
+
33
+ const checks = [];
34
+ let composition = {
35
+ schemaVersion: 1,
36
+ profile: 'standard',
37
+ status: 'pass',
38
+ summary: { errors: 0, warnings: 0 },
39
+ metrics: {
40
+ properCrossings: 0,
41
+ ambiguousCorridors: 0,
42
+ containerBorderRuns: 0,
43
+ labelRouteClearanceIssues: 0,
44
+ minLabelRouteClearance: null,
45
+ maxBends: 0,
46
+ routesOverSuggestedBends: 0,
47
+ maxStretch: null,
48
+ routesOverSuggestedStretch: 0,
49
+ minSegmentPx: null,
50
+ minInteriorSegmentPx: null,
51
+ shortSegmentCount: 0,
52
+ shortEndpointSegmentCount: 0,
53
+ shortInteriorSegmentCount: 0,
54
+ microSegmentCount: 0,
55
+ desktopReadabilityIssues: 0,
56
+ minProjectedNodeTextPx: null,
57
+ },
58
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
59
+ issues: [],
60
+ };
61
+
62
+ function addCheck(name, ok, details = []) {
63
+ checks.push({ name, ok, details });
64
+ }
65
+
66
+ const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
67
+ addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
68
+
69
+ if (svgMatches.length === 1) {
70
+ const svg = svgMatches[0][0];
71
+ const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
72
+ const svgAttrs = parseAttrs(svgRoot);
73
+ const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
74
+ const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
75
+ addCheck('finite_svg', !/\b(?:NaN|undefined|Infinity|-Infinity)\b/.test(svg));
76
+ const legendStart = svg.indexOf('<!-- Legend -->');
77
+ const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
78
+ const desktopReadabilityIssue = collectDesktopReadability(svgAttrs, beforeLegend);
79
+ const arrows = collectArrows(beforeLegend);
80
+ const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
81
+ addCheck(
82
+ 'orthogonal_arrows',
83
+ diagonal.length === 0,
84
+ diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
85
+ );
86
+ const relationshipCrossings = collectRelationshipCrossings(arrows);
87
+ const compositionFrames = collectCompositionFrames(beforeLegend);
88
+ const containerBorderRuns = collectBorderRuns({
89
+ routedRelations: arrows
90
+ .filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
91
+ .map((arrow) => ({
92
+ relation: arrow,
93
+ relationIndex: arrow.index,
94
+ segments: arrow.borderSegments,
95
+ })),
96
+ frames: compositionFrames,
97
+ });
98
+ const routedRelationships = arrows
99
+ .filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
100
+ .map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
101
+ const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
102
+ const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
103
+ const ambiguousCorridors = collectAmbiguousCorridors({ routedRelations: routedRelationships });
104
+ const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
105
+ const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
106
+ const labelRouteMeasurements = collectLabelRouteClearance({
107
+ labels: relationshipLabels,
108
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
109
+ threshold: Number.MAX_VALUE,
110
+ });
111
+ const labelRouteClearance = collectLabelRouteClearance({
112
+ labels: relationshipLabels,
113
+ routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
114
+ threshold: labelClearanceThreshold,
115
+ });
116
+ const crossingIsError = qualityProfile === 'showcase';
117
+ const corridorIsError = qualityProfile === 'showcase';
118
+ const rhythmIsError = qualityProfile === 'showcase';
119
+ const labelClearanceIsError = qualityProfile === 'showcase';
120
+ const desktopReadabilityIsError = qualityProfile === 'showcase';
121
+ const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
122
+ + (crossingIsError ? relationshipCrossings.length : 0)
123
+ + (corridorIsError ? ambiguousCorridors.length : 0)
124
+ + (labelClearanceIsError ? labelRouteClearance.length : 0)
125
+ + (rhythmIsError ? routeRhythmIssues.length : 0)
126
+ + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0);
127
+ const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
128
+ + (crossingIsError ? 0 : relationshipCrossings.length)
129
+ + (corridorIsError ? 0 : ambiguousCorridors.length)
130
+ + (labelClearanceIsError ? 0 : labelRouteClearance.length)
131
+ + (rhythmIsError ? 0 : routeRhythmIssues.length)
132
+ + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1);
133
+ composition = {
134
+ schemaVersion: 1,
135
+ profile: qualityProfile,
136
+ status: compositionErrors ? 'fail' : 'pass',
137
+ summary: {
138
+ errors: compositionErrors,
139
+ warnings: compositionWarnings,
140
+ },
141
+ metrics: {
142
+ properCrossings: relationshipCrossings.length,
143
+ ambiguousCorridors: ambiguousCorridors.length,
144
+ containerBorderRuns: containerBorderRuns.length,
145
+ labelRouteClearanceIssues: labelRouteClearance.length,
146
+ minLabelRouteClearance: labelRouteMeasurements.length
147
+ ? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10
148
+ : null,
149
+ desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
150
+ minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null,
151
+ ...roundedRouteMetrics(routeMetrics),
152
+ },
153
+ suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
154
+ issues: [
155
+ ...containerBorderRuns.map((hit) => ({
156
+ severity: qualityGatesEnforced ? 'error' : 'warning',
157
+ code: 'composition/container-border-run',
158
+ relationship: relationshipRecord(hit.relation),
159
+ frame: frameRecord(hit.frame),
160
+ side: hit.side,
161
+ segmentIndex: hit.segmentIndex,
162
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
163
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
164
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
165
+ })),
166
+ ...labelRouteClearance.map((hit) => ({
167
+ severity: labelClearanceIsError ? 'error' : 'warning',
168
+ code: 'composition/label-route-clearance',
169
+ label: hit.label?.label || hit.labelRelation?.label || '',
170
+ labelRelationship: relationshipRecord(hit.labelRelation),
171
+ otherRelationship: relationshipRecord(hit.otherRelation),
172
+ segmentIndex: hit.segmentIndex,
173
+ labelRect: roundedRect(hit.rect),
174
+ clearance: Math.round(hit.clearance * 10) / 10,
175
+ intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
176
+ threshold: hit.threshold,
177
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
178
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
179
+ })),
180
+ ...relationshipCrossings.map((hit) => ({
181
+ severity: crossingIsError ? 'error' : 'warning',
182
+ code: 'composition/proper-crossing',
183
+ relationship: relationshipRecord(hit.left),
184
+ otherRelationship: relationshipRecord(hit.right),
185
+ point: hit.point.map((value) => Math.round(value * 10) / 10),
186
+ })),
187
+ ...ambiguousCorridors.map((hit) => ({
188
+ severity: corridorIsError ? 'error' : 'warning',
189
+ code: 'composition/ambiguous-corridor',
190
+ relationship: relationshipRecord(hit.left.relation),
191
+ otherRelationship: relationshipRecord(hit.right.relation),
192
+ segmentIndex: hit.leftSegment,
193
+ otherSegmentIndex: hit.rightSegment,
194
+ overlapLength: Math.round(hit.overlapLength * 10) / 10,
195
+ from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
196
+ to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
197
+ })),
198
+ ...routeRhythmIssues.map((hit) => ({
199
+ severity: rhythmIsError ? 'error' : 'warning',
200
+ code: hit.code,
201
+ relationship: relationshipRecord(hit.relation),
202
+ segmentIndex: hit.segmentIndex,
203
+ position: hit.position,
204
+ length: Math.round(hit.length * 10) / 10,
205
+ from: hit.start.map((value) => Math.round(value * 10) / 10),
206
+ to: hit.end.map((value) => Math.round(value * 10) / 10),
207
+ })),
208
+ ...(desktopReadabilityIssue ? [{
209
+ severity: desktopReadabilityIsError ? 'error' : 'warning',
210
+ code: 'composition/desktop-readability',
211
+ viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
212
+ viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
213
+ availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH,
214
+ viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
215
+ scale: desktopReadabilityIssue.scale,
216
+ text: desktopReadabilityIssue.text,
217
+ detail: desktopReadabilityIssue.detail,
218
+ sourceFontPx: desktopReadabilityIssue.sourceFontPx,
219
+ projectedFontPx: desktopReadabilityIssue.projectedFontPx,
220
+ minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
221
+ }] : []),
222
+ ],
223
+ };
224
+ addCheck(
225
+ 'label_route_clearance',
226
+ !labelClearanceIsError || labelRouteClearance.length === 0,
227
+ labelRouteClearance.map((hit) => (
228
+ `[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
229
+ )),
230
+ );
231
+ addCheck(
232
+ 'relationship_crossings',
233
+ !crossingIsError || relationshipCrossings.length === 0,
234
+ relationshipCrossings.map((hit) => (
235
+ `[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
236
+ )),
237
+ );
238
+ addCheck(
239
+ 'relationship_corridors',
240
+ !corridorIsError || ambiguousCorridors.length === 0,
241
+ ambiguousCorridors.map((hit) => (
242
+ `[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
243
+ )),
244
+ );
245
+ addCheck(
246
+ 'container_border_runs',
247
+ !qualityGatesEnforced || containerBorderRuns.length === 0,
248
+ containerBorderRuns.map((hit) => (
249
+ `[composition/container-border-run] ${relationshipName(hit.relation)} follows ${frameName(hit.frame)} ${hit.side} border for ${Math.round(hit.overlapLength * 10) / 10}px on segment ${hit.segmentIndex} [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
250
+ )),
251
+ );
252
+ addCheck(
253
+ 'route_rhythm',
254
+ !rhythmIsError || routeRhythmIssues.length === 0,
255
+ routeRhythmIssues.map((hit) => (
256
+ `[${hit.code}] ${qualityProfile} ${relationshipName(hit.relation)} has a ${Math.round(hit.length * 10) / 10}px ${hit.position} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]`
257
+ )),
258
+ );
259
+
260
+ if (legendStart >= 0) {
261
+ const legendFragment = svg.slice(legendStart);
262
+ const legendBoxes = collectLegendBoxes(legendFragment);
263
+ const collisions = collectLegendCollisions(arrows, legendBoxes);
264
+ addCheck(
265
+ 'legend_clearance',
266
+ collisions.length === 0,
267
+ collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
268
+ );
269
+ } else {
270
+ addCheck('legend_clearance', true, ['no legend marker found']);
271
+ }
272
+ }
273
+
274
+ const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
275
+ console.log(JSON.stringify({ ok, file: htmlPath, checks, composition }, null, 2));
276
+ // Let pending stdout writes drain: large receipts are asynchronous when piped.
277
+ process.exitCode = ok ? 0 : 1;
278
+
279
+ function collectArrows(fragment) {
280
+ const arrows = [];
281
+ let index = 0;
282
+
283
+ for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
284
+ const raw = tag[0];
285
+ if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
286
+ if (!/\bmarker-end=/.test(raw)) continue;
287
+ const attrs = parseAttrs(raw);
288
+ const segments = tag[1].toLowerCase() === 'line'
289
+ ? lineSegments(attrs)
290
+ : pathSegments(attrs.d || '');
291
+ const borderSegments = tag[1].toLowerCase() === 'line'
292
+ ? segments
293
+ : straightPathSegments(attrs.d || '');
294
+ arrows.push({
295
+ kind: tag[1].toLowerCase(),
296
+ index: index += 1,
297
+ raw,
298
+ segments,
299
+ borderSegments,
300
+ routePoints: parseRoutePoints(attrs['data-composition-points']) || (
301
+ borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
302
+ ),
303
+ from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
304
+ to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
305
+ id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
306
+ key: attrs['data-edge-key'],
307
+ label: attrs['data-edge-label'],
308
+ offset: tag.index,
309
+ });
310
+ }
311
+
312
+ return arrows;
313
+ }
314
+
315
+ function collectRelationshipLabelMasks(fragment, arrows) {
316
+ const labels = [];
317
+ for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
318
+ const group = match[0];
319
+ const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
320
+ const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
321
+ .map((item) => item[0])
322
+ .find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
323
+ if (!rectTag) continue;
324
+ const attrs = parseAttrs(rectTag);
325
+ const rect = {
326
+ x: numberAttr(attrs, 'x'),
327
+ y: numberAttr(attrs, 'y'),
328
+ width: numberAttr(attrs, 'width'),
329
+ height: numberAttr(attrs, 'height'),
330
+ };
331
+ if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
332
+ const groupStart = match.index;
333
+ const groupEnd = groupStart + group.length;
334
+ const containedOwner = arrows.find((arrow) => (
335
+ arrow.offset > groupStart
336
+ && arrow.offset < groupEnd
337
+ && arrow.from === groupAttrs['data-edge-from']
338
+ && arrow.to === groupAttrs['data-edge-to']
339
+ && (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
340
+ ));
341
+ const owner = arrows.find((arrow) => (
342
+ groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
343
+ )) || containedOwner || arrows.find((arrow) => (
344
+ arrow.id === groupAttrs['data-edge-id']
345
+ && arrow.from === groupAttrs['data-edge-from']
346
+ && arrow.to === groupAttrs['data-edge-to']
347
+ ));
348
+ if (!owner) continue;
349
+ if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
350
+ if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
351
+ if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
352
+ labels.push({
353
+ relation: owner,
354
+ relationIndex: owner.index,
355
+ label: groupAttrs['data-edge-label'] || '',
356
+ rect,
357
+ });
358
+ }
359
+ return labels;
360
+ }
361
+
362
+ function roundedRect(rect) {
363
+ return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
364
+ }
365
+
366
+ function roundedRouteMetrics(metrics) {
367
+ return {
368
+ ...metrics,
369
+ maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
370
+ minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
371
+ minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
372
+ };
373
+ }
374
+
375
+ function parseRoutePoints(value) {
376
+ if (!value) return null;
377
+ const points = value.split(';').map((pair) => pair.split(',').map(Number));
378
+ return points.length >= 2 && points.every(isPoint) ? points : null;
379
+ }
380
+
381
+ function collectRelationshipCrossings(arrows) {
382
+ const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length);
383
+ const crossings = [];
384
+ for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
385
+ const left = relationships[leftIndex];
386
+ for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
387
+ const right = relationships[rightIndex];
388
+ if ([left.from, left.to].some((id) => id === right.from || id === right.to)) continue;
389
+ let point = null;
390
+ for (const leftSegment of left.segments) {
391
+ for (const rightSegment of right.segments) {
392
+ point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
393
+ if (point) break;
394
+ }
395
+ if (point) break;
396
+ }
397
+ if (point) crossings.push({ left, right, point });
398
+ }
399
+ }
400
+ return crossings;
401
+ }
402
+
403
+ function relationshipName(arrow) {
404
+ return arrow.id
405
+ ? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
406
+ : `relationship "${arrow.from}" -> "${arrow.to}"`;
407
+ }
408
+
409
+ function relationshipRecord(arrow) {
410
+ const stableIndex = Number(arrow.key);
411
+ return {
412
+ id: arrow.id,
413
+ from: arrow.from,
414
+ to: arrow.to,
415
+ label: arrow.label || '',
416
+ collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
417
+ artifactIndex: arrow.index,
418
+ };
419
+ }
420
+
421
+ function collectCompositionFrames(fragment) {
422
+ const frames = [];
423
+ for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
424
+ const attrs = parseAttrs(match[0]);
425
+ const kind = attrs['data-composition-frame-kind'];
426
+ if (!kind) continue;
427
+ const identity = attrs['data-composition-frame-id'] || frames.length;
428
+ if (match[1].toLowerCase() === 'rect') {
429
+ const frame = {
430
+ kind,
431
+ id: identity,
432
+ x: numberAttr(attrs, 'x'),
433
+ y: numberAttr(attrs, 'y'),
434
+ width: numberAttr(attrs, 'width'),
435
+ height: numberAttr(attrs, 'height'),
436
+ radius: numberAttr(attrs, 'rx') || 0,
437
+ };
438
+ if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
439
+ continue;
440
+ }
441
+ const segments = match[1].toLowerCase() === 'line'
442
+ ? lineSegments(attrs)
443
+ : pathSegments(attrs.d || '');
444
+ for (const [segmentIndex, segment] of segments.entries()) {
445
+ frames.push({
446
+ kind,
447
+ id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
448
+ shape: 'line',
449
+ start: segment.start,
450
+ end: segment.end,
451
+ });
452
+ }
453
+ }
454
+ return frames;
455
+ }
456
+
457
+ function frameName(frame) {
458
+ return `${frame.kind || 'frame'} "${frame.id}"`;
459
+ }
460
+
461
+ function frameRecord(frame) {
462
+ return { kind: frame.kind, id: frame.id };
463
+ }
464
+
465
+ function formatPoint(point) {
466
+ return point.map((value) => Math.round(value * 10) / 10).join(', ');
467
+ }
468
+
469
+ function lineSegments(attrs) {
470
+ const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
471
+ const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
472
+ if (!isPoint(start) || !isPoint(end)) return [];
473
+ return [{ start, end }];
474
+ }
475
+
476
+ function pathSegments(d) {
477
+ const points = pointsFromPath(d);
478
+ const segments = [];
479
+ for (let i = 1; i < points.length; i += 1) {
480
+ segments.push({ start: points[i - 1], end: points[i] });
481
+ }
482
+ return segments;
483
+ }
484
+
485
+ // Border runs use exact visible primitives. Non-collinear Q curves are never
486
+ // flattened into chords here: a tangent or sampled near-horizontal curve is
487
+ // not a structural border run. A fully collinear Q remains a straight visible
488
+ // primitive and is included.
489
+ function straightPathSegments(d) {
490
+ const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
491
+ const segments = [];
492
+ let i = 0;
493
+ let command = '';
494
+ let current = [0, 0];
495
+ let start = null;
496
+ while (i < tokens.length) {
497
+ if (isCommand(tokens[i])) command = tokens[i++];
498
+ if (!command) break;
499
+ const absolute = command === command.toUpperCase();
500
+ switch (command.toUpperCase()) {
501
+ case 'M':
502
+ case 'L': {
503
+ let first = true;
504
+ while (i + 1 < tokens.length && !isCommand(tokens[i])) {
505
+ const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
506
+ if (!point.every(Number.isFinite)) break;
507
+ const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
508
+ if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
509
+ current = next;
510
+ if (!start) start = current;
511
+ first = false;
512
+ }
513
+ break;
514
+ }
515
+ case 'H': {
516
+ while (i < tokens.length && !isCommand(tokens[i])) {
517
+ const value = Number.parseFloat(tokens[i++]);
518
+ if (!Number.isFinite(value)) break;
519
+ const next = [absolute ? value : current[0] + value, current[1]];
520
+ segments.push({ start: current, end: next });
521
+ current = next;
522
+ }
523
+ break;
524
+ }
525
+ case 'V': {
526
+ while (i < tokens.length && !isCommand(tokens[i])) {
527
+ const value = Number.parseFloat(tokens[i++]);
528
+ if (!Number.isFinite(value)) break;
529
+ const next = [current[0], absolute ? value : current[1] + value];
530
+ segments.push({ start: current, end: next });
531
+ current = next;
532
+ }
533
+ break;
534
+ }
535
+ case 'Q': {
536
+ while (i + 3 < tokens.length && !isCommand(tokens[i])) {
537
+ const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
538
+ if (!values.every(Number.isFinite)) break;
539
+ const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
540
+ const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
541
+ if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
542
+ current = end;
543
+ }
544
+ break;
545
+ }
546
+ case 'Z':
547
+ if (start) segments.push({ start: current, end: start });
548
+ current = start || current;
549
+ command = '';
550
+ break;
551
+ default:
552
+ return [];
553
+ }
554
+ }
555
+ return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
556
+ }
557
+
558
+ function diagonalStraightSegments(arrow) {
559
+ return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
560
+ Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
561
+ ? [{ segmentIndex, start, end }]
562
+ : []
563
+ ));
564
+ }
565
+
566
+ function collectLegendBoxes(fragment) {
567
+ const boxes = [];
568
+
569
+ for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
570
+ const attrs = parseAttrs(match[0]);
571
+ const x = numberAttr(attrs, 'x');
572
+ const y = numberAttr(attrs, 'y');
573
+ const width = numberAttr(attrs, 'width');
574
+ const height = numberAttr(attrs, 'height');
575
+ if ([x, y, width, height].every(Number.isFinite)) {
576
+ boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
577
+ }
578
+ }
579
+
580
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
581
+ const attrs = parseAttrs(match[1]);
582
+ const box = textBox(attrs, stripTags(match[2]).trim());
583
+ if (box) boxes.push(box);
584
+ }
585
+
586
+ return boxes;
587
+ }
588
+
589
+ function collectLegendCollisions(arrows, boxes) {
590
+ const collisions = [];
591
+ for (const arrow of arrows) {
592
+ for (const segment of arrow.segments) {
593
+ for (const box of boxes) {
594
+ if (segmentIntersectsBox(segment, padBox(box, 2))) {
595
+ collisions.push({ arrow, box });
596
+ }
597
+ }
598
+ }
599
+ }
600
+ return collisions;
601
+ }
602
+
603
+ function textBox(attrs, text) {
604
+ const x = numberAttr(attrs, 'x');
605
+ const y = numberAttr(attrs, 'y');
606
+ const fontSize = Number.parseFloat(attrs['font-size'] || '10');
607
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
608
+ const width = estimatedTextWidth(text, fontSize);
609
+ const anchor = attrs['text-anchor'] || 'start';
610
+ let x1 = x;
611
+ if (anchor === 'middle') x1 = x - width / 2;
612
+ if (anchor === 'end') x1 = x - width;
613
+ return {
614
+ x1,
615
+ y1: y - fontSize,
616
+ x2: x1 + width,
617
+ y2: y + fontSize * 0.25,
618
+ label: text || `text@${x},${y}`,
619
+ };
620
+ }
621
+
622
+ function collectDesktopReadability(svgAttrs, fragment) {
623
+ const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
624
+ const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN;
625
+ if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null;
626
+ const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth);
627
+ let worst = null;
628
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
629
+ const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]);
630
+ const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]);
631
+ const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]);
632
+ if (!primary && !boundary && !context) continue;
633
+ const attrs = parseAttrs(match[1]);
634
+ const fontSize = Number.parseFloat(attrs['font-size'] || '');
635
+ if (!Number.isFinite(fontSize)) continue;
636
+ const projected = projectedNodeTextPx(fontSize, viewBoxWidth);
637
+ if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue;
638
+ const candidate = {
639
+ viewBoxWidth,
640
+ scale,
641
+ text: stripTags(match[2]).trim(),
642
+ detail: primary
643
+ ? 'primary'
644
+ : boundary ? 'boundary' : 'context',
645
+ sourceFontPx: fontSize,
646
+ projectedFontPx: projected,
647
+ };
648
+ if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
649
+ }
650
+ return worst;
651
+ }
652
+
653
+ function estimatedTextWidth(text, fontSize) {
654
+ let units = 0;
655
+ for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
656
+ return Math.max(fontSize, units * fontSize);
657
+ }
658
+
659
+ function pointsFromPath(d) {
660
+ const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
661
+ const points = [];
662
+ let i = 0;
663
+ let command = '';
664
+ let current = [0, 0];
665
+ let start = null;
666
+
667
+ while (i < tokens.length) {
668
+ if (isCommand(tokens[i])) command = tokens[i++];
669
+ if (!command) break;
670
+
671
+ const absolute = command === command.toUpperCase();
672
+ switch (command.toUpperCase()) {
673
+ case 'M':
674
+ case 'L': {
675
+ while (i + 1 < tokens.length && !isCommand(tokens[i])) {
676
+ const x = Number.parseFloat(tokens[i++]);
677
+ const y = Number.parseFloat(tokens[i++]);
678
+ if (!Number.isFinite(x) || !Number.isFinite(y)) break;
679
+ current = absolute ? [x, y] : [current[0] + x, current[1] + y];
680
+ points.push(current);
681
+ if (!start) start = current;
682
+ }
683
+ break;
684
+ }
685
+ case 'H': {
686
+ while (i < tokens.length && !isCommand(tokens[i])) {
687
+ const x = Number.parseFloat(tokens[i++]);
688
+ if (!Number.isFinite(x)) break;
689
+ current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
690
+ points.push(current);
691
+ }
692
+ break;
693
+ }
694
+ case 'V': {
695
+ while (i < tokens.length && !isCommand(tokens[i])) {
696
+ const y = Number.parseFloat(tokens[i++]);
697
+ if (!Number.isFinite(y)) break;
698
+ current = absolute ? [current[0], y] : [current[0], current[1] + y];
699
+ points.push(current);
700
+ }
701
+ break;
702
+ }
703
+ case 'Q': {
704
+ while (i + 3 < tokens.length && !isCommand(tokens[i])) {
705
+ const controlX = Number.parseFloat(tokens[i++]);
706
+ const controlY = Number.parseFloat(tokens[i++]);
707
+ const endX = Number.parseFloat(tokens[i++]);
708
+ const endY = Number.parseFloat(tokens[i++]);
709
+ if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
710
+ const control = absolute
711
+ ? [controlX, controlY]
712
+ : [current[0] + controlX, current[1] + controlY];
713
+ const end = absolute
714
+ ? [endX, endY]
715
+ : [current[0] + endX, current[1] + endY];
716
+ const startPoint = current;
717
+ for (let step = 1; step <= 8; step += 1) {
718
+ const amount = step / 8;
719
+ const remaining = 1 - amount;
720
+ points.push([
721
+ remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
722
+ remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
723
+ ]);
724
+ }
725
+ current = end;
726
+ }
727
+ break;
728
+ }
729
+ case 'Z': {
730
+ if (start) points.push(start);
731
+ break;
732
+ }
733
+ default:
734
+ return [];
735
+ }
736
+ }
737
+
738
+ return points.filter(isPoint);
739
+ }
740
+
741
+ function properSegmentIntersection(a, b, c, d) {
742
+ const abC = crossProduct(a, b, c);
743
+ const abD = crossProduct(a, b, d);
744
+ const cdA = crossProduct(c, d, a);
745
+ const cdB = crossProduct(c, d, b);
746
+ const epsilon = 1e-9;
747
+ const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
748
+ if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
749
+ const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
750
+ if (Math.abs(denominator) < epsilon) return null;
751
+ const ab = a[0] * b[1] - a[1] * b[0];
752
+ const cd = c[0] * d[1] - c[1] * d[0];
753
+ return [
754
+ (ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
755
+ (ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
756
+ ];
757
+ }
758
+
759
+ function crossProduct(a, b, c) {
760
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
761
+ }
762
+
763
+ function segmentIntersectsBox(segment, box) {
764
+ const { start, end } = segment;
765
+ if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
766
+ const edges = [
767
+ [[box.x1, box.y1], [box.x2, box.y1]],
768
+ [[box.x2, box.y1], [box.x2, box.y2]],
769
+ [[box.x2, box.y2], [box.x1, box.y2]],
770
+ [[box.x1, box.y2], [box.x1, box.y1]],
771
+ ];
772
+ return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
773
+ }
774
+
775
+ function segmentsIntersect(a, b, c, d) {
776
+ const o1 = orientation(a, b, c);
777
+ const o2 = orientation(a, b, d);
778
+ const o3 = orientation(c, d, a);
779
+ const o4 = orientation(c, d, b);
780
+
781
+ if (o1 !== o2 && o3 !== o4) return true;
782
+ if (o1 === 0 && onSegment(a, c, b)) return true;
783
+ if (o2 === 0 && onSegment(a, d, b)) return true;
784
+ if (o3 === 0 && onSegment(c, a, d)) return true;
785
+ if (o4 === 0 && onSegment(c, b, d)) return true;
786
+ return false;
787
+ }
788
+
789
+ function orientation(a, b, c) {
790
+ const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
791
+ if (Math.abs(value) < 1e-9) return 0;
792
+ return value > 0 ? 1 : 2;
793
+ }
794
+
795
+ function onSegment(a, b, c) {
796
+ return b[0] <= Math.max(a[0], c[0]) + 1e-9
797
+ && b[0] + 1e-9 >= Math.min(a[0], c[0])
798
+ && b[1] <= Math.max(a[1], c[1]) + 1e-9
799
+ && b[1] + 1e-9 >= Math.min(a[1], c[1]);
800
+ }
801
+
802
+ function pointInsideBox(point, box) {
803
+ return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
804
+ }
805
+
806
+ function padBox(box, padding) {
807
+ return {
808
+ ...box,
809
+ x1: box.x1 - padding,
810
+ y1: box.y1 - padding,
811
+ x2: box.x2 + padding,
812
+ y2: box.y2 + padding,
813
+ };
814
+ }
815
+
816
+ function parseAttrs(tag) {
817
+ const attrs = {};
818
+ for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
819
+ return attrs;
820
+ }
821
+
822
+ function numberAttr(attrs, name) {
823
+ return Number.parseFloat(attrs[name]);
824
+ }
825
+
826
+ function isCommand(token) {
827
+ return /^[A-Za-z]$/.test(token);
828
+ }
829
+
830
+ function isPoint(point) {
831
+ return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
832
+ }
833
+
834
+ function stripTags(value) {
835
+ return value.replace(/<[^>]*>/g, '');
836
+ }