@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,1458 @@
1
+ /**
2
+ * The GraphML exporter (design section 8.5; research note 07 section 9): `<key>` declarations
3
+ * regenerated from ColumnMeta.origin when the column came from GraphML (key id, attr.name,
4
+ * attr.type, yfiles.type) and derived from the dtype otherwise; node ids sanitised to NMTOKEN
5
+ * (`sanitizeIds: "error"` refuses, `"mangle"` rewrites and keeps the original in a
6
+ * `graphty:originalId` data attribute the importer restores); mixed direction written with
7
+ * `edgedefault` plus per-edge `directed` attributes, folding expanded pairs back through the
8
+ * `pair` / `directed` role columns; explicit weights only (the role-weight column's validity);
9
+ * containment (`parent` role) as nested graphs; yFiles json columns as nested XML again.
10
+ *
11
+ * check() lists every loss before anything is written: the generic capability gaps of
12
+ * checkCapabilities() (dict / u32 / u8 dtypes, lists, components, options, positions, visual and
13
+ * temporal roles, extension tables) plus the GraphML-specific ones (mutual edges written as
14
+ * undirected, json columns without a yfiles origin written as JSON text, multi-parent columns,
15
+ * containment order, ids that change type under the canonical rule, edge ids that read back as
16
+ * strings, roles GraphML cannot express, yfiles values that are not serialisable trees).
17
+ */
18
+
19
+ import { type Column, GraphFormatError, type GraphSnapshot, INVALID_INDEX } from "@graphty/graph-format";
20
+
21
+ import { childrenCsr } from "../../children.js";
22
+ import { type PairFolding, pairFolding } from "../../common/direction.js";
23
+ import { escapeXmlAttribute, escapeXmlText } from "../../common/escape.js";
24
+ import {
25
+ capabilities,
26
+ checkCapabilities,
27
+ isNmtoken,
28
+ LOSS,
29
+ mangleNmtoken,
30
+ type SanitizedIds,
31
+ sanitizeIds,
32
+ } from "../../common/export.js";
33
+ import { formatF32, formatF64, formatInteger } from "../../common/format.js";
34
+ import { isCanonicalIntegerText } from "../../common/ids.js";
35
+ import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
36
+ import { type ExplicitWeights, explicitWeights } from "../../common/weights.js";
37
+ import { encodeChunks, joinText } from "../../common/writer.js";
38
+ import { xmlIllegalTextNotes } from "../../common/xml.js";
39
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
40
+ import {
41
+ EDGE_ID_COLUMN,
42
+ FORMAT,
43
+ GRAPHML_LOSS,
44
+ GRAPHML_NAMESPACE,
45
+ type GraphmlMeta,
46
+ LABEL_COLUMN,
47
+ META_KEY,
48
+ ORIGINAL_ID_ATTRIBUTE,
49
+ PARENT_COLUMN,
50
+ RESERVED_EDGE_NAMES,
51
+ RESERVED_NODE_NAMES,
52
+ SCHEMA_LOCATION,
53
+ SOURCE_PORT_COLUMN,
54
+ TARGET_PORT_COLUMN,
55
+ XSI_NAMESPACE,
56
+ YFILES_NAMESPACE,
57
+ } from "./constants.js";
58
+ import { treeProblem, writeXmlTree } from "./tree.js";
59
+
60
+ /** The format-specific options of the GraphML exporter. */
61
+ export interface GraphmlExportOptions {
62
+ /** Indent nested elements (default true); false writes one element per line without indentation. */
63
+ pretty?: boolean | undefined;
64
+ /**
65
+ * The top-level `edgedefault`. By default the one the importer recorded in
66
+ * `meta.extra.graphml` (so a mixed file re-imports with the same edge layout), else the
67
+ * snapshot's direction, with the majority direction for a mixed snapshot.
68
+ */
69
+ edgedefault?: "directed" | "undirected" | undefined;
70
+ }
71
+
72
+ /** What GraphML keeps as declared (research note 07 section 9). */
73
+ const CAPABILITIES: ExportCapabilities = capabilities({
74
+ mixedDirection: true,
75
+ multiEdges: true,
76
+ selfLoops: true,
77
+ edgeIds: "optional",
78
+ idCharset: "nmtoken",
79
+ dtypes: ["bool", "i32", "f32", "f64", "string"],
80
+ components: false,
81
+ lists: false,
82
+ json: false,
83
+ defaults: true,
84
+ options: false,
85
+ hierarchy: true,
86
+ temporal: "none",
87
+ graphAttributes: true,
88
+ positions: false,
89
+ viz: false,
90
+ });
91
+
92
+ /** Roles handled structurally (never written as a key) or reported by checkCapabilities() and not written. */
93
+ const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
94
+ "directed",
95
+ "pair",
96
+ "mutual",
97
+ "weight",
98
+ "timeText",
99
+ "originalId",
100
+ ]);
101
+ const DROPPED_ROLES: ReadonlySet<string> = new Set([
102
+ "position",
103
+ "color",
104
+ "size",
105
+ "shape",
106
+ "thickness",
107
+ "start",
108
+ "end",
109
+ "timestamp",
110
+ "timestamps",
111
+ "spells",
112
+ "open",
113
+ ]);
114
+
115
+ /**
116
+ * The roles GraphML has a slot for (checkCapabilities() reports every other role as lost): the
117
+ * label key, the edge `id`, `sourceport` and `targetport` attributes and nested graphs.
118
+ */
119
+ const SLOT_ROLES: ReadonlySet<string> = new Set(["label", "id", "sourcePort", "targetPort", "parent"]);
120
+
121
+ /** The slot roles written structurally per domain (never as a key); a label is a key titled `label`. */
122
+ const SLOT_ROLES_BY_DOMAIN: Readonly<Record<Domain, ReadonlySet<string>>> = {
123
+ graph: new Set(),
124
+ node: new Set(["parent"]),
125
+ edge: new Set(["id", "sourcePort", "targetPort"]),
126
+ };
127
+
128
+ /** The names the importer gives the slot columns (design section 5.6), for the name-change notes. */
129
+ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
130
+ label: LABEL_COLUMN,
131
+ id: EDGE_ID_COLUMN,
132
+ sourcePort: SOURCE_PORT_COLUMN,
133
+ targetPort: TARGET_PORT_COLUMN,
134
+ parent: PARENT_COLUMN,
135
+ });
136
+
137
+ /** The attr.name of the weight key when the weight did not come from GraphML (the importer's weightFrom default). */
138
+ const DEFAULT_WEIGHT_NAME = "weight";
139
+
140
+ /** A note-recording callback. */
141
+ type NoteFn = (code: string, message: string, column?: string | null, count?: number | null) => void;
142
+
143
+ /** The GraphML attr.type values and the dtype each maps to, for restoring origin.type. */
144
+ const TYPE_DTYPES: Readonly<Record<string, string>> = {
145
+ boolean: "bool",
146
+ int: "i32",
147
+ long: "f64",
148
+ float: "f32",
149
+ double: "f64",
150
+ string: "string",
151
+ };
152
+
153
+ const I32_MAX = 2147483647;
154
+
155
+ /** The yfiles.type written for a yfiles json column whose origin records none. */
156
+ const DEFAULT_YFILES_TYPES: Readonly<Record<Domain, string>> = {
157
+ node: "nodegraphics",
158
+ edge: "edgegraphics",
159
+ graph: "resources",
160
+ };
161
+
162
+ /** The element domains. */
163
+ type Domain = "graph" | "node" | "edge";
164
+
165
+ /** One column written as a key. */
166
+ interface ColumnPlan {
167
+ readonly column: Column;
168
+ readonly domain: Domain;
169
+ /** The key id. */
170
+ keyId: string;
171
+ /** The attr.type, or null for a yfiles key. */
172
+ readonly attrType: string | null;
173
+ /** The attr.name, or null for a yfiles key. */
174
+ readonly attrName: string | null;
175
+ /** The yfiles.type, or null. */
176
+ readonly yfilesType: string | null;
177
+ /** Whether values are written as nested XML trees. */
178
+ readonly yfiles: boolean;
179
+ }
180
+
181
+ /** One `<key>` element (a for="all" key merges up to three column plans). */
182
+ interface KeyPlan {
183
+ readonly id: string;
184
+ readonly domains: readonly Domain[];
185
+ readonly attrName: string | null;
186
+ readonly attrType: string | null;
187
+ readonly yfilesType: string | null;
188
+ /** The column whose default and desc are written; null for the weight key. */
189
+ readonly column: Column | null;
190
+ }
191
+
192
+ /** Containment as the exporter writes it. */
193
+ interface Hierarchy {
194
+ /** The parent column, or null. */
195
+ readonly parent: Column | null;
196
+ /** Nodes written at the top level, in order (roots, then the roots forced out of cycles). */
197
+ readonly roots: Uint32Array;
198
+ /** CSR of children by parent: childStart[u]..childStart[u+1] over childList. */
199
+ readonly childStart: Uint32Array;
200
+ readonly childList: Uint32Array;
201
+ /** Whether the written order differs from index order. */
202
+ readonly reordered: boolean;
203
+ /** Nodes whose parent chain never reaches a root. */
204
+ readonly unreachable: number;
205
+ }
206
+
207
+ /** Everything check() and export() agree on. */
208
+ interface Plan {
209
+ readonly options: ResolvedExportOptions;
210
+ readonly pretty: boolean;
211
+ readonly notes: LossNote[];
212
+ readonly keys: KeyPlan[];
213
+ readonly graphColumns: ColumnPlan[];
214
+ readonly nodeColumns: ColumnPlan[];
215
+ readonly edgeColumns: ColumnPlan[];
216
+ /** The weight key id, attr.type and the explicit weights, or null for an unweighted snapshot. */
217
+ readonly weight: { readonly keyId: string; readonly attrType: string; readonly weights: ExplicitWeights } | null;
218
+ /** The key id of the originalId attribute, when mangled ids are written. */
219
+ readonly originalIdKey: string | null;
220
+ readonly edgedefault: "directed" | "undirected";
221
+ /** The pair folding: mirrors skipped, source directions. */
222
+ readonly folding: PairFolding;
223
+ readonly edgeIdColumn: Column | null;
224
+ readonly sourcePortColumn: Column | null;
225
+ readonly targetPortColumn: Column | null;
226
+ readonly hierarchy: Hierarchy;
227
+ readonly meta: GraphmlMeta | null;
228
+ readonly needsOriginalIdKey: boolean;
229
+ }
230
+
231
+ /** The resolved format-specific options. */
232
+ interface FormatOptions {
233
+ /** Whether to indent. */
234
+ readonly pretty: boolean;
235
+ /** The edgedefault override, or null. */
236
+ readonly edgedefault: "directed" | "undirected" | null;
237
+ }
238
+
239
+ /**
240
+ * Resolve the format-specific options.
241
+ * @param options - the caller's options
242
+ * @returns the pretty flag and the edgedefault override
243
+ */
244
+ function resolveFormatOptions(options: GraphmlExportOptions | undefined): FormatOptions {
245
+ const pretty = options?.pretty ?? true;
246
+ if (typeof pretty !== "boolean") {
247
+ throw new GraphFormatError("E_UNSUPPORTED", "option pretty must be a boolean", {
248
+ option: "pretty",
249
+ found: typeof pretty,
250
+ });
251
+ }
252
+ const edgedefault = options?.edgedefault ?? null;
253
+ if (edgedefault !== null && edgedefault !== "directed" && edgedefault !== "undirected") {
254
+ throw new GraphFormatError(
255
+ "E_UNSUPPORTED",
256
+ `option edgedefault: ${JSON.stringify(edgedefault)} is not "directed" or "undirected"`,
257
+ { option: "edgedefault", found: edgedefault },
258
+ );
259
+ }
260
+ return { pretty, edgedefault };
261
+ }
262
+
263
+ /**
264
+ * The importer's `meta.extra.graphml`, when the snapshot has one of the expected shape.
265
+ * @param snapshot - the snapshot
266
+ * @returns the meta, or null
267
+ */
268
+ function graphmlMetaOf(snapshot: GraphSnapshot): GraphmlMeta | null {
269
+ const value = snapshot.meta.extra[META_KEY];
270
+ if (typeof value !== "object" || value === null) {
271
+ return null;
272
+ }
273
+ const record = value as Record<string, unknown>;
274
+ const graphId = typeof record.graphId === "string" ? record.graphId : null;
275
+ const edgedefault =
276
+ record.edgedefault === "directed" || record.edgedefault === "undirected" ? record.edgedefault : null;
277
+ const namespaces: Record<string, string> = {};
278
+ if (typeof record.namespaces === "object" && record.namespaces !== null) {
279
+ for (const [prefix, uri] of Object.entries(record.namespaces as Record<string, unknown>)) {
280
+ if (typeof uri === "string") {
281
+ namespaces[prefix] = uri;
282
+ }
283
+ }
284
+ }
285
+ return { graphId, edgedefault, namespaces };
286
+ }
287
+
288
+ /**
289
+ * Build the plan: classify every column, assign key ids, decide the edgedefault and the
290
+ * containment order, and collect every loss note.
291
+ * @param snapshot - the snapshot
292
+ * @param options - the resolved common options
293
+ * @param format - the resolved format options
294
+ * @returns the plan
295
+ */
296
+ function planExport(snapshot: GraphSnapshot, options: ResolvedExportOptions, format: FormatOptions): Plan {
297
+ // the generic json note is replaced by planTable()'s (yfiles trees are kept, other json is text)
298
+ const notes = checkCapabilities(snapshot, CAPABILITIES, options, {
299
+ roles: SLOT_ROLES,
300
+ roleNames: ROLE_NAMES,
301
+ }).filter((n) => n.code !== LOSS.JSON);
302
+ const note: NoteFn = (code, message, column = null, count = null): void => {
303
+ notes.push(Object.freeze({ code, message, column, count }));
304
+ };
305
+ notes.push(...xmlIllegalTextNotes(snapshot));
306
+ const meta = graphmlMetaOf(snapshot);
307
+ const keyIds = new KeyIds();
308
+
309
+ const graphColumns = planTable(snapshot.graph, "graph", notes, note);
310
+ const nodeColumns = planTable(snapshot.nodes, "node", notes, note);
311
+ const edgeColumns = planTable(snapshot.edges, "edge", notes, note);
312
+ const keys = planKeys([...graphColumns, ...nodeColumns, ...edgeColumns], keyIds);
313
+ reservedNameNotes(nodeColumns, RESERVED_NODE_NAMES, note);
314
+ reservedNameNotes(edgeColumns, RESERVED_EDGE_NAMES, note);
315
+ const weight = planWeight(snapshot, edgeColumns, keyIds, keys, note);
316
+
317
+ // ids
318
+ const idTypeChanges = countIdTypeChanges(snapshot);
319
+ if (idTypeChanges > 0) {
320
+ note(
321
+ GRAPHML_LOSS.ID_TEXT_TYPE,
322
+ `${idTypeChanges} node id(s) change type when read back under ids: "canonical" (string ids that are integer text, non-integer numbers)`,
323
+ null,
324
+ idTypeChanges,
325
+ );
326
+ }
327
+ const edgeIdColumn = snapshot.edges.byRole("id");
328
+ if (edgeIdColumn !== null) {
329
+ planEdgeIds(snapshot, edgeIdColumn, options, note);
330
+ }
331
+ const unrepresentable = countUnrepresentableNodeIds(snapshot);
332
+ const needsOriginalIdKey = unrepresentable > 0 && options.sanitizeIds === "mangle";
333
+ const originalIdKey = needsOriginalIdKey ? keyIds.next(null) : null;
334
+
335
+ // direction: an undirected pair folds to one undirected edge, a mutual pair too (the mark is lost)
336
+ const folding = pairFolding(snapshot, { foldMutual: true });
337
+ if (folding.mutualCount > 0) {
338
+ note(
339
+ GRAPHML_LOSS.MUTUAL_AS_UNDIRECTED,
340
+ `${folding.mutualCount} mutual pair(s) are written as undirected edges; the mutual mark is lost`,
341
+ null,
342
+ folding.mutualCount,
343
+ );
344
+ }
345
+ const edgedefault = planEdgedefault(snapshot, folding, format, meta);
346
+
347
+ // containment
348
+ const hierarchy = planHierarchy(snapshot);
349
+ if (hierarchy.reordered) {
350
+ note(
351
+ GRAPHML_LOSS.HIERARCHY_REORDERED,
352
+ "nodes are written in containment order (children nested under their parent); node indices change after a round trip",
353
+ hierarchy.parent?.meta.name ?? null,
354
+ );
355
+ }
356
+ if (hierarchy.unreachable > 0) {
357
+ note(
358
+ GRAPHML_LOSS.PARENT_CYCLE,
359
+ `${hierarchy.unreachable} node(s) whose parent chain never reaches a root are written at the top level`,
360
+ hierarchy.parent?.meta.name ?? null,
361
+ hierarchy.unreachable,
362
+ );
363
+ }
364
+
365
+ return {
366
+ options,
367
+ pretty: format.pretty,
368
+ notes,
369
+ keys,
370
+ graphColumns,
371
+ nodeColumns,
372
+ edgeColumns,
373
+ weight,
374
+ originalIdKey,
375
+ edgedefault,
376
+ folding,
377
+ edgeIdColumn,
378
+ sourcePortColumn: snapshot.edges.byRole("sourcePort"),
379
+ targetPortColumn: snapshot.edges.byRole("targetPort"),
380
+ hierarchy,
381
+ meta,
382
+ needsOriginalIdKey,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * A plain column titled like one of the importer's XML-derived columns (`id`, `sourceport`,
388
+ * `targetport` on edges, `parent` on nodes) reads back renamed `<name>#<key id>` (design section
389
+ * 5.6); the note says so.
390
+ * @param plans - the column plans of one domain, key ids assigned
391
+ * @param reserved - the reserved names of that domain
392
+ * @param note - the note recorder
393
+ */
394
+ function reservedNameNotes(plans: readonly ColumnPlan[], reserved: ReadonlySet<string>, note: NoteFn): void {
395
+ for (const plan of plans) {
396
+ const { column, attrName, keyId } = plan;
397
+ if (attrName === null || !reserved.has(attrName)) {
398
+ continue;
399
+ }
400
+ note(
401
+ LOSS.COLUMN_NAME_CHANGED,
402
+ `${plan.domain} column "${column.meta.name}" is titled like the importer's ${attrName} column and reads back as "${attrName}#${keyId}"`,
403
+ column.meta.name,
404
+ column.length - column.nullCount,
405
+ );
406
+ }
407
+ }
408
+
409
+ /** The key ids handed out so far: a preferred NMTOKEN when free, else the next free `d<n>`. */
410
+ class KeyIds {
411
+ private readonly used = new Set<string>();
412
+ private counter = 0;
413
+
414
+ /**
415
+ * Take a key id.
416
+ * @param preferred - the id to keep when it is an NMTOKEN not handed out yet, or null
417
+ * @returns the id
418
+ */
419
+ next(preferred: string | null): string {
420
+ if (preferred !== null && isNmtoken(preferred) && !this.used.has(preferred)) {
421
+ this.used.add(preferred);
422
+ return preferred;
423
+ }
424
+ for (;;) {
425
+ const candidate = `d${this.counter}`;
426
+ this.counter++;
427
+ if (!this.used.has(candidate)) {
428
+ this.used.add(candidate);
429
+ return candidate;
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * The `<key>` elements: columns sharing a GraphML origin.id across domains become one for="all"
437
+ * key when their name, type and default agree; every other column is its own key.
438
+ * @param all - the column plans of the three domains, in writing order
439
+ * @param keyIds - the key id allocator
440
+ * @returns the keys, in declaration order
441
+ */
442
+ function planKeys(all: readonly ColumnPlan[], keyIds: KeyIds): KeyPlan[] {
443
+ const keys: KeyPlan[] = [];
444
+ const byOrigin = new Map<string, ColumnPlan[]>();
445
+ for (const plan of all) {
446
+ const { origin } = plan.column.meta;
447
+ if (origin !== null && origin.format === FORMAT && origin.id !== null) {
448
+ const group = byOrigin.get(origin.id) ?? [];
449
+ group.push(plan);
450
+ byOrigin.set(origin.id, group);
451
+ }
452
+ }
453
+ const planned = new Set<ColumnPlan>();
454
+ for (const plan of all) {
455
+ if (planned.has(plan)) {
456
+ continue;
457
+ }
458
+ const { origin } = plan.column.meta;
459
+ const group =
460
+ origin !== null && origin.format === FORMAT && origin.id !== null
461
+ ? (byOrigin.get(origin.id) ?? [plan])
462
+ : [plan];
463
+ const shared = group.length > 1 && group.every((other) => other === plan || sameKey(plan, other));
464
+ const members = shared ? group : [plan];
465
+ const id = keyIds.next(origin !== null && origin.format === FORMAT ? origin.id : null);
466
+ for (const member of members) {
467
+ member.keyId = id;
468
+ planned.add(member);
469
+ }
470
+ const domains = members.map((member) => member.domain);
471
+ // a yfiles key carries attr.name only when the column name is not the key id (yEd writes none)
472
+ const yfilesName = plan.column.meta.origin?.title ?? plan.column.meta.name;
473
+ let { attrName } = plan;
474
+ if (plan.yfiles) {
475
+ attrName = yfilesName === id ? null : yfilesName;
476
+ }
477
+ keys.push({
478
+ id,
479
+ domains: domains.length > 1 ? ["graph", "node", "edge"] : domains,
480
+ attrName,
481
+ attrType: plan.attrType,
482
+ yfilesType: plan.yfilesType,
483
+ column: plan.column,
484
+ });
485
+ }
486
+ return keys;
487
+ }
488
+
489
+ /**
490
+ * The weight key of a weighted snapshot (its attr.name and attr.type restored from
491
+ * meta.weightOrigin when the weight came from GraphML), and the note for an edge column the
492
+ * importer would read as THE weight: every edge key titled like the weight key (`weight` by
493
+ * default) is a weight key on import, so such a column reads back as the weight, not as a column.
494
+ * @param snapshot - the snapshot
495
+ * @param edgeColumns - the edge column plans
496
+ * @param keyIds - the key id allocator
497
+ * @param keys - receives the weight key
498
+ * @param note - the note recorder
499
+ * @returns the weight plan, or null for an unweighted snapshot
500
+ */
501
+ function planWeight(
502
+ snapshot: GraphSnapshot,
503
+ edgeColumns: readonly ColumnPlan[],
504
+ keyIds: KeyIds,
505
+ keys: KeyPlan[],
506
+ note: NoteFn,
507
+ ): Plan["weight"] {
508
+ const weights = explicitWeights(snapshot);
509
+ const origin = snapshot.meta.weightOrigin;
510
+ const fromGraphml = weights.weighted && origin !== null && origin.format === FORMAT;
511
+ const attrName = fromGraphml && origin.title !== null ? origin.title : DEFAULT_WEIGHT_NAME;
512
+ for (const plan of edgeColumns) {
513
+ if (plan.attrName === attrName) {
514
+ note(
515
+ LOSS.WEIGHT_KEY_CLASH,
516
+ `edge column "${plan.column.meta.name}" is written as a key titled "${attrName}", which the importer reads as THE weight (weightFrom); it reads back as the weight, not as a column`,
517
+ plan.column.meta.name,
518
+ plan.column.length - plan.column.nullCount,
519
+ );
520
+ }
521
+ }
522
+ if (!weights.weighted) {
523
+ return null;
524
+ }
525
+ let attrType = "double";
526
+ if (fromGraphml && origin.type !== null) {
527
+ const declared = origin.type.trim().toLowerCase();
528
+ if (declared === "float" || declared === "double" || declared === "long" || declared === "int") {
529
+ attrType = declared;
530
+ }
531
+ }
532
+ if ((attrType === "int" || attrType === "long") && !weightsIntegral(snapshot, weights)) {
533
+ // a declared integer weight only survives when every explicit weight is integral
534
+ attrType = "double";
535
+ }
536
+ const id = keyIds.next(fromGraphml ? origin.id : null);
537
+ keys.push({ id, domains: ["edge"], attrName, attrType, yfilesType: null, column: null });
538
+ return { keyId: id, attrType, weights };
539
+ }
540
+
541
+ /**
542
+ * The notes about the edge id column: a numeric column reads back as string; ids outside the
543
+ * NMTOKEN charset are refused or mangled per sanitizeIds.
544
+ * @param snapshot - the snapshot
545
+ * @param column - the edge id column
546
+ * @param options - the resolved options
547
+ * @param note - the note recorder
548
+ */
549
+ function planEdgeIds(snapshot: GraphSnapshot, column: Column, options: ResolvedExportOptions, note: NoteFn): void {
550
+ if (column.dtype !== "string" && column.dtype !== "dict") {
551
+ note(
552
+ GRAPHML_LOSS.EDGE_ID_TEXT,
553
+ `edge id column "${column.meta.name}" is ${column.dtype}; GraphML edge ids read back as strings`,
554
+ column.meta.name,
555
+ snapshot.edgeCount - column.nullCount,
556
+ );
557
+ }
558
+ const bad = countBadEdgeIds(column);
559
+ if (bad === 0) {
560
+ return;
561
+ }
562
+ if (options.sanitizeIds === "mangle") {
563
+ note(LOSS.ID_MANGLED, `${bad} edge id(s) outside the nmtoken charset are rewritten`, column.meta.name, bad);
564
+ } else {
565
+ note(
566
+ LOSS.ID_CHARSET,
567
+ `${bad} edge id(s) outside the nmtoken charset; export() will throw unless sanitizeIds is "mangle"`,
568
+ column.meta.name,
569
+ bad,
570
+ );
571
+ }
572
+ }
573
+
574
+ /**
575
+ * The top-level edgedefault: the override, else undirected for an undirected snapshot, else
576
+ * directed when no written edge is undirected, else the one the importer recorded, else the
577
+ * majority direction.
578
+ * @param snapshot - the snapshot
579
+ * @param folding - the pair folding
580
+ * @param format - the resolved format options
581
+ * @param meta - the importer's meta, or null
582
+ * @returns the edgedefault
583
+ */
584
+ function planEdgedefault(
585
+ snapshot: GraphSnapshot,
586
+ folding: PairFolding,
587
+ format: FormatOptions,
588
+ meta: GraphmlMeta | null,
589
+ ): "directed" | "undirected" {
590
+ if (format.edgedefault !== null) {
591
+ return format.edgedefault;
592
+ }
593
+ if (!snapshot.directed) {
594
+ return "undirected";
595
+ }
596
+ let directedCount = 0;
597
+ let undirectedCount = 0;
598
+ for (let e = 0; e < snapshot.edgeCount; e++) {
599
+ if (folding.folded(e)) {
600
+ continue;
601
+ }
602
+ if (isDirectedEdge(folding, e)) {
603
+ directedCount++;
604
+ } else {
605
+ undirectedCount++;
606
+ }
607
+ }
608
+ if (undirectedCount === 0) {
609
+ return "directed";
610
+ }
611
+ const recorded = meta === null ? null : meta.edgedefault;
612
+ if (recorded !== null) {
613
+ return recorded;
614
+ }
615
+ return undirectedCount > directedCount ? "undirected" : "directed";
616
+ }
617
+
618
+ /**
619
+ * Whether two column plans can share one for="all" key.
620
+ * @param a - one plan
621
+ * @param b - another
622
+ * @returns true when name, type and default agree
623
+ */
624
+ function sameKey(a: ColumnPlan, b: ColumnPlan): boolean {
625
+ return (
626
+ a.attrName === b.attrName &&
627
+ a.attrType === b.attrType &&
628
+ a.yfilesType === b.yfilesType &&
629
+ a.domain !== b.domain &&
630
+ JSON.stringify(a.column.meta.default ?? null) === JSON.stringify(b.column.meta.default ?? null)
631
+ );
632
+ }
633
+
634
+ /**
635
+ * Classify the columns of one table: which are written as keys and what each loses. A label
636
+ * column is written into the label slot (a key titled `label`, which the importer reads back
637
+ * with the role) unless the table holds another column of that name, in which case it keeps
638
+ * its own title and the role is lost.
639
+ * @param table - the table
640
+ * @param domain - its domain
641
+ * @param notes - the notes so far (the generic name-change note of a label column that cannot take the slot is withdrawn)
642
+ * @param note - the note recorder
643
+ * @returns the plans of the written columns, in declaration order
644
+ */
645
+ function planTable(table: Iterable<Column>, domain: Domain, notes: LossNote[], note: NoteFn): ColumnPlan[] {
646
+ const plans: ColumnPlan[] = [];
647
+ const columns = [...table];
648
+ const names = new Set(columns.map((column) => column.meta.name));
649
+ for (const column of columns) {
650
+ const { meta } = column;
651
+ const { role, name } = meta;
652
+ if (role === "parents") {
653
+ note(
654
+ GRAPHML_LOSS.PARENTS_DROPPED,
655
+ `${domain} column "${name}" (parents) cannot be written: nested graphs hold one parent per node`,
656
+ name,
657
+ column.length - column.nullCount,
658
+ );
659
+ continue;
660
+ }
661
+ if (
662
+ role !== null &&
663
+ (STRUCTURAL_ROLES.has(role) || DROPPED_ROLES.has(role) || SLOT_ROLES_BY_DOMAIN[domain].has(role))
664
+ ) {
665
+ continue;
666
+ }
667
+ if (role !== null && role !== "label" && SLOT_ROLES.has(role)) {
668
+ // the slot belongs to another domain (an edge id on a node, a port on a graph)
669
+ withdrawNameChange(notes, name);
670
+ note(
671
+ LOSS.ROLE,
672
+ `${domain} column "${name}" (${role}) is written as a plain attribute; GraphML has no ${role} slot for a ${domain} and the role is lost`,
673
+ name,
674
+ column.length - column.nullCount,
675
+ );
676
+ }
677
+ const yfiles = meta.dtype === "json" && meta.origin?.namespace === "yfiles";
678
+ if (meta.dtype === "json" && !yfiles) {
679
+ note(
680
+ LOSS.JSON,
681
+ `${domain} column "${name}" holds nested values; written as JSON text, which reads back as string`,
682
+ name,
683
+ column.length - column.nullCount,
684
+ );
685
+ }
686
+ if (yfiles) {
687
+ const bad = countBadTrees(column);
688
+ if (bad > 0) {
689
+ note(
690
+ GRAPHML_LOSS.YFILES_TREE,
691
+ `${bad} value(s) of yfiles column "${name}" are not XML trees; export() will throw E_COLUMN_TYPE`,
692
+ name,
693
+ bad,
694
+ );
695
+ }
696
+ }
697
+ const { origin } = meta;
698
+ const fromGraphml = origin !== null && origin.format === FORMAT;
699
+ let attrName: string | null = null;
700
+ if (!yfiles) {
701
+ attrName = fromGraphml && origin.title !== null ? origin.title : name;
702
+ if (role === "label" && domain !== "graph" && attrName !== LABEL_COLUMN) {
703
+ attrName = labelSlot(column, domain, names, notes, note);
704
+ } else if (role === null && domain !== "graph" && attrName === LABEL_COLUMN) {
705
+ note(
706
+ LOSS.ROLE_ASSUMED,
707
+ `${domain} column "${name}" has no role but is titled "${LABEL_COLUMN}", which the importer reads back with the label role`,
708
+ name,
709
+ column.length - column.nullCount,
710
+ );
711
+ }
712
+ }
713
+ plans.push({
714
+ column,
715
+ domain,
716
+ keyId: "",
717
+ attrType: yfiles ? null : attrTypeFor(column),
718
+ attrName,
719
+ yfilesType: yfiles ? (origin?.type ?? DEFAULT_YFILES_TYPES[domain]) : null,
720
+ yfiles,
721
+ });
722
+ }
723
+ return plans;
724
+ }
725
+
726
+ /**
727
+ * The title a label column not named `label` is written under: the label slot when the table has
728
+ * no other column of that name (checkCapabilities() has noted the name change), else its own
729
+ * name with the role lost (the name-change note is withdrawn and a role note recorded).
730
+ * @param column - the label column
731
+ * @param domain - its domain
732
+ * @param names - the column names of the table
733
+ * @param notes - the notes so far
734
+ * @param note - the note recorder
735
+ * @returns the attr.name
736
+ */
737
+ function labelSlot(
738
+ column: Column,
739
+ domain: Domain,
740
+ names: ReadonlySet<string>,
741
+ notes: LossNote[],
742
+ note: NoteFn,
743
+ ): string {
744
+ const { name } = column.meta;
745
+ if (!names.has(LABEL_COLUMN)) {
746
+ return LABEL_COLUMN;
747
+ }
748
+ withdrawNameChange(notes, name);
749
+ note(
750
+ LOSS.ROLE,
751
+ `${domain} column "${name}" (label) is written as a plain attribute: the label slot (a key titled "${LABEL_COLUMN}") is taken by column "${LABEL_COLUMN}" and the role is lost`,
752
+ name,
753
+ column.length - column.nullCount,
754
+ );
755
+ return name;
756
+ }
757
+
758
+ /**
759
+ * Withdraw the name-change note checkCapabilities() recorded for a role column that does not
760
+ * take the slot after all.
761
+ * @param notes - the notes so far
762
+ * @param name - the column name
763
+ */
764
+ function withdrawNameChange(notes: LossNote[], name: string): void {
765
+ const at = notes.findIndex((n) => n.code === LOSS.COLUMN_NAME_CHANGED && n.column === name);
766
+ if (at >= 0) {
767
+ notes.splice(at, 1);
768
+ }
769
+ }
770
+
771
+ /**
772
+ * The attr.type a column is declared with: the GraphML origin.type when it still describes the
773
+ * column, else the type of the dtype (u32 / u8 as int or long, dict as string, list / json /
774
+ * multi-component as string holding JSON text).
775
+ * @param column - the column
776
+ * @returns the attr.type text
777
+ */
778
+ function attrTypeFor(column: Column): string {
779
+ const { meta } = column;
780
+ const { origin } = meta;
781
+ const declared = origin !== null && origin.format === FORMAT && origin.type !== null ? origin.type : null;
782
+ if (meta.components > 1) {
783
+ return "string";
784
+ }
785
+ switch (meta.dtype) {
786
+ case "bool":
787
+ return "boolean";
788
+ case "i32":
789
+ return "int";
790
+ case "f32":
791
+ return "float";
792
+ case "f64": {
793
+ if (declared !== null && declared.trim().toLowerCase() === "long" && columnIntegral(column)) {
794
+ return "long";
795
+ }
796
+ return "double";
797
+ }
798
+ case "u8":
799
+ return "int";
800
+ case "u32":
801
+ return columnMax(column) <= I32_MAX ? "int" : "long";
802
+ case "string":
803
+ if (declared !== null && TYPE_DTYPES[declared.trim().toLowerCase()] === undefined) {
804
+ // an unknown declared type kept as text (W_UNKNOWN_ATTR_TYPE on import) is restored as declared
805
+ return declared;
806
+ }
807
+ return "string";
808
+ case "dict":
809
+ case "list":
810
+ case "json":
811
+ return "string";
812
+ default: {
813
+ const { dtype } = meta;
814
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown dtype ${String(dtype)}`, { dtype });
815
+ }
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Whether every set value of a numeric column is an integer.
821
+ * @param column - an f64 column
822
+ * @returns true when all set values are integral
823
+ */
824
+ function columnIntegral(column: Column): boolean {
825
+ if (column.dtype !== "f64") {
826
+ return false;
827
+ }
828
+ const { data } = column;
829
+ for (let r = 0; r < column.length; r++) {
830
+ if (column.isSet(r) && !Number.isInteger(data[r])) {
831
+ return false;
832
+ }
833
+ }
834
+ return true;
835
+ }
836
+
837
+ /**
838
+ * The largest set value of a u32 column.
839
+ * @param column - a u32 column
840
+ * @returns the maximum, 0 for an empty column
841
+ */
842
+ function columnMax(column: Column): number {
843
+ if (column.dtype !== "u32") {
844
+ return 0;
845
+ }
846
+ let max = 0;
847
+ const { data } = column;
848
+ for (let r = 0; r < column.length; r++) {
849
+ if (column.isSet(r) && data[r] > max) {
850
+ max = data[r];
851
+ }
852
+ }
853
+ return max;
854
+ }
855
+
856
+ /**
857
+ * Whether every explicit weight is integral (so a declared int / long weight key survives).
858
+ * @param snapshot - the snapshot
859
+ * @param weights - the explicit weights
860
+ * @returns true when all explicit weights are integers
861
+ */
862
+ function weightsIntegral(snapshot: GraphSnapshot, weights: ExplicitWeights): boolean {
863
+ for (let e = 0; e < snapshot.edgeCount; e++) {
864
+ if (weights.isExplicit(e) && !Number.isInteger(weights.value(e))) {
865
+ return false;
866
+ }
867
+ }
868
+ return true;
869
+ }
870
+
871
+ /**
872
+ * How many values of a yfiles column are not serialisable trees.
873
+ * @param column - a json column
874
+ * @returns the count
875
+ */
876
+ function countBadTrees(column: Column): number {
877
+ if (column.dtype !== "json") {
878
+ return 0;
879
+ }
880
+ let bad = 0;
881
+ for (let r = 0; r < column.length; r++) {
882
+ if (column.isSet(r) && treeProblem(column.values[r]) !== null) {
883
+ bad++;
884
+ }
885
+ }
886
+ return bad;
887
+ }
888
+
889
+ /**
890
+ * Node ids that read back as another type under the canonical rule: string ids that are
891
+ * canonical integer text, and numbers that are not safe integers.
892
+ * @param snapshot - the snapshot
893
+ * @returns the count
894
+ */
895
+ function countIdTypeChanges(snapshot: GraphSnapshot): number {
896
+ const { ids } = snapshot;
897
+ if (ids.kind === "identity" || ids.kind === "dense") {
898
+ return 0;
899
+ }
900
+ let count = 0;
901
+ for (let i = 0; i < ids.size; i++) {
902
+ const id = ids.idOf(i);
903
+ if (typeof id === "string" ? isNmtoken(id) && isCanonicalIntegerText(id) : !Number.isSafeInteger(id)) {
904
+ count++;
905
+ }
906
+ }
907
+ return count;
908
+ }
909
+
910
+ /**
911
+ * Node ids the nmtoken charset cannot hold.
912
+ * @param snapshot - the snapshot
913
+ * @returns the count
914
+ */
915
+ function countUnrepresentableNodeIds(snapshot: GraphSnapshot): number {
916
+ const { ids } = snapshot;
917
+ if (ids.kind === "identity" || ids.kind === "dense") {
918
+ return 0;
919
+ }
920
+ let count = 0;
921
+ for (let i = 0; i < ids.size; i++) {
922
+ if (!isNmtoken(String(ids.idOf(i)))) {
923
+ count++;
924
+ }
925
+ }
926
+ return count;
927
+ }
928
+
929
+ /**
930
+ * Edge ids that are not NMTOKENs.
931
+ * @param column - the edge id column
932
+ * @returns the count
933
+ */
934
+ function countBadEdgeIds(column: Column): number {
935
+ let bad = 0;
936
+ for (let e = 0; e < column.length; e++) {
937
+ if (column.isSet(e) && !isNmtoken(edgeIdText(column, e))) {
938
+ bad++;
939
+ }
940
+ }
941
+ return bad;
942
+ }
943
+
944
+ /**
945
+ * The text of an edge id cell.
946
+ * @param column - the edge id column
947
+ * @param e - the edge
948
+ * @returns the id as text
949
+ */
950
+ function edgeIdText(column: Column, e: number): string {
951
+ const value = column.value(e);
952
+ if (typeof value === "string") {
953
+ return value;
954
+ }
955
+ if (typeof value === "number") {
956
+ return Number.isInteger(value) ? formatInteger(value) : formatF64(value);
957
+ }
958
+ return String(value);
959
+ }
960
+
961
+ /**
962
+ * Whether a logical edge is written as directed: never in an undirected snapshot; never for a
963
+ * half of an expanded pair (an undirected or a mutual source edge, whose halves the resolver
964
+ * flags directed); otherwise per the directed role column, with directed as the default.
965
+ * @param folding - the pair folding
966
+ * @param e - the edge
967
+ * @returns false for a paired edge or an edge flagged undirected
968
+ */
969
+ function isDirectedEdge(folding: PairFolding, e: number): boolean {
970
+ return folding.mateOf(e) === INVALID_INDEX && folding.sourceDirected(e);
971
+ }
972
+
973
+ /**
974
+ * The containment order: the children CSR over the parent column (src/children.ts), roots in
975
+ * index order, unreachable nodes (cycles) forced to the top level.
976
+ * @param snapshot - the snapshot
977
+ * @returns the hierarchy
978
+ */
979
+ function planHierarchy(snapshot: GraphSnapshot): Hierarchy {
980
+ const parent = snapshot.nodes.byRole("parent");
981
+ const csr = childrenCsr(snapshot, { column: parent !== null && parent.dtype === "u32" ? parent : null });
982
+ const walk = csr.depthFirst();
983
+ // the top-level nodes in written order: the roots, then the cycle members forced out
984
+ const roots = walk.order.filter((u) => walk.depth[u] === 0);
985
+ return {
986
+ parent: csr.column,
987
+ roots,
988
+ childStart: csr.rowPtr,
989
+ childList: csr.children,
990
+ reordered: walk.reordered,
991
+ unreachable: csr.unreachable,
992
+ };
993
+ }
994
+
995
+ // ============================================================ writing
996
+
997
+ /**
998
+ * The text of one set cell for a `<data>` element or a `<default>`.
999
+ * @param column - the column
1000
+ * @param row - the row
1001
+ * @param attrType - the declared attr.type
1002
+ * @returns the text
1003
+ */
1004
+ function cellText(column: Column, row: number, attrType: string | null): string {
1005
+ if (column.meta.components > 1) {
1006
+ const value = column.value(row);
1007
+ return JSON.stringify(Array.from(value as ArrayLike<number>));
1008
+ }
1009
+ switch (column.dtype) {
1010
+ case "bool":
1011
+ return column.value(row) === true ? "true" : "false";
1012
+ case "i32":
1013
+ case "u32":
1014
+ case "u8":
1015
+ return String(column.data[row]);
1016
+ case "f32":
1017
+ return formatF32(column.data[row]);
1018
+ case "f64": {
1019
+ const value = column.data[row];
1020
+ return (attrType === "long" || attrType === "int") && Number.isInteger(value)
1021
+ ? formatInteger(value)
1022
+ : formatF64(value);
1023
+ }
1024
+ case "string":
1025
+ return column.valueAt(row);
1026
+ case "dict":
1027
+ return column.dictionary[column.codes[row]];
1028
+ case "list":
1029
+ return JSON.stringify(Array.from(column.sliceOf(row)));
1030
+ case "json":
1031
+ return JSON.stringify(column.values[row]) ?? "";
1032
+ default: {
1033
+ const dtype: never = column;
1034
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown dtype ${String(dtype)}`, {});
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * The text of a declared default.
1041
+ * @param column - the column
1042
+ * @param attrType - the declared attr.type
1043
+ * @returns the text, or null when the column has no default
1044
+ */
1045
+ function defaultText(column: Column, attrType: string | null): string | null {
1046
+ const value = column.meta.default;
1047
+ if (value === undefined) {
1048
+ return null;
1049
+ }
1050
+ switch (typeof value) {
1051
+ case "boolean":
1052
+ return value ? "true" : "false";
1053
+ case "number":
1054
+ if (column.dtype === "f32") {
1055
+ return formatF32(value);
1056
+ }
1057
+ return (attrType === "long" || attrType === "int") && Number.isInteger(value)
1058
+ ? formatInteger(value)
1059
+ : formatF64(value);
1060
+ case "string":
1061
+ return value;
1062
+ default:
1063
+ return JSON.stringify(value) ?? "";
1064
+ }
1065
+ }
1066
+
1067
+ /**
1068
+ * Write one `<data>` element.
1069
+ * @param plan - the column plan
1070
+ * @param row - the row
1071
+ * @param indent - the indentation
1072
+ * @param out - receives the parts
1073
+ */
1074
+ function writeData(plan: ColumnPlan, row: number, indent: string, out: string[]): void {
1075
+ const { column } = plan;
1076
+ if (!column.isSet(row)) {
1077
+ return;
1078
+ }
1079
+ if (plan.yfiles && column.dtype === "json") {
1080
+ writeTreeData(plan.keyId, column.values[row], indent, out);
1081
+ return;
1082
+ }
1083
+ out.push(`\n${indent}<data key="${plan.keyId}">${escapeXmlText(cellText(column, row, plan.attrType))}</data>`);
1084
+ }
1085
+
1086
+ /**
1087
+ * Write a `<data>` holding a yfiles tree.
1088
+ * @param keyId - the key id
1089
+ * @param value - the tree
1090
+ * @param indent - the indentation
1091
+ * @param out - receives the parts
1092
+ */
1093
+ function writeTreeData(keyId: string, value: unknown, indent: string, out: string[]): void {
1094
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1095
+ const parts: string[] = [];
1096
+ writeXmlTree(value, indent, parts);
1097
+ out.push(`\n${indent}<data key="${keyId}">${parts.join("")}</data>`);
1098
+ return;
1099
+ }
1100
+ out.push(`\n${indent}<data key="${keyId}">`);
1101
+ writeXmlTree(value, `${indent} `, out);
1102
+ out.push(`\n${indent}</data>`);
1103
+ }
1104
+
1105
+ /**
1106
+ * The `<key>` element of a plan.
1107
+ * @param key - the key plan
1108
+ * @param indent - the indentation
1109
+ * @returns the element text
1110
+ */
1111
+ function keyElement(key: KeyPlan, indent: string): string {
1112
+ const domain = key.domains.length === 3 ? "all" : key.domains[0];
1113
+ let text = `${indent}<key id="${escapeXmlAttribute(key.id)}" for="${domain}"`;
1114
+ if (key.attrName !== null) {
1115
+ text += ` attr.name="${escapeXmlAttribute(key.attrName)}"`;
1116
+ }
1117
+ if (key.attrType !== null) {
1118
+ text += ` attr.type="${escapeXmlAttribute(key.attrType)}"`;
1119
+ }
1120
+ if (key.yfilesType !== null) {
1121
+ text += ` yfiles.type="${escapeXmlAttribute(key.yfilesType)}"`;
1122
+ }
1123
+ const { column } = key;
1124
+ const desc = column?.meta.extra.desc;
1125
+ const hasDefault = column !== null && column.meta.default !== undefined;
1126
+ if (column === null || (typeof desc !== "string" && !hasDefault)) {
1127
+ return `${text}/>\n`;
1128
+ }
1129
+ const parts: string[] = [`${text}>`];
1130
+ const inner = `${indent} `;
1131
+ if (typeof desc === "string") {
1132
+ parts.push(`\n${inner}<desc>${escapeXmlText(desc)}</desc>`);
1133
+ }
1134
+ if (hasDefault) {
1135
+ if (key.yfilesType !== null) {
1136
+ const value = column.meta.default;
1137
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1138
+ parts.push(`\n${inner}<default>`);
1139
+ writeXmlTree(value, `${inner} `, parts);
1140
+ parts.push(`\n${inner}</default>`);
1141
+ } else {
1142
+ const tree: string[] = [];
1143
+ writeXmlTree(value, inner, tree);
1144
+ parts.push(`\n${inner}<default>${tree.join("")}</default>`);
1145
+ }
1146
+ } else {
1147
+ parts.push(`\n${inner}<default>${escapeXmlText(defaultText(column, key.attrType) ?? "")}</default>`);
1148
+ }
1149
+ }
1150
+ parts.push(`\n${indent}</key>\n`);
1151
+ return parts.join("");
1152
+ }
1153
+
1154
+ /**
1155
+ * Sanitised edge ids: NMTOKENs as they are; the rest refused or mangled per sanitizeIds.
1156
+ * @param column - the edge id column, or null
1157
+ * @param mode - the resolved sanitizeIds option
1158
+ * @param edgeCount - the edge count
1159
+ * @returns the id text per edge (null for edges without one)
1160
+ */
1161
+ function sanitizeEdgeIds(column: Column | null, mode: "error" | "mangle", edgeCount: number): (string | null)[] {
1162
+ const out: (string | null)[] = new Array<string | null>(edgeCount).fill(null);
1163
+ if (column === null) {
1164
+ return out;
1165
+ }
1166
+ const used = new Set<string>();
1167
+ const bad: number[] = [];
1168
+ for (let e = 0; e < edgeCount; e++) {
1169
+ if (!column.isSet(e)) {
1170
+ continue;
1171
+ }
1172
+ const text = edgeIdText(column, e);
1173
+ if (isNmtoken(text)) {
1174
+ out[e] = text;
1175
+ used.add(text);
1176
+ } else {
1177
+ bad.push(e);
1178
+ }
1179
+ }
1180
+ if (bad.length > 0 && mode === "error") {
1181
+ throw new GraphFormatError(
1182
+ "E_INVALID_ID",
1183
+ `${bad.length} edge id(s) cannot be written as nmtoken (first: ${JSON.stringify(edgeIdText(column, bad[0]))} at edge ${bad[0]}); pass sanitizeIds: "mangle" to rewrite them`,
1184
+ { reason: "charset", charset: "nmtoken", count: bad.length, edge: bad[0] },
1185
+ );
1186
+ }
1187
+ for (const e of bad) {
1188
+ const base = mangleNmtoken(edgeIdText(column, e));
1189
+ let candidate = base;
1190
+ for (let k = 2; used.has(candidate); k++) {
1191
+ candidate = `${base}_${k}`;
1192
+ }
1193
+ used.add(candidate);
1194
+ out[e] = candidate;
1195
+ }
1196
+ return out;
1197
+ }
1198
+
1199
+ /**
1200
+ * The document as text parts.
1201
+ * @param snapshot - the snapshot
1202
+ * @param plan - the plan
1203
+ * @yields one element (or a group of small ones) at a time
1204
+ * @returns nothing
1205
+ */
1206
+ function* writeGraphml(snapshot: GraphSnapshot, plan: Plan): Generator<string, void, undefined> {
1207
+ const ids: SanitizedIds = sanitizeIds(snapshot, "nmtoken", plan.options.sanitizeIds);
1208
+ const edgeIds = sanitizeEdgeIds(plan.edgeIdColumn, plan.options.sanitizeIds, snapshot.edgeCount);
1209
+ const i1 = plan.pretty ? " " : "";
1210
+ const i2 = plan.pretty ? " " : "";
1211
+ const i3 = plan.pretty ? " " : "";
1212
+ const step = plan.pretty ? " " : "";
1213
+
1214
+ yield '<?xml version="1.0" encoding="UTF-8"?>\n';
1215
+ let root = `<graphml xmlns="${GRAPHML_NAMESPACE}"`;
1216
+ const namespaces: Record<string, string> = { ...(plan.meta?.namespaces ?? {}) };
1217
+ if (
1218
+ (plan.nodeColumns.some((c) => c.yfiles) ||
1219
+ plan.edgeColumns.some((c) => c.yfiles) ||
1220
+ plan.graphColumns.some((c) => c.yfiles)) &&
1221
+ !Object.values(namespaces).includes(YFILES_NAMESPACE)
1222
+ ) {
1223
+ namespaces.y = YFILES_NAMESPACE;
1224
+ }
1225
+ if (!Object.values(namespaces).includes(XSI_NAMESPACE)) {
1226
+ namespaces.xsi = XSI_NAMESPACE;
1227
+ }
1228
+ for (const [prefix, uri] of Object.entries(namespaces)) {
1229
+ root += ` xmlns:${prefix}="${escapeXmlAttribute(uri)}"`;
1230
+ }
1231
+ const xsiPrefix = Object.entries(namespaces).find(([, uri]) => uri === XSI_NAMESPACE)?.[0] ?? "xsi";
1232
+ root += ` ${xsiPrefix}:schemaLocation="${SCHEMA_LOCATION}">\n`;
1233
+ yield root;
1234
+
1235
+ for (const key of plan.keys) {
1236
+ yield keyElement(key, i1);
1237
+ }
1238
+ if (plan.originalIdKey !== null) {
1239
+ yield `${i1}<key id="${plan.originalIdKey}" for="node" attr.name="${ORIGINAL_ID_ATTRIBUTE}" attr.type="string"/>\n`;
1240
+ }
1241
+
1242
+ const graphId = plan.meta?.graphId ?? "G";
1243
+ yield `${i1}<graph id="${escapeXmlAttribute(graphId)}" edgedefault="${plan.edgedefault}">`;
1244
+ const { description } = snapshot.meta;
1245
+ if (description !== null) {
1246
+ yield `\n${i2}<desc>${escapeXmlText(description)}</desc>`;
1247
+ }
1248
+ const graphParts: string[] = [];
1249
+ for (const column of plan.graphColumns) {
1250
+ writeData(column, 0, i2, graphParts);
1251
+ }
1252
+ if (graphParts.length > 0) {
1253
+ yield graphParts.join("");
1254
+ }
1255
+
1256
+ // nodes in containment order, children nested under their parent
1257
+ const { hierarchy } = plan;
1258
+ const nested = hierarchy.childList.length > 0;
1259
+ const stackNode: number[] = [];
1260
+ const stackCursor: number[] = [];
1261
+ const written = nested ? new Uint8Array(snapshot.nodeCount) : null;
1262
+ for (const rootNode of hierarchy.roots) {
1263
+ yield* writeNodeSubtree(rootNode, plan, ids, i2, step, stackNode, stackCursor, written);
1264
+ }
1265
+
1266
+ // edges: primaries only, in index order
1267
+ const list = snapshot.edgeList();
1268
+ const { folding } = plan;
1269
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1270
+ if (folding.folded(e)) {
1271
+ continue;
1272
+ }
1273
+ const parts: string[] = [`\n${i2}<edge`];
1274
+ const id = edgeIds[e];
1275
+ if (id !== null) {
1276
+ parts.push(` id="${escapeXmlAttribute(id)}"`);
1277
+ }
1278
+ parts.push(
1279
+ ` source="${escapeXmlAttribute(String(ids.idAt(list.src[e])))}" target="${escapeXmlAttribute(String(ids.idAt(list.dst[e])))}"`,
1280
+ );
1281
+ const directed = snapshot.directed && isDirectedEdge(folding, e);
1282
+ if (directed !== (plan.edgedefault === "directed")) {
1283
+ parts.push(` directed="${directed ? "true" : "false"}"`);
1284
+ }
1285
+ const sourcePort = portText(plan.sourcePortColumn, e);
1286
+ if (sourcePort !== null) {
1287
+ parts.push(` sourceport="${escapeXmlAttribute(sourcePort)}"`);
1288
+ }
1289
+ const targetPort = portText(plan.targetPortColumn, e);
1290
+ if (targetPort !== null) {
1291
+ parts.push(` targetport="${escapeXmlAttribute(targetPort)}"`);
1292
+ }
1293
+ const open = parts.length;
1294
+ if (plan.weight !== null) {
1295
+ const { keyId, attrType } = plan.weight;
1296
+ const weight = plan.weight.weights.text(e, attrType === "int" || attrType === "long");
1297
+ if (weight !== null) {
1298
+ parts.push(`\n${i3}<data key="${keyId}">${escapeXmlText(weight)}</data>`);
1299
+ }
1300
+ }
1301
+ for (const column of plan.edgeColumns) {
1302
+ writeData(column, e, i3, parts);
1303
+ }
1304
+ if (parts.length === open) {
1305
+ parts.push("/>");
1306
+ } else {
1307
+ parts.splice(open, 0, ">");
1308
+ parts.push(`\n${i2}</edge>`);
1309
+ }
1310
+ yield parts.join("");
1311
+ }
1312
+
1313
+ yield `\n${i1}</graph>\n</graphml>\n`;
1314
+ }
1315
+
1316
+ /**
1317
+ * The text of a port reference cell.
1318
+ * @param column - the sourcePort / targetPort column, or null
1319
+ * @param e - the edge
1320
+ * @returns the port name, or null when unset
1321
+ */
1322
+ function portText(column: Column | null, e: number): string | null {
1323
+ if (column === null || !column.isSet(e)) {
1324
+ return null;
1325
+ }
1326
+ const value = column.value(e);
1327
+ return typeof value === "string" ? value : String(value);
1328
+ }
1329
+
1330
+ /**
1331
+ * Write a top-level node and, nested, every node contained in it (iteratively, so a deep chain
1332
+ * cannot overflow the call stack).
1333
+ * @param root - the top-level node
1334
+ * @param plan - the plan
1335
+ * @param ids - the sanitised ids
1336
+ * @param indent - the indentation of top-level nodes
1337
+ * @param step - one indentation step
1338
+ * @param stackNode - a reusable stack of open nodes
1339
+ * @param stackCursor - a reusable stack of child cursors
1340
+ * @param written - nodes already written (null when nothing is nested)
1341
+ * @yields the node elements
1342
+ * @returns nothing
1343
+ */
1344
+ function* writeNodeSubtree(
1345
+ root: number,
1346
+ plan: Plan,
1347
+ ids: SanitizedIds,
1348
+ indent: string,
1349
+ step: string,
1350
+ stackNode: number[],
1351
+ stackCursor: number[],
1352
+ written: Uint8Array | null,
1353
+ ): Generator<string, void, undefined> {
1354
+ const { hierarchy } = plan;
1355
+ const hasChildren = (u: number): boolean => hierarchy.childStart[u + 1] > hierarchy.childStart[u];
1356
+ const openNode = (u: number, depth: number): string => {
1357
+ const pad = indent + step.repeat(depth * 2);
1358
+ const parts: string[] = [`\n${pad}<node id="${escapeXmlAttribute(String(ids.idAt(u)))}"`];
1359
+ const open = parts.length;
1360
+ if (plan.originalIdKey !== null && ids.isChanged(u)) {
1361
+ parts.push(
1362
+ `\n${pad}${step}<data key="${plan.originalIdKey}">${escapeXmlText(String(ids.originalAt(u)))}</data>`,
1363
+ );
1364
+ }
1365
+ for (const column of plan.nodeColumns) {
1366
+ writeData(column, u, pad + step, parts);
1367
+ }
1368
+ if (hasChildren(u)) {
1369
+ parts.splice(open, 0, ">");
1370
+ parts.push(
1371
+ `\n${pad}${step}<graph id="${escapeXmlAttribute(`${String(ids.idAt(u))}:`)}" edgedefault="${plan.edgedefault}">`,
1372
+ );
1373
+ return parts.join("");
1374
+ }
1375
+ if (parts.length === open) {
1376
+ parts.push("/>");
1377
+ } else {
1378
+ parts.splice(open, 0, ">");
1379
+ parts.push(`\n${pad}</node>`);
1380
+ }
1381
+ return parts.join("");
1382
+ };
1383
+ const closeNode = (depth: number): string => {
1384
+ const pad = indent + step.repeat(depth * 2);
1385
+ return `\n${pad}${step}</graph>\n${pad}</node>`;
1386
+ };
1387
+ if (written !== null) {
1388
+ written[root] = 1;
1389
+ }
1390
+ yield openNode(root, 0);
1391
+ if (!hasChildren(root)) {
1392
+ return;
1393
+ }
1394
+ stackNode.push(root);
1395
+ stackCursor.push(hierarchy.childStart[root]);
1396
+ while (stackNode.length > 0) {
1397
+ const top = stackNode.length - 1;
1398
+ const u = stackNode[top];
1399
+ const cursor = stackCursor[top];
1400
+ if (cursor >= hierarchy.childStart[u + 1]) {
1401
+ stackNode.pop();
1402
+ stackCursor.pop();
1403
+ yield closeNode(top);
1404
+ continue;
1405
+ }
1406
+ stackCursor[top] = cursor + 1;
1407
+ const v = hierarchy.childList[cursor];
1408
+ if (written !== null && written[v] === 1) {
1409
+ continue;
1410
+ }
1411
+ if (written !== null) {
1412
+ written[v] = 1;
1413
+ }
1414
+ yield openNode(v, top + 1);
1415
+ if (hasChildren(v)) {
1416
+ stackNode.push(v);
1417
+ stackCursor.push(hierarchy.childStart[v]);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ /**
1423
+ * The GraphML exporter (design section 8.5).
1424
+ */
1425
+ export const graphmlExporter: GraphExporter<GraphmlExportOptions> = Object.freeze({
1426
+ format: FORMAT,
1427
+ capabilities: CAPABILITIES,
1428
+ /**
1429
+ * Pre-flight: every loss the export would incur.
1430
+ * @param snapshot - the snapshot
1431
+ * @param options - format-specific and common options
1432
+ * @returns the notes
1433
+ */
1434
+ check(snapshot: GraphSnapshot, options?: GraphmlExportOptions & CommonExportOptions): readonly LossNote[] {
1435
+ const plan = planExport(snapshot, resolveExportOptions(options), resolveFormatOptions(options));
1436
+ return Object.freeze(plan.notes);
1437
+ },
1438
+ /**
1439
+ * Write the document as UTF-8 chunks.
1440
+ * @param snapshot - the snapshot
1441
+ * @param options - format-specific and common options
1442
+ * @returns the chunks
1443
+ */
1444
+ export(snapshot: GraphSnapshot, options?: GraphmlExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
1445
+ const plan = planExport(snapshot, resolveExportOptions(options), resolveFormatOptions(options));
1446
+ return encodeChunks(writeGraphml(snapshot, plan));
1447
+ },
1448
+ /**
1449
+ * Write the document as one string.
1450
+ * @param snapshot - the snapshot
1451
+ * @param options - format-specific and common options
1452
+ * @returns the document
1453
+ */
1454
+ exportToString(snapshot: GraphSnapshot, options?: GraphmlExportOptions & CommonExportOptions): Promise<string> {
1455
+ const plan = planExport(snapshot, resolveExportOptions(options), resolveFormatOptions(options));
1456
+ return joinText(writeGraphml(snapshot, plan));
1457
+ },
1458
+ });