@bovinphang/craft-common 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -2
  3. package/dist/content/catalog.d.ts +4 -0
  4. package/dist/content/resources.d.ts +2 -0
  5. package/dist/content/types.d.ts +21 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +668 -0
  8. package/dist/install/identity.d.ts +5 -0
  9. package/dist/install/planner.d.ts +6 -0
  10. package/dist/install/transaction.d.ts +7 -0
  11. package/dist/install/types.d.ts +44 -0
  12. package/docs/consuming-common.md +33 -0
  13. package/localized/zh-CN/methods/debug.md +13 -0
  14. package/localized/zh-CN/methods/doc-sync.md +12 -0
  15. package/localized/zh-CN/methods/refactoring.md +16 -0
  16. package/localized/zh-CN/methods/source-driven-development.md +19 -0
  17. package/localized/zh-CN/methods/tdd.md +13 -0
  18. package/localized/zh-CN/skills/cc-debug-methodology/SKILL.md +8 -0
  19. package/localized/zh-CN/skills/cc-debug-methodology/references/method.md +13 -0
  20. package/localized/zh-CN/skills/cc-doc-sync-core/SKILL.md +8 -0
  21. package/localized/zh-CN/skills/cc-doc-sync-core/references/method.md +12 -0
  22. package/localized/zh-CN/skills/cc-drawio-studio/SKILL.md +73 -0
  23. package/localized/zh-CN/skills/cc-drawio-studio/data/THIRD_PARTY_NOTICES.md +10 -0
  24. package/localized/zh-CN/skills/cc-drawio-studio/data/brand-icons.json +878 -0
  25. package/localized/zh-CN/skills/cc-drawio-studio/data/shape-index.json +83570 -0
  26. package/localized/zh-CN/skills/cc-drawio-studio/references/data-residency.md +27 -0
  27. package/localized/zh-CN/skills/cc-drawio-studio/references/diagram-patterns.md +60 -0
  28. package/localized/zh-CN/skills/cc-drawio-studio/references/flowchart-quality.md +49 -0
  29. package/localized/zh-CN/skills/cc-drawio-studio/references/layout-and-export.md +72 -0
  30. package/localized/zh-CN/skills/cc-drawio-studio/references/troubleshooting.md +29 -0
  31. package/localized/zh-CN/skills/cc-drawio-studio/references/xml-and-mermaid.md +58 -0
  32. package/localized/zh-CN/skills/cc-drawio-studio/scripts/brand-symbols.mjs +99 -0
  33. package/localized/zh-CN/skills/cc-drawio-studio/scripts/diagram-lint.mjs +205 -0
  34. package/localized/zh-CN/skills/cc-drawio-studio/scripts/diagram-url.mjs +102 -0
  35. package/localized/zh-CN/skills/cc-drawio-studio/scripts/drawio-export.mjs +134 -0
  36. package/localized/zh-CN/skills/cc-drawio-studio/scripts/layout-graph.mjs +107 -0
  37. package/localized/zh-CN/skills/cc-drawio-studio/scripts/png-embed-fix.mjs +26 -0
  38. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-go-packages.mjs +36 -0
  39. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-js-modules.mjs +52 -0
  40. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-python-classes.mjs +30 -0
  41. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-python-modules.mjs +55 -0
  42. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-rust-modules.mjs +35 -0
  43. package/localized/zh-CN/skills/cc-drawio-studio/scripts/scan-ts-modules.mjs +7 -0
  44. package/localized/zh-CN/skills/cc-drawio-studio/scripts/shape-query.mjs +121 -0
  45. package/localized/zh-CN/skills/cc-drawio-studio/scripts/studio-core.mjs +139 -0
  46. package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/corporate.json +49 -0
  47. package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/default.json +49 -0
  48. package/localized/zh-CN/skills/cc-drawio-studio/styles/built-in/handdrawn.json +49 -0
  49. package/localized/zh-CN/skills/cc-drawio-studio/styles/schema.json +112 -0
  50. package/localized/zh-CN/skills/cc-refactoring-core/SKILL.md +8 -0
  51. package/localized/zh-CN/skills/cc-refactoring-core/references/method.md +16 -0
  52. package/localized/zh-CN/skills/cc-source-driven-development/SKILL.md +8 -0
  53. package/localized/zh-CN/skills/cc-source-driven-development/references/method.md +19 -0
  54. package/localized/zh-CN/skills/cc-tdd-methodology/SKILL.md +8 -0
  55. package/localized/zh-CN/skills/cc-tdd-methodology/references/method.md +13 -0
  56. package/localized/zh-CN/skills/cc-technical-diagrams/SKILL.md +34 -0
  57. package/localized/zh-CN/skills/cc-technical-diagrams/assets/interactive-diagram.html +371 -0
  58. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/README.md +14 -0
  59. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/architecture.json +121 -0
  60. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/invalid-explicit.json +41 -0
  61. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/sequence.json +53 -0
  62. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/sequence.mmd +21 -0
  63. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/workflow.json +98 -0
  64. package/localized/zh-CN/skills/cc-technical-diagrams/assets/quality-examples/workflow.mmd +7 -0
  65. package/localized/zh-CN/skills/cc-technical-diagrams/references/diagram-workflows.md +81 -0
  66. package/localized/zh-CN/skills/cc-technical-diagrams/references/html-technical-diagrams.md +243 -0
  67. package/localized/zh-CN/skills/cc-technical-diagrams/references/png-qa-autofix.md +37 -0
  68. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/diagram-browser.mjs +153 -0
  69. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/diagram-layout.mjs +176 -0
  70. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/export-diagram.mjs +332 -0
  71. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/interactive-diagram-server.mjs +654 -0
  72. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/mermaid-render.mjs +64 -0
  73. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/png-qa.mjs +462 -0
  74. package/localized/zh-CN/skills/cc-technical-diagrams/scripts/tech-diagram-render.mjs +1537 -0
  75. package/localized/zh-CN/skills/eval_queries.json +160 -0
  76. package/localized/zh-CN/skills/metadata.json +223 -0
  77. package/localized/zh-CN/skills/relations.json +53 -0
  78. package/methods/debug.md +13 -0
  79. package/methods/doc-sync.md +12 -0
  80. package/methods/refactoring.md +16 -0
  81. package/methods/source-driven-development.md +19 -0
  82. package/methods/tdd.md +13 -0
  83. package/package.json +51 -4
  84. package/skills/cc-debug-methodology/SKILL.md +8 -0
  85. package/skills/cc-debug-methodology/references/method.md +13 -0
  86. package/skills/cc-doc-sync-core/SKILL.md +8 -0
  87. package/skills/cc-doc-sync-core/references/method.md +12 -0
  88. package/skills/cc-drawio-studio/SKILL.md +73 -0
  89. package/skills/cc-drawio-studio/data/THIRD_PARTY_NOTICES.md +10 -0
  90. package/skills/cc-drawio-studio/data/brand-icons.json +878 -0
  91. package/skills/cc-drawio-studio/data/shape-index.json +83570 -0
  92. package/skills/cc-drawio-studio/references/data-residency.md +27 -0
  93. package/skills/cc-drawio-studio/references/diagram-patterns.md +60 -0
  94. package/skills/cc-drawio-studio/references/flowchart-quality.md +49 -0
  95. package/skills/cc-drawio-studio/references/layout-and-export.md +72 -0
  96. package/skills/cc-drawio-studio/references/troubleshooting.md +29 -0
  97. package/skills/cc-drawio-studio/references/xml-and-mermaid.md +58 -0
  98. package/skills/cc-drawio-studio/scripts/brand-symbols.mjs +99 -0
  99. package/skills/cc-drawio-studio/scripts/diagram-lint.mjs +205 -0
  100. package/skills/cc-drawio-studio/scripts/diagram-url.mjs +102 -0
  101. package/skills/cc-drawio-studio/scripts/drawio-export.mjs +134 -0
  102. package/skills/cc-drawio-studio/scripts/layout-graph.mjs +107 -0
  103. package/skills/cc-drawio-studio/scripts/png-embed-fix.mjs +26 -0
  104. package/skills/cc-drawio-studio/scripts/scan-go-packages.mjs +36 -0
  105. package/skills/cc-drawio-studio/scripts/scan-js-modules.mjs +52 -0
  106. package/skills/cc-drawio-studio/scripts/scan-python-classes.mjs +30 -0
  107. package/skills/cc-drawio-studio/scripts/scan-python-modules.mjs +55 -0
  108. package/skills/cc-drawio-studio/scripts/scan-rust-modules.mjs +35 -0
  109. package/skills/cc-drawio-studio/scripts/scan-ts-modules.mjs +7 -0
  110. package/skills/cc-drawio-studio/scripts/shape-query.mjs +121 -0
  111. package/skills/cc-drawio-studio/scripts/studio-core.mjs +139 -0
  112. package/skills/cc-drawio-studio/styles/built-in/corporate.json +49 -0
  113. package/skills/cc-drawio-studio/styles/built-in/default.json +49 -0
  114. package/skills/cc-drawio-studio/styles/built-in/handdrawn.json +49 -0
  115. package/skills/cc-drawio-studio/styles/schema.json +112 -0
  116. package/skills/cc-refactoring-core/SKILL.md +8 -0
  117. package/skills/cc-refactoring-core/references/method.md +16 -0
  118. package/skills/cc-source-driven-development/SKILL.md +8 -0
  119. package/skills/cc-source-driven-development/references/method.md +19 -0
  120. package/skills/cc-tdd-methodology/SKILL.md +8 -0
  121. package/skills/cc-tdd-methodology/references/method.md +13 -0
  122. package/skills/cc-technical-diagrams/SKILL.md +34 -0
  123. package/skills/cc-technical-diagrams/assets/interactive-diagram.html +371 -0
  124. package/skills/cc-technical-diagrams/assets/quality-examples/README.md +14 -0
  125. package/skills/cc-technical-diagrams/assets/quality-examples/architecture.json +121 -0
  126. package/skills/cc-technical-diagrams/assets/quality-examples/invalid-explicit.json +41 -0
  127. package/skills/cc-technical-diagrams/assets/quality-examples/sequence.json +53 -0
  128. package/skills/cc-technical-diagrams/assets/quality-examples/sequence.mmd +21 -0
  129. package/skills/cc-technical-diagrams/assets/quality-examples/workflow.json +98 -0
  130. package/skills/cc-technical-diagrams/assets/quality-examples/workflow.mmd +7 -0
  131. package/skills/cc-technical-diagrams/references/diagram-workflows.md +81 -0
  132. package/skills/cc-technical-diagrams/references/html-technical-diagrams.md +243 -0
  133. package/skills/cc-technical-diagrams/references/png-qa-autofix.md +37 -0
  134. package/skills/cc-technical-diagrams/scripts/diagram-browser.mjs +153 -0
  135. package/skills/cc-technical-diagrams/scripts/diagram-layout.mjs +176 -0
  136. package/skills/cc-technical-diagrams/scripts/export-diagram.mjs +332 -0
  137. package/skills/cc-technical-diagrams/scripts/interactive-diagram-server.mjs +654 -0
  138. package/skills/cc-technical-diagrams/scripts/mermaid-render.mjs +64 -0
  139. package/skills/cc-technical-diagrams/scripts/png-qa.mjs +462 -0
  140. package/skills/cc-technical-diagrams/scripts/tech-diagram-render.mjs +1537 -0
  141. package/skills/eval_queries.json +160 -0
  142. package/skills/metadata.json +223 -0
  143. package/skills/relations.json +53 -0
@@ -0,0 +1,176 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {{x:number,y:number,width:number,height:number}} Rect */
4
+ /** @typedef {[number,number]} Point */
5
+ /** @typedef {{code:string,id:string,message:string}} QualityIssue */
6
+ /** @typedef {{lines:string[],width:number,height:number,fontSize:number,lineHeight:number}} TextLayout */
7
+ /** @typedef {Rect & {id:string,label:string,sublabel:string,type:string,actor?:string,step?:string,shape?:"box"|"diamond"|"terminal"}} Box */
8
+
9
+ /** @param {string} text @param {number} fontSize */
10
+ function textWidth(text, fontSize) {
11
+ return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)]
12
+ .reduce((sum, item) => sum + (/^[\x00-\x7f]+$/.test(item.segment) ? fontSize * 0.6 : fontSize), 0);
13
+ }
14
+
15
+ /** @param {string} text @param {number} maxWidth @param {number} [fontSize] @param {number} [lineHeight] @returns {TextLayout} */
16
+ export function layoutText(text, maxWidth, fontSize = 14, lineHeight = 20) {
17
+ const limit = Math.max(fontSize, maxWidth);
18
+ /** @type {string[]} */
19
+ const lines = [];
20
+ for (const paragraph of text.split(/\r?\n/)) {
21
+ let line = "";
22
+ for (const { segment: word } of new Intl.Segmenter(undefined, { granularity: "word" }).segment(paragraph)) {
23
+ if (line && textWidth(word,fontSize) <= limit && textWidth(line+word,fontSize)>limit) {lines.push(line);line="";}
24
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)) {
25
+ if (line && textWidth(line + segment, fontSize) > limit) {
26
+ lines.push(line);
27
+ line = "";
28
+ }
29
+ line += segment;
30
+ }
31
+ }
32
+ lines.push(line);
33
+ }
34
+ return { lines, width: Math.max(0, ...lines.map((line) => textWidth(line, fontSize))), height: lines.length * lineHeight, fontSize, lineHeight };
35
+ }
36
+
37
+ /** @param {Box} source @param {{width:boolean,height:boolean}} explicit */
38
+ export function fitNodeText(source, explicit) {
39
+ const box = { ...source };
40
+ if (![box.width, box.height].every((v) => Number.isFinite(v) && v > 0)) throw new Error(`Invalid size for node "${box.id}".`);
41
+ const diamond = box.shape === "diamond" || box.type === "decision";
42
+ const semantic = ["agent", "model", "memory", "vectorstore", "graphdb", "tool", "document", "queue", "browser", "user", "gateway"].includes(box.type);
43
+ if (!explicit.width) box.width = Math.max(box.width, diamond ? 240 : 200);
44
+ const available = diamond ? box.width * 0.5 - 12 : (box.type === "queue" ? box.width - 64 : box.width - 32);
45
+ const main = layoutText(box.label, available);
46
+ const sub = layoutText(box.sublabel, available, 12, 16);
47
+ const header = ["database", "vectorstore"].includes(box.type) ? 30 : box.type === "queue" ? 20 : semantic ? 46 : box.actor ? 40 : 20;
48
+ const contentHeight = main.height + (box.sublabel ? sub.height + 6 : 0);
49
+ const needed = diamond ? (contentHeight + 24) * 2 : header + contentHeight + (["database", "vectorstore"].includes(box.type) ? 30 : 18);
50
+ if (!explicit.height) box.height = Math.max(box.height, needed, 72);
51
+ /** @type {QualityIssue[]} */
52
+ const issues = [];
53
+ if (box.height < needed || available < main.width) issues.push({ code: "node-text-overflow", id: box.id, message: `Node "${box.id}" has an explicit size too small for its complete text.` });
54
+ return { box, labels: [main, sub], issues };
55
+ }
56
+
57
+ /** @param {Rect} box @param {'top'|'right'|'bottom'|'left'} side @returns {Point} */
58
+ export function boundaryPort(box, side) {
59
+ if (side === "top") return [box.x + box.width / 2, box.y];
60
+ if (side === "bottom") return [box.x + box.width / 2, box.y + box.height];
61
+ if (side === "left") return [box.x, box.y + box.height / 2];
62
+ return [box.x + box.width, box.y + box.height / 2];
63
+ }
64
+
65
+ /** Strict interior intersection; boundary contact is allowed. @param {Point} a @param {Point} b @param {Rect} r */
66
+ export function segmentHits(a, b, r) {
67
+ let lo = 0, hi = 1;
68
+ for (const axis of [0, 1]) {
69
+ const start = axis === 0 ? r.x : r.y;
70
+ const end = start + (axis === 0 ? r.width : r.height);
71
+ const delta = b[axis] - a[axis];
72
+ if (Math.abs(delta) < 1e-8) {
73
+ if (a[axis] <= start + 1e-6 || a[axis] >= end - 1e-6) return false;
74
+ } else {
75
+ const t1 = (start - a[axis]) / delta, t2 = (end - a[axis]) / delta;
76
+ lo = Math.max(lo, Math.min(t1, t2)); hi = Math.min(hi, Math.max(t1, t2));
77
+ }
78
+ }
79
+ return hi - lo > 1e-6;
80
+ }
81
+
82
+ /** @param {Rect} a @param {Rect} b */
83
+ export function overlaps(a, b) {
84
+ return Math.min(a.x + a.width, b.x + b.width) > Math.max(a.x, b.x) + 1e-6 && Math.min(a.y + a.height, b.y + b.height) > Math.max(a.y, b.y) + 1e-6;
85
+ }
86
+
87
+ /** @param {Point[]} points @returns {Point[]} */
88
+ function simplify(points) {
89
+ return points.filter((p, i) => {
90
+ if (i && p[0] === points[i - 1][0] && p[1] === points[i - 1][1]) return false;
91
+ if (!i || i === points.length - 1) return true;
92
+ const prev = points[i - 1], next = points[i + 1];
93
+ return !((prev[0] === p[0] && next[0] === p[0]) || (prev[1] === p[1] && next[1] === p[1]));
94
+ });
95
+ }
96
+
97
+ /** @param {Rect & {id:string}} from @param {Rect & {id:string}} to @param {Rect[]} obstacles @param {Point[][]} occupied @param {Point[]} [waypoints] */
98
+ export function routeConnector(from, to, obstacles, occupied, waypoints) {
99
+ const horizontal = Math.abs(to.x - from.x) >= Math.abs(to.y - from.y);
100
+ const forward = horizontal ? to.x >= from.x : to.y >= from.y;
101
+ const start = boundaryPort(from, horizontal ? (forward ? "right" : "left") : (forward ? "bottom" : "top"));
102
+ const end = boundaryPort(to, horizontal ? (forward ? "left" : "right") : (forward ? "top" : "bottom"));
103
+ /** @type {QualityIssue[]} */
104
+ const issues = [];
105
+ if (waypoints) {
106
+ const points = [start, ...waypoints, end];
107
+ if (points.some((p, i) => i > 0 && obstacles.some((r) => segmentHits(points[i - 1], p, r)))) issues.push({ code: "route-obstructed", id: `${from.id}-${to.id}`, message: "Explicit waypoints cross a non-endpoint node." });
108
+ return { points, issues };
109
+ }
110
+ if (from.id === to.id) {
111
+ const x = from.x + from.width + 40 + occupied.length * 4;
112
+ return { points: /** @type {Point[]} */ ([[from.x + from.width, from.y + from.height / 3], [x, from.y + from.height / 3], [x, from.y + from.height * 2 / 3], [from.x + from.width, from.y + from.height * 2 / 3]]), issues };
113
+ }
114
+ const padded = obstacles.map((r) => ({ x: r.x - 12, y: r.y - 12, width: r.width + 24, height: r.height + 24 }));
115
+ const xSet = new Set([start[0], end[0], 28]), ySet = new Set([start[1], end[1], 28]);
116
+ for (const r of [...padded, from, to]) { xSet.add(r.x - 16); xSet.add(r.x + r.width + 16); ySet.add(r.y - 16); ySet.add(r.y + r.height + 16); }
117
+ for (const route of occupied) for (const [x, y] of route) { xSet.add(x - 16); xSet.add(x + 16); ySet.add(y - 16); ySet.add(y + 16); }
118
+ const xs = [...xSet].filter((x) => x >= 24).sort((a, b) => a - b), ys = [...ySet].filter((y) => y >= 24).sort((a, b) => a - b);
119
+ if (xs.length > 128 || ys.length > 128) return { points: [start, end], issues: [{ code: "route-unresolved", id: `${from.id}-${to.id}`, message: "Routing grid exceeded its bounded search budget." }] };
120
+ const nx = xs.length, ny = ys.length;
121
+ const startId = ys.indexOf(start[1]) * nx + xs.indexOf(start[0]), endId = ys.indexOf(end[1]) * nx + xs.indexOf(end[0]);
122
+ /** @param {number} id @returns {Point} */
123
+ const point = (id) => [xs[id % nx], ys[Math.floor(id / nx)]];
124
+ /** @type {Array<{node:number,dir:number,cost:number,key:string}>} */
125
+ const pending = [{ node: startId, dir: 0, cost: 0, key: `${startId}:0` }];
126
+ const best = new Map([[`${startId}:0`, 0]]), previous = new Map();
127
+ let finish = "";
128
+ while (pending.length) {
129
+ pending.sort((a, b) => a.cost - b.cost || a.node - b.node || a.dir - b.dir);
130
+ const current = pending.shift();
131
+ if (!current || current.cost !== best.get(current.key)) continue;
132
+ if (current.node === endId) { finish = current.key; break; }
133
+ const ix = current.node % nx, iy = Math.floor(current.node / nx);
134
+ for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
135
+ if (ix + dx < 0 || ix + dx >= nx || iy + dy < 0 || iy + dy >= ny) continue;
136
+ const next = (iy + dy) * nx + ix + dx, a = point(current.node), b = point(next);
137
+ if ([...padded, from, to].some((r) => segmentHits(a, b, r))) continue;
138
+ const dir = dx ? 1 : 2;
139
+ let penalty = 0;
140
+ for (const route of occupied) for (let i = 1; i < route.length; i++) {
141
+ const p = route[i - 1], q = route[i];
142
+ if ((a[0] === b[0] && p[0] === q[0] && a[0] === p[0] && Math.min(b[1], a[1]) < Math.max(p[1], q[1]) && Math.max(b[1], a[1]) > Math.min(p[1], q[1])) || (a[1] === b[1] && p[1] === q[1] && a[1] === p[1] && Math.min(b[0], a[0]) < Math.max(p[0], q[0]) && Math.max(b[0], a[0]) > Math.min(p[0], q[0]))) penalty += 80;
143
+ }
144
+ const cost = current.cost + Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]) + (current.dir && current.dir !== dir ? 24 : 0) + penalty;
145
+ const key = `${next}:${dir}`;
146
+ if (cost < (best.get(key) ?? Infinity)) { best.set(key, cost); previous.set(key, current.key); pending.push({ node: next, dir, cost, key }); }
147
+ }
148
+ }
149
+ if (!finish) return { points: [start, end], issues: [{ code: "route-unresolved", id: `${from.id}-${to.id}`, message: "No obstacle-free route exists without moving explicit nodes." }] };
150
+ /** @type {Point[]} */
151
+ const route = [];
152
+ for (let key = finish; key; key = previous.get(key)) route.unshift(point(Number(key.split(":")[0])));
153
+ return { points: simplify(route), issues };
154
+ }
155
+
156
+ /** @param {Point[]} points @param {TextLayout} layout @param {Rect[]} obstacles @param {Point[][]} [routes] */
157
+ export function placeEdgeLabel(points, layout, obstacles, routes = []) {
158
+ const lengths = points.slice(1).map((p, i) => Math.hypot(p[0] - points[i][0], p[1] - points[i][1]));
159
+ let remaining = lengths.reduce((a, b) => a + b, 0) / 2;
160
+ let cx = points[0][0], cy = points[0][1];
161
+ for (let i = 0; i < lengths.length; i++) {
162
+ if (remaining <= lengths[i]) {
163
+ const ratio = lengths[i] ? remaining / lengths[i] : 0;
164
+ cx = points[i][0] + (points[i + 1][0] - points[i][0]) * ratio;
165
+ cy = points[i][1] + (points[i + 1][1] - points[i][1]) * ratio;
166
+ break;
167
+ }
168
+ remaining -= lengths[i];
169
+ }
170
+ let rect = { x: cx - layout.width / 2, y: cy - layout.height - 12, width: layout.width, height: layout.height };
171
+ for (const offset of [12,24,36,52,72,96]) for (const direction of ["above","below","left","right"]) {
172
+ const candidate = direction === "above" ? {...rect,y:cy-layout.height-offset} : direction === "below" ? {...rect,y:cy+offset} : direction === "left" ? {...rect,x:cx-layout.width-offset,y:cy-layout.height/2} : {...rect,x:cx+offset,y:cy-layout.height/2};
173
+ if (candidate.x >= 24 && candidate.y >= 24 && !obstacles.some((r) => overlaps(candidate, r)) && !routes.some(route=>route.slice(1).some((point,index)=>segmentHits(route[index],point,{x:candidate.x-4,y:candidate.y-4,width:candidate.width+8,height:candidate.height+8})))) return { rect: candidate, issues: /** @type {QualityIssue[]} */ ([]) };
174
+ }
175
+ return { rect, issues: [{ code: "edge-label-collision", id: "edge", message: "No free label position near route midpoint." }] };
176
+ }
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { analyzeManifest } from "./png-qa.mjs";
5
+ import { withDiagramPage } from "./diagram-browser.mjs";
6
+ import { spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ /** @typedef {{id:string,x:number,y:number,width:number,height:number,[key:string]:unknown}} LabelRect */
11
+ /** @typedef {{canvas:{width:number,height:number},boxes:LabelRect[],groups?:LabelRect[],labels?:LabelRect[],connectors:Array<{points:Array<[number,number]>,[key:string]:unknown}>,[key:string]:unknown}} Manifest */
12
+
13
+ const SUPPORTED_FORMATS = new Set(["svg", "png", "jpg", "jpeg"]);
14
+ const JPEG_FORMATS = new Set(["jpg", "jpeg"]);
15
+
16
+ try {
17
+ const args = parseArgs(process.argv.slice(2));
18
+ const inputPath = path.resolve(requireArg(args, "input"));
19
+ const format = requireArg(args, "format").toLowerCase();
20
+ const scale = args.scale === undefined ? 2 : parseScale(args.scale);
21
+ const background = args.background ?? (args.theme === "dark" ? "#0f172a" : "#ffffff");
22
+
23
+ if (!SUPPORTED_FORMATS.has(format)) {
24
+ throw usageError(`Unsupported --format "${format}". Expected svg, png, jpg, or jpeg.`);
25
+ }
26
+ if (!fs.existsSync(inputPath)) {
27
+ throw usageError(`Input file does not exist: ${inputPath}`);
28
+ }
29
+
30
+ let svg = extractSvg(inputPath);
31
+ const theme = args.theme ?? "light";
32
+ if (theme !== "light" && theme !== "dark") throw usageError("--theme must be light or dark");
33
+ svg = svg.replace(/data-theme=["'][^"']*["']/, `data-theme="${theme}"`);
34
+ /** @type {Manifest|null} */
35
+ const manifest = args.manifest ? JSON.parse(fs.readFileSync(path.resolve(args.manifest), "utf8")) : null;
36
+ const outputPath = path.resolve(resolveOutputPath(inputPath, format, args.output));
37
+
38
+ for(const metadataPath of [args["output-manifest"],args["qa-report"]]) if(metadataPath) fs.mkdirSync(path.dirname(path.resolve(metadataPath)),{recursive:true});
39
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
40
+ if (format === "svg") {
41
+ fs.writeFileSync(outputPath, `${svg.trim()}\n`, "utf8");
42
+ if (manifest && args["output-manifest"]) fs.writeFileSync(path.resolve(args["output-manifest"]), JSON.stringify({...manifest,coordinateSpace:"svg",scale:1,measurement:"estimated"},null,2)+"\n");
43
+ if (args["qa-report"]) fs.writeFileSync(path.resolve(args["qa-report"]),JSON.stringify({status:"estimated",measurement:"estimated",theme,issues:manifest?.issues??[],visualQA:"pending"},null,2)+"\n");
44
+ } else {
45
+ const dimensions = measureSvg(svg);
46
+ const pageHtml = `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:${background};}svg.tech-diagram,body>svg{display:block;width:${dimensions.width}px;min-width:0;height:${dimensions.height}px;}</style>${svg}`;
47
+ const result = await withDiagramPage(pageHtml, { theme, timeoutMs: 30000 }, async (session) => {
48
+ await session.evaluate(`(async()=>{let last="";for(let i=0;i<8;i++){let next=Array.from(document.querySelectorAll('[data-label-id]')).map(el=>el.getBBox().width).join(',');if(next===last)return;last=next;await new Promise(r=>requestAnimationFrame(r));}throw new Error('Text layout did not stabilize');})()`);
49
+ const labels = await session.evaluate(`Array.from(document.querySelectorAll('[data-label-id]')).map(el=>{const r=el.getBBox();return {id:el.getAttribute('data-label-id'),x:r.x,y:r.y,width:r.width,height:r.height,fontFamily:getComputedStyle(el).fontFamily,fontSize:getComputedStyle(el).fontSize};})`);
50
+ const png = await session.screenshot(dimensions.width, dimensions.height, scale);
51
+ return { png, labels };
52
+ });
53
+ if (result.png.length < 24 || result.png.readUInt32BE(16) !== Math.ceil(dimensions.width * scale) || result.png.readUInt32BE(20) !== Math.ceil(dimensions.height * scale)) throw usageError("Browser raster export failed: unexpected image dimensions.");
54
+ if(manifest) {
55
+ const measured=/** @type {LabelRect[]} */ (result.labels);
56
+ const missing=(manifest.labels??[]).filter(label=>!measured.some(actual=>actual.id===label.id));
57
+ if(missing.length) throw usageError("Browser measurement missing manifest labels: "+missing.map(label=>label.id).join(", "));
58
+ }
59
+ if (JPEG_FORMATS.has(format)) {
60
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "fec-diagram-jpeg-"));
61
+ try { const pngPath = path.join(temp, "image.png"); fs.writeFileSync(pngPath, result.png); convertPngToJpeg(pngPath, outputPath); } finally { fs.rmSync(temp, { recursive: true, force: true }); }
62
+ } else fs.writeFileSync(outputPath, result.png);
63
+ if (manifest) {
64
+ manifest.labels = (manifest.labels ?? []).map((label) => ({ ...label, .../** @type {LabelRect[]} */ (result.labels).find((actual) => actual.id === label.id) }));
65
+ manifest.measurement = "browser";
66
+ if (args["output-manifest"]) fs.writeFileSync(path.resolve(args["output-manifest"]), JSON.stringify(scaleManifest(manifest, scale), null, 2) + "\n");
67
+ }
68
+ /** @type {Array<{code:string,message:string}>} */
69
+ const boundaryIssues=[];
70
+ if(manifest) analyzeManifest({width:Math.ceil(dimensions.width*scale),height:Math.ceil(dimensions.height*scale)},scaleManifest(manifest,scale),boundaryIssues,new Set(),false);
71
+ if (args["qa-report"]) fs.writeFileSync(path.resolve(args["qa-report"]), JSON.stringify({ status: boundaryIssues.length ? "partial" : "measured", issues:boundaryIssues, measurement: "browser", theme, width: dimensions.width * scale, height: dimensions.height * scale, note: "Run PNG QA and inspect the final image; browser measurement alone is not approval." }, null, 2) + "\n");
72
+ }
73
+
74
+ console.log(`Exported ${format.toUpperCase()} diagram: ${outputPath}`);
75
+ } catch (error) {
76
+ console.error(error instanceof Error ? error.message : String(error));
77
+ process.exitCode = 1;
78
+ }
79
+
80
+ /**
81
+ * @param {string[]} argv
82
+ * @returns {Record<string, string | undefined>}
83
+ */
84
+ function parseArgs(argv) {
85
+ /** @type {Record<string, string | undefined>} */
86
+ const parsed = {};
87
+ for (let index = 0; index < argv.length; index += 1) {
88
+ const token = argv[index];
89
+ if (!token.startsWith("--")) {
90
+ throw usageError(`Unexpected positional argument "${token}".`);
91
+ }
92
+ const key = token.slice(2);
93
+ const value = argv[index + 1];
94
+ if (!value || value.startsWith("--")) {
95
+ throw usageError(`Missing value for --${key}.`);
96
+ }
97
+ parsed[key] = value;
98
+ index += 1;
99
+ }
100
+ return parsed;
101
+ }
102
+
103
+ /**
104
+ * @param {Record<string, string | undefined>} args
105
+ * @param {string} key
106
+ */
107
+ function requireArg(args, key) {
108
+ const value = args[key];
109
+ if (!value) throw usageError(`Missing required --${key}.`);
110
+ return value;
111
+ }
112
+
113
+ /**
114
+ * @param {string} value
115
+ */
116
+ function parseScale(value) {
117
+ const scale = Number(value);
118
+ if (!Number.isFinite(scale) || scale <= 0) {
119
+ throw usageError("--scale must be a positive number.");
120
+ }
121
+ return scale;
122
+ }
123
+
124
+ /**
125
+ * @param {string} inputPath
126
+ * @param {string} format
127
+ * @param {string | undefined} output
128
+ */
129
+ function resolveOutputPath(inputPath, format, output) {
130
+ if (output) return output;
131
+ const extension = JPEG_FORMATS.has(format) ? ".jpg" : `.${format}`;
132
+ return inputPath.replace(/\.[^.\\/]+$/, "") + extension;
133
+ }
134
+
135
+ /**
136
+ * @param {string} inputPath
137
+ */
138
+ function extractSvg(inputPath) {
139
+ const content = fs.readFileSync(inputPath, "utf8");
140
+ if (inputPath.toLowerCase().endsWith(".svg")) {
141
+ if (!/<svg[\s>]/i.test(content)) {
142
+ throw usageError("SVG input does not contain an <svg> root.");
143
+ }
144
+ return content;
145
+ }
146
+
147
+ const svgStart = content.search(/<svg[\s>]/i);
148
+ if (svgStart === -1) {
149
+ throw usageError("HTML input does not contain an inline <svg> element.");
150
+ }
151
+ const afterStart = content.slice(svgStart);
152
+ const svgEnd = afterStart.search(/<\/svg>/i);
153
+ if (svgEnd === -1) {
154
+ throw usageError("HTML input contains an <svg> start tag without a closing </svg>.");
155
+ }
156
+ return afterStart.slice(0, svgEnd + "</svg>".length);
157
+ }
158
+
159
+ /**
160
+ * @param {string} svg
161
+ * @param {string} outputPath
162
+ * @param {string} format
163
+ * @param {number} scale
164
+ * @param {string} background
165
+ */
166
+ /**
167
+ * @param {string} pngPath
168
+ * @param {string} outputPath
169
+ */
170
+ function convertPngToJpeg(pngPath, outputPath) {
171
+ const converters = [
172
+ () => convertWithMagick(pngPath, outputPath),
173
+ () => convertWithFfmpeg(pngPath, outputPath),
174
+ () => process.platform === "win32" ? convertWithPowerShell(pngPath, outputPath) : false,
175
+ () => process.platform === "darwin" ? convertWithSips(pngPath, outputPath) : false,
176
+ ];
177
+
178
+ for (const convert of converters) {
179
+ try {
180
+ if (convert() && fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) return;
181
+ } catch {
182
+ // Try the next system converter.
183
+ }
184
+ }
185
+
186
+ throw usageError("JPG/JPEG export needs a local image converter after Chromium captures the SVG. Install ImageMagick, FFmpeg, macOS sips, or use SVG/PNG output.");
187
+ }
188
+
189
+ /**
190
+ * @param {string} pngPath
191
+ * @param {string} outputPath
192
+ */
193
+ function convertWithMagick(pngPath, outputPath) {
194
+ for (const command of ["magick", "convert"]) {
195
+ const result = spawnSync(command, [pngPath, "-background", "white", "-alpha", "remove", "-quality", "95", outputPath], {
196
+ encoding: "utf8",
197
+ timeout: 15000,
198
+ windowsHide: true,
199
+ });
200
+ if (result.status === 0) return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ /**
206
+ * @param {string} pngPath
207
+ * @param {string} outputPath
208
+ */
209
+ function convertWithFfmpeg(pngPath, outputPath) {
210
+ const result = spawnSync("ffmpeg", ["-y", "-i", pngPath, "-q:v", "2", outputPath], {
211
+ encoding: "utf8",
212
+ timeout: 15000,
213
+ windowsHide: true,
214
+ });
215
+ return result.status === 0;
216
+ }
217
+
218
+ /**
219
+ * @param {string} pngPath
220
+ * @param {string} outputPath
221
+ */
222
+ function convertWithPowerShell(pngPath, outputPath) {
223
+ const script = [
224
+ "Add-Type -AssemblyName System.Drawing",
225
+ `$img = [System.Drawing.Image]::FromFile(${quotePowerShell(pngPath)})`,
226
+ "$bmp = New-Object System.Drawing.Bitmap $img.Width, $img.Height",
227
+ "$graphics = [System.Drawing.Graphics]::FromImage($bmp)",
228
+ "$graphics.Clear([System.Drawing.Color]::White)",
229
+ "$graphics.DrawImage($img, 0, 0, $img.Width, $img.Height)",
230
+ `$bmp.Save(${quotePowerShell(outputPath)}, [System.Drawing.Imaging.ImageFormat]::Jpeg)`,
231
+ "$graphics.Dispose(); $bmp.Dispose(); $img.Dispose()",
232
+ ].join("; ");
233
+ const result = spawnSync("powershell", ["-NoProfile", "-Command", script], {
234
+ encoding: "utf8",
235
+ timeout: 15000,
236
+ windowsHide: true,
237
+ });
238
+ return result.status === 0;
239
+ }
240
+
241
+ /**
242
+ * @param {string} pngPath
243
+ * @param {string} outputPath
244
+ */
245
+ function convertWithSips(pngPath, outputPath) {
246
+ fs.copyFileSync(pngPath, outputPath);
247
+ const result = spawnSync("sips", ["-s", "format", "jpeg", outputPath, "--out", outputPath], {
248
+ encoding: "utf8",
249
+ timeout: 15000,
250
+ });
251
+ return result.status === 0;
252
+ }
253
+
254
+ /**
255
+ * @param {string} value
256
+ */
257
+ function quotePowerShell(value) {
258
+ return `'${value.replace(/'/g, "''")}'`;
259
+ }
260
+
261
+ /**
262
+ * @param {string} svg
263
+ */
264
+ function measureSvg(svg) {
265
+ const openTag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? "";
266
+ const width = numberAttr(openTag, "width");
267
+ const height = numberAttr(openTag, "height");
268
+ if (width && height) return { width, height };
269
+
270
+ const viewBox = openTag.match(/\bviewBox=(["'])(.*?)\1/i)?.[2]?.trim().split(/\s+/).map(Number);
271
+ if (viewBox && viewBox.length === 4 && viewBox.every(Number.isFinite)) {
272
+ return { width: viewBox[2], height: viewBox[3] };
273
+ }
274
+ return { width: 1280, height: 720 };
275
+ }
276
+
277
+ /**
278
+ * @param {string} tag
279
+ * @param {string} name
280
+ */
281
+ function numberAttr(tag, name) {
282
+ const value = tag.match(new RegExp(`\\b${name}=(["'])(.*?)\\1`, "i"))?.[2];
283
+ if (!value) return null;
284
+ const number = Number.parseFloat(value);
285
+ return Number.isFinite(number) && number > 0 ? number : null;
286
+ }
287
+
288
+ /**
289
+ * @param {string} svg
290
+ * @param {number} width
291
+ * @param {number} height
292
+ * @param {number} scale
293
+ * @param {string} background
294
+ */
295
+ function renderScreenshotHtml(svg, width, height, scale, background) {
296
+ return `<!doctype html>
297
+ <html lang="en">
298
+ <head>
299
+ <meta charset="utf-8">
300
+ <style>
301
+ html, body {
302
+ margin: 0;
303
+ width: ${Math.ceil(width * scale)}px;
304
+ height: ${Math.ceil(height * scale)}px;
305
+ overflow: hidden;
306
+ background: ${background};
307
+ }
308
+ svg {
309
+ display: block;
310
+ width: ${Math.ceil(width * scale)}px;
311
+ height: ${Math.ceil(height * scale)}px;
312
+ }
313
+ </style>
314
+ </head>
315
+ <body>${svg}</body>
316
+ </html>
317
+ `;
318
+ }
319
+
320
+ /**
321
+ * @param {string} message
322
+ */
323
+ function usageError(message) {
324
+ return new Error(message);
325
+ }
326
+
327
+ /** @param {Manifest} manifest @param {number} scale */
328
+ function scaleManifest(manifest, scale) {
329
+ /** @param {LabelRect} r */
330
+ const rect = (r) => ({ ...r, x: r.x * scale, y: r.y * scale, width: r.width * scale, height: r.height * scale });
331
+ return { ...manifest, coordinateSpace: "png", scale, canvas: { width: Math.ceil(manifest.canvas.width * scale), height: Math.ceil(manifest.canvas.height * scale) }, boxes: manifest.boxes.map(rect), groups: (manifest.groups ?? []).map(rect), labels: (manifest.labels ?? []).map(rect), connectors: manifest.connectors.map((c) => ({ ...c, points: c.points.map(([x, y]) => [x * scale, y * scale]) })) };
332
+ }