@bendyline/squisq 1.5.1 → 2.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 (268) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  6. package/dist/chunk-3E5F2XMR.js.map +1 -0
  7. package/dist/chunk-4X3JQXNM.js +35 -0
  8. package/dist/chunk-4X3JQXNM.js.map +1 -0
  9. package/dist/{chunk-2OIBZYKP.js → chunk-5DKBFXPY.js} +31 -121
  10. package/dist/chunk-5DKBFXPY.js.map +1 -0
  11. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  12. package/dist/chunk-5J4HKRJF.js.map +1 -0
  13. package/dist/chunk-5UYI3RJB.js +2503 -0
  14. package/dist/chunk-5UYI3RJB.js.map +1 -0
  15. package/dist/{chunk-TEEEILMP.js → chunk-66QO7XFB.js} +19 -292
  16. package/dist/chunk-66QO7XFB.js.map +1 -0
  17. package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
  18. package/dist/chunk-6PJRB2CF.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-FVJUUTEM.js +74 -0
  22. package/dist/chunk-FVJUUTEM.js.map +1 -0
  23. package/dist/{chunk-LH7I6SH7.js → chunk-GF3BVNLU.js} +6360 -5723
  24. package/dist/chunk-GF3BVNLU.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
  30. package/dist/chunk-LYNJEOAC.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
  36. package/dist/chunk-QTSJOY54.js.map +1 -0
  37. package/dist/chunk-QVANWDPH.js +2358 -0
  38. package/dist/chunk-QVANWDPH.js.map +1 -0
  39. package/dist/{chunk-6ATE2PSM.js → chunk-R2GC6RLD.js} +7 -7
  40. package/dist/chunk-R2GC6RLD.js.map +1 -0
  41. package/dist/chunk-R2YZTCLO.js +292 -0
  42. package/dist/chunk-R2YZTCLO.js.map +1 -0
  43. package/dist/chunk-S7A5D7ZJ.js +174 -0
  44. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  45. package/dist/chunk-T3TI7GSZ.js +884 -0
  46. package/dist/chunk-T3TI7GSZ.js.map +1 -0
  47. package/dist/{chunk-RUDYOTA6.js → chunk-TUWMK62B.js} +110 -4
  48. package/dist/chunk-TUWMK62B.js.map +1 -0
  49. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  50. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +134 -34
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +253 -81
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/coverBlock.test.ts +33 -2
  97. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  98. package/src/__tests__/dataTable.test.ts +25 -15
  99. package/src/__tests__/diagramBlock.test.ts +3 -3
  100. package/src/__tests__/diagramText.test.ts +26 -0
  101. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  102. package/src/__tests__/drawingBlock.test.ts +3 -3
  103. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  104. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  105. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  106. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  107. package/src/__tests__/getLayers.test.ts +32 -18
  108. package/src/__tests__/getLayersFallback.test.ts +8 -8
  109. package/src/__tests__/imageEditVersions.test.ts +2 -0
  110. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  111. package/src/__tests__/layoutBlock.test.ts +3 -3
  112. package/src/__tests__/majorApiTypes.test.ts +18 -0
  113. package/src/__tests__/markdown.test.ts +41 -0
  114. package/src/__tests__/markdownToDoc.test.ts +5 -2
  115. package/src/__tests__/matchFontFamily.test.ts +114 -0
  116. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  117. package/src/__tests__/materializeTestUtils.ts +17 -0
  118. package/src/__tests__/narrationAlign.test.ts +150 -0
  119. package/src/__tests__/narrationFeatures.test.ts +71 -0
  120. package/src/__tests__/narrationNuclei.test.ts +45 -0
  121. package/src/__tests__/narrationPacing.test.ts +189 -0
  122. package/src/__tests__/narrationScript.test.ts +149 -0
  123. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  124. package/src/__tests__/narrationSidecar.test.ts +88 -0
  125. package/src/__tests__/narrationSyllables.test.ts +65 -0
  126. package/src/__tests__/narrationTestSignals.ts +167 -0
  127. package/src/__tests__/narrationVad.test.ts +66 -0
  128. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  129. package/src/__tests__/rootExports.test.ts +18 -0
  130. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  131. package/src/__tests__/shapeGeometry.test.ts +49 -0
  132. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  133. package/src/__tests__/templateInputs.test.ts +69 -0
  134. package/src/__tests__/templateMetadata.test.ts +2 -1
  135. package/src/__tests__/templates.test.ts +143 -17
  136. package/src/__tests__/themeCompile.test.ts +49 -22
  137. package/src/__tests__/transformProvenance.test.ts +160 -0
  138. package/src/__tests__/transformV2.test.ts +40 -14
  139. package/src/__tests__/treeviewBattle.test.ts +76 -0
  140. package/src/__tests__/treeviewDetect.test.ts +168 -0
  141. package/src/__tests__/treeviewParse.test.ts +118 -0
  142. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  143. package/src/__tests__/treeviewRender.test.ts +91 -0
  144. package/src/__tests__/versions.test.ts +44 -0
  145. package/src/doc/applyNarrationTiming.ts +244 -0
  146. package/src/doc/asciiDiagram/chars.ts +210 -0
  147. package/src/doc/asciiDiagram/detect.ts +175 -0
  148. package/src/doc/asciiDiagram/index.ts +38 -0
  149. package/src/doc/asciiDiagram/mapping.ts +225 -0
  150. package/src/doc/asciiDiagram/parse.ts +1095 -0
  151. package/src/doc/asciiDiagram/render.ts +963 -0
  152. package/src/doc/asciiDiagram/repair.ts +400 -0
  153. package/src/doc/asciiDiagram/types.ts +65 -0
  154. package/src/doc/asciiTimeline/detect.ts +138 -0
  155. package/src/doc/asciiTimeline/index.ts +26 -0
  156. package/src/doc/asciiTimeline/mapping.ts +43 -0
  157. package/src/doc/asciiTimeline/parse.ts +870 -0
  158. package/src/doc/asciiTimeline/render.ts +427 -0
  159. package/src/doc/asciiTimeline/types.ts +73 -0
  160. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  161. package/src/doc/audioMapping.ts +60 -46
  162. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  163. package/src/doc/docToMarkdown.ts +126 -39
  164. package/src/doc/index.ts +76 -4
  165. package/src/doc/markdownToDoc.ts +135 -1
  166. package/src/doc/materializeBlockLayers.ts +304 -0
  167. package/src/doc/mediaAnnotations.ts +20 -0
  168. package/src/doc/resolveDocTheme.ts +8 -7
  169. package/src/doc/templateInputs.ts +185 -2
  170. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  171. package/src/doc/templates/coverBlock.ts +49 -13
  172. package/src/doc/templates/diagramBlock.ts +237 -38
  173. package/src/doc/templates/drawingBlock.ts +2 -2
  174. package/src/doc/templates/fallbackBlock.ts +1 -1
  175. package/src/doc/templates/index.ts +134 -279
  176. package/src/doc/templates/inputDescriptors.ts +20 -1
  177. package/src/doc/templates/metadata.ts +10 -0
  178. package/src/doc/templates/persistentLayers.ts +12 -126
  179. package/src/doc/templates/registry.ts +88 -0
  180. package/src/doc/templates/templateNames.ts +22 -0
  181. package/src/doc/templates/timelineBlock.ts +613 -0
  182. package/src/doc/templates/treeBlock.ts +131 -0
  183. package/src/doc/treeview/chars.ts +72 -0
  184. package/src/doc/treeview/detect.ts +145 -0
  185. package/src/doc/treeview/index.ts +30 -0
  186. package/src/doc/treeview/mapping.ts +95 -0
  187. package/src/doc/treeview/parse.ts +201 -0
  188. package/src/doc/treeview/render.ts +41 -0
  189. package/src/doc/treeview/types.ts +43 -0
  190. package/src/doc/utils/applyRenderStyle.ts +2 -9
  191. package/src/doc/utils/diagramText.ts +78 -0
  192. package/src/doc/utils/shapeGeometry.ts +157 -7
  193. package/src/doc/validate.ts +1 -1
  194. package/src/generate/templateMapper.ts +7 -0
  195. package/src/imageEdit/export.ts +6 -5
  196. package/src/imageEdit/versionPaths.ts +13 -26
  197. package/src/imageEdit/versions.ts +21 -100
  198. package/src/index.ts +1 -0
  199. package/src/internal/immutable.ts +58 -0
  200. package/src/jsonForm/index.ts +9 -1
  201. package/src/jsonForm/pointer.ts +68 -23
  202. package/src/markdown/attrTokens.ts +7 -5
  203. package/src/markdown/convert.ts +11 -1
  204. package/src/markdown/index.ts +1 -0
  205. package/src/markdown/stringify.ts +4 -9
  206. package/src/markdown/utils.ts +36 -14
  207. package/src/narration/align.ts +514 -0
  208. package/src/narration/features.ts +231 -0
  209. package/src/narration/index.ts +86 -0
  210. package/src/narration/nuclei.ts +118 -0
  211. package/src/narration/pacing.ts +211 -0
  212. package/src/narration/script.ts +239 -0
  213. package/src/narration/session.ts +100 -0
  214. package/src/narration/sidecar.ts +187 -0
  215. package/src/narration/syllables.ts +85 -0
  216. package/src/narration/trace.ts +50 -0
  217. package/src/narration/types.ts +299 -0
  218. package/src/narration/vad.ts +131 -0
  219. package/src/recommend/numberHighlight.ts +25 -0
  220. package/src/recommend/templates.ts +108 -12
  221. package/src/schemas/BlockTemplates.ts +156 -7
  222. package/src/schemas/Doc.ts +72 -9
  223. package/src/schemas/Media.ts +12 -2
  224. package/src/schemas/Theme.ts +45 -25
  225. package/src/schemas/Viewport.ts +2 -2
  226. package/src/schemas/fontStacks.ts +79 -0
  227. package/src/schemas/themeCompile.ts +15 -0
  228. package/src/schemas/themeConstants.ts +2 -0
  229. package/src/schemas/themeLibrary.ts +25 -19
  230. package/src/schemas/themeValidator.ts +1 -1
  231. package/src/spatial/Geohash.ts +0 -3
  232. package/src/storage/ContentContainer.ts +17 -9
  233. package/src/storage/ScopedContentContainer.ts +45 -15
  234. package/src/transform/applyTransform.ts +5 -5
  235. package/src/transform/index.ts +3 -1
  236. package/src/transform/registry.ts +169 -49
  237. package/src/transform/templateSelector.ts +21 -2
  238. package/src/transform/timingAllocator.ts +152 -21
  239. package/src/transform/types.ts +14 -1
  240. package/src/versions/gitignore.ts +34 -0
  241. package/src/versions/index.ts +2 -0
  242. package/src/versions/operations.ts +18 -104
  243. package/src/versions/paths.ts +13 -28
  244. package/src/versions/snapshotStore.ts +202 -0
  245. package/src/versions/types.ts +3 -1
  246. package/dist/chunk-2OIBZYKP.js.map +0 -1
  247. package/dist/chunk-6ATE2PSM.js.map +0 -1
  248. package/dist/chunk-A4O7GIWE.js.map +0 -1
  249. package/dist/chunk-DLXZMS5K.js.map +0 -1
  250. package/dist/chunk-HWVFJAAH.js.map +0 -1
  251. package/dist/chunk-IMSCRLLV.js.map +0 -1
  252. package/dist/chunk-JELIRVDP.js +0 -24
  253. package/dist/chunk-JELIRVDP.js.map +0 -1
  254. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  255. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  256. package/dist/chunk-LH7I6SH7.js.map +0 -1
  257. package/dist/chunk-RUDYOTA6.js.map +0 -1
  258. package/dist/chunk-T5UK6YOB.js.map +0 -1
  259. package/dist/chunk-TEEEILMP.js.map +0 -1
  260. package/dist/chunk-UY7KGQ5R.js +0 -192
  261. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  262. package/dist/chunk-VLJJHDAC.js +0 -218
  263. package/dist/chunk-VLJJHDAC.js.map +0 -1
  264. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  265. package/dist/story/index.d.ts +0 -1777
  266. package/dist/story/index.js +0 -273
  267. package/src/doc/getLayers.ts +0 -195
  268. /package/dist/{story → narration}/index.js.map +0 -0
@@ -0,0 +1,1095 @@
1
+ /**
2
+ * ASCII diagram parser: box-and-line art → semantic nodes/edges.
3
+ *
4
+ * Pure and deterministic; never throws. Unparseable input degrades to an
5
+ * empty diagram (plus warnings), never an exception — callers decide what
6
+ * to do with sparse results (the detector requires ≥2 nodes, for example).
7
+ *
8
+ * Pipeline: normalize → char grid → box tracing (corner walks; `+` is
9
+ * resolved smallest-rectangle-first) → containment tree → labels/ids →
10
+ * edge extraction (mask box perimeters + leaf interiors, then connected
11
+ * components of line/arrow cells with label "bridges") → loose text.
12
+ */
13
+
14
+ import {
15
+ arrowDirection,
16
+ asciiArrowDirection,
17
+ isAsciiStructural,
18
+ isBlCorner,
19
+ isBrCorner,
20
+ isFlatBorderChar,
21
+ isHoriz,
22
+ isJunction,
23
+ isLineChar,
24
+ isPlus,
25
+ isSideBorderChar,
26
+ isTlCorner,
27
+ isTrCorner,
28
+ isUnicodeStructural,
29
+ isVert,
30
+ type ArrowDirection,
31
+ } from './chars.js';
32
+ import type { AsciiDiagram, AsciiDiagramEdge, AsciiDiagramNode } from './types.js';
33
+
34
+ /** Internal stats consumed by the detector (loose-content ratio etc.). */
35
+ export interface AsciiParseStats {
36
+ totalNonSpaceCells: number;
37
+ looseNonSpaceCells: number;
38
+ hasWideChars: boolean;
39
+ }
40
+
41
+ export interface Rect {
42
+ r0: number;
43
+ c0: number;
44
+ r1: number;
45
+ c1: number;
46
+ }
47
+
48
+ interface ParsedBox extends Rect {
49
+ /** Title embedded in the top border (`┌── Title ──┐`), if any. */
50
+ borderTitle?: string;
51
+ parentIdx: number;
52
+ labelLines: string[];
53
+ id: string;
54
+ }
55
+
56
+ const MAX_TITLE_RUN = 60;
57
+ const MAX_BRIDGE_GAP = 40;
58
+ const MAX_LOOSE_WARNINGS = 10;
59
+ /**
60
+ * Consecutive non-border cells tolerated while walking a box's side wall.
61
+ * Hand-drawn art frequently has ragged side borders — a label line that
62
+ * overflowed its box (e.g. a rename widened the text) leaves a space where
63
+ * the `│` should be. The four corners plus the solid top/bottom borders are
64
+ * the real box anchors, so a short run of gaps in a side wall shouldn't stop
65
+ * the wall from reaching its corner. Kept small so two unrelated horizontal
66
+ * rules can't span into a phantom box.
67
+ */
68
+ const MAX_WALL_GAP = 3;
69
+ /** Larger side-wall gap budget for the opt-in repair pass (badly-drawn art). */
70
+ const REPAIR_WALL_GAP = 10;
71
+
72
+ /** True crossings: vertical and horizontal runs pass through independently. */
73
+ const CROSS_CHARS = new Set(['┼', '╋', '╬', '╫', '╪']);
74
+
75
+ export function parseAsciiDiagram(text: string): AsciiDiagram {
76
+ return parseAsciiDiagramWithStats(text).diagram;
77
+ }
78
+
79
+ export function parseAsciiDiagramWithStats(text: string): {
80
+ diagram: AsciiDiagram;
81
+ stats: AsciiParseStats;
82
+ } {
83
+ const warnings: string[] = [];
84
+ const grid = buildGrid(text, warnings);
85
+ const H = grid.length;
86
+ const W = H > 0 ? grid[0].length : 0;
87
+
88
+ let totalNonSpaceCells = 0;
89
+ let hasWideChars = false;
90
+ for (let r = 0; r < H; r++) {
91
+ for (let c = 0; c < W; c++) {
92
+ const ch = grid[r][c];
93
+ if (ch !== ' ') totalNonSpaceCells++;
94
+ if (!hasWideChars && isWideChar(ch)) hasWideChars = true;
95
+ }
96
+ }
97
+ if (hasWideChars) warnings.push('wide-chars');
98
+
99
+ const style = detectStyle(grid);
100
+
101
+ // ---- Boxes -------------------------------------------------------------
102
+ const rects = traceBoxes(grid);
103
+ const boxes = buildBoxes(grid, rects, warnings);
104
+
105
+ // ---- Mask: box perimeters, leaf interiors, container label text --------
106
+ const mask = new Uint8Array(W * H);
107
+ const perimeterOwners = new Map<number, number[]>(); // cell index → box indices
108
+ for (let b = 0; b < boxes.length; b++) {
109
+ const box = boxes[b];
110
+ for (let c = box.c0; c <= box.c1; c++) {
111
+ for (const r of [box.r0, box.r1]) {
112
+ mask[r * W + c] = 1;
113
+ const owners = perimeterOwners.get(r * W + c) ?? [];
114
+ owners.push(b);
115
+ perimeterOwners.set(r * W + c, owners);
116
+ }
117
+ }
118
+ for (let r = box.r0 + 1; r <= box.r1 - 1; r++) {
119
+ for (const c of [box.c0, box.c1]) {
120
+ mask[r * W + c] = 1;
121
+ const owners = perimeterOwners.get(r * W + c) ?? [];
122
+ owners.push(b);
123
+ perimeterOwners.set(r * W + c, owners);
124
+ }
125
+ }
126
+ }
127
+ const isContainer = boxes.map((_, i) => boxes.some((b) => b.parentIdx === i));
128
+ for (let b = 0; b < boxes.length; b++) {
129
+ const box = boxes[b];
130
+ if (!isContainer[b]) {
131
+ // Leaf: whole interior is label space.
132
+ for (let r = box.r0 + 1; r <= box.r1 - 1; r++) {
133
+ for (let c = box.c0 + 1; c <= box.c1 - 1; c++) mask[r * W + c] = 1;
134
+ }
135
+ } else {
136
+ // Container: only the label text cells above the topmost child.
137
+ const minChildR0 = Math.min(
138
+ ...boxes.filter((child) => child.parentIdx === b).map((child) => child.r0),
139
+ );
140
+ for (let r = box.r0 + 1; r < minChildR0; r++) {
141
+ for (let c = box.c0 + 1; c <= box.c1 - 1; c++) {
142
+ const ch = grid[r][c];
143
+ if (ch !== ' ' && !isLineChar(ch) && arrowDirection(ch) === null) mask[r * W + c] = 1;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // ---- Edges ---------------------------------------------------------------
150
+ const { edges, edgeCells } = extractEdges(grid, mask, perimeterOwners, boxes, warnings);
151
+
152
+ // ---- Loose text ---------------------------------------------------------
153
+ let looseNonSpaceCells = 0;
154
+ const looseRuns: string[] = [];
155
+ for (let r = 0; r < H; r++) {
156
+ let run = '';
157
+ const flush = () => {
158
+ if (run.trim().length > 0) looseRuns.push(`@${r}: ${truncate(run.trim(), 30)}`);
159
+ run = '';
160
+ };
161
+ const isLooseCell = (c: number): boolean => {
162
+ if (c < 0 || c >= W) return false;
163
+ const idx = r * W + c;
164
+ return grid[r][c] !== ' ' && !mask[idx] && !edgeCells.has(idx);
165
+ };
166
+ for (let c = 0; c < W; c++) {
167
+ if (isLooseCell(c)) {
168
+ run += grid[r][c];
169
+ looseNonSpaceCells++;
170
+ } else if (run !== '' && grid[r][c] === ' ' && isLooseCell(c + 1)) {
171
+ run += ' '; // single interior space keeps phrases together
172
+ } else {
173
+ flush();
174
+ }
175
+ }
176
+ flush();
177
+ }
178
+ for (const runText of looseRuns.slice(0, MAX_LOOSE_WARNINGS)) {
179
+ warnings.push(`loose-text ${runText}`);
180
+ }
181
+ if (looseRuns.length > MAX_LOOSE_WARNINGS) {
182
+ warnings.push(`loose-text (+${looseRuns.length - MAX_LOOSE_WARNINGS} more)`);
183
+ }
184
+
185
+ // ---- Assemble -----------------------------------------------------------
186
+ const nodes: AsciiDiagramNode[] = boxes.map((box) => ({
187
+ id: box.id,
188
+ label: box.labelLines.join('\n'),
189
+ col: box.c0,
190
+ row: box.r0,
191
+ wCols: box.c1 - box.c0 + 1,
192
+ hRows: box.r1 - box.r0 + 1,
193
+ ...(box.parentIdx >= 0 ? { containerId: boxes[box.parentIdx].id } : {}),
194
+ }));
195
+
196
+ return {
197
+ diagram: { nodes, edges, style, warnings },
198
+ stats: { totalNonSpaceCells, looseNonSpaceCells, hasWideChars },
199
+ };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Grid
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function buildGrid(text: string, warnings: string[]): string[][] {
207
+ const rawLines = text.replace(/\r\n?/g, '\n').split('\n');
208
+ let hadTab = false;
209
+ const rows: string[][] = rawLines.map((line) => {
210
+ const cells: string[] = [];
211
+ for (const ch of line) {
212
+ if (ch === '\t') {
213
+ hadTab = true;
214
+ do {
215
+ cells.push(' ');
216
+ } while (cells.length % 4 !== 0);
217
+ } else {
218
+ cells.push(ch);
219
+ }
220
+ }
221
+ return cells;
222
+ });
223
+ if (hadTab) warnings.push('tabs-expanded');
224
+ let width = 0;
225
+ for (const row of rows) width = Math.max(width, row.length);
226
+ for (const row of rows) {
227
+ while (row.length < width) row.push(' ');
228
+ }
229
+ return rows;
230
+ }
231
+
232
+ function isWideChar(ch: string): boolean {
233
+ const c = ch.codePointAt(0) ?? 0;
234
+ return (
235
+ (c >= 0x1100 && c <= 0x115f) ||
236
+ (c >= 0x2e80 && c <= 0x303e) ||
237
+ (c >= 0x3041 && c <= 0x33ff) ||
238
+ (c >= 0x3400 && c <= 0x4dbf) ||
239
+ (c >= 0x4e00 && c <= 0x9fff) ||
240
+ (c >= 0xac00 && c <= 0xd7a3) ||
241
+ (c >= 0xf900 && c <= 0xfaff) ||
242
+ (c >= 0xfe30 && c <= 0xfe4f) ||
243
+ (c >= 0xff00 && c <= 0xff60) ||
244
+ (c >= 0x1f300 && c <= 0x1faff)
245
+ );
246
+ }
247
+
248
+ function detectStyle(grid: string[][]): 'unicode' | 'ascii' {
249
+ let unicode = 0;
250
+ let ascii = 0;
251
+ for (const row of grid) {
252
+ for (const ch of row) {
253
+ if (isUnicodeStructural(ch)) unicode++;
254
+ else if (isAsciiStructural(ch)) ascii++;
255
+ }
256
+ }
257
+ return ascii > unicode ? 'ascii' : 'unicode';
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Box tracing
262
+ // ---------------------------------------------------------------------------
263
+
264
+ export interface TracedRect extends Rect {
265
+ borderTitle?: string;
266
+ }
267
+
268
+ /**
269
+ * Trace every box rectangle in the grid. `repair` mode is the aggressive
270
+ * variant used only by the opt-in `repairAsciiDiagram` pass: it lets a side
271
+ * wall cross edge-routing chars (a hand-drawn box whose label overflowed can
272
+ * push arrows/lines through where its `│` should be), so more boxes of
273
+ * genuinely-broken art are recovered. Conservative auto-detection never uses
274
+ * it (a phantom box there would hide real code).
275
+ */
276
+ export function traceBoxes(grid: string[][], repair = false): TracedRect[] {
277
+ const H = grid.length;
278
+ const W = H > 0 ? grid[0].length : 0;
279
+ const found: TracedRect[] = [];
280
+ for (let r = 0; r < H; r++) {
281
+ for (let c = 0; c < W; c++) {
282
+ if (!isTlCandidate(grid, r, c)) continue;
283
+ const rect = traceBoxFrom(grid, r, c, repair);
284
+ if (rect) found.push(rect);
285
+ }
286
+ }
287
+ return found;
288
+ }
289
+
290
+ /** Build the code-point grid for a fence's text (CRLF/tab-normalized, padded). */
291
+ export function toGrid(text: string): string[][] {
292
+ return buildGrid(text, []);
293
+ }
294
+
295
+ function isTlCandidate(grid: string[][], r: number, c: number): boolean {
296
+ const ch = grid[r][c];
297
+ if (isTlCorner(ch)) return true;
298
+ // `+` (ASCII) and junction chars (Unicode `├ ┬ ┼` on shared borders /
299
+ // sectioned cards) can open a box when a border continues right + down.
300
+ if (!isPlus(ch) && !isJunction(ch)) return false;
301
+ const right = grid[r][c + 1] ?? ' ';
302
+ const below = grid[r + 1]?.[c] ?? ' ';
303
+ const rightOk = isFlatBorderChar(right) || isTrCorner(right);
304
+ const belowOk = isSideBorderChar(below) || isBlCorner(below);
305
+ return rightOk && belowOk;
306
+ }
307
+
308
+ /**
309
+ * Attempt to confirm a full rectangle starting at a top-left corner.
310
+ * Smallest-rectangle-first: the first (nearest) TR/bottom-row combination
311
+ * that closes wins, so `+` lattices decompose into cells and stacked
312
+ * shared-border boxes parse as siblings.
313
+ */
314
+ function traceBoxFrom(grid: string[][], r0: number, c0: number, repair = false): TracedRect | null {
315
+ const H = grid.length;
316
+ const W = grid[0]?.length ?? 0;
317
+
318
+ // -- Top border walk: collect TR candidates; allow one embedded title run.
319
+ const trCandidates: number[] = [];
320
+ let titleRun: { from: number; to: number } | null = null;
321
+ let c = c0 + 1;
322
+ while (c < W) {
323
+ const ch = grid[r0][c];
324
+ if (isTrCorner(ch)) {
325
+ trCandidates.push(c);
326
+ break; // a Unicode TR corner definitively ends the border
327
+ }
328
+ if (isPlus(ch) || isJunction(ch)) {
329
+ trCandidates.push(c); // ambiguous: candidate AND possible continuation
330
+ c++;
331
+ continue;
332
+ }
333
+ if (isFlatBorderChar(ch)) {
334
+ c++;
335
+ continue;
336
+ }
337
+ // An arrowhead embedded in the border (`┌───▼──┐`, `┌─▼──▼──▼─┐`) is a
338
+ // stem where an edge attaches, not a title — walk through it so the box
339
+ // still closes (and so a border can hold several arrival points). Edge
340
+ // extraction connects the line above/below to this box.
341
+ if (arrowDirection(ch) !== null) {
342
+ c++;
343
+ continue;
344
+ }
345
+ // Possible embedded title (`┌── Title ──┐`): a single non-border run
346
+ // bounded by border chars on both sides.
347
+ if (titleRun) break;
348
+ let end = c;
349
+ let sawText = false;
350
+ while (end < W && end - c <= MAX_TITLE_RUN) {
351
+ const tch = grid[r0][end];
352
+ if (isFlatBorderChar(tch) || isTrCorner(tch) || arrowDirection(tch) !== null) break;
353
+ if (tch !== ' ') sawText = true;
354
+ end++;
355
+ }
356
+ if (end >= W || end - c > MAX_TITLE_RUN || !sawText || end === c) break;
357
+ titleRun = { from: c, to: end - 1 };
358
+ c = end;
359
+ }
360
+ if (trCandidates.length === 0) return null;
361
+
362
+ // -- Left wall (same for every TR candidate).
363
+ const leftCandidates = walkSideWall(grid, c0, r0, H, isBlCorner, repair);
364
+ if (leftCandidates.length === 0) return null;
365
+ const leftSet = new Set(leftCandidates);
366
+
367
+ for (const c1 of trCandidates) {
368
+ if (c1 < c0 + 2) continue; // minimum width 3
369
+
370
+ // -- Right wall for this candidate.
371
+ const rightCandidates = walkSideWall(grid, c1, r0, H, isBrCorner, repair);
372
+
373
+ for (const r1 of rightCandidates) {
374
+ if (r1 < r0 + 2) continue; // minimum height 3
375
+ if (!leftSet.has(r1)) continue;
376
+ // -- Bottom border must be solid.
377
+ let ok = true;
378
+ for (let bc = c0 + 1; bc <= c1 - 1; bc++) {
379
+ if (!isFlatBorderChar(grid[r1][bc])) {
380
+ ok = false;
381
+ break;
382
+ }
383
+ }
384
+ if (!ok) continue;
385
+ // -- Anti-phantom guard for the gap tolerance: a real box has an actual
386
+ // side-border char on EACH wall's interior. A rectangle bridged purely
387
+ // through gap cells (e.g. the `┌──┴──┐`/`└──┬──┘` routing corners of a
388
+ // fan-out/fan-in, whose sides are empty) has none, and must not become a
389
+ // box. Clean boxes always pass — their interior rows carry `│`/`├`/`┤`.
390
+ // Repair mode widens the check to a ±1 column window so a uniformly
391
+ // SHIFTED side border (corners at c1 but the `│` a column over — common
392
+ // in hand-drawn art) still counts as evidence; a phantom's empty sides
393
+ // stay empty in the window too, so it's still rejected.
394
+ const win = repair ? 1 : 0;
395
+ let leftBorders = 0;
396
+ let rightBorders = 0;
397
+ for (let ir = r0 + 1; ir <= r1 - 1; ir++) {
398
+ if (hasSideBorderNear(grid[ir], c0, win)) leftBorders++;
399
+ if (hasSideBorderNear(grid[ir], c1, win)) rightBorders++;
400
+ }
401
+ if (leftBorders === 0 || rightBorders === 0) continue;
402
+ const title =
403
+ titleRun && titleRun.to < c1
404
+ ? grid[r0]
405
+ .slice(titleRun.from, titleRun.to + 1)
406
+ .join('')
407
+ .trim()
408
+ : undefined;
409
+ return { r0, c0, r1, c1, ...(title ? { borderTitle: title } : {}) };
410
+ }
411
+ }
412
+ return null;
413
+ }
414
+
415
+ /**
416
+ * Walk one side wall of a box down from just below its top corner, collecting
417
+ * corner-candidate rows (and junction rows, which double as shared-border
418
+ * candidates). Tolerates up to {@link MAX_WALL_GAP} consecutive non-border
419
+ * cells so a ragged / overflowed side border (hand-drawn art, or a rename
420
+ * that widened a label past its box) still reaches the bottom corner. The
421
+ * caller still requires the intersecting BL/BR corners and a solid bottom
422
+ * border, so the gap tolerance can't invent a box out of empty columns.
423
+ */
424
+ /** True when a side-border char sits within `win` columns of `col` on `row`. */
425
+ function hasSideBorderNear(row: string[], col: number, win: number): boolean {
426
+ for (let c = col - win; c <= col + win; c++) {
427
+ const ch = row[c];
428
+ if (ch !== undefined && isSideBorderChar(ch)) return true;
429
+ }
430
+ return false;
431
+ }
432
+
433
+ function walkSideWall(
434
+ grid: string[][],
435
+ col: number,
436
+ r0: number,
437
+ H: number,
438
+ isCorner: (ch: string) => boolean,
439
+ repair = false,
440
+ ): number[] {
441
+ const candidates: number[] = [];
442
+ const maxGap = repair ? REPAIR_WALL_GAP : MAX_WALL_GAP;
443
+ let gap = 0;
444
+ for (let r = r0 + 1; r < H; r++) {
445
+ const ch = grid[r][col];
446
+ if (isCorner(ch)) {
447
+ candidates.push(r);
448
+ break;
449
+ }
450
+ if (isPlus(ch) || isJunction(ch)) {
451
+ candidates.push(r);
452
+ gap = 0;
453
+ continue;
454
+ }
455
+ if (isSideBorderChar(ch)) {
456
+ gap = 0;
457
+ continue;
458
+ }
459
+ // Only spaces / overflow label text are tolerable ragged-wall gaps. A
460
+ // line or arrowhead sitting in the wall path is edge routing crossing
461
+ // here (e.g. a fan-in `▼` between a source box and the collector), not a
462
+ // box wall — stop, so routing can't bridge into a phantom box. In the
463
+ // opt-in repair pass we tolerate even that (badly-drawn boxes push routing
464
+ // through their walls); the side-border-evidence guard still applies.
465
+ if (!repair && (isLineChar(ch) || arrowDirection(ch) !== null)) break;
466
+ if (++gap > maxGap) break;
467
+ }
468
+ return candidates;
469
+ }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Containment, labels, ids
473
+ // ---------------------------------------------------------------------------
474
+
475
+ function strictlyContains(parent: Rect, child: Rect): boolean {
476
+ return (
477
+ parent.r0 < child.r0 && parent.c0 < child.c0 && parent.r1 > child.r1 && parent.c1 > child.c1
478
+ );
479
+ }
480
+
481
+ /**
482
+ * True when the boxes' INTERIORS overlap. Sharing a border line (stacked
483
+ * `+--+ / +--+` cards, lattices) is legal and must not warn.
484
+ */
485
+ function interiorsOverlap(a: Rect, b: Rect): boolean {
486
+ return (
487
+ a.r0 + 1 <= b.r1 - 1 && b.r0 + 1 <= a.r1 - 1 && a.c0 + 1 <= b.c1 - 1 && b.c0 + 1 <= a.c1 - 1
488
+ );
489
+ }
490
+
491
+ function area(rect: Rect): number {
492
+ return (rect.r1 - rect.r0 + 1) * (rect.c1 - rect.c0 + 1);
493
+ }
494
+
495
+ function buildBoxes(grid: string[][], rects: TracedRect[], warnings: string[]): ParsedBox[] {
496
+ // Reading order: row, then col.
497
+ const sorted = [...rects].sort((a, b) => a.r0 - b.r0 || a.c0 - b.c0);
498
+
499
+ // Parents: smallest strictly-containing box; ties → first in reading order.
500
+ const parentIdx = sorted.map((child, i) => {
501
+ let best = -1;
502
+ let bestArea = Infinity;
503
+ for (let j = 0; j < sorted.length; j++) {
504
+ if (j === i) continue;
505
+ if (!strictlyContains(sorted[j], child)) continue;
506
+ const a = area(sorted[j]);
507
+ if (a < bestArea) {
508
+ bestArea = a;
509
+ best = j;
510
+ }
511
+ }
512
+ return best;
513
+ });
514
+
515
+ // Overlap (interiors intersect, neither contains) → keep both + warning.
516
+ let warnedOverlap = false;
517
+ for (let i = 0; i < sorted.length && !warnedOverlap; i++) {
518
+ for (let j = i + 1; j < sorted.length; j++) {
519
+ if (
520
+ interiorsOverlap(sorted[i], sorted[j]) &&
521
+ !strictlyContains(sorted[i], sorted[j]) &&
522
+ !strictlyContains(sorted[j], sorted[i])
523
+ ) {
524
+ warnings.push('overlapping-boxes');
525
+ warnedOverlap = true;
526
+ break;
527
+ }
528
+ }
529
+ }
530
+
531
+ const hasChildren = sorted.map((_, i) => parentIdx.includes(i));
532
+
533
+ const usedIds = new Map<string, number>();
534
+ const boxes: ParsedBox[] = sorted.map((rect, i) => {
535
+ const labelLines: string[] = [];
536
+ if (rect.borderTitle) labelLines.push(rect.borderTitle);
537
+ if (!hasChildren[i]) {
538
+ for (let r = rect.r0 + 1; r <= rect.r1 - 1; r++) {
539
+ const rowText = grid[r].slice(rect.c0 + 1, rect.c1).join('');
540
+ if (isDividerRow(rowText)) continue;
541
+ const trimmed = rowText.trim();
542
+ if (trimmed.length > 0) labelLines.push(trimmed);
543
+ }
544
+ } else {
545
+ const minChildR0 = Math.min(
546
+ ...sorted.filter((_, j) => parentIdx[j] === i).map((child) => child.r0),
547
+ );
548
+ for (let r = rect.r0 + 1; r < minChildR0; r++) {
549
+ const rowText = grid[r]
550
+ .slice(rect.c0 + 1, rect.c1)
551
+ .map((ch) => (isLineChar(ch) || arrowDirection(ch) !== null ? ' ' : ch))
552
+ .join('')
553
+ .trim();
554
+ if (rowText.length > 0) labelLines.push(rowText);
555
+ }
556
+ }
557
+ const base = slugify(labelLines[0] ?? '');
558
+ const count = usedIds.get(base) ?? 0;
559
+ usedIds.set(base, count + 1);
560
+ const id = count === 0 ? base : `${base}-${count + 1}`;
561
+ return { ...rect, parentIdx: parentIdx[i], labelLines, id };
562
+ });
563
+ return boxes;
564
+ }
565
+
566
+ /** A leaf interior row that is purely a horizontal divider (`├────┤` style). */
567
+ function isDividerRow(rowText: string): boolean {
568
+ let sawLine = false;
569
+ for (const ch of rowText) {
570
+ if (ch === ' ') continue;
571
+ if (isHoriz(ch) || isJunction(ch) || isPlus(ch)) {
572
+ sawLine = true;
573
+ continue;
574
+ }
575
+ return false;
576
+ }
577
+ return sawLine;
578
+ }
579
+
580
+ function slugify(s: string): string {
581
+ const slug = s
582
+ .toLowerCase()
583
+ .replace(/[^a-z0-9]+/g, '-')
584
+ .replace(/^-+|-+$/g, '');
585
+ return slug || 'node';
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Edge extraction
590
+ // ---------------------------------------------------------------------------
591
+
592
+ interface EdgeCellInfo {
593
+ arrow: ArrowDirection | null;
594
+ bridgeLabel?: string;
595
+ }
596
+
597
+ function extractEdges(
598
+ grid: string[][],
599
+ mask: Uint8Array,
600
+ perimeterOwners: Map<number, number[]>,
601
+ boxes: ParsedBox[],
602
+ warnings: string[],
603
+ ): { edges: AsciiDiagramEdge[]; edgeCells: Set<number> } {
604
+ const H = grid.length;
605
+ const W = H > 0 ? grid[0].length : 0;
606
+
607
+ // -- Eligible cells: line/arrow chars outside the mask.
608
+ const cells = new Map<number, EdgeCellInfo>();
609
+ for (let r = 0; r < H; r++) {
610
+ for (let c = 0; c < W; c++) {
611
+ const idx = r * W + c;
612
+ if (mask[idx]) continue;
613
+ const ch = grid[r][c];
614
+ const uni = arrowDirection(ch);
615
+ if (uni) {
616
+ cells.set(idx, { arrow: uni });
617
+ continue;
618
+ }
619
+ const asc = asciiArrowDirection(ch);
620
+ if (asc && asciiArrowGate(grid, r, c, asc)) {
621
+ cells.set(idx, { arrow: asc });
622
+ continue;
623
+ }
624
+ if (isLineChar(ch)) cells.set(idx, { arrow: null });
625
+ }
626
+ }
627
+
628
+ // -- Bridges: `──label──` glue (label + interior spaces become cells).
629
+ for (let r = 0; r < H; r++) {
630
+ let c = 0;
631
+ while (c < W) {
632
+ const idx = r * W + c;
633
+ if (!cells.has(idx) || !isHoriz(grid[r][c])) {
634
+ c++;
635
+ continue;
636
+ }
637
+ // End of this horizontal run.
638
+ let runEnd = c;
639
+ while (runEnd + 1 < W && cells.has(r * W + runEnd + 1) && isHoriz(grid[r][runEnd + 1])) {
640
+ runEnd++;
641
+ }
642
+ // Candidate gap after the run.
643
+ let g = runEnd + 1;
644
+ let sawText = false;
645
+ let failed = false;
646
+ while (g < W && g - runEnd - 1 <= MAX_BRIDGE_GAP) {
647
+ const gch = grid[r][g];
648
+ const gidx = r * W + g;
649
+ if (mask[gidx] || isVert(gch)) {
650
+ failed = true;
651
+ break;
652
+ }
653
+ if (isHoriz(gch)) break;
654
+ if (isLineChar(gch) || arrowDirection(gch) !== null) {
655
+ failed = true;
656
+ break;
657
+ }
658
+ const asc = asciiArrowDirection(gch);
659
+ if (asc && asciiArrowGate(grid, r, g, asc)) {
660
+ failed = true;
661
+ break;
662
+ }
663
+ if (gch !== ' ') sawText = true;
664
+ g++;
665
+ }
666
+ if (
667
+ !failed &&
668
+ g < W &&
669
+ g > runEnd + 1 &&
670
+ sawText &&
671
+ isHoriz(grid[r][g]) &&
672
+ !mask[r * W + g]
673
+ ) {
674
+ const label = grid[r]
675
+ .slice(runEnd + 1, g)
676
+ .join('')
677
+ .trim();
678
+ for (let bc = runEnd + 1; bc < g; bc++) {
679
+ cells.set(r * W + bc, { arrow: null, bridgeLabel: label });
680
+ }
681
+ c = g;
682
+ } else {
683
+ c = runEnd + 1;
684
+ }
685
+ }
686
+ }
687
+
688
+ // -- Connected components (4-connectivity), with cross cells (`┼`)
689
+ // acting as PASS-THROUGH: at a cross, the vertical and horizontal runs
690
+ // connect independently, so two edges crossing each other stay two
691
+ // edges instead of merging into one four-armed blob. (ASCII `+` cannot
692
+ // be disambiguated from a turn, so it stays fully connective.)
693
+ const isCrossCell = (idx: number): boolean => CROSS_CHARS.has(grid[Math.floor(idx / W)][idx % W]);
694
+ // State key = idx * 2 + channel (0 = horizontal, 1 = vertical); non-cross
695
+ // cells always live on channel 0.
696
+ const stateKey = (idx: number, channel: 0 | 1): number => idx * 2 + channel;
697
+ const visited = new Set<number>();
698
+ const components: number[][] = [];
699
+ const seeds: Array<[number, 0 | 1]> = [];
700
+ for (const idx of cells.keys()) {
701
+ seeds.push([idx, 0]);
702
+ if (isCrossCell(idx)) seeds.push([idx, 1]);
703
+ }
704
+ for (const [seedIdx, seedChannel] of seeds) {
705
+ if (visited.has(stateKey(seedIdx, seedChannel))) continue;
706
+ const componentCells = new Set<number>();
707
+ const queue: Array<[number, 0 | 1]> = [[seedIdx, seedChannel]];
708
+ visited.add(stateKey(seedIdx, seedChannel));
709
+ while (queue.length > 0) {
710
+ const [idx, channel] = queue.pop() as [number, 0 | 1];
711
+ componentCells.add(idx);
712
+ const r = Math.floor(idx / W);
713
+ const c = idx % W;
714
+ const neighbors: Array<[number, number]> =
715
+ isCrossCell(idx) && channel === 1
716
+ ? [
717
+ [r - 1, c],
718
+ [r + 1, c],
719
+ ]
720
+ : isCrossCell(idx)
721
+ ? [
722
+ [r, c - 1],
723
+ [r, c + 1],
724
+ ]
725
+ : [
726
+ [r - 1, c],
727
+ [r + 1, c],
728
+ [r, c - 1],
729
+ [r, c + 1],
730
+ ];
731
+ for (const [nr, nc] of neighbors) {
732
+ if (nr < 0 || nr >= H || nc < 0 || nc >= W) continue;
733
+ const nidx = nr * W + nc;
734
+ if (!cells.has(nidx)) continue;
735
+ const nChannel: 0 | 1 = isCrossCell(nidx) ? (nr !== r ? 1 : 0) : 0;
736
+ const key = stateKey(nidx, nChannel);
737
+ if (!visited.has(key)) {
738
+ visited.add(key);
739
+ queue.push([nidx, nChannel]);
740
+ }
741
+ }
742
+ }
743
+ components.push([...componentCells].sort((a, b) => a - b));
744
+ }
745
+ components.sort((a, b) => a[0] - b[0]);
746
+
747
+ // -- Component → edges.
748
+ const edges: AsciiDiagramEdge[] = [];
749
+ const seen = new Set<string>();
750
+ let mergedParallel = false;
751
+ const looseComponents = new Set<number>();
752
+ const sideLabelCells = new Set<number>();
753
+
754
+ for (const component of components) {
755
+ const inComponent = new Set(component);
756
+ // Endpoints: ≤1 neighbor within the component, plus every arrow cell.
757
+ const endpoints: number[] = [];
758
+ for (const idx of component) {
759
+ const r = Math.floor(idx / W);
760
+ const c = idx % W;
761
+ let neighbors = 0;
762
+ for (const [nr, nc] of [
763
+ [r - 1, c],
764
+ [r + 1, c],
765
+ [r, c - 1],
766
+ [r, c + 1],
767
+ ]) {
768
+ if (inComponent.has(nr * W + nc)) neighbors++;
769
+ }
770
+ const info = cells.get(idx) as EdgeCellInfo;
771
+ if (neighbors <= 1 || info.arrow !== null) endpoints.push(idx);
772
+ }
773
+
774
+ // Attachments: endpoint adjacent (Chebyshev ≤ 1) to a box perimeter.
775
+ interface Attachment {
776
+ boxIdx: number;
777
+ isArrowEnd: boolean;
778
+ }
779
+ const attachmentsByBox = new Map<number, Attachment>();
780
+ let dangling = false;
781
+ for (const idx of endpoints) {
782
+ const r = Math.floor(idx / W);
783
+ const c = idx % W;
784
+ const info = cells.get(idx) as EdgeCellInfo;
785
+ const boxIdx = snapEndpoint(r, c, info.arrow, perimeterOwners, boxes, W, H);
786
+ if (boxIdx === null) {
787
+ dangling = true;
788
+ continue;
789
+ }
790
+ const existing = attachmentsByBox.get(boxIdx);
791
+ if (existing) {
792
+ existing.isArrowEnd = existing.isArrowEnd || info.arrow !== null;
793
+ } else {
794
+ attachmentsByBox.set(boxIdx, { boxIdx, isArrowEnd: info.arrow !== null });
795
+ }
796
+ }
797
+ const nearestOwner = (r: number, c: number): number | null => {
798
+ const owners = perimeterOwners.get(r * W + c) ?? [];
799
+ if (owners.length === 0) return null;
800
+ return [...owners].sort(
801
+ (a, b) => boxes[a].r0 - boxes[b].r0 || boxes[a].c0 - boxes[b].c0 || a - b,
802
+ )[0];
803
+ };
804
+
805
+ // Junction cells touching a perimeter are attachments too — the
806
+ // `┌──┴──┐` bus-split shape connects to the box it branched from even
807
+ // though the junction cell has 2+ neighbors and no arrowhead.
808
+ for (const idx of component) {
809
+ const r = Math.floor(idx / W);
810
+ const c = idx % W;
811
+ const ch = grid[r][c];
812
+ if (!isJunction(ch) && !isPlus(ch)) continue;
813
+ for (const [dr, dc] of [
814
+ [-1, 0],
815
+ [1, 0],
816
+ [0, -1],
817
+ [0, 1],
818
+ ]) {
819
+ const boxIdx = nearestOwner(r + dr, c + dc);
820
+ if (boxIdx !== null && !attachmentsByBox.has(boxIdx)) {
821
+ attachmentsByBox.set(boxIdx, { boxIdx, isArrowEnd: false });
822
+ }
823
+ }
824
+ }
825
+
826
+ // Arrow cells attach to the box on the side OPPOSITE the arrowhead:
827
+ // a tight vertical stack (`└─┬─┘` / `▼` / `┌───┐`, boxes one row apart)
828
+ // is a single arrow cell whose only endpoint snaps to the pointed box —
829
+ // without this the source box is missed and the edge drops. Very common
830
+ // in AI output.
831
+ for (const idx of component) {
832
+ const r = Math.floor(idx / W);
833
+ const c = idx % W;
834
+ const info = cells.get(idx) as EdgeCellInfo;
835
+ if (info.arrow === null) continue;
836
+ const [dr, dc] =
837
+ info.arrow === 'down'
838
+ ? [-1, 0]
839
+ : info.arrow === 'up'
840
+ ? [1, 0]
841
+ : info.arrow === 'right'
842
+ ? [0, -1]
843
+ : [0, 1];
844
+ const boxIdx = nearestOwner(r + dr, c + dc);
845
+ if (boxIdx !== null && !attachmentsByBox.has(boxIdx)) {
846
+ attachmentsByBox.set(boxIdx, { boxIdx, isArrowEnd: false });
847
+ }
848
+ }
849
+
850
+ const attachments = [...attachmentsByBox.values()].sort(
851
+ (a, b) => boxes[a.boxIdx].r0 - boxes[b.boxIdx].r0 || boxes[a.boxIdx].c0 - boxes[b.boxIdx].c0,
852
+ );
853
+
854
+ if (attachments.length === 0) {
855
+ for (const idx of component) looseComponents.add(idx);
856
+ warnings.push('dangling-edge');
857
+ continue;
858
+ }
859
+ if (attachments.length === 1) {
860
+ warnings.push('dangling-edge');
861
+ continue;
862
+ }
863
+ if (dangling) warnings.push('dangling-edge');
864
+
865
+ // Label: first bridge in the component, else a side label — a text run
866
+ // sitting one space to the right of one of the component's vertical
867
+ // cells (`│ born`), the way labels attach to vertical drops.
868
+ const bridgeLabels: string[] = [];
869
+ for (const idx of component) {
870
+ const info = cells.get(idx) as EdgeCellInfo;
871
+ if (info.bridgeLabel && !bridgeLabels.includes(info.bridgeLabel)) {
872
+ bridgeLabels.push(info.bridgeLabel);
873
+ }
874
+ }
875
+ // A label run stops at a box border, an arrow, or a structural line —
876
+ // but an ASCII hyphen (`-`) inside a word (`on-fail`, `read-only`) is
877
+ // label text, not a line, so it's allowed. The leading-space gate keeps
878
+ // real lines (which abut the `│` directly) from being read as labels.
879
+ const isLabelStop = (ch: string): boolean =>
880
+ (isLineChar(ch) && ch !== '-') || arrowDirection(ch) !== null;
881
+ const hasAlnum = (s: string): boolean => /[a-z0-9]/i.test(s);
882
+
883
+ let label = bridgeLabels[0];
884
+ if (!label) {
885
+ // Try the right side (`│ born`) then the left side (`born │`) of
886
+ // each vertical cell, topmost-first.
887
+ outer: for (const idx of component) {
888
+ const r = Math.floor(idx / W);
889
+ const c = idx % W;
890
+ if (!isVert(grid[r][c])) continue;
891
+ if ((grid[r][c + 1] ?? '') === ' ') {
892
+ let end = c + 2;
893
+ let run = '';
894
+ while (end < W) {
895
+ const ch = grid[r][end];
896
+ if (mask[r * W + end] || isLabelStop(ch)) break;
897
+ if (ch === ' ' && (grid[r][end + 1] ?? ' ') === ' ') break;
898
+ run += ch;
899
+ end++;
900
+ }
901
+ const clean = run.trim().replace(/^-+|-+$/g, '');
902
+ if (clean.length > 0 && clean.length <= MAX_BRIDGE_GAP && hasAlnum(clean)) {
903
+ label = clean;
904
+ for (let lc = c + 2; lc < end; lc++) sideLabelCells.add(r * W + lc);
905
+ break outer;
906
+ }
907
+ }
908
+ if ((grid[r][c - 1] ?? '') === ' ') {
909
+ let start = c - 2;
910
+ let run = '';
911
+ while (start >= 0) {
912
+ const ch = grid[r][start];
913
+ if (mask[r * W + start] || isLabelStop(ch)) break;
914
+ if (ch === ' ' && (grid[r][start - 1] ?? ' ') === ' ') break;
915
+ run = ch + run;
916
+ start--;
917
+ }
918
+ const clean = run.trim().replace(/^-+|-+$/g, '');
919
+ if (clean.length > 0 && clean.length <= MAX_BRIDGE_GAP && hasAlnum(clean)) {
920
+ label = clean;
921
+ for (let lc = start + 1; lc <= c - 2; lc++) sideLabelCells.add(r * W + lc);
922
+ break outer;
923
+ }
924
+ }
925
+ }
926
+ }
927
+ if (bridgeLabels.length > 1) warnings.push('multiple-edge-labels');
928
+
929
+ const pushEdge = (sourceIdx: number, targetIdx: number, directed: boolean) => {
930
+ const edge: AsciiDiagramEdge = {
931
+ source: boxes[sourceIdx].id,
932
+ target: boxes[targetIdx].id,
933
+ ...(label ? { label } : {}),
934
+ directed,
935
+ };
936
+ const key = `${edge.source}|${edge.target}|${edge.directed}|${edge.label ?? ''}`;
937
+ if (seen.has(key)) {
938
+ mergedParallel = true;
939
+ return;
940
+ }
941
+ seen.add(key);
942
+ edges.push(edge);
943
+ };
944
+
945
+ const arrowEnds = attachments.filter((a) => a.isArrowEnd);
946
+ const plainEnds = attachments.filter((a) => !a.isArrowEnd);
947
+
948
+ if (attachments.length === 2) {
949
+ const [a, b] = attachments;
950
+ if (arrowEnds.length === 2) {
951
+ pushEdge(a.boxIdx, b.boxIdx, true);
952
+ pushEdge(b.boxIdx, a.boxIdx, true);
953
+ } else if (arrowEnds.length === 1) {
954
+ const target = arrowEnds[0];
955
+ const source = a === target ? b : a;
956
+ pushEdge(source.boxIdx, target.boxIdx, true);
957
+ } else {
958
+ pushEdge(a.boxIdx, b.boxIdx, false);
959
+ }
960
+ } else if (plainEnds.length === 1 && arrowEnds.length >= 1) {
961
+ for (const target of arrowEnds) pushEdge(plainEnds[0].boxIdx, target.boxIdx, true); // fan-out
962
+ } else if (arrowEnds.length === 1 && plainEnds.length >= 1) {
963
+ for (const source of plainEnds) pushEdge(source.boxIdx, arrowEnds[0].boxIdx, true); // fan-in
964
+ } else if (arrowEnds.length === 0) {
965
+ warnings.push('ambiguous-bus');
966
+ for (let i = 1; i < attachments.length; i++) {
967
+ pushEdge(attachments[0].boxIdx, attachments[i].boxIdx, false);
968
+ }
969
+ } else {
970
+ warnings.push('ambiguous-bus');
971
+ for (const target of arrowEnds) {
972
+ if (plainEnds.length > 0) pushEdge(plainEnds[0].boxIdx, target.boxIdx, true);
973
+ }
974
+ }
975
+ }
976
+
977
+ if (mergedParallel) warnings.push('parallel-edges-merged');
978
+
979
+ edges.sort(
980
+ (a, b) =>
981
+ a.source.localeCompare(b.source) ||
982
+ a.target.localeCompare(b.target) ||
983
+ (a.label ?? '').localeCompare(b.label ?? ''),
984
+ );
985
+
986
+ // Edge cells for loose-text accounting: everything in a component that
987
+ // produced (or tried to produce) an edge, minus fully-detached art,
988
+ // plus consumed side-label runs.
989
+ const edgeCells = new Set<number>();
990
+ for (const component of components) {
991
+ for (const idx of component) {
992
+ if (!looseComponents.has(idx)) edgeCells.add(idx);
993
+ }
994
+ }
995
+ for (const idx of sideLabelCells) edgeCells.add(idx);
996
+ return { edges, edgeCells };
997
+ }
998
+
999
+ /** ASCII arrow chars count only when a matching line char sits behind them. */
1000
+ function asciiArrowGate(grid: string[][], r: number, c: number, dir: ArrowDirection): boolean {
1001
+ const behind =
1002
+ dir === 'down'
1003
+ ? grid[r - 1]?.[c]
1004
+ : dir === 'up'
1005
+ ? grid[r + 1]?.[c]
1006
+ : dir === 'right'
1007
+ ? grid[r]?.[c - 1]
1008
+ : grid[r]?.[c + 1];
1009
+ if (behind === undefined) return false;
1010
+ return dir === 'down' || dir === 'up' ? isVert(behind) : isHoriz(behind);
1011
+ }
1012
+
1013
+ /**
1014
+ * Snap an endpoint cell to a box: prefer a perimeter cell in the arrow's
1015
+ * pointing direction, else the nearest (Chebyshev ≤ 1) perimeter owner,
1016
+ * ties broken by reading order.
1017
+ */
1018
+ function snapEndpoint(
1019
+ r: number,
1020
+ c: number,
1021
+ arrow: ArrowDirection | null,
1022
+ perimeterOwners: Map<number, number[]>,
1023
+ boxes: ParsedBox[],
1024
+ W: number,
1025
+ H: number,
1026
+ ): number | null {
1027
+ const ownersAt = (rr: number, cc: number): number[] => {
1028
+ if (rr < 0 || rr >= H || cc < 0 || cc >= W) return [];
1029
+ return perimeterOwners.get(rr * W + cc) ?? [];
1030
+ };
1031
+ if (arrow) {
1032
+ const [dr, dc] =
1033
+ arrow === 'down' ? [1, 0] : arrow === 'up' ? [-1, 0] : arrow === 'right' ? [0, 1] : [0, -1];
1034
+ // Pointed cell at distance 1, then 2 (`--> ` with a space before the box).
1035
+ for (const dist of [1, 2]) {
1036
+ const pointed = ownersAt(r + dr * dist, c + dc * dist);
1037
+ if (pointed.length > 0) {
1038
+ // The pointed-at box: the one whose border faces the arrow.
1039
+ const sorted = [...pointed].sort((a, b) => {
1040
+ const da = directionDepth(boxes[a], r + dr * dist, c + dc * dist, arrow);
1041
+ const db = directionDepth(boxes[b], r + dr * dist, c + dc * dist, arrow);
1042
+ return db - da || a - b;
1043
+ });
1044
+ return sorted[0];
1045
+ }
1046
+ }
1047
+ }
1048
+ const candidates = new Map<number, number>(); // boxIdx → priority (0 = orthogonal, 1 = diagonal, 2 = one space away)
1049
+ for (const [dr, dc, priority] of [
1050
+ [-1, 0, 0],
1051
+ [1, 0, 0],
1052
+ [0, -1, 0],
1053
+ [0, 1, 0],
1054
+ [-1, -1, 1],
1055
+ [-1, 1, 1],
1056
+ [1, -1, 1],
1057
+ [1, 1, 1],
1058
+ [-2, 0, 2],
1059
+ [2, 0, 2],
1060
+ [0, -2, 2],
1061
+ [0, 2, 2],
1062
+ ]) {
1063
+ for (const owner of ownersAt(r + dr, c + dc)) {
1064
+ const existing = candidates.get(owner);
1065
+ if (existing === undefined || priority < existing) candidates.set(owner, priority);
1066
+ }
1067
+ }
1068
+ if (candidates.size === 0) return null;
1069
+ const ranked = [...candidates.entries()].sort(
1070
+ (a, b) =>
1071
+ a[1] - b[1] ||
1072
+ boxes[a[0]].r0 - boxes[b[0]].r0 ||
1073
+ boxes[a[0]].c0 - boxes[b[0]].c0 ||
1074
+ a[0] - b[0],
1075
+ );
1076
+ return ranked[0][0];
1077
+ }
1078
+
1079
+ /** How deep the cell sits toward a box's interior along the arrow direction (higher = more pointed-at). */
1080
+ function directionDepth(box: Rect, r: number, c: number, arrow: ArrowDirection): number {
1081
+ switch (arrow) {
1082
+ case 'down':
1083
+ return box.r0 === r ? 1 : 0; // arrow points down at the box's TOP border
1084
+ case 'up':
1085
+ return box.r1 === r ? 1 : 0;
1086
+ case 'right':
1087
+ return box.c0 === c ? 1 : 0;
1088
+ case 'left':
1089
+ return box.c1 === c ? 1 : 0;
1090
+ }
1091
+ }
1092
+
1093
+ function truncate(s: string, max: number): string {
1094
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
1095
+ }