@litfamily/lithermes 1.0.28 → 1.0.30

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 (167) hide show
  1. package/README.md +12 -9
  2. package/README_Ko-KR.md +12 -9
  3. package/assets/lithermes-plugin/README.md +15 -2
  4. package/assets/lithermes-plugin/__init__.py +49 -10
  5. package/assets/lithermes-plugin/adhd_stack.py +228 -0
  6. package/assets/lithermes-plugin/core.py +30 -4
  7. package/assets/lithermes-plugin/core_commands.py +99 -0
  8. package/assets/lithermes-plugin/core_contexts.py +54 -4
  9. package/assets/lithermes-plugin/core_contract.py +2 -2
  10. package/assets/lithermes-plugin/core_routing.py +126 -0
  11. package/assets/lithermes-plugin/diagnostics.py +3 -2
  12. package/assets/lithermes-plugin/lit_mark.py +75 -0
  13. package/assets/lithermes-plugin/payload-version.json +472 -60
  14. package/assets/lithermes-plugin/plugin.yaml +1 -1
  15. package/assets/lithermes-plugin/scientific_visualization.py +23 -0
  16. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +7 -7
  17. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  18. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/fonts.json +42 -0
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  25. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  26. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  27. package/assets/lithermes-plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  28. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/README.md +2 -4
  29. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +4 -4
  30. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/craft-floor.md +10 -0
  31. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  32. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe-rules.json +6 -1
  33. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +76 -4
  34. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +194 -0
  35. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +55 -0
  36. package/assets/lithermes-plugin/skills/lit-adhd/references/card-examples.md +86 -0
  37. package/assets/lithermes-plugin/skills/lit-adhd/references/status-taxonomy.md +40 -0
  38. package/assets/lithermes-plugin/skills/lit-adhd/scripts/adhd.mjs +11 -0
  39. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +554 -0
  40. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/gauge.mjs +89 -0
  41. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/hermes-sessions.mjs +219 -0
  42. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +101 -0
  43. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
  44. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/redact.mjs +42 -0
  45. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/secret-shapes.mjs +59 -0
  46. package/assets/lithermes-plugin/skills/lit-diagram-drawer/SKILL.md +7 -4
  47. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  48. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  49. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  50. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  51. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  52. package/assets/lithermes-plugin/skills/lit-diagram-drawer/references/verifier-guide.md +5 -0
  53. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/diagram_quality.py +4 -4
  54. package/assets/lithermes-plugin/skills/lit-docx/SKILL.md +1 -1
  55. package/assets/lithermes-plugin/skills/lit-hijack/SKILL.md +239 -0
  56. package/assets/lithermes-plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  57. package/assets/lithermes-plugin/skills/lit-hijack/references/clean-room-build.md +128 -0
  58. package/assets/lithermes-plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  59. package/assets/lithermes-plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  60. package/assets/lithermes-plugin/skills/lit-hijack/references/parity-and-report.md +131 -0
  61. package/assets/lithermes-plugin/skills/lit-hijack/scripts/hijack.mjs +995 -0
  62. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +1 -0
  63. package/assets/lithermes-plugin/skills/lit-pptx/SKILL.md +11 -6
  64. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  65. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  66. package/assets/lithermes-plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  67. package/assets/lithermes-plugin/skills/lit-pptx/fonts/provenance.json +1 -0
  68. package/assets/lithermes-plugin/skills/lit-pptx/references/anti-slop-checklist.md +3 -0
  69. package/assets/lithermes-plugin/skills/lit-pptx/references/authoring-guide.md +1 -1
  70. package/assets/lithermes-plugin/skills/lit-pptx/references/consensus-protocol.md +1 -1
  71. package/assets/lithermes-plugin/skills/lit-pptx/references/direction-step.md +8 -2
  72. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +175 -0
  73. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +156 -0
  74. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +141 -0
  75. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +160 -0
  76. package/assets/lithermes-plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +183 -0
  77. package/assets/lithermes-plugin/skills/lit-pptx/references/layout-families.md +1 -1
  78. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/bauhaus.md +105 -0
  79. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +111 -0
  80. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +114 -0
  81. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/newsroom.md +106 -0
  82. package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/riso.md +101 -0
  83. package/assets/lithermes-plugin/skills/lit-pptx/scripts/craft_extras.py +49 -0
  84. package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +4 -2
  85. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -10
  86. package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +50 -6
  87. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +103 -0
  88. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  89. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  90. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  91. package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  92. package/assets/lithermes-plugin/skills/lit-recap/SKILL.md +1 -1
  93. package/assets/lithermes-plugin/skills/lit-schematic/SKILL.md +144 -0
  94. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  95. package/assets/lithermes-plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  96. package/assets/lithermes-plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  97. package/assets/lithermes-plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  98. package/assets/lithermes-plugin/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  99. package/assets/lithermes-plugin/skills/lit-schematic/references/isometric-craft.md +101 -0
  100. package/assets/lithermes-plugin/skills/lit-schematic/references/journal-figures.md +72 -0
  101. package/assets/lithermes-plugin/skills/lit-schematic/references/kit-api.md +134 -0
  102. package/assets/lithermes-plugin/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  103. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-drafting.md +103 -0
  104. package/assets/lithermes-plugin/skills/lit-schematic/references/pid-symbols.md +163 -0
  105. package/assets/lithermes-plugin/skills/lit-schematic/references/verification.md +100 -0
  106. package/assets/lithermes-plugin/skills/lit-schematic/scripts/doctor.mjs +57 -0
  107. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  108. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
  109. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  110. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  111. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  112. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  113. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  114. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  115. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  116. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  117. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  118. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  119. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  120. package/assets/lithermes-plugin/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  121. package/assets/lithermes-plugin/skills/lit-schematic/scripts/motion.mjs +129 -0
  122. package/assets/lithermes-plugin/skills/lit-schematic/scripts/qa.mjs +92 -0
  123. package/assets/lithermes-plugin/skills/lit-schematic/scripts/render.mjs +233 -0
  124. package/assets/lithermes-plugin/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  125. package/assets/lithermes-plugin/skills/lit-scientific-visualization/SKILL.md +19 -1
  126. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +12 -11
  127. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +56 -2
  128. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +129 -4
  129. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +9 -0
  130. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  131. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/blender.mjs +154 -0
  132. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/board.mjs +132 -0
  133. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/completion.mjs +48 -5
  134. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/gate.mjs +13 -0
  135. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/house.mjs +44 -0
  136. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/scaffold.mjs +238 -0
  137. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/score.mjs +643 -0
  138. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound-dsp.mjs +21 -0
  139. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +171 -0
  140. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-qa.mjs +23 -3
  141. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +124 -17
  142. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/styles.mjs +240 -0
  143. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +152 -10
  144. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  145. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  146. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  147. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  148. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +67 -0
  149. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  150. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/depth.md +74 -0
  151. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/studio.md +87 -0
  152. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/style-bibles.md +9 -2
  153. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +24 -8
  154. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  155. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  156. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  157. package/assets/lithermes-plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  158. package/assets/lithermes-plugin/skills/litresearch/SKILL.md +4 -0
  159. package/assets/lithermes-plugin/skills/readme-studio/SKILL.md +1 -1
  160. package/assets/lithermes-plugin/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
  161. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  162. package/assets/lithermes-plugin/skills/review-work/SKILL.md +2 -0
  163. package/assets/lithermes-plugin/skills/start-work/SKILL.md +2 -1
  164. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  165. package/package.json +1 -1
  166. package/readme-assets/badge-version.svg +1 -1
  167. package/src/lib/skillPayload.js +1 -1
@@ -0,0 +1,658 @@
1
+ // Static QA for lit-schematic SVG files. It parses the file itself (no browser), applies every
2
+ // transform, and reads the markers the kit writes (data-sym, data-box, data-ports, data-line,
3
+ // data-part, data-hull, data-leader, data-panel, data-legend-item, data-within). A drawing made
4
+ // by hand passes only if it carries the same markers, so a checker cannot be silenced by
5
+ // leaving geometry unmarked: unmarked strokes still count for STROKE.MIN, unmarked text for
6
+ // every text check.
7
+
8
+ import { boxesOverlap, collinearOverlap, contains, dist, insideLength, overlapArea, pointIn, pointSegmentDistance, segmentIntersection, segmentsOf } from "./geom.mjs";
9
+ import { parseEquipmentTag, parseInstrumentTag, parseLineNumber } from "./isa.mjs";
10
+ import { mmToPt, textBox } from "./svg.mjs";
11
+
12
+ // Two leaders closer than this at print size merge into one stroke for the eye.
13
+ const LEADER_GAP_MM = 1.2;
14
+ function leaderGap(p, q) {
15
+ let best = Infinity;
16
+ for (const [a, b] of segmentsOf(p)) {
17
+ for (const [c, d] of segmentsOf(q)) {
18
+ best = Math.min(best, pointSegmentDistance(a, c, d), pointSegmentDistance(b, c, d), pointSegmentDistance(c, a, b), pointSegmentDistance(d, a, b));
19
+ }
20
+ }
21
+ return best;
22
+ }
23
+
24
+ export const CODES = Object.freeze({
25
+ "XML.WELLFORMED": "The file is not well-formed XML.",
26
+ "XML.EXTERNAL": "The file loads something from outside itself (href, url() or @import other than #fragment or data:).",
27
+ "SVG.ACTIVE": "The file carries script or event-handler attributes.",
28
+ "SVG.SIZE": "The root needs a viewBox and a physical width and height (mm, cm, in or pt).",
29
+ "TEXT.MIN_SIZE": "Text is smaller than the minimum at final print size.",
30
+ "TEXT.OVERLAP": "Two labels overlap.",
31
+ "TEXT.ON_SYMBOL": "A label sits on a symbol it does not belong to.",
32
+ "TEXT.ON_ART": "A label sits on the drawn object instead of beside it.",
33
+ "TEXT.ON_LINE": "A line runs through a label.",
34
+ "TEXT.CLIPPED": "A label runs past the edge of the drawing.",
35
+ "TEXT.OUT_OF_CELL": "A label runs out of the cell or block it belongs to.",
36
+ "LINE.THROUGH_SYMBOL": "A line passes through a symbol instead of ending at its port.",
37
+ "LINE.DANGLING": "A line ends in the air: not on a port, another line, or a terminal.",
38
+ "LINE.BEND_ON_LINE": "A line turns on top of another line, which reads as a joint.",
39
+ "LINE.OVERLAP": "Two lines run along each other.",
40
+ "LINE.CROSSING": "Two process lines cross without a hop (advisory).",
41
+ "LINE.ARROW_IN_SYMBOL": "A flow arrow is drawn inside a symbol, where it reads as part of the valve or instrument.",
42
+ "LEADER.LOOSE": "A leader does not end on the part it names or does not start at its label.",
43
+ "LEADER.CROSSING": "Two leaders cross.",
44
+ "LEADER.SPACING": "A leader runs closer than 1.2 mm (print size) to another leader or to its dot, so the two read as one.",
45
+ "LINE.NUMBER": "A P&ID line number does not follow size-fluid-sequence-class[-insulation].",
46
+ "STROKE.MIN": "A stroke is thinner than the printable minimum.",
47
+ "SYMBOL.NO_BOX": "A symbol group has no data-box, so nothing can check it.",
48
+ "TAG.GRAMMAR": "A tag does not follow the instrument (ISA-5.1) or equipment tag grammar.",
49
+ "TAG.DUPLICATE": "Two items carry the same tag.",
50
+ "LEGEND.COVERAGE": "A symbol or line kind used in the drawing is missing from the legend.",
51
+ "TITLEBLOCK.FIELDS": "The title block is missing a required field.",
52
+ "PALETTE.CVD": "Two category colours become hard to tell apart for normal or colour-deficient vision.",
53
+ "PANEL.LABELS": "Panel labels are missing, out of order, inconsistent, or not at the panel's top left.",
54
+ "FONT.FAMILIES": "More than two font families are used (advisory).",
55
+ });
56
+
57
+ const ADVISORY = new Set(["LINE.CROSSING", "FONT.FAMILIES"]);
58
+
59
+ // ------------------------------------------------------------------ XML
60
+
61
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
62
+ const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/giu, (m, e) => {
63
+ if (e[0] === "#") return String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1)));
64
+ return ENTITIES[e] ?? m;
65
+ });
66
+
67
+ export function parseXml(text) {
68
+ const root = { name: "#document", attrs: {}, children: [], parent: null };
69
+ let node = root;
70
+ const re = /<!--[\s\S]*?-->|<!\[CDATA\[([\s\S]*?)\]\]>|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<\/\s*([\w:.-]+)\s*>|<([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)|(<)/gu;
71
+ let m;
72
+ while ((m = re.exec(text))) {
73
+ if (m[7]) throw new Error(`unexpected "<" at offset ${m.index}`);
74
+ if (m[2]) {
75
+ if (node.name !== m[2]) throw new Error(`closing </${m[2]}> does not match <${node.name}> at offset ${m.index}`);
76
+ node = node.parent;
77
+ } else if (m[3]) {
78
+ const attrs = {};
79
+ for (const a of m[4].matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)) attrs[a[1]] = decode(a[2] ?? a[3] ?? "");
80
+ const child = { name: m[3], attrs, children: [], parent: node, text: "" };
81
+ node.children.push(child);
82
+ if (!m[5]) node = child;
83
+ } else if (m[1] !== undefined) {
84
+ node.text = (node.text ?? "") + m[1];
85
+ } else if (m[6]) {
86
+ if (node !== root) node.text = (node.text ?? "") + decode(m[6]);
87
+ else if (m[6].trim()) throw new Error("text outside the root element");
88
+ }
89
+ }
90
+ if (node !== root) throw new Error(`<${node.name}> is never closed`);
91
+ const elements = root.children.filter((c) => c.name);
92
+ if (elements.length !== 1 || elements[0].name !== "svg") throw new Error("the document root must be a single <svg> element");
93
+ return elements[0];
94
+ }
95
+
96
+ // ------------------------------------------------------------------ transforms
97
+
98
+ const I = [1, 0, 0, 1, 0, 0];
99
+ const mul = (a, b) => [
100
+ a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
101
+ a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
102
+ a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5],
103
+ ];
104
+ const apply = (m, [x, y]) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
105
+ const scaleOf = (m) => Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2]));
106
+
107
+ export function parseTransform(text) {
108
+ let m = I;
109
+ for (const [, op, raw] of String(text ?? "").matchAll(/(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)/gu)) {
110
+ const v = raw.trim().split(/[\s,]+/u).filter(Boolean).map(Number);
111
+ let t = I;
112
+ if (op === "matrix") t = v;
113
+ else if (op === "translate") t = [1, 0, 0, 1, v[0] ?? 0, v[1] ?? 0];
114
+ else if (op === "scale") t = [v[0], 0, 0, v[1] ?? v[0], 0, 0];
115
+ else if (op === "rotate") {
116
+ const a = ((v[0] ?? 0) * Math.PI) / 180;
117
+ const r = [Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0];
118
+ t = v.length >= 3 ? mul(mul([1, 0, 0, 1, v[1], v[2]], r), [1, 0, 0, 1, -v[1], -v[2]]) : r;
119
+ } else if (op === "skewX") t = [1, 0, Math.tan((v[0] * Math.PI) / 180), 1, 0, 0];
120
+ else if (op === "skewY") t = [1, Math.tan((v[0] * Math.PI) / 180), 0, 1, 0, 0];
121
+ m = mul(m, t);
122
+ }
123
+ return m;
124
+ }
125
+
126
+ // ------------------------------------------------------------------ path geometry
127
+
128
+ function arcPoints(x1, y1, rx, ry, phi, large, sweep, x2, y2) {
129
+ if (rx === 0 || ry === 0) return [[x2, y2]];
130
+ const p = (phi * Math.PI) / 180;
131
+ const cos = Math.cos(p);
132
+ const sin = Math.sin(p);
133
+ const dx = (x1 - x2) / 2;
134
+ const dy = (y1 - y2) / 2;
135
+ const x1p = cos * dx + sin * dy;
136
+ const y1p = -sin * dx + cos * dy;
137
+ rx = Math.abs(rx);
138
+ ry = Math.abs(ry);
139
+ const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
140
+ if (lambda > 1) { rx *= Math.sqrt(lambda); ry *= Math.sqrt(lambda); }
141
+ const sign = large === sweep ? -1 : 1;
142
+ const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
143
+ const coef = sign * Math.sqrt(Math.max(0, num / (rx * rx * y1p * y1p + ry * ry * x1p * x1p)));
144
+ const cxp = (coef * rx * y1p) / ry;
145
+ const cyp = (-coef * ry * x1p) / rx;
146
+ const cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
147
+ const cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
148
+ const angle = (ux, uy, vx, vy) => {
149
+ const a = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
150
+ return a;
151
+ };
152
+ const t1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
153
+ let dt = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
154
+ if (!sweep && dt > 0) dt -= 2 * Math.PI;
155
+ if (sweep && dt < 0) dt += 2 * Math.PI;
156
+ const steps = Math.max(4, Math.ceil(Math.abs(dt) / (Math.PI / 12)));
157
+ const out = [];
158
+ for (let i = 1; i <= steps; i++) {
159
+ const t = t1 + (dt * i) / steps;
160
+ const xr = rx * Math.cos(t);
161
+ const yr = ry * Math.sin(t);
162
+ out.push([cos * xr - sin * yr + cx, sin * xr + cos * yr + cy]);
163
+ }
164
+ out[out.length - 1] = [x2, y2];
165
+ return out;
166
+ }
167
+
168
+ export function pathPolylines(d) {
169
+ const tokens = String(d ?? "").match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gu) ?? [];
170
+ const out = [];
171
+ let current = null;
172
+ let x = 0, y = 0, sx = 0, sy = 0, cmd = null, lastCtrl = null;
173
+ let i = 0;
174
+ const num = () => Number(tokens[i++]);
175
+ const start = (px, py) => { current = { points: [[px, py]], closed: false }; out.push(current); x = sx = px; y = sy = py; };
176
+ const to = (px, py) => { if (!current) start(x, y); current.points.push([px, py]); x = px; y = py; };
177
+ const bezier = (pts) => {
178
+ for (let k = 1; k <= 8; k++) {
179
+ const t = k / 8;
180
+ const mt = 1 - t;
181
+ if (pts.length === 4) to(mt ** 3 * pts[0][0] + 3 * mt * mt * t * pts[1][0] + 3 * mt * t * t * pts[2][0] + t ** 3 * pts[3][0], mt ** 3 * pts[0][1] + 3 * mt * mt * t * pts[1][1] + 3 * mt * t * t * pts[2][1] + t ** 3 * pts[3][1]);
182
+ else to(mt * mt * pts[0][0] + 2 * mt * t * pts[1][0] + t * t * pts[2][0], mt * mt * pts[0][1] + 2 * mt * t * pts[1][1] + t * t * pts[2][1]);
183
+ }
184
+ };
185
+ while (i < tokens.length) {
186
+ if (/[a-zA-Z]/u.test(tokens[i])) cmd = tokens[i++];
187
+ else if (!cmd) throw new Error("path data starts without a command");
188
+ const rel = cmd === cmd.toLowerCase();
189
+ const ox = rel ? x : 0;
190
+ const oy = rel ? y : 0;
191
+ switch (cmd.toUpperCase()) {
192
+ case "M": start(ox + num(), oy + num()); cmd = rel ? "l" : "L"; lastCtrl = null; break;
193
+ case "L": to(ox + num(), oy + num()); lastCtrl = null; break;
194
+ case "H": to(ox + num(), y); lastCtrl = null; break;
195
+ case "V": to(x, oy + num()); lastCtrl = null; break;
196
+ case "Z": if (current) { current.closed = true; to(sx, sy); } current = null; x = sx; y = sy; lastCtrl = null; break;
197
+ case "C": { const p1 = [ox + num(), oy + num()]; const p2 = [ox + num(), oy + num()]; const p = [ox + num(), oy + num()]; bezier([[x, y], p1, p2, p]); lastCtrl = p2; break; }
198
+ case "S": { const p1 = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y]; const p2 = [ox + num(), oy + num()]; const p = [ox + num(), oy + num()]; bezier([[x, y], p1, p2, p]); lastCtrl = p2; break; }
199
+ case "Q": { const p1 = [ox + num(), oy + num()]; const p = [ox + num(), oy + num()]; bezier([[x, y], p1, p]); lastCtrl = p1; break; }
200
+ case "T": { const p1 = lastCtrl ? [2 * x - lastCtrl[0], 2 * y - lastCtrl[1]] : [x, y]; const p = [ox + num(), oy + num()]; bezier([[x, y], p1, p]); lastCtrl = p1; break; }
201
+ case "A": {
202
+ const rx = num(), ry = num(), phi = num(), large = num(), sweep = num();
203
+ const px = ox + num(), py = oy + num();
204
+ if (!current) start(x, y);
205
+ for (const pt of arcPoints(x, y, rx, ry, phi, large, sweep, px, py)) {
206
+ to(pt[0], pt[1]);
207
+ // A small arc on a line is a hop: its points touch the line it jumps, by design.
208
+ if (Math.max(rx, ry) <= 2) (current.hops ??= new Set()).add(current.points.length - 1);
209
+ }
210
+ lastCtrl = null;
211
+ break;
212
+ }
213
+ default: throw new Error(`unsupported path command ${cmd}`);
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ // ------------------------------------------------------------------ colour
220
+
221
+ const hex = (c) => {
222
+ const s = String(c ?? "").trim().toLowerCase();
223
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(s);
224
+ if (!m) return null;
225
+ const v = m[1].length === 3 ? [...m[1]].map((h) => h + h).join("") : m[1];
226
+ return [0, 2, 4].map((k) => parseInt(v.slice(k, k + 2), 16) / 255);
227
+ };
228
+ const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
229
+ const lab = ([r, g, b]) => {
230
+ const X = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047;
231
+ const Y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
232
+ const Z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883;
233
+ const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
234
+ return [116 * f(Y) - 16, 500 * (f(X) - f(Y)), 200 * (f(Y) - f(Z))];
235
+ };
236
+ // Machado, Oliveira and Fernandes (2009), severity 1.0, applied to linear RGB.
237
+ const CVD = {
238
+ normal: null,
239
+ deuteranopia: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
240
+ protanopia: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
241
+ tritanopia: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
242
+ };
243
+ export function deltaE(c1, c2, vision = "normal") {
244
+ const a = hex(c1);
245
+ const b = hex(c2);
246
+ if (!a || !b) return Infinity;
247
+ const sim = (rgb) => {
248
+ const l = rgb.map(lin);
249
+ const m = CVD[vision];
250
+ const v = m ? m.map((row) => Math.min(1, Math.max(0, row[0] * l[0] + row[1] * l[1] + row[2] * l[2]))) : l;
251
+ return lab(v);
252
+ };
253
+ const p = sim(a);
254
+ const q = sim(b);
255
+ return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]);
256
+ }
257
+
258
+ // ------------------------------------------------------------------ collection
259
+
260
+ const inherited = (node, name) => {
261
+ for (let n = node; n; n = n.parent) {
262
+ if (n.attrs?.[name] !== undefined) return n.attrs[name];
263
+ const style = n.attrs?.style;
264
+ if (style) {
265
+ const m = new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, "u").exec(style);
266
+ if (m) return m[1].trim();
267
+ }
268
+ }
269
+ return undefined;
270
+ };
271
+ const ancestor = (node, attr) => { for (let n = node; n; n = n.parent) if (n.attrs?.[attr] !== undefined) return n; return null; };
272
+ const numbers = (s) => String(s ?? "").trim().split(/[\s,]+/u).filter(Boolean).map(Number);
273
+ const toMm = (value) => {
274
+ const m = /^(-?[\d.]+)\s*(mm|cm|in|pt|px)?$/u.exec(String(value ?? "").trim());
275
+ if (!m) return null;
276
+ const v = Number(m[1]);
277
+ return { mm: v, cm: v * 10, in: v * 25.4, pt: (v * 25.4) / 72, px: (v * 25.4) / 96, undefined: (v * 25.4) / 96 }[m[2]];
278
+ };
279
+ // Font sizes and stroke widths: unitless and px values are user units (mm in kit drawings),
280
+ // physical units are converted on the assumption that one user unit is one millimetre.
281
+ const toUser = (value) => {
282
+ const m = /^(-?[\d.]+)\s*(mm|cm|in|pt|px)?$/u.exec(String(value ?? "").trim());
283
+ if (!m) return null;
284
+ const v = Number(m[1]);
285
+ return { mm: v, cm: v * 10, in: v * 25.4, pt: (v * 25.4) / 72, px: v, undefined: v }[m[2]];
286
+ };
287
+ const boxFromPoints = (pts) => [Math.min(...pts.map((p) => p[0])), Math.min(...pts.map((p) => p[1])), Math.max(...pts.map((p) => p[0])), Math.max(...pts.map((p) => p[1]))];
288
+ const transformBox = (m, b) => boxFromPoints([[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map((p) => apply(m, p)));
289
+
290
+ function polygonHitsBox(poly, box) {
291
+ if (poly.length < 3) return false;
292
+ if (poly.some((p) => pointIn(p, box))) return true;
293
+ const [x0, y0, x1, y1] = box;
294
+ const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
295
+ const inside = (p) => {
296
+ let c = false;
297
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
298
+ if ((poly[i][1] > p[1]) !== (poly[j][1] > p[1]) && p[0] < ((poly[j][0] - poly[i][0]) * (p[1] - poly[i][1])) / (poly[j][1] - poly[i][1]) + poly[i][0]) c = !c;
299
+ }
300
+ return c;
301
+ };
302
+ if (corners.some(inside)) return true;
303
+ const edges = [[corners[0], corners[1]], [corners[1], corners[2]], [corners[2], corners[3]], [corners[3], corners[0]]];
304
+ for (let i = 0; i < poly.length; i++) {
305
+ const a = poly[i];
306
+ const b = poly[(i + 1) % poly.length];
307
+ if (edges.some(([c, d]) => segmentIntersection(a, b, c, d, true))) return true;
308
+ }
309
+ return false;
310
+ }
311
+
312
+ export function collect(svgText) {
313
+ const root = parseXml(svgText);
314
+ const out = { root, texts: [], symbols: [], lines: [], strokes: [], parts: [], leaders: [], dots: [], panels: [], arrows: [], legend: new Set(), titleblock: new Map(), external: [], active: [], families: new Set(), roleColors: new Map(), legendGlyphs: 0 };
315
+ const walk = (node, ctm) => {
316
+ const m = node.attrs.transform ? mul(ctm, parseTransform(node.attrs.transform)) : ctm;
317
+ const a = node.attrs;
318
+ for (const [k, v] of Object.entries(a)) {
319
+ if (/^on/iu.test(k)) out.active.push(k);
320
+ if ((k === "href" || k === "xlink:href") && !/^(?:#|data:)/u.test(v)) out.external.push(v);
321
+ if (/url\(\s*['"]?(?!#|data:)/iu.test(v)) out.external.push(v);
322
+ }
323
+ if (node.name === "script" || node.name === "foreignObject") out.active.push(node.name);
324
+ if (node.name === "style") {
325
+ const css = node.text ?? "";
326
+ if (/@import|url\(\s*['"]?(?!#|data:)/iu.test(css)) out.external.push("style: @import or url()");
327
+ }
328
+ if (a["data-legend-item"]) out.legend.add(a["data-legend-item"]);
329
+ if (a["data-legend-glyph"]) out.legendGlyphs++;
330
+ if (a["data-titleblock-value"]) out.titleblock.set(a["data-titleblock-value"], (out.titleblock.get(a["data-titleblock-value"]) ?? "") + (node.text ?? "").trim());
331
+ if (a["data-sym"]) {
332
+ if (!a["data-box"]) out.symbols.push({ node, kind: a["data-sym"], boxes: [], ports: [], noBox: true });
333
+ else {
334
+ const boxes = a["data-box"].split("|").map((b) => transformBox(m, numbers(b)));
335
+ const ports = (a["data-ports"] ?? "").split(";").filter(Boolean).map((p) => {
336
+ const [name, rest] = p.split(":");
337
+ const v = numbers(rest);
338
+ return { name, point: apply(m, [v[0], v[1]]) };
339
+ });
340
+ out.symbols.push({ node, id: a["data-sym-id"], kind: a["data-sym"], tag: a["data-tag"], boxes, ports });
341
+ }
342
+ }
343
+ if (a["data-part"] && a["data-hull"]) {
344
+ const hull = a["data-hull"].trim().split(/\s+/u).map((p) => apply(m, numbers(p)));
345
+ out.parts.push({ node, name: a["data-part"], hull });
346
+ }
347
+ if (a["data-panel"] && a["data-panel-box"]) out.panels.push({ node, label: a["data-panel"], box: transformBox(m, numbers(a["data-panel-box"])) });
348
+ if (node.name === "circle" && a["data-leader-dot"] !== undefined) out.dots.push({ node, point: apply(m, [Number(a.cx), Number(a.cy)]), r: Number(a.r) * scaleOf(m) });
349
+ if (node.name === "text") {
350
+ const content = [node.text ?? "", ...node.children.filter((c) => c.name === "tspan").map((c) => c.text ?? "")].join("").replace(/\s+/gu, " ").trim();
351
+ if (content) {
352
+ const size = toUser(inherited(node, "font-size") ?? "16") ?? 16;
353
+ const family = inherited(node, "font-family");
354
+ if (family) out.families.add(family.split(",")[0].trim().replace(/['"]/gu, ""));
355
+ const local = textBox(Number(a.x ?? 0), Number(a.y ?? 0), content, size, inherited(node, "text-anchor") ?? "start");
356
+ out.texts.push({
357
+ node, content, size: size * scaleOf(m), box: transformBox(m, local),
358
+ symbol: ancestor(node, "data-sym"), part: ancestor(node, "data-part"),
359
+ label: a["data-label"] ?? "", within: a["data-within"] ? numbers(a["data-within"]) : null, withinM: m,
360
+ legendGlyph: Boolean(ancestor(node, "data-legend-glyph")), panelLabel: a["data-label"] === "panel-label",
361
+ forLeader: a["data-for-leader"] ?? null,
362
+ });
363
+ }
364
+ }
365
+ if (["path", "line", "polyline", "polygon", "rect", "circle", "ellipse"].includes(node.name)) {
366
+ const stroke = inherited(node, "stroke");
367
+ if (stroke && stroke !== "none") {
368
+ const w = toUser(inherited(node, "stroke-width") ?? "1") ?? 1;
369
+ out.strokes.push({ node, width: w * scaleOf(m), legend: Boolean(ancestor(node, "data-legend-glyph") || ancestor(node, "data-legend-item")) });
370
+ }
371
+ }
372
+ if (node.name === "path" && a["data-arrow"]) {
373
+ const pts = pathPolylines(a.d).flatMap((p) => p.points.map((pt) => apply(m, pt)));
374
+ if (pts.length) out.arrows.push({ id: a["data-arrow"], point: [pts.reduce((t, p) => t + p[0], 0) / pts.length, pts.reduce((t, p) => t + p[1], 0) / pts.length] });
375
+ }
376
+ if (node.name === "path" && (a["data-line"] || a["data-leader"])) {
377
+ let polys;
378
+ try {
379
+ polys = pathPolylines(a.d).map((p) => ({ ...p, points: p.points.map((pt) => apply(m, pt)) }));
380
+ } catch (error) {
381
+ polys = [];
382
+ out.active.push(`path parse: ${error.message}`);
383
+ }
384
+ const stroke = inherited(node, "stroke");
385
+ if (a["data-line"]) {
386
+ for (const p of polys) out.lines.push({ node, kind: a["data-line"], id: a["data-line-id"] ?? "", points: p.points, closed: p.closed, hops: p.hops, color: stroke });
387
+ if (a["data-role"] && stroke) out.roleColors.set(a["data-role"], stroke);
388
+ } else {
389
+ for (const p of polys) out.leaders.push({ node, id: a["data-leader"], target: a["data-target"] ?? null, points: p.points });
390
+ }
391
+ }
392
+ for (const child of node.children) walk(child, m);
393
+ };
394
+ walk(root, I);
395
+ return out;
396
+ }
397
+
398
+ // ------------------------------------------------------------------ checks
399
+
400
+ const isProcess = (kind) => /^(?:process|utility|stream)/u.test(kind);
401
+ const at = (p) => `@ ${p[0].toFixed(1)},${p[1].toFixed(1)}`;
402
+
403
+ export function check(svgText, options = {}) {
404
+ const findings = [];
405
+ const add = (code, message, where = null) => findings.push({ code, level: ADVISORY.has(code) ? "WARN" : "FAIL", message, where });
406
+ let c;
407
+ try {
408
+ c = collect(svgText);
409
+ } catch (error) {
410
+ add("XML.WELLFORMED", error.message);
411
+ return summarize(findings, {});
412
+ }
413
+ const root = c.root;
414
+ const mode = options.mode ?? root.attrs["data-schematic-mode"] ?? "figure";
415
+ const view = numbers(root.attrs.viewBox);
416
+ const width = toMm(root.attrs.width);
417
+ const height = toMm(root.attrs.height);
418
+ if (view.length !== 4 || !width || !height || !/(?:mm|cm|in|pt)$/u.test(root.attrs.width ?? "")) add("SVG.SIZE", `width="${root.attrs.width ?? ""}" height="${root.attrs.height ?? ""}" viewBox="${root.attrs.viewBox ?? ""}"`);
419
+ const unit = view.length === 4 && width ? width / view[2] : 1;
420
+ const canvas = view.length === 4 ? [view[0], view[1], view[0] + view[2], view[1] + view[3]] : [0, 0, Infinity, Infinity];
421
+ for (const e of new Set(c.external)) add("XML.EXTERNAL", e.slice(0, 120));
422
+ for (const e of new Set(c.active)) add("SVG.ACTIVE", e);
423
+
424
+ const minPt = options.minTextPt ?? Number(root.attrs["data-min-text-pt"] ?? (mode === "pid" || mode === "pfd" ? 5.6 : 6));
425
+ const minStrokeMm = options.minStrokeMm ?? 0.088;
426
+ const texts = c.texts;
427
+ for (const t of texts) {
428
+ const pt = mmToPt(t.size * unit);
429
+ if (pt < minPt - 0.05) add("TEXT.MIN_SIZE", `"${t.content}" is ${pt.toFixed(1)} pt; the minimum is ${minPt} pt`, [t.box[0], t.box[3]]);
430
+ if (!contains(canvas, t.box, 0.05)) add("TEXT.CLIPPED", `"${t.content}" runs past the drawing edge`, [t.box[0], t.box[3]]);
431
+ if (t.within && !contains(transformBox(t.withinM, t.within), t.box, 0.15)) add("TEXT.OUT_OF_CELL", `"${t.content}" runs out of its block`, [t.box[0], t.box[3]]);
432
+ }
433
+ for (let i = 0; i < texts.length; i++) {
434
+ for (let j = i + 1; j < texts.length; j++) {
435
+ const a = texts[i];
436
+ const b = texts[j];
437
+ const ov = overlapArea(a.box, b.box);
438
+ if (ov > 0.04 * Math.min((a.box[2] - a.box[0]) * (a.box[3] - a.box[1]), (b.box[2] - b.box[0]) * (b.box[3] - b.box[1])) && ov > 0.05) {
439
+ add("TEXT.OVERLAP", `"${a.content}" overlaps "${b.content}"`, [a.box[0], a.box[3]]);
440
+ }
441
+ }
442
+ }
443
+ const symbols = c.symbols.filter((s) => !s.noBox);
444
+ for (const s of c.symbols.filter((s) => s.noBox)) add("SYMBOL.NO_BOX", `${s.kind} has no data-box`);
445
+ for (const t of texts) {
446
+ if (t.legendGlyph) continue;
447
+ for (const s of symbols) {
448
+ if (t.symbol === s.node) continue;
449
+ if (s.boxes.some((b) => boxesOverlap(t.box, b, 0.25))) { add("TEXT.ON_SYMBOL", `"${t.content}" sits on ${s.kind}${s.tag ? ` ${s.tag}` : ""}`, [t.box[0], t.box[3]]); break; }
450
+ }
451
+ if (t.label !== "engraved") {
452
+ for (const p of c.parts) {
453
+ if (t.part === p.node) continue;
454
+ if (polygonHitsBox(p.hull, [t.box[0] + 0.2, t.box[1] + 0.2, t.box[2] - 0.2, t.box[3] - 0.2])) { add("TEXT.ON_ART", `"${t.content}" sits on ${p.name}`, [t.box[0], t.box[3]]); break; }
455
+ }
456
+ }
457
+ }
458
+ const segs = c.lines.flatMap((l) => segmentsOf(l.points).map(([a, b]) => ({ line: l, a, b })));
459
+ for (const t of texts) {
460
+ const inner = [t.box[0] + 0.2, t.box[1] + 0.2, t.box[2] - 0.2, t.box[3] - 0.2];
461
+ if (inner[2] <= inner[0] || inner[3] <= inner[1]) continue;
462
+ const hit = segs.find(({ a, b }) => insideLength(a, b, inner) > 0.05);
463
+ // A stream number sits in its diamond on the line by convention.
464
+ if (hit && !t.symbol && t.label !== "stream-number") add("TEXT.ON_LINE", `${hit.line.kind} ${hit.line.id} runs through "${t.content}"`, [t.box[0], t.box[3]]);
465
+ }
466
+
467
+ // Flow arrows sit on open line, never inside a body.
468
+ for (const arrow of c.arrows) {
469
+ const inSym = symbols.find((s) => s.boxes.some((bx) => arrow.point[0] > bx[0] + 0.2 && arrow.point[0] < bx[2] - 0.2 && arrow.point[1] > bx[1] + 0.2 && arrow.point[1] < bx[3] - 0.2));
470
+ if (inSym) add("LINE.ARROW_IN_SYMBOL", `flow arrow on ${arrow.id} sits inside ${inSym.kind}${inSym.tag ? ` ${inSym.tag}` : ""}`, arrow.point);
471
+ }
472
+
473
+ // Lines against symbols.
474
+ const portsOf = (s) => s.ports.map((p) => p.point);
475
+ const allPorts = symbols.flatMap((s) => s.ports.map((p) => ({ s, point: p.point })));
476
+ for (const l of c.lines) {
477
+ for (const [a, b] of segmentsOf(l.points)) {
478
+ for (const s of symbols) {
479
+ const shrunk = s.boxes.map((bx) => [bx[0] + 0.25, bx[1] + 0.25, bx[2] - 0.25, bx[3] - 0.25]);
480
+ const inside = shrunk.reduce((sum, bx) => sum + insideLength(a, b, bx), 0);
481
+ if (inside <= 0.3) continue;
482
+ const atPort = portsOf(s).find((p) => dist(p, a) < 0.4 || dist(p, b) < 0.4);
483
+ if (atPort) {
484
+ // Allowed only while running out from the port: once outside, the segment may not re-enter.
485
+ const from = dist(atPort, a) < 0.4 ? a : b;
486
+ const toPt = from === a ? b : a;
487
+ const len = dist(from, toPt);
488
+ let left = false;
489
+ let reenter = false;
490
+ for (let k = 0.25; k < len; k += 0.25) {
491
+ const p = [from[0] + ((toPt[0] - from[0]) * k) / len, from[1] + ((toPt[1] - from[1]) * k) / len];
492
+ const isIn = shrunk.some((bx) => pointIn(p, bx));
493
+ if (!isIn) left = true;
494
+ else if (left) { reenter = true; break; }
495
+ }
496
+ if (!reenter) continue;
497
+ }
498
+ add("LINE.THROUGH_SYMBOL", `${l.kind} ${l.id} passes through ${s.kind}${s.tag ? ` ${s.tag}` : ""}`, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]);
499
+ }
500
+ }
501
+ }
502
+ // Open line ends must land on something.
503
+ const landed = (p, self) => {
504
+ if (allPorts.some(({ point }) => dist(point, p) < 0.45)) return true;
505
+ for (const l of c.lines) {
506
+ if (l === self) continue;
507
+ for (const [a, b] of segmentsOf(l.points)) if (pointSegmentDistance(p, a, b) < 0.35) return true;
508
+ }
509
+ return false;
510
+ };
511
+ for (const l of c.lines) {
512
+ if (l.closed || l.points.length < 2) continue;
513
+ for (const end of [l.points[0], l.points.at(-1)]) {
514
+ if (!landed(end, l)) add("LINE.DANGLING", `${l.kind} ${l.id} ends in the air`, end);
515
+ }
516
+ }
517
+ // Turns on other lines, runs along other lines, and unmarked process crossings.
518
+ for (const l of c.lines) {
519
+ for (let k = 1; k < l.points.length - 1; k++) {
520
+ const v = l.points[k];
521
+ const turn = Math.abs((v[0] - l.points[k - 1][0]) * (l.points[k + 1][1] - v[1]) - (v[1] - l.points[k - 1][1]) * (l.points[k + 1][0] - v[0]));
522
+ if (turn < 1e-3 || l.hops?.has(k)) continue;
523
+ for (const o of c.lines) {
524
+ if (o === l || (o.id && o.id === l.id)) continue;
525
+ const onO = segmentsOf(o.points).some(([a, b]) => pointSegmentDistance(v, a, b) < 0.2) && !(dist(v, o.points[0]) < 0.4 || dist(v, o.points.at(-1)) < 0.4);
526
+ if (onO) { add("LINE.BEND_ON_LINE", `${l.kind} ${l.id} turns on ${o.kind} ${o.id}`, v); break; }
527
+ }
528
+ }
529
+ }
530
+ for (let i = 0; i < c.lines.length; i++) {
531
+ for (let j = i + 1; j < c.lines.length; j++) {
532
+ const p = c.lines[i];
533
+ const q = c.lines[j];
534
+ if (p.id && p.id === q.id) continue;
535
+ for (const [a, b] of segmentsOf(p.points)) {
536
+ for (const [d, e] of segmentsOf(q.points)) {
537
+ if (collinearOverlap(a, b, d, e) > 0.5) add("LINE.OVERLAP", `${p.kind} ${p.id} runs along ${q.kind} ${q.id}`, a);
538
+ else if (isProcess(p.kind) && isProcess(q.kind)) {
539
+ const hit = segmentIntersection(a, b, d, e);
540
+ if (hit) add("LINE.CROSSING", `${p.id} crosses ${q.id} without a hop`, hit.point);
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ // Leaders: one end at the label, the other on a dot on the part or symbol it names.
547
+ for (const l of c.leaders) {
548
+ const ends = [l.points[0], l.points.at(-1)];
549
+ const label = texts.find((t) => t.forLeader === l.id);
550
+ const nearLabel = (p) => label && pointSegmentDistance(p, [label.box[0] - 1.5, (label.box[1] + label.box[3]) / 2], [label.box[2] + 1.5, (label.box[1] + label.box[3]) / 2]) < 1.6 + (label.box[3] - label.box[1]) / 2;
551
+ const onTarget = (p) => c.dots.some((d) => dist(d.point, p) <= d.r + 0.3) && (c.parts.some((pt) => polygonHitsBox(pt.hull, [p[0] - 0.3, p[1] - 0.3, p[0] + 0.3, p[1] + 0.3])) || symbols.some((s) => s.boxes.some((b) => pointIn(p, b, -0.3))));
552
+ const named = l.target ? c.parts.filter((pt) => pt.name === l.target) : null;
553
+ if (named && named.length && !ends.some((p) => named.some((pt) => polygonHitsBox(pt.hull, [p[0] - 0.3, p[1] - 0.3, p[0] + 0.3, p[1] + 0.3])))) {
554
+ add("LEADER.LOOSE", `leader ${l.id} names ${l.target} but its dot is not on that part`, ends.at(-1));
555
+ }
556
+ if (!label) add("LEADER.LOOSE", `leader ${l.id} has no label (data-for-leader)`, ends[0]);
557
+ else if (!((nearLabel(ends[0]) && onTarget(ends[1])) || (nearLabel(ends[1]) && onTarget(ends[0])))) add("LEADER.LOOSE", `leader ${l.id} for "${label.content}" does not run from its label to a dot on its part`, ends[1]);
558
+ }
559
+ for (let i = 0; i < c.leaders.length; i++) {
560
+ for (let j = i + 1; j < c.leaders.length; j++) {
561
+ const hit = segmentsOf(c.leaders[i].points).some(([a, b]) => segmentsOf(c.leaders[j].points).some(([d, e]) => segmentIntersection(a, b, d, e)));
562
+ if (hit) add("LEADER.CROSSING", `leaders ${c.leaders[i].id} and ${c.leaders[j].id} cross`, c.leaders[i].points[0]);
563
+ else {
564
+ const gap = leaderGap(c.leaders[i].points, c.leaders[j].points) * unit;
565
+ if (gap < LEADER_GAP_MM) add("LEADER.SPACING", `leaders ${c.leaders[i].id} and ${c.leaders[j].id} come within ${gap.toFixed(2)} mm; keep ${LEADER_GAP_MM} mm (move a dot or the label order)`, c.leaders[i].points.at(-1));
566
+ }
567
+ }
568
+ }
569
+ if (mode === "pid") {
570
+ for (const l of c.lines) {
571
+ const number = l.node.attrs["data-number"];
572
+ if (!number || !isProcess(l.kind)) continue;
573
+ const parsed = parseLineNumber(number);
574
+ if (!parsed.ok) add("LINE.NUMBER", `${l.id} "${number}": ${parsed.error}`, l.points[0]);
575
+ }
576
+ }
577
+ for (const s of c.strokes) {
578
+ const mm = s.width * unit;
579
+ if (mm < minStrokeMm - 1e-4) add("STROKE.MIN", `${s.node.name} stroke ${mm.toFixed(3)} mm (${mmToPt(mm).toFixed(2)} pt) is under ${mmToPt(minStrokeMm).toFixed(2)} pt`);
580
+ }
581
+ // Tags.
582
+ const seen = new Map();
583
+ for (const s of symbols) {
584
+ if (!s.tag) continue;
585
+ const instrument = s.kind.startsWith("instrument.") || s.kind.startsWith("valve.") || s.kind.startsWith("relief.") || s.kind.startsWith("flow.");
586
+ const equipment = /^(?:vessel|pump|compressor|exchanger|package|gas)\./u.test(s.kind);
587
+ const parsed = instrument ? parseInstrumentTag(s.tag) : equipment ? parseEquipmentTag(s.tag) : { ok: true };
588
+ if (!parsed.ok) add("TAG.GRAMMAR", `${s.tag}: ${parsed.error}`);
589
+ if (seen.has(s.tag)) add("TAG.DUPLICATE", `${s.tag} appears on ${seen.get(s.tag)} and ${s.kind}`);
590
+ seen.set(s.tag, s.kind);
591
+ }
592
+ // Legend and title block (sheets).
593
+ if (mode === "pid" || mode === "pfd") {
594
+ const used = new Set([...symbols.map((s) => s.kind), ...c.lines.map((l) => `line:${l.kind}`)]);
595
+ for (const kind of used) if (!c.legend.has(kind)) add("LEGEND.COVERAGE", `${kind} is drawn but not in the legend`);
596
+ } else if (c.legend.size) {
597
+ // Figures label equipment directly; a key must cover every stream colour it uses.
598
+ const roles = new Set(c.lines.map((l) => l.node.attrs["data-role"]).filter(Boolean));
599
+ for (const role of roles) if (!c.legend.has(`role:${role}`)) add("LEGEND.COVERAGE", `stream role ${role} is drawn but not in the key`);
600
+ }
601
+ if (mode === "pid" || mode === "pfd") {
602
+ for (const field of ["title", "number", "rev", "date", "scale"]) {
603
+ const v = c.titleblock.get(field);
604
+ if (!v || v === "-") add("TITLEBLOCK.FIELDS", `title block field "${field}" is empty`);
605
+ }
606
+ }
607
+ // Category colours.
608
+ const roles = [...c.roleColors.entries()].filter(([, color]) => hex(color));
609
+ for (let i = 0; i < roles.length; i++) {
610
+ for (let j = i + 1; j < roles.length; j++) {
611
+ if (roles[i][1].toLowerCase() === roles[j][1].toLowerCase()) continue;
612
+ for (const vision of ["normal", "deuteranopia", "protanopia", "tritanopia"]) {
613
+ const d = deltaE(roles[i][1], roles[j][1], vision);
614
+ if (d < (vision === "normal" ? 12 : 9)) { add("PALETTE.CVD", `${roles[i][0]} ${roles[i][1]} and ${roles[j][0]} ${roles[j][1]} differ by dE ${d.toFixed(1)} under ${vision} vision`); break; }
615
+ }
616
+ }
617
+ }
618
+ // Panels.
619
+ if (c.panels.length) {
620
+ const labels = texts.filter((t) => t.panelLabel);
621
+ const expected = c.panels.map((_, i) => String.fromCharCode(97 + i));
622
+ const got = c.panels.map((p) => p.label);
623
+ if (got.join() !== expected.join()) add("PANEL.LABELS", `panels are labelled ${got.join(", ")}; expected ${expected.join(", ")}`);
624
+ const sizes = new Set(labels.map((t) => t.size.toFixed(2)));
625
+ if (labels.length !== c.panels.length) add("PANEL.LABELS", `${c.panels.length} panels but ${labels.length} panel labels`);
626
+ if (sizes.size > 1) add("PANEL.LABELS", "panel labels use different sizes");
627
+ for (const p of c.panels) {
628
+ const t = labels.find((x) => x.content.replace(/[()]/gu, "").toLowerCase() === p.label);
629
+ if (!t) { add("PANEL.LABELS", `panel ${p.label} has no label`); continue; }
630
+ const [x0, y0, x1, y1] = p.box;
631
+ if (!(t.box[0] <= x0 + (x1 - x0) * 0.2 && t.box[1] <= y0 + (y1 - y0) * 0.2 && contains(grow4(p.box, 0.5), t.box))) add("PANEL.LABELS", `label ${t.content} is not at the top left of its panel`, [t.box[0], t.box[3]]);
632
+ }
633
+ }
634
+ if (c.families.size > 2) add("FONT.FAMILIES", `families: ${[...c.families].join(", ")}`);
635
+ return summarize(findings, { mode, texts: texts.length, symbols: symbols.length, lines: c.lines.length, leaders: c.leaders.length, parts: c.parts.length, widthMm: width, heightMm: height });
636
+ }
637
+
638
+ const grow4 = (b, by) => [b[0] - by, b[1] - by, b[2] + by, b[3] + by];
639
+
640
+ function summarize(findings, stats) {
641
+ const unique = [];
642
+ const keys = new Set();
643
+ for (const f of findings) {
644
+ const key = `${f.code}|${f.message}`;
645
+ if (keys.has(key)) continue;
646
+ keys.add(key);
647
+ unique.push(f);
648
+ }
649
+ const fails = unique.filter((f) => f.level === "FAIL");
650
+ return { verdict: fails.length ? "FAIL" : "PASS", fails: fails.length, warnings: unique.length - fails.length, findings: unique, stats };
651
+ }
652
+
653
+ export function formatReport(result, file = "") {
654
+ const lines = result.findings.map((f) => `${f.level} ${f.code} ${f.message}${f.where ? ` ${at(f.where)}` : ""}`);
655
+ const s = result.stats;
656
+ lines.push(`QA ${result.verdict}${file ? ` ${file}` : ""} · ${result.fails} fail · ${result.warnings} warn${s.mode ? ` · mode ${s.mode} · ${s.symbols} symbols · ${s.lines} lines · ${s.texts} labels` : ""}`);
657
+ return lines.join("\n");
658
+ }