@graphty/graph-io 0.0.0 → 0.2.1

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 (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -28
  3. package/dist/chunks/children-CL3Cy0ez.js +238 -0
  4. package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
  5. package/dist/chunks/escape-DyI8JofU.js +938 -0
  6. package/dist/chunks/escape-DyI8JofU.js.map +1 -0
  7. package/dist/chunks/importer-CQnJuWJw.js +2987 -0
  8. package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
  9. package/dist/chunks/importer-CpCpfbxr.js +2015 -0
  10. package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
  11. package/dist/chunks/importer-DbnGYr3_.js +2342 -0
  12. package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
  13. package/dist/chunks/importer-GozH8DkN.js +3050 -0
  14. package/dist/chunks/importer-GozH8DkN.js.map +1 -0
  15. package/dist/chunks/records-CGpxszm1.js +605 -0
  16. package/dist/chunks/records-CGpxszm1.js.map +1 -0
  17. package/dist/chunks/text-CajMdVFy.js +189 -0
  18. package/dist/chunks/text-CajMdVFy.js.map +1 -0
  19. package/dist/chunks/writer-DxSKC7TL.js +2842 -0
  20. package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
  21. package/dist/csv.d.ts +1 -0
  22. package/dist/csv.js +1702 -0
  23. package/dist/csv.js.map +1 -0
  24. package/dist/dot.d.ts +1 -0
  25. package/dist/dot.js +8 -0
  26. package/dist/dot.js.map +1 -0
  27. package/dist/gexf.d.ts +1 -0
  28. package/dist/gexf.js +3466 -0
  29. package/dist/gexf.js.map +1 -0
  30. package/dist/gml.d.ts +1 -0
  31. package/dist/gml.js +2647 -0
  32. package/dist/gml.js.map +1 -0
  33. package/dist/graph-io.d.ts +1 -0
  34. package/dist/graph-io.js +790 -0
  35. package/dist/graph-io.js.map +1 -0
  36. package/dist/graphml.d.ts +1 -0
  37. package/dist/graphml.js +8 -0
  38. package/dist/graphml.js.map +1 -0
  39. package/dist/json.d.ts +1 -0
  40. package/dist/json.js +11 -0
  41. package/dist/json.js.map +1 -0
  42. package/dist/neo4j.d.ts +1 -0
  43. package/dist/neo4j.js +2046 -0
  44. package/dist/neo4j.js.map +1 -0
  45. package/dist/pajek.d.ts +1 -0
  46. package/dist/pajek.js +8 -0
  47. package/dist/pajek.js.map +1 -0
  48. package/dist/src/children.d.ts +134 -0
  49. package/dist/src/children.d.ts.map +1 -0
  50. package/dist/src/children.js +274 -0
  51. package/dist/src/children.js.map +1 -0
  52. package/dist/src/common/attributes.d.ts +229 -0
  53. package/dist/src/common/attributes.d.ts.map +1 -0
  54. package/dist/src/common/attributes.js +368 -0
  55. package/dist/src/common/attributes.js.map +1 -0
  56. package/dist/src/common/codes.d.ts +105 -0
  57. package/dist/src/common/codes.d.ts.map +1 -0
  58. package/dist/src/common/codes.js +107 -0
  59. package/dist/src/common/codes.js.map +1 -0
  60. package/dist/src/common/declared-types.d.ts +84 -0
  61. package/dist/src/common/declared-types.d.ts.map +1 -0
  62. package/dist/src/common/declared-types.js +326 -0
  63. package/dist/src/common/declared-types.js.map +1 -0
  64. package/dist/src/common/direction.d.ts +206 -0
  65. package/dist/src/common/direction.d.ts.map +1 -0
  66. package/dist/src/common/direction.js +370 -0
  67. package/dist/src/common/direction.js.map +1 -0
  68. package/dist/src/common/escape.d.ts +92 -0
  69. package/dist/src/common/escape.d.ts.map +1 -0
  70. package/dist/src/common/escape.js +212 -0
  71. package/dist/src/common/escape.js.map +1 -0
  72. package/dist/src/common/export.d.ts +249 -0
  73. package/dist/src/common/export.d.ts.map +1 -0
  74. package/dist/src/common/export.js +594 -0
  75. package/dist/src/common/export.js.map +1 -0
  76. package/dist/src/common/format.d.ts +59 -0
  77. package/dist/src/common/format.d.ts.map +1 -0
  78. package/dist/src/common/format.js +106 -0
  79. package/dist/src/common/format.js.map +1 -0
  80. package/dist/src/common/ids.d.ts +83 -0
  81. package/dist/src/common/ids.d.ts.map +1 -0
  82. package/dist/src/common/ids.js +158 -0
  83. package/dist/src/common/ids.js.map +1 -0
  84. package/dist/src/common/input.d.ts +100 -0
  85. package/dist/src/common/input.d.ts.map +1 -0
  86. package/dist/src/common/input.js +335 -0
  87. package/dist/src/common/input.js.map +1 -0
  88. package/dist/src/common/lists.d.ts +34 -0
  89. package/dist/src/common/lists.d.ts.map +1 -0
  90. package/dist/src/common/lists.js +185 -0
  91. package/dist/src/common/lists.js.map +1 -0
  92. package/dist/src/common/options.d.ts +108 -0
  93. package/dist/src/common/options.d.ts.map +1 -0
  94. package/dist/src/common/options.js +265 -0
  95. package/dist/src/common/options.js.map +1 -0
  96. package/dist/src/common/report.d.ts +187 -0
  97. package/dist/src/common/report.d.ts.map +1 -0
  98. package/dist/src/common/report.js +274 -0
  99. package/dist/src/common/report.js.map +1 -0
  100. package/dist/src/common/temporal.d.ts +71 -0
  101. package/dist/src/common/temporal.d.ts.map +1 -0
  102. package/dist/src/common/temporal.js +266 -0
  103. package/dist/src/common/temporal.js.map +1 -0
  104. package/dist/src/common/text.d.ts +104 -0
  105. package/dist/src/common/text.d.ts.map +1 -0
  106. package/dist/src/common/text.js +255 -0
  107. package/dist/src/common/text.js.map +1 -0
  108. package/dist/src/common/weights.d.ts +77 -0
  109. package/dist/src/common/weights.d.ts.map +1 -0
  110. package/dist/src/common/weights.js +156 -0
  111. package/dist/src/common/weights.js.map +1 -0
  112. package/dist/src/common/writer.d.ts +51 -0
  113. package/dist/src/common/writer.d.ts.map +1 -0
  114. package/dist/src/common/writer.js +108 -0
  115. package/dist/src/common/writer.js.map +1 -0
  116. package/dist/src/common/xml.d.ts +245 -0
  117. package/dist/src/common/xml.d.ts.map +1 -0
  118. package/dist/src/common/xml.js +942 -0
  119. package/dist/src/common/xml.js.map +1 -0
  120. package/dist/src/formats/csv/exporter.d.ts +70 -0
  121. package/dist/src/formats/csv/exporter.d.ts.map +1 -0
  122. package/dist/src/formats/csv/exporter.js +682 -0
  123. package/dist/src/formats/csv/exporter.js.map +1 -0
  124. package/dist/src/formats/csv/header.d.ts +66 -0
  125. package/dist/src/formats/csv/header.d.ts.map +1 -0
  126. package/dist/src/formats/csv/header.js +152 -0
  127. package/dist/src/formats/csv/header.js.map +1 -0
  128. package/dist/src/formats/csv/importer.d.ts +82 -0
  129. package/dist/src/formats/csv/importer.d.ts.map +1 -0
  130. package/dist/src/formats/csv/importer.js +849 -0
  131. package/dist/src/formats/csv/importer.js.map +1 -0
  132. package/dist/src/formats/csv/index.d.ts +60 -0
  133. package/dist/src/formats/csv/index.d.ts.map +1 -0
  134. package/dist/src/formats/csv/index.js +63 -0
  135. package/dist/src/formats/csv/index.js.map +1 -0
  136. package/dist/src/formats/csv/records.d.ts +188 -0
  137. package/dist/src/formats/csv/records.d.ts.map +1 -0
  138. package/dist/src/formats/csv/records.js +702 -0
  139. package/dist/src/formats/csv/records.js.map +1 -0
  140. package/dist/src/formats/csv/values.d.ts +105 -0
  141. package/dist/src/formats/csv/values.d.ts.map +1 -0
  142. package/dist/src/formats/csv/values.js +192 -0
  143. package/dist/src/formats/csv/values.js.map +1 -0
  144. package/dist/src/formats/dot/exporter.d.ts +52 -0
  145. package/dist/src/formats/dot/exporter.d.ts.map +1 -0
  146. package/dist/src/formats/dot/exporter.js +836 -0
  147. package/dist/src/formats/dot/exporter.js.map +1 -0
  148. package/dist/src/formats/dot/importer.d.ts +102 -0
  149. package/dist/src/formats/dot/importer.d.ts.map +1 -0
  150. package/dist/src/formats/dot/importer.js +1291 -0
  151. package/dist/src/formats/dot/importer.js.map +1 -0
  152. package/dist/src/formats/dot/index.d.ts +7 -0
  153. package/dist/src/formats/dot/index.d.ts.map +1 -0
  154. package/dist/src/formats/dot/index.js +7 -0
  155. package/dist/src/formats/dot/index.js.map +1 -0
  156. package/dist/src/formats/dot/names.d.ts +29 -0
  157. package/dist/src/formats/dot/names.d.ts.map +1 -0
  158. package/dist/src/formats/dot/names.js +28 -0
  159. package/dist/src/formats/dot/names.js.map +1 -0
  160. package/dist/src/formats/dot/tokenizer.d.ts +114 -0
  161. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
  162. package/dist/src/formats/dot/tokenizer.js +341 -0
  163. package/dist/src/formats/dot/tokenizer.js.map +1 -0
  164. package/dist/src/formats/gexf/exporter.d.ts +56 -0
  165. package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
  166. package/dist/src/formats/gexf/exporter.js +1395 -0
  167. package/dist/src/formats/gexf/exporter.js.map +1 -0
  168. package/dist/src/formats/gexf/importer.d.ts +73 -0
  169. package/dist/src/formats/gexf/importer.d.ts.map +1 -0
  170. package/dist/src/formats/gexf/importer.js +1880 -0
  171. package/dist/src/formats/gexf/importer.js.map +1 -0
  172. package/dist/src/formats/gexf/index.d.ts +96 -0
  173. package/dist/src/formats/gexf/index.d.ts.map +1 -0
  174. package/dist/src/formats/gexf/index.js +97 -0
  175. package/dist/src/formats/gexf/index.js.map +1 -0
  176. package/dist/src/formats/gexf/schema.d.ts +135 -0
  177. package/dist/src/formats/gexf/schema.d.ts.map +1 -0
  178. package/dist/src/formats/gexf/schema.js +323 -0
  179. package/dist/src/formats/gexf/schema.js.map +1 -0
  180. package/dist/src/formats/gml/exporter.d.ts +69 -0
  181. package/dist/src/formats/gml/exporter.d.ts.map +1 -0
  182. package/dist/src/formats/gml/exporter.js +1093 -0
  183. package/dist/src/formats/gml/exporter.js.map +1 -0
  184. package/dist/src/formats/gml/importer.d.ts +66 -0
  185. package/dist/src/formats/gml/importer.d.ts.map +1 -0
  186. package/dist/src/formats/gml/importer.js +1331 -0
  187. package/dist/src/formats/gml/importer.js.map +1 -0
  188. package/dist/src/formats/gml/index.d.ts +85 -0
  189. package/dist/src/formats/gml/index.d.ts.map +1 -0
  190. package/dist/src/formats/gml/index.js +88 -0
  191. package/dist/src/formats/gml/index.js.map +1 -0
  192. package/dist/src/formats/gml/syntax.d.ts +186 -0
  193. package/dist/src/formats/gml/syntax.d.ts.map +1 -0
  194. package/dist/src/formats/gml/syntax.js +467 -0
  195. package/dist/src/formats/gml/syntax.js.map +1 -0
  196. package/dist/src/formats/graphml/constants.d.ts +169 -0
  197. package/dist/src/formats/graphml/constants.d.ts.map +1 -0
  198. package/dist/src/formats/graphml/constants.js +165 -0
  199. package/dist/src/formats/graphml/constants.js.map +1 -0
  200. package/dist/src/formats/graphml/exporter.d.ts +34 -0
  201. package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
  202. package/dist/src/formats/graphml/exporter.js +1176 -0
  203. package/dist/src/formats/graphml/exporter.js.map +1 -0
  204. package/dist/src/formats/graphml/importer.d.ts +31 -0
  205. package/dist/src/formats/graphml/importer.d.ts.map +1 -0
  206. package/dist/src/formats/graphml/importer.js +1607 -0
  207. package/dist/src/formats/graphml/importer.js.map +1 -0
  208. package/dist/src/formats/graphml/index.d.ts +8 -0
  209. package/dist/src/formats/graphml/index.d.ts.map +1 -0
  210. package/dist/src/formats/graphml/index.js +8 -0
  211. package/dist/src/formats/graphml/index.js.map +1 -0
  212. package/dist/src/formats/graphml/tree.d.ts +72 -0
  213. package/dist/src/formats/graphml/tree.d.ts.map +1 -0
  214. package/dist/src/formats/graphml/tree.js +290 -0
  215. package/dist/src/formats/graphml/tree.js.map +1 -0
  216. package/dist/src/formats/json/dialect.d.ts +125 -0
  217. package/dist/src/formats/json/dialect.d.ts.map +1 -0
  218. package/dist/src/formats/json/dialect.js +262 -0
  219. package/dist/src/formats/json/dialect.js.map +1 -0
  220. package/dist/src/formats/json/exporter.d.ts +89 -0
  221. package/dist/src/formats/json/exporter.d.ts.map +1 -0
  222. package/dist/src/formats/json/exporter.js +1358 -0
  223. package/dist/src/formats/json/exporter.js.map +1 -0
  224. package/dist/src/formats/json/importer.d.ts +108 -0
  225. package/dist/src/formats/json/importer.d.ts.map +1 -0
  226. package/dist/src/formats/json/importer.js +1838 -0
  227. package/dist/src/formats/json/importer.js.map +1 -0
  228. package/dist/src/formats/json/index.d.ts +8 -0
  229. package/dist/src/formats/json/index.d.ts.map +1 -0
  230. package/dist/src/formats/json/index.js +8 -0
  231. package/dist/src/formats/json/index.js.map +1 -0
  232. package/dist/src/formats/neo4j/exporter.d.ts +68 -0
  233. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
  234. package/dist/src/formats/neo4j/exporter.js +1055 -0
  235. package/dist/src/formats/neo4j/exporter.js.map +1 -0
  236. package/dist/src/formats/neo4j/header.d.ts +52 -0
  237. package/dist/src/formats/neo4j/header.d.ts.map +1 -0
  238. package/dist/src/formats/neo4j/header.js +131 -0
  239. package/dist/src/formats/neo4j/header.js.map +1 -0
  240. package/dist/src/formats/neo4j/importer.d.ts +73 -0
  241. package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
  242. package/dist/src/formats/neo4j/importer.js +932 -0
  243. package/dist/src/formats/neo4j/importer.js.map +1 -0
  244. package/dist/src/formats/neo4j/index.d.ts +79 -0
  245. package/dist/src/formats/neo4j/index.d.ts.map +1 -0
  246. package/dist/src/formats/neo4j/index.js +83 -0
  247. package/dist/src/formats/neo4j/index.js.map +1 -0
  248. package/dist/src/formats/pajek/exporter.d.ts +58 -0
  249. package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
  250. package/dist/src/formats/pajek/exporter.js +825 -0
  251. package/dist/src/formats/pajek/exporter.js.map +1 -0
  252. package/dist/src/formats/pajek/importer.d.ts +88 -0
  253. package/dist/src/formats/pajek/importer.d.ts.map +1 -0
  254. package/dist/src/formats/pajek/importer.js +1047 -0
  255. package/dist/src/formats/pajek/importer.js.map +1 -0
  256. package/dist/src/formats/pajek/index.d.ts +7 -0
  257. package/dist/src/formats/pajek/index.d.ts.map +1 -0
  258. package/dist/src/formats/pajek/index.js +7 -0
  259. package/dist/src/formats/pajek/index.js.map +1 -0
  260. package/dist/src/formats/pajek/syntax.d.ts +112 -0
  261. package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
  262. package/dist/src/formats/pajek/syntax.js +269 -0
  263. package/dist/src/formats/pajek/syntax.js.map +1 -0
  264. package/dist/src/index.d.ts +35 -0
  265. package/dist/src/index.d.ts.map +1 -0
  266. package/dist/src/index.js +39 -0
  267. package/dist/src/index.js.map +1 -0
  268. package/dist/src/registry.d.ts +207 -0
  269. package/dist/src/registry.d.ts.map +1 -0
  270. package/dist/src/registry.js +481 -0
  271. package/dist/src/registry.js.map +1 -0
  272. package/dist/src/sniff.d.ts +104 -0
  273. package/dist/src/sniff.d.ts.map +1 -0
  274. package/dist/src/sniff.js +357 -0
  275. package/dist/src/sniff.js.map +1 -0
  276. package/dist/src/types.d.ts +238 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +29 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/tsconfig.build.tsbuildinfo +1 -0
  281. package/package.json +122 -7
  282. package/src/children.ts +335 -0
  283. package/src/common/attributes.ts +520 -0
  284. package/src/common/codes.ts +153 -0
  285. package/src/common/declared-types.ts +374 -0
  286. package/src/common/direction.ts +518 -0
  287. package/src/common/escape.ts +231 -0
  288. package/src/common/export.ts +817 -0
  289. package/src/common/format.ts +111 -0
  290. package/src/common/ids.ts +176 -0
  291. package/src/common/input.ts +378 -0
  292. package/src/common/lists.ts +196 -0
  293. package/src/common/options.ts +377 -0
  294. package/src/common/report.ts +352 -0
  295. package/src/common/temporal.ts +302 -0
  296. package/src/common/text.ts +294 -0
  297. package/src/common/weights.ts +202 -0
  298. package/src/common/writer.ts +123 -0
  299. package/src/common/xml.ts +1053 -0
  300. package/src/formats/csv/exporter.ts +894 -0
  301. package/src/formats/csv/header.ts +172 -0
  302. package/src/formats/csv/importer.ts +1104 -0
  303. package/src/formats/csv/index.ts +88 -0
  304. package/src/formats/csv/records.ts +813 -0
  305. package/src/formats/csv/values.ts +224 -0
  306. package/src/formats/dot/exporter.ts +1014 -0
  307. package/src/formats/dot/importer.ts +1549 -0
  308. package/src/formats/dot/index.ts +7 -0
  309. package/src/formats/dot/names.ts +40 -0
  310. package/src/formats/dot/tokenizer.ts +384 -0
  311. package/src/formats/gexf/exporter.ts +1696 -0
  312. package/src/formats/gexf/importer.ts +2333 -0
  313. package/src/formats/gexf/index.ts +142 -0
  314. package/src/formats/gexf/schema.ts +361 -0
  315. package/src/formats/gml/exporter.ts +1404 -0
  316. package/src/formats/gml/importer.ts +1591 -0
  317. package/src/formats/gml/index.ts +128 -0
  318. package/src/formats/gml/syntax.ts +545 -0
  319. package/src/formats/graphml/constants.ts +225 -0
  320. package/src/formats/graphml/exporter.ts +1458 -0
  321. package/src/formats/graphml/importer.ts +2027 -0
  322. package/src/formats/graphml/index.ts +8 -0
  323. package/src/formats/graphml/tree.ts +318 -0
  324. package/src/formats/json/dialect.ts +317 -0
  325. package/src/formats/json/exporter.ts +1616 -0
  326. package/src/formats/json/importer.ts +2271 -0
  327. package/src/formats/json/index.ts +8 -0
  328. package/src/formats/neo4j/exporter.ts +1287 -0
  329. package/src/formats/neo4j/header.ts +156 -0
  330. package/src/formats/neo4j/importer.ts +1220 -0
  331. package/src/formats/neo4j/index.ts +116 -0
  332. package/src/formats/pajek/exporter.ts +1000 -0
  333. package/src/formats/pajek/importer.ts +1311 -0
  334. package/src/formats/pajek/index.ts +7 -0
  335. package/src/formats/pajek/syntax.ts +307 -0
  336. package/src/index.ts +244 -0
  337. package/src/registry.ts +617 -0
  338. package/src/sniff.ts +397 -0
  339. package/src/types.ts +262 -0
@@ -0,0 +1,1014 @@
1
+ /**
2
+ * The DOT / Graphviz exporter (design section 8.5; research note 07 section 9). Writes one
3
+ * `[strict] graph | digraph [name] { ... }` with the graph attributes first, every node in index
4
+ * order (a node statement carrying its set cells as attributes, or a `subgraph` block for a cluster
5
+ * container node with its members nested inside), then every logical edge in index order with its
6
+ * explicit weight, `key` (the edge id role), ports and attributes.
7
+ *
8
+ * What the format keeps and what check() reports: any id and any text (DOT quotes everything
9
+ * except a text ending in a backslash, which the lexer cannot read back); bool / i32 / f64 cells
10
+ * (f64 written with a decimal point so the dtype survives re-import; other numeric dtypes are
11
+ * written and read back inferred); containment through the parent role (clusters); graph
12
+ * attributes; node positions as `pos="x,y"`. Mixed direction is folded per onMixedDirection
13
+ * (DOT has none); lists, json, defaults, options, temporal and visual roles cannot be written.
14
+ */
15
+
16
+ import { type Column, GraphFormatError, type GraphSnapshot, type NodeId } from "@graphty/graph-format";
17
+
18
+ import { type ChildrenCsr, childrenCsr } from "../../children.js";
19
+ import { type PairFolding, pairFolding } from "../../common/direction.js";
20
+ import { isWritableDotText, quoteDotId } from "../../common/escape.js";
21
+ import { capabilities, checkCapabilities, countMixedEdges, LOSS, sanitizeIds } from "../../common/export.js";
22
+ import { formatDecimal, formatF32, formatF64, formatInteger } from "../../common/format.js";
23
+ import { canonicalId } from "../../common/ids.js";
24
+ import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
25
+ import { inferTextDtype } from "../../common/text.js";
26
+ import { type ExplicitWeights, explicitWeights } from "../../common/weights.js";
27
+ import { encodeChunks, joinText } from "../../common/writer.js";
28
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
29
+ import {
30
+ CLUSTER_COLUMN,
31
+ DOT_FORMAT,
32
+ DOT_META_KEY,
33
+ KEY_ATTRIBUTE,
34
+ LABEL_ATTRIBUTE,
35
+ PARENT_COLUMN,
36
+ POS_ATTRIBUTE,
37
+ SOURCE_PORT_COLUMN,
38
+ TARGET_PORT_COLUMN,
39
+ } from "./names.js";
40
+
41
+ /** The DOT exporter's format-specific options. */
42
+ export interface DotExportOptions {
43
+ /** The indentation of one nesting level; four spaces by default. */
44
+ indent?: string | undefined;
45
+ /** The graph name to write; `meta.name` by default, null for an anonymous graph. */
46
+ name?: string | null | undefined;
47
+ /** Whether to write `strict`; by default when `meta.extra.dot.strict` is true. */
48
+ strict?: boolean | undefined;
49
+ }
50
+
51
+ /** The LossNote codes of the DOT exporter; the shared ones are LOSS's. */
52
+ export const DOT_LOSS = Object.freeze({
53
+ /** An id, name or text with a backslash before a quote or at its end cannot be written as a DOT quoted string; export() throws. */
54
+ TRAILING_BACKSLASH: "E_DOT_TRAILING_BACKSLASH",
55
+ /** A non-finite f32 / f64 cell has no numeric DOT spelling and reads back as text. */
56
+ NON_FINITE: "W_DOT_NON_FINITE",
57
+ /** Text cells that look like numbers or booleans read back as such (DOT attribute values are untyped). */
58
+ TEXT_INFERRED: LOSS.TEXT_INFERRED,
59
+ /** A plain column named like an attribute the exporter writes for a role (weight, key, pos) is not written. */
60
+ ATTRIBUTE_CLASH: "W_DOT_ATTRIBUTE_CLASH",
61
+ /** A mutual pair is written as two directed edges. */
62
+ MUTUAL_EXPANDED: LOSS.MUTUAL_EXPANDED,
63
+ /** A parents (multi-parent) column cannot be written; DOT clusters nest. */
64
+ PARENTS_DROPPED: LOSS.PARENTS,
65
+ /** A position column that is not a node column of 2 or 3 components is not written. */
66
+ POSITION_SHAPE: "W_DOT_POSITION_SHAPE",
67
+ /** An id whose text reads back as the other type under ids: "canonical" (1.5 as text, "1" as 1). */
68
+ ID_TEXT_TYPE: LOSS.ID_TEXT_TYPE,
69
+ /** A declared column whose every row is unset is not written (DOT writes cells, never declarations). */
70
+ EMPTY_COLUMN_DROPPED: LOSS.EMPTY_COLUMN,
71
+ /** A role-less column named `label` reads back with the label role. */
72
+ ROLE_ASSUMED: LOSS.ROLE_ASSUMED,
73
+ });
74
+
75
+ /** The roles DOT has a slot for (the label attribute, key, ports, clusters); every other role is reported. */
76
+ const KEPT_ROLES: ReadonlySet<string> = new Set(["label", "id", "sourcePort", "targetPort", "parent"]);
77
+
78
+ /** The column name the importer gives each mapped role on re-import. */
79
+ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
80
+ label: LABEL_ATTRIBUTE,
81
+ id: KEY_ATTRIBUTE,
82
+ sourcePort: SOURCE_PORT_COLUMN,
83
+ targetPort: TARGET_PORT_COLUMN,
84
+ parent: PARENT_COLUMN,
85
+ position: POS_ATTRIBUTE,
86
+ });
87
+
88
+ const CAPABILITIES: ExportCapabilities = capabilities({
89
+ mixedDirection: false,
90
+ multiEdges: true,
91
+ selfLoops: true,
92
+ edgeIds: "optional",
93
+ idCharset: "any",
94
+ dtypes: ["bool", "i32", "f64", "string"],
95
+ components: false,
96
+ lists: false,
97
+ json: false,
98
+ defaults: false,
99
+ options: false,
100
+ hierarchy: true,
101
+ temporal: "none",
102
+ graphAttributes: true,
103
+ positions: true,
104
+ viz: false,
105
+ });
106
+
107
+ const DEFAULT_INDENT = " ";
108
+ const CLUSTER_PREFIX = "cluster";
109
+ const COMPASS_POINTS: ReadonlySet<string> = new Set(["n", "ne", "e", "se", "s", "sw", "w", "nw", "c", "_"]);
110
+
111
+ /** Roles never written as attributes, per domain. */
112
+ const SKIPPED_NODE_ROLES: ReadonlySet<string> = new Set([
113
+ "parent",
114
+ "parents",
115
+ "position",
116
+ "color",
117
+ "size",
118
+ "shape",
119
+ "thickness",
120
+ "start",
121
+ "end",
122
+ "timestamp",
123
+ "timestamps",
124
+ "spells",
125
+ "open",
126
+ "timeText",
127
+ "directed",
128
+ "pair",
129
+ "mutual",
130
+ "weight",
131
+ ]);
132
+ const SKIPPED_EDGE_ROLES: ReadonlySet<string> = new Set([
133
+ "id",
134
+ "weight",
135
+ "directed",
136
+ "pair",
137
+ "mutual",
138
+ "sourcePort",
139
+ "targetPort",
140
+ "position",
141
+ "color",
142
+ "size",
143
+ "shape",
144
+ "thickness",
145
+ "start",
146
+ "end",
147
+ "timestamp",
148
+ "timestamps",
149
+ "spells",
150
+ "open",
151
+ "timeText",
152
+ "parent",
153
+ "parents",
154
+ ]);
155
+ const SKIPPED_GRAPH_ROLES: ReadonlySet<string> = new Set(["position", "color", "size", "shape", "thickness"]);
156
+ /** Text columns whose values are read back as text regardless of their spelling (no TEXT_INFERRED note). */
157
+ const TEXT_ROLES: ReadonlySet<string> = new Set(["label", "id", "sourcePort", "targetPort"]);
158
+
159
+ /**
160
+ * The exporter plugin for DOT / Graphviz text (design section 12.4).
161
+ */
162
+ export const dotExporter: GraphExporter<DotExportOptions> = Object.freeze({
163
+ format: DOT_FORMAT,
164
+ capabilities: CAPABILITIES,
165
+
166
+ /**
167
+ * Pre-flight: every loss the DOT text would incur, without writing anything.
168
+ * @param snapshot - the snapshot to check
169
+ * @param options - format-specific and common options
170
+ * @returns the notes, empty when the export is exact
171
+ */
172
+ check(snapshot: GraphSnapshot, options?: DotExportOptions & CommonExportOptions): readonly LossNote[] {
173
+ const resolved = resolveExportOptions(options);
174
+ const notes = checkCapabilities(snapshot, CAPABILITIES, resolved, {
175
+ positionDtype: "f32",
176
+ roles: KEPT_ROLES,
177
+ roleNames: ROLE_NAMES,
178
+ });
179
+ const plan = new ExportPlan(snapshot, resolved, options);
180
+ return [...notes, ...plan.notes()];
181
+ },
182
+
183
+ /**
184
+ * Write the snapshot as UTF-8 chunks.
185
+ * @param snapshot - the snapshot to write
186
+ * @param options - format-specific and common options
187
+ * @returns the encoded chunks
188
+ */
189
+ export(snapshot: GraphSnapshot, options?: DotExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
190
+ return encodeChunks(writeDot(snapshot, options));
191
+ },
192
+
193
+ /**
194
+ * Write the snapshot as one string.
195
+ * @param snapshot - the snapshot to write
196
+ * @param options - format-specific and common options
197
+ * @returns the whole document
198
+ */
199
+ exportToString(snapshot: GraphSnapshot, options?: DotExportOptions & CommonExportOptions): Promise<string> {
200
+ return joinText(writeDot(snapshot, options));
201
+ },
202
+ });
203
+
204
+ /**
205
+ * The text parts of a DOT document.
206
+ * @param snapshot - the snapshot
207
+ * @param options - the caller's options
208
+ * @yields one statement (or header / footer) at a time
209
+ * @returns nothing
210
+ */
211
+ function* writeDot(snapshot: GraphSnapshot, options?: DotExportOptions & CommonExportOptions): Generator<string> {
212
+ const resolved = resolveExportOptions(options);
213
+ const plan = new ExportPlan(snapshot, resolved, options);
214
+ plan.refuse();
215
+ yield* plan.write();
216
+ }
217
+
218
+ /**
219
+ * Whether a text ends in a backslash, which a DOT quoted string cannot carry (the lexer would read
220
+ * the backslash and the closing quote as an escaped quote; common/escape.ts isWritableDotText).
221
+ * @param text - the text
222
+ * @returns true when unwritable
223
+ */
224
+ function endsWithBackslash(text: string): boolean {
225
+ return !isWritableDotText(text);
226
+ }
227
+
228
+ /**
229
+ * Whether a text is an HTML string (balanced angle brackets around the whole text), which DOT
230
+ * writes bare as `<...>`.
231
+ * @param text - the text
232
+ * @returns true for an HTML string
233
+ */
234
+ function isHtmlString(text: string): boolean {
235
+ if (text.length < 2 || !text.startsWith("<") || !text.endsWith(">")) {
236
+ return false;
237
+ }
238
+ let depth = 0;
239
+ for (let i = 0; i < text.length; i++) {
240
+ const c = text.charCodeAt(i);
241
+ if (c === 0x3c) {
242
+ depth++;
243
+ } else if (c === 0x3e) {
244
+ depth--;
245
+ if (depth === 0 && i !== text.length - 1) {
246
+ return false;
247
+ }
248
+ }
249
+ }
250
+ return depth === 0;
251
+ }
252
+
253
+ /**
254
+ * Write a text as a DOT ID: an HTML string bare, everything else through quoteDotId.
255
+ * @param text - the text
256
+ * @returns the DOT ID
257
+ */
258
+ function writeId(text: string): string {
259
+ return isHtmlString(text) ? text : quoteDotId(text);
260
+ }
261
+
262
+ /**
263
+ * Write a port text (`f0`, `f0:ne`, `ne`) as its DOT spelling after the endpoint.
264
+ * @param port - the port text
265
+ * @returns `:port[:compass]`
266
+ */
267
+ function writePort(port: string): string {
268
+ const colon = port.lastIndexOf(":");
269
+ if (colon > 0 && COMPASS_POINTS.has(port.slice(colon + 1))) {
270
+ return `:${quoteDotId(port.slice(0, colon))}:${port.slice(colon + 1)}`;
271
+ }
272
+ return `:${quoteDotId(port)}`;
273
+ }
274
+
275
+ /**
276
+ * Whether a column can be written as attribute text: every scalar dtype; lists and json cannot.
277
+ * @param column - the column
278
+ * @returns true when writable
279
+ */
280
+ function isTextualDtype(column: Column): boolean {
281
+ return column.dtype !== "list" && column.dtype !== "json";
282
+ }
283
+
284
+ /**
285
+ * Everything one export needs to know about a snapshot, computed once and shared by check() and
286
+ * write(): the output direction, the columns written per table, the cluster structure, and the
287
+ * format-specific loss notes.
288
+ */
289
+ class ExportPlan {
290
+ private readonly snapshot: GraphSnapshot;
291
+
292
+ private readonly resolved: ResolvedExportOptions;
293
+
294
+ private readonly indent: string;
295
+
296
+ private readonly name: string | null;
297
+
298
+ private readonly strict: boolean;
299
+
300
+ private readonly mixed: number;
301
+
302
+ private readonly directed: boolean;
303
+
304
+ private readonly nodeColumns: Column[];
305
+
306
+ private readonly edgeColumns: Column[];
307
+
308
+ private readonly graphColumns: Column[];
309
+
310
+ private readonly position: Column | null;
311
+
312
+ private readonly positionDims: 2 | 3;
313
+
314
+ private readonly parent: Column | null;
315
+
316
+ private readonly cluster: Column | null;
317
+
318
+ private readonly edgeId: Column | null;
319
+
320
+ private readonly weights: ExplicitWeights;
321
+
322
+ private readonly folding: PairFolding;
323
+
324
+ private readonly sourcePort: Column | null;
325
+
326
+ private readonly targetPort: Column | null;
327
+
328
+ private readonly children: ChildrenCsr;
329
+
330
+ private readonly extraNotes: LossNote[] = [];
331
+
332
+ /**
333
+ * Plan one export.
334
+ * @param snapshot - the snapshot
335
+ * @param resolved - the resolved common options
336
+ * @param options - the caller's options (format-specific fields read here)
337
+ */
338
+ constructor(snapshot: GraphSnapshot, resolved: ResolvedExportOptions, options?: DotExportOptions) {
339
+ this.snapshot = snapshot;
340
+ this.resolved = resolved;
341
+ this.indent = options?.indent ?? DEFAULT_INDENT;
342
+ this.name = options?.name === undefined ? snapshot.meta.name : options.name;
343
+ this.strict = options?.strict ?? isStrictMeta(snapshot);
344
+ this.mixed = countMixedEdges(snapshot);
345
+ if (this.mixed > 0 && resolved.onMixedDirection !== "error") {
346
+ this.directed = resolved.onMixedDirection === "directed";
347
+ } else {
348
+ this.directed = snapshot.directed;
349
+ }
350
+ const { nodes, edges, graph } = snapshot;
351
+ this.position = nodes.byRole("position");
352
+ this.positionDims = positionDims(this.position);
353
+ this.parent = nodes.byRole("parent");
354
+ const cluster = nodes.get(CLUSTER_COLUMN);
355
+ this.cluster = cluster !== null && cluster.dtype === "bool" ? cluster : null;
356
+ this.edgeId = edges.byRole("id");
357
+ this.weights = explicitWeights(snapshot);
358
+ this.folding = pairFolding(snapshot);
359
+ this.sourcePort = edges.byRole("sourcePort");
360
+ this.targetPort = edges.byRole("targetPort");
361
+ this.nodeColumns = [...nodes].filter((c) => this.writesNodeColumn(c));
362
+ this.edgeColumns = [...edges].filter((c) => this.writesEdgeColumn(c));
363
+ this.graphColumns = [...graph].filter(
364
+ (c) => isTextualDtype(c) && (c.meta.role === null || !SKIPPED_GRAPH_ROLES.has(c.meta.role)),
365
+ );
366
+ this.children = childrenCsr(snapshot, {
367
+ column: this.parent !== null && this.parent.dtype === "u32" ? this.parent : null,
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Whether a node column is written as node attributes.
373
+ * @param column - the column
374
+ * @returns true when written
375
+ */
376
+ private writesNodeColumn(column: Column): boolean {
377
+ const { name, role } = column.meta;
378
+ if (!isTextualDtype(column) || name === CLUSTER_COLUMN) {
379
+ return false;
380
+ }
381
+ if (role !== null && SKIPPED_NODE_ROLES.has(role)) {
382
+ return false;
383
+ }
384
+ if (name === POS_ATTRIBUTE && role !== "position") {
385
+ this.clash("node", name, "the position role is written as pos");
386
+ return false;
387
+ }
388
+ return true;
389
+ }
390
+
391
+ /**
392
+ * Whether an edge column is written as edge attributes.
393
+ * @param column - the column
394
+ * @returns true when written
395
+ */
396
+ private writesEdgeColumn(column: Column): boolean {
397
+ const { name, role } = column.meta;
398
+ if (!isTextualDtype(column)) {
399
+ return false;
400
+ }
401
+ if (role !== null && SKIPPED_EDGE_ROLES.has(role)) {
402
+ return false;
403
+ }
404
+ if (name === KEY_ATTRIBUTE && this.edgeId !== null) {
405
+ this.clash("edge", name, "the edge id role is written as key");
406
+ return false;
407
+ }
408
+ if (name === "weight") {
409
+ this.clash("edge", name, "the weight is written as weight");
410
+ return false;
411
+ }
412
+ return true;
413
+ }
414
+
415
+ /**
416
+ * Record a column not written because its name is an attribute the exporter writes for a role.
417
+ * @param domain - the table
418
+ * @param name - the column name
419
+ * @param reason - why
420
+ */
421
+ private clash(domain: string, name: string, reason: string): void {
422
+ this.extraNotes.push(
423
+ Object.freeze({
424
+ code: DOT_LOSS.ATTRIBUTE_CLASH,
425
+ message: `${domain} column "${name}" has no role and is not written: ${reason}`,
426
+ column: name,
427
+ count: null,
428
+ }),
429
+ );
430
+ }
431
+
432
+ /**
433
+ * The format-specific loss notes (the generic ones come from checkCapabilities).
434
+ * @returns the notes
435
+ */
436
+ notes(): LossNote[] {
437
+ const notes: LossNote[] = [...this.extraNotes];
438
+ const note = (
439
+ code: string,
440
+ message: string,
441
+ column: string | null = null,
442
+ count: number | null = null,
443
+ ): void => {
444
+ notes.push(Object.freeze({ code, message, column, count }));
445
+ };
446
+ const { snapshot } = this;
447
+ const unwritable = this.countUnwritable();
448
+ if (unwritable > 0) {
449
+ note(
450
+ DOT_LOSS.TRAILING_BACKSLASH,
451
+ `${unwritable} id(s), name(s) or text value(s) hold a backslash before a quote or at the end, which a DOT quoted string cannot carry; export() will throw`,
452
+ null,
453
+ unwritable,
454
+ );
455
+ }
456
+ for (const [label, columns] of [
457
+ ["node", this.nodeColumns],
458
+ ["edge", this.edgeColumns],
459
+ ["graph", this.graphColumns],
460
+ ] as const) {
461
+ for (const column of columns) {
462
+ if (label !== "graph" && column.nullCount === column.length) {
463
+ note(
464
+ DOT_LOSS.EMPTY_COLUMN_DROPPED,
465
+ `${label} column "${column.meta.name}" has no set cell and is not written: DOT writes cells, never declarations`,
466
+ column.meta.name,
467
+ 0,
468
+ );
469
+ continue;
470
+ }
471
+ if (
472
+ label !== "graph" &&
473
+ column.meta.role === null &&
474
+ column.meta.name === LABEL_ATTRIBUTE &&
475
+ (column.dtype === "string" || column.dtype === "dict")
476
+ ) {
477
+ note(
478
+ DOT_LOSS.ROLE_ASSUMED,
479
+ `${label} column "${column.meta.name}" reads back with the label role`,
480
+ column.meta.name,
481
+ column.length - column.nullCount,
482
+ );
483
+ }
484
+ if (column.dtype === "f32" || column.dtype === "f64") {
485
+ const bad = countNonFinite(column);
486
+ if (bad > 0) {
487
+ note(
488
+ DOT_LOSS.NON_FINITE,
489
+ `${label} column "${column.meta.name}" holds ${bad} non-finite value(s) with no numeric DOT spelling; they read back as text`,
490
+ column.meta.name,
491
+ bad,
492
+ );
493
+ }
494
+ }
495
+ if (
496
+ (column.dtype === "string" || column.dtype === "dict") &&
497
+ (column.meta.role === null || !TEXT_ROLES.has(column.meta.role))
498
+ ) {
499
+ const typed = countTypedLookingText(column);
500
+ if (typed > 0) {
501
+ note(
502
+ DOT_LOSS.TEXT_INFERRED,
503
+ `${label} column "${column.meta.name}" holds ${typed} text value(s) that look like numbers or booleans; DOT values are untyped and they read back as such`,
504
+ column.meta.name,
505
+ typed,
506
+ );
507
+ }
508
+ }
509
+ }
510
+ }
511
+ const mutual = this.folding.mutualCount;
512
+ if (mutual > 0) {
513
+ note(
514
+ DOT_LOSS.MUTUAL_EXPANDED,
515
+ `${mutual} mutual pair(s) are written as two directed edges each; the mutual mark is lost`,
516
+ null,
517
+ mutual,
518
+ );
519
+ }
520
+ const parents = snapshot.nodes.byRole("parents");
521
+ if (parents !== null) {
522
+ note(
523
+ DOT_LOSS.PARENTS_DROPPED,
524
+ `node column "${parents.meta.name}" (parents) cannot be written: a DOT node is in one cluster`,
525
+ parents.meta.name,
526
+ parents.length - parents.nullCount,
527
+ );
528
+ }
529
+ if (this.position !== null && this.position.meta.components !== 2 && this.position.meta.components !== 3) {
530
+ note(
531
+ DOT_LOSS.POSITION_SHAPE,
532
+ `node column "${this.position.meta.name}" (position) has ${this.position.meta.components} components and is not written; pos takes 2 or 3`,
533
+ this.position.meta.name,
534
+ this.position.length - this.position.nullCount,
535
+ );
536
+ }
537
+ for (const [label, table] of [
538
+ ["edge", snapshot.edges],
539
+ ["graph", snapshot.graph],
540
+ ] as const) {
541
+ const column = table.byRole("position");
542
+ if (column !== null) {
543
+ note(
544
+ DOT_LOSS.POSITION_SHAPE,
545
+ `${label} column "${column.meta.name}" (position) is not written; only node positions map to pos`,
546
+ column.meta.name,
547
+ column.length - column.nullCount,
548
+ );
549
+ }
550
+ }
551
+ const textIds = countTextIds(snapshot);
552
+ if (textIds > 0) {
553
+ note(
554
+ DOT_LOSS.ID_TEXT_TYPE,
555
+ `${textIds} node id(s) read back as the other type under ids: "canonical" (a string "1" becomes 1, a number 1.5 becomes "1.5")`,
556
+ null,
557
+ textIds,
558
+ );
559
+ }
560
+ return notes;
561
+ }
562
+
563
+ /**
564
+ * Throw for the conditions check() reports as errors: mixed direction under "error", and an
565
+ * unwritable id / name / text.
566
+ */
567
+ refuse(): void {
568
+ if (this.mixed > 0 && this.resolved.onMixedDirection === "error") {
569
+ throw new GraphFormatError(
570
+ "E_DIRECTED",
571
+ `${this.mixed} undirected edge(s) in a directed graph; DOT has no mixed direction and onMixedDirection is "error"`,
572
+ { reason: LOSS.MIXED_DIRECTION_ERROR, count: this.mixed },
573
+ );
574
+ }
575
+ const first = this.firstUnwritable();
576
+ if (first !== null) {
577
+ throw new GraphFormatError(
578
+ first.kind === "id" ? "E_INVALID_ID" : "E_COLUMN_TYPE",
579
+ `${first.kind} ${JSON.stringify(first.text)} ends in a backslash, which a DOT quoted string cannot carry`,
580
+ { reason: "trailing backslash", kind: first.kind, value: first.text },
581
+ );
582
+ }
583
+ sanitizeIds(this.snapshot, CAPABILITIES.idCharset, this.resolved.sanitizeIds);
584
+ }
585
+
586
+ /**
587
+ * How many ids, names and text values end in a backslash.
588
+ * @returns the count
589
+ */
590
+ private countUnwritable(): number {
591
+ let count = 0;
592
+ this.forEachText((text) => {
593
+ if (endsWithBackslash(text)) {
594
+ count++;
595
+ }
596
+ });
597
+ return count;
598
+ }
599
+
600
+ /**
601
+ * The first id, name or text value ending in a backslash.
602
+ * @returns what it is and its text, or null
603
+ */
604
+ private firstUnwritable(): { kind: string; text: string } | null {
605
+ let found: { kind: string; text: string } | null = null;
606
+ this.forEachText((text, kind) => {
607
+ if (found === null && endsWithBackslash(text)) {
608
+ found = { kind, text };
609
+ }
610
+ });
611
+ return found;
612
+ }
613
+
614
+ /**
615
+ * Visit every text the document writes as a quoted string: the graph name, node ids, column
616
+ * names and string / dict cells.
617
+ * @param visit - the visitor
618
+ */
619
+ private forEachText(visit: (text: string, kind: string) => void): void {
620
+ const { snapshot } = this;
621
+ if (this.name !== null) {
622
+ visit(this.name, "graph name");
623
+ }
624
+ for (let i = 0; i < snapshot.nodeCount; i++) {
625
+ const id = snapshot.ids.idOf(i);
626
+ if (typeof id === "string") {
627
+ visit(id, "id");
628
+ }
629
+ }
630
+ for (const columns of [this.nodeColumns, this.edgeColumns, this.graphColumns]) {
631
+ for (const column of columns) {
632
+ visit(column.meta.name, "attribute name");
633
+ if (column.dtype === "string" || column.dtype === "dict") {
634
+ for (let r = 0; r < column.length; r++) {
635
+ if (column.isSet(r)) {
636
+ visit(column.value(r) as string, "value");
637
+ }
638
+ }
639
+ }
640
+ }
641
+ }
642
+ for (const column of [this.edgeId, this.sourcePort, this.targetPort]) {
643
+ if (column !== null && (column.dtype === "string" || column.dtype === "dict")) {
644
+ for (let r = 0; r < column.length; r++) {
645
+ if (column.isSet(r)) {
646
+ visit(column.value(r) as string, "value");
647
+ }
648
+ }
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * The document, one statement per part.
655
+ * @yields the parts
656
+ * @returns nothing
657
+ */
658
+ *write(): Generator<string> {
659
+ const { snapshot, indent } = this;
660
+ const keyword = this.directed ? "digraph" : "graph";
661
+ const name = this.name === null ? "" : ` ${quoteDotId(this.name)}`;
662
+ yield `${this.strict ? "strict " : ""}${keyword}${name} {\n`;
663
+ const graphAttributes = this.attributesOf(this.graphColumns, 0);
664
+ if (graphAttributes.length > 0) {
665
+ yield `${indent}graph [${graphAttributes.join(", ")}];\n`;
666
+ }
667
+ const emitted = new Uint8Array(snapshot.nodeCount);
668
+ for (let i = 0; i < snapshot.nodeCount; i++) {
669
+ yield* this.writeNode(i, 1, emitted);
670
+ }
671
+ yield* this.writeEdges();
672
+ yield "}\n";
673
+ }
674
+
675
+ /**
676
+ * Write one node (and, for a container, its cluster block with the members nested).
677
+ * @param i - the node index
678
+ * @param depth - the nesting depth
679
+ * @param emitted - which nodes were written already
680
+ * @yields the statements
681
+ * @returns nothing
682
+ */
683
+ private *writeNode(i: number, depth: number, emitted: Uint8Array): Generator<string> {
684
+ if (emitted[i] === 1) {
685
+ return;
686
+ }
687
+ emitted[i] = 1;
688
+ const pad = this.indent.repeat(depth);
689
+ const idText = this.idText(i);
690
+ const kids = this.children.childrenOf(i);
691
+ const marked = this.cluster !== null && this.cluster.isSet(i) && this.cluster.value(i) === true;
692
+ if (kids.length === 0 && !marked) {
693
+ yield `${pad}${this.nodeStatement(i, idText)};\n`;
694
+ return;
695
+ }
696
+ if (!marked) {
697
+ // a real node that is also a parent: its cells are node attributes, the cluster only groups
698
+ yield `${pad}${this.nodeStatement(i, idText)};\n`;
699
+ }
700
+ const inner = `${pad}${this.indent}`;
701
+ yield `${pad}subgraph ${writeId(idText)} {\n`;
702
+ if (!idText.startsWith(CLUSTER_PREFIX)) {
703
+ yield `${inner}cluster=true;\n`;
704
+ }
705
+ if (marked) {
706
+ const attributes = this.nodeAttributes(i);
707
+ if (attributes.length > 0) {
708
+ yield `${inner}graph [${attributes.join(", ")}];\n`;
709
+ }
710
+ }
711
+ for (const child of kids) {
712
+ if (emitted[child] === 1) {
713
+ yield `${inner}${writeId(this.idText(child))};\n`;
714
+ } else {
715
+ yield* this.writeNode(child, depth + 1, emitted);
716
+ }
717
+ }
718
+ yield `${pad}}\n`;
719
+ }
720
+
721
+ /**
722
+ * A node statement without its terminator.
723
+ * @param i - the node index
724
+ * @param idText - the id text
725
+ * @returns `id` or `id [attrs]`
726
+ */
727
+ private nodeStatement(i: number, idText: string): string {
728
+ const attributes = this.nodeAttributes(i);
729
+ return attributes.length === 0 ? writeId(idText) : `${writeId(idText)} [${attributes.join(", ")}]`;
730
+ }
731
+
732
+ /**
733
+ * The attributes of a node: its set cells plus `pos` from the position column.
734
+ * @param i - the node index
735
+ * @returns the attribute texts
736
+ */
737
+ private nodeAttributes(i: number): string[] {
738
+ const attributes = this.attributesOf(this.nodeColumns, i);
739
+ const pos = this.positionText(i);
740
+ if (pos !== null) {
741
+ attributes.push(`${POS_ATTRIBUTE}=${quoteDotId(pos)}`);
742
+ }
743
+ return attributes;
744
+ }
745
+
746
+ /**
747
+ * The `pos` text of a node, or null when it has none.
748
+ * @param i - the node index
749
+ * @returns `x,y` or `x,y,z`
750
+ */
751
+ private positionText(i: number): string | null {
752
+ const column = this.position;
753
+ if (column === null || !column.isSet(i)) {
754
+ return null;
755
+ }
756
+ if (column.dtype !== "f32" && column.dtype !== "f64" && column.dtype !== "i32") {
757
+ return null;
758
+ }
759
+ const { components } = column.meta;
760
+ if (components !== 2 && components !== 3) {
761
+ return null;
762
+ }
763
+ const format = column.dtype === "f32" ? formatF32 : formatF64;
764
+ const base = i * components;
765
+ const { data } = column;
766
+ const parts = [format(data[base]), format(data[base + 1])];
767
+ if (this.positionDims === 3) {
768
+ parts.push(format(components === 3 ? data[base + 2] : 0));
769
+ }
770
+ return parts.join(",");
771
+ }
772
+
773
+ /**
774
+ * The `name=value` attributes of one row over a column list (set cells only).
775
+ * @param columns - the columns
776
+ * @param row - the row
777
+ * @returns the attribute texts
778
+ */
779
+ private attributesOf(columns: readonly Column[], row: number): string[] {
780
+ const out: string[] = [];
781
+ for (const column of columns) {
782
+ if (!column.isSet(row)) {
783
+ continue;
784
+ }
785
+ const text = cellText(column, row);
786
+ if (text !== null) {
787
+ out.push(`${quoteDotId(column.meta.name)}=${writeId(text)}`);
788
+ }
789
+ }
790
+ return out;
791
+ }
792
+
793
+ /**
794
+ * Every logical edge in index order, mirror halves of expanded undirected pairs folded away.
795
+ * @yields the edge statements
796
+ * @returns nothing
797
+ */
798
+ private *writeEdges(): Generator<string> {
799
+ const { snapshot, indent } = this;
800
+ const list = snapshot.edgeList();
801
+ const op = this.directed ? "->" : "--";
802
+ for (let e = 0; e < snapshot.edgeCount; e++) {
803
+ if (this.folding.folded(e)) {
804
+ continue;
805
+ }
806
+ const attributes = this.attributesOf(this.edgeColumns, e);
807
+ const weight = this.weights.text(e);
808
+ if (weight !== null) {
809
+ attributes.unshift(`weight=${quoteDotId(weight)}`);
810
+ }
811
+ const key = this.keyText(e);
812
+ if (key !== null) {
813
+ attributes.unshift(`${KEY_ATTRIBUTE}=${writeId(key)}`);
814
+ }
815
+ const source = `${writeId(this.idText(list.src[e]))}${this.portText(this.sourcePort, e)}`;
816
+ const target = `${writeId(this.idText(list.dst[e]))}${this.portText(this.targetPort, e)}`;
817
+ const tail = attributes.length === 0 ? "" : ` [${attributes.join(", ")}]`;
818
+ yield `${indent}${source} ${op} ${target}${tail};\n`;
819
+ }
820
+ }
821
+
822
+ /**
823
+ * The edge id of an edge as `key` text, or null.
824
+ * @param e - the edge index
825
+ * @returns the text
826
+ */
827
+ private keyText(e: number): string | null {
828
+ const column = this.edgeId;
829
+ if (column === null || !column.isSet(e)) {
830
+ return null;
831
+ }
832
+ if (column.dtype === "f32" || column.dtype === "f64") {
833
+ return formatF64(column.data[e]);
834
+ }
835
+ return cellText(column, e);
836
+ }
837
+
838
+ /**
839
+ * The port suffix of an endpoint.
840
+ * @param column - the port column, or null
841
+ * @param e - the edge index
842
+ * @returns `:port` or ""
843
+ */
844
+ private portText(column: Column | null, e: number): string {
845
+ if (column === null || !column.isSet(e)) {
846
+ return "";
847
+ }
848
+ const text = cellText(column, e);
849
+ return text === null || text.length === 0 ? "" : writePort(text);
850
+ }
851
+
852
+ /**
853
+ * The id text of a node.
854
+ * @param i - the node index
855
+ * @returns String(id)
856
+ */
857
+ private idText(i: number): string {
858
+ return idToText(this.snapshot.ids.idOf(i));
859
+ }
860
+ }
861
+
862
+ /**
863
+ * The text of a node id: the string itself, or the shortest decimal of a number.
864
+ * @param id - the id
865
+ * @returns the text
866
+ */
867
+ function idToText(id: NodeId): string {
868
+ return typeof id === "string" ? id : formatF64(id);
869
+ }
870
+
871
+ /**
872
+ * Whether the snapshot's meta records a strict graph.
873
+ * @param snapshot - the snapshot
874
+ * @returns true when meta.extra.dot.strict is true
875
+ */
876
+ function isStrictMeta(snapshot: GraphSnapshot): boolean {
877
+ const dot: unknown = snapshot.meta.extra[DOT_META_KEY];
878
+ return typeof dot === "object" && dot !== null && (dot as { strict?: unknown }).strict === true;
879
+ }
880
+
881
+ /**
882
+ * The dimensions `pos` is written with: what the importer recorded in extra.sourceDims, else 2
883
+ * for a 2-component column and 3 otherwise.
884
+ * @param column - the position column, or null
885
+ * @returns 2 or 3
886
+ */
887
+ function positionDims(column: Column | null): 2 | 3 {
888
+ if (column === null) {
889
+ return 2;
890
+ }
891
+ const dims: unknown = column.meta.extra.sourceDims;
892
+ if (dims === 2 || dims === 3) {
893
+ return dims;
894
+ }
895
+ return column.meta.components === 2 ? 2 : 3;
896
+ }
897
+
898
+ /**
899
+ * The attribute text of one set cell, or null for a dtype the format cannot write.
900
+ * @param column - the column
901
+ * @param row - the row
902
+ * @returns the text
903
+ */
904
+ function cellText(column: Column, row: number): string | null {
905
+ switch (column.dtype) {
906
+ case "bool":
907
+ return column.value(row) === true ? "true" : "false";
908
+ case "i32":
909
+ case "u32":
910
+ case "u8":
911
+ return numericText(column, row, formatInteger);
912
+ case "f32":
913
+ return numericText(column, row, (v) => formatDecimal(v, "f32"));
914
+ case "f64":
915
+ return numericText(column, row, (v) => formatDecimal(v, "f64"));
916
+ case "dict":
917
+ case "string":
918
+ return column.value(row) ?? "";
919
+ case "list":
920
+ case "json":
921
+ return null;
922
+ default: {
923
+ const name: string = (column as Column).dtype;
924
+ throw new GraphFormatError("E_COLUMN_TYPE", `unknown dtype ${name}`, { dtype: name });
925
+ }
926
+ }
927
+ }
928
+
929
+ /**
930
+ * The text of a numeric cell: one number, or the `components` numbers joined by commas.
931
+ * @param column - a numeric column
932
+ * @param row - the row
933
+ * @param format - the per-number formatter
934
+ * @returns the text
935
+ */
936
+ function numericText(
937
+ column: Column & { readonly data: ArrayLike<number> },
938
+ row: number,
939
+ format: (value: number) => string,
940
+ ): string {
941
+ const { components } = column.meta;
942
+ if (components === 1) {
943
+ return format(column.data[row]);
944
+ }
945
+ const parts: string[] = [];
946
+ for (let k = 0; k < components; k++) {
947
+ parts.push(format(column.data[row * components + k]));
948
+ }
949
+ return parts.join(",");
950
+ }
951
+
952
+ /**
953
+ * How many set cells of an f32 / f64 column are non-finite.
954
+ * @param column - the column
955
+ * @returns the count
956
+ */
957
+ function countNonFinite(column: Column): number {
958
+ if (column.dtype !== "f32" && column.dtype !== "f64") {
959
+ return 0;
960
+ }
961
+ const { components } = column.meta;
962
+ let count = 0;
963
+ for (let r = 0; r < column.length; r++) {
964
+ if (!column.isSet(r)) {
965
+ continue;
966
+ }
967
+ for (let k = 0; k < components; k++) {
968
+ if (!Number.isFinite(column.data[r * components + k])) {
969
+ count++;
970
+ break;
971
+ }
972
+ }
973
+ }
974
+ return count;
975
+ }
976
+
977
+ /**
978
+ * How many set cells of a text column read back as something other than text under the 5.1 grammar.
979
+ * @param column - a string or dict column
980
+ * @returns the count
981
+ */
982
+ function countTypedLookingText(column: Column): number {
983
+ if (column.dtype !== "string" && column.dtype !== "dict") {
984
+ return 0;
985
+ }
986
+ let count = 0;
987
+ for (let r = 0; r < column.length; r++) {
988
+ if (column.isSet(r) && inferTextDtype(column.value(r) ?? "") !== "string") {
989
+ count++;
990
+ }
991
+ }
992
+ return count;
993
+ }
994
+
995
+ /**
996
+ * How many ids read back as the other type under the canonical rule: numeric ids that are not
997
+ * canonical integer text (read back as strings) and string ids that are (read back as numbers).
998
+ * @param snapshot - the snapshot
999
+ * @returns the count
1000
+ */
1001
+ function countTextIds(snapshot: GraphSnapshot): number {
1002
+ const { ids } = snapshot;
1003
+ if (ids.kind === "identity" || ids.kind === "dense") {
1004
+ return 0;
1005
+ }
1006
+ let count = 0;
1007
+ for (let i = 0; i < ids.size; i++) {
1008
+ const id = ids.idOf(i);
1009
+ if (canonicalId(idToText(id)) !== id) {
1010
+ count++;
1011
+ }
1012
+ }
1013
+ return count;
1014
+ }