@graphty/graph-io 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,1000 @@
1
+ /**
2
+ * The Pajek NET exporter (design section 8.5; research note 07 sections 2.5 and 9): vertices are
3
+ * numbered 1..N in index order (the `dense-1-based` id charset: an id that is not its 1-based
4
+ * index survives as the label only, which check() reports as W_ID_RENUMBERED; under
5
+ * `sanitizeIds: "mangle"` it is also written as a `graphty_originalId` parameter the importer
6
+ * restores under `restoreMangledIds`), the label role
7
+ * column is the label, the position role column the coordinates, a `shape` column the shape
8
+ * keyword, every other writable column a `key value` parameter and the spells role a time
9
+ * interval token. Logical edges are written in index order in runs of `*Arcs` (directed) and
10
+ * `*Edges` (undirected) sections, folding expanded pairs back through the pair role and reading
11
+ * attributes from the primary half, so a mixed file round-trips in its original edge order. The
12
+ * weight is written for edges whose weight was explicit (the role-weight column's validity).
13
+ */
14
+
15
+ import { type Column, GraphFormatError, type GraphSnapshot, type NodeId } from "@graphty/graph-format";
16
+
17
+ import { pairFolding } from "../../common/direction.js";
18
+ import { isPajekLabel, quotePajekLabel } from "../../common/escape.js";
19
+ import { capabilities, checkCapabilities, LOSS, type SanitizedIds, sanitizeIds } from "../../common/export.js";
20
+ import { formatDecimal, formatF32, formatF64, formatInteger } from "../../common/format.js";
21
+ import { canonicalId } from "../../common/ids.js";
22
+ import { resolveExportOptions } from "../../common/options.js";
23
+ import { explicitWeights } from "../../common/weights.js";
24
+ import { encodeChunks, joinText } from "../../common/writer.js";
25
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
26
+ import {
27
+ formatIntervals,
28
+ isParameterKey,
29
+ LABEL_COLUMN,
30
+ ORIGINAL_ID_KEY,
31
+ RELATION_COLUMN,
32
+ SHAPE_COLUMN,
33
+ SHAPES,
34
+ } from "./syntax.js";
35
+
36
+ /** The format-specific options of the Pajek exporter. */
37
+ export interface PajekExportOptions {
38
+ /**
39
+ * Write a `*Network <name>` header line (the .paj project-file convention) when the snapshot's
40
+ * meta.name is set; default false, since plain .net readers do not expect it.
41
+ */
42
+ networkHeader?: boolean | undefined;
43
+ }
44
+
45
+ /**
46
+ * The LossNote codes of the Pajek exporter's check(): the Pajek-specific ones and, aliased, the
47
+ * shared ones it records itself (`LOSS` holds the rest of the generic pre-flight's codes). A key
48
+ * is the code without its severity and format prefixes.
49
+ */
50
+ export const PAJEK_LOSS = Object.freeze({
51
+ /** A label or text value holds a double quote or a line break; export() will throw E_UNSUPPORTED. */
52
+ TEXT: "E_PAJEK_TEXT",
53
+ /** A column whose name cannot be a parameter key (whitespace, a quote, numeric, a shape keyword); skipped. */
54
+ KEY_DROPPED: "W_PAJEK_KEY_DROPPED",
55
+ /** A label role column that is not text; its values are written as text and re-import as string. */
56
+ LABEL_AS_TEXT: "W_PAJEK_LABEL_AS_TEXT",
57
+ /** A non-finite f64 value; written as Infinity / NaN text, which re-imports as string. */
58
+ NONFINITE_AS_TEXT: "W_PAJEK_NONFINITE_AS_TEXT",
59
+ /** A mutual pair; written as one undirected edge, the mark lost. */
60
+ MUTUAL_AS_UNDIRECTED: LOSS.MUTUAL_AS_UNDIRECTED,
61
+ /** A start / end / timestamp role column; Pajek intervals are written from the spells role only. */
62
+ TEMPORAL_DROPPED: LOSS.TEMPORAL,
63
+ /** A position column with a stride other than 2 or 3. */
64
+ POSITION_STRIDE: "W_PAJEK_POSITION_STRIDE",
65
+ /** meta.extra.pajek.firstMode is not a count within 0..N; the two-mode header is not written. */
66
+ FIRST_MODE_DROPPED: "W_PAJEK_FIRST_MODE_DROPPED",
67
+ /** A role column Pajek has no slot for (kind, ...) written as a plain parameter; the role is lost. */
68
+ ROLE_DROPPED: LOSS.ROLE,
69
+ /** A `shape` column with a value outside the shape keywords is written as a parameter (a string on re-import). */
70
+ SHAPE_AS_PARAMETER: "W_PAJEK_SHAPE_AS_PARAMETER",
71
+ /** A vertex line with coordinates, a shape or parameters needs a label: the id text is written and reads back as a label. */
72
+ LABEL_GAINED: "W_PAJEK_LABEL_GAINED",
73
+ /** A role-less node column named `label` reads back with the label role (its values become the vertex labels). */
74
+ ROLE_ASSUMED: LOSS.ROLE_ASSUMED,
75
+ /** Under sanitizeIds "mangle": an original id whose text reads back as the other type under ids "canonical". */
76
+ ID_TEXT_TYPE: LOSS.ID_TEXT_TYPE,
77
+ });
78
+
79
+ /** The roles Pajek has a slot for beyond the structural, position and temporal ones. */
80
+ const SLOT_ROLES: ReadonlySet<string> = new Set(["label"]);
81
+
82
+ /** The names the importer gives the slot columns, for the name-change notes. */
83
+ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({ label: "label" });
84
+
85
+ /** The roles the exporter handles structurally rather than as parameters. */
86
+ const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
87
+ "directed",
88
+ "pair",
89
+ "mutual",
90
+ "weight",
91
+ "timeText",
92
+ "originalId",
93
+ ]);
94
+
95
+ /** The roles the generic checker reports for a format without viz, hierarchy or open-interval support. */
96
+ const CHECKED_ROLES: ReadonlySet<string> = new Set([
97
+ "color",
98
+ "size",
99
+ "shape",
100
+ "thickness",
101
+ "parent",
102
+ "parents",
103
+ "open",
104
+ ]);
105
+
106
+ /** Roles the generic checker lets through under temporal "spells" that Pajek cannot write. */
107
+ const DROPPED_TEMPORAL_ROLES: ReadonlySet<string> = new Set(["start", "end", "timestamp", "timestamps"]);
108
+
109
+ const CAPABILITIES: ExportCapabilities = capabilities({
110
+ mixedDirection: true,
111
+ multiEdges: true,
112
+ selfLoops: true,
113
+ edgeIds: "none",
114
+ idCharset: "dense-1-based",
115
+ dtypes: ["f64", "i32", "bool", "string"],
116
+ temporal: "spells",
117
+ positions: true,
118
+ });
119
+
120
+ /** Where a column's values go on a row. */
121
+ type Slot = "label" | "position" | "shape" | "spells" | "relation" | "param" | "skip";
122
+
123
+ /** A column with its resolved slot, for the write loops. */
124
+ interface PlannedColumn {
125
+ readonly column: Column;
126
+ readonly slot: Slot;
127
+ }
128
+
129
+ /**
130
+ * Decide the slot of every column of a table.
131
+ * @param table - the node or edge table
132
+ * @param domain - node or edge
133
+ * @param notes - the note list check() fills; null when writing
134
+ * @returns the plan, in declaration order
135
+ */
136
+ function plan(table: Iterable<Column>, domain: "node" | "edge", notes: LossNote[] | null): PlannedColumn[] {
137
+ const planned: PlannedColumn[] = [];
138
+ const rows = (column: Column): number => column.length - column.nullCount;
139
+ for (const column of table) {
140
+ const { meta } = column;
141
+ const { role, name, dtype } = meta;
142
+ if (role === "label" && domain === "node") {
143
+ if (dtype !== "string" && dtype !== "dict" && notes !== null) {
144
+ notes.push(
145
+ note(
146
+ PAJEK_LOSS.LABEL_AS_TEXT,
147
+ `node column "${name}" (label) is ${dtype}; labels are text and re-import as string`,
148
+ name,
149
+ rows(column),
150
+ ),
151
+ );
152
+ }
153
+ planned.push({ column, slot: "label" });
154
+ continue;
155
+ }
156
+ if (role === "position") {
157
+ if (domain === "node" && (meta.components === 2 || meta.components === 3)) {
158
+ planned.push({ column, slot: "position" });
159
+ } else if (notes !== null && domain === "node") {
160
+ notes.push(
161
+ note(
162
+ PAJEK_LOSS.POSITION_STRIDE,
163
+ `node column "${name}" (position) has ${meta.components} components; Pajek coordinates are x y [z]`,
164
+ name,
165
+ rows(column),
166
+ ),
167
+ );
168
+ } else if (notes !== null) {
169
+ notes.push(
170
+ note(LOSS.POSITIONS, `edge column "${name}" (position) cannot be written`, name, rows(column)),
171
+ );
172
+ }
173
+ continue;
174
+ }
175
+ if (role === "spells") {
176
+ if (
177
+ dtype === "list" &&
178
+ (meta.itemDtype === "f64" || meta.itemDtype === "i32" || meta.itemDtype === "f32")
179
+ ) {
180
+ planned.push({ column, slot: "spells" });
181
+ continue;
182
+ }
183
+ if (notes !== null) {
184
+ notes.push(
185
+ note(
186
+ LOSS.SPELLS,
187
+ `${domain} column "${name}" (spells) is not a list of number pairs; it cannot be written`,
188
+ name,
189
+ rows(column),
190
+ ),
191
+ );
192
+ }
193
+ continue;
194
+ }
195
+ if (role !== null && DROPPED_TEMPORAL_ROLES.has(role)) {
196
+ if (notes !== null) {
197
+ notes.push(
198
+ note(
199
+ PAJEK_LOSS.TEMPORAL_DROPPED,
200
+ `${domain} column "${name}" (${role}) cannot be written; Pajek intervals come from the spells role`,
201
+ name,
202
+ rows(column),
203
+ ),
204
+ );
205
+ }
206
+ continue;
207
+ }
208
+ if (role !== null && (STRUCTURAL_ROLES.has(role) || CHECKED_ROLES.has(role))) {
209
+ // structural columns are folded into the topology; viz and hierarchy roles are reported by the generic checker
210
+ continue;
211
+ }
212
+ if (domain === "edge" && role === "id") {
213
+ continue;
214
+ }
215
+ if (dtype === "list" || dtype === "json") {
216
+ // reported by the generic checker (lists: false, json: false)
217
+ continue;
218
+ }
219
+ if (domain === "node" && name === SHAPE_COLUMN && (dtype === "dict" || dtype === "string")) {
220
+ if (allShapeKeywords(column)) {
221
+ planned.push({ column, slot: "shape" });
222
+ continue;
223
+ }
224
+ // a value outside the keywords: the whole column is a `shape "<text>"` parameter, which the
225
+ // importer reads back as one string column (a change for a dict column only)
226
+ if (notes !== null && dtype === "dict") {
227
+ notes.push(
228
+ note(
229
+ PAJEK_LOSS.SHAPE_AS_PARAMETER,
230
+ `node column "${name}" holds values outside the Pajek shape keywords; it is written as a parameter and reads back as string`,
231
+ name,
232
+ rows(column),
233
+ ),
234
+ );
235
+ }
236
+ planned.push({ column, slot: "param" });
237
+ continue;
238
+ }
239
+ if (domain === "edge" && name === RELATION_COLUMN && (dtype === "dict" || dtype === "string")) {
240
+ planned.push({ column, slot: "relation" });
241
+ continue;
242
+ }
243
+ if (!isParameterKey(name)) {
244
+ if (notes !== null) {
245
+ notes.push(
246
+ note(
247
+ PAJEK_LOSS.KEY_DROPPED,
248
+ `${domain} column "${name}" cannot be a Pajek parameter key; it is not written`,
249
+ name,
250
+ rows(column),
251
+ ),
252
+ );
253
+ }
254
+ continue;
255
+ }
256
+ planned.push({ column, slot: "param" });
257
+ }
258
+ return planned;
259
+ }
260
+
261
+ /**
262
+ * The notes about vertex labels: Pajek's grammar puts the label second, so a vertex line that
263
+ * carries coordinates, a shape, parameters or an interval needs one, and a node without a label
264
+ * value gets its id text written there (it reads back as a label); a role-less column named
265
+ * `label` reads back with the label role.
266
+ * @param snapshot - the snapshot
267
+ * @param nodePlan - the node column plan
268
+ * @param notes - where to record
269
+ */
270
+ function labelNotes(snapshot: GraphSnapshot, nodePlan: readonly PlannedColumn[], notes: LossNote[]): void {
271
+ let labels: Column | null = null;
272
+ let carried = 0;
273
+ for (const { column, slot } of nodePlan) {
274
+ if (slot === "label") {
275
+ labels = column;
276
+ } else if (slot !== "skip" && slot !== "relation") {
277
+ carried++;
278
+ }
279
+ }
280
+ const plain = [...snapshot.nodes].find((c) => c.meta.role === null && c.meta.name === LABEL_COLUMN);
281
+ if (plain !== undefined) {
282
+ notes.push(
283
+ note(
284
+ PAJEK_LOSS.ROLE_ASSUMED,
285
+ `node column "${LABEL_COLUMN}" has no role; written as a parameter, it reads back as the vertex label (role label)`,
286
+ LABEL_COLUMN,
287
+ plain.length - plain.nullCount,
288
+ ),
289
+ );
290
+ }
291
+ if (carried === 0) {
292
+ return;
293
+ }
294
+ let gained = 0;
295
+ for (let i = 0; i < snapshot.nodeCount; i++) {
296
+ if (labels === null || !labels.isSet(i)) {
297
+ gained++;
298
+ }
299
+ }
300
+ if (gained > 0) {
301
+ notes.push(
302
+ note(
303
+ PAJEK_LOSS.LABEL_GAINED,
304
+ `${gained} vertex line(s) carry coordinates, a shape or parameters and need a label; the id text is written there and reads back as a label`,
305
+ labels === null ? LABEL_COLUMN : labels.meta.name,
306
+ gained,
307
+ ),
308
+ );
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Whether every set value of a shape column is a Pajek shape keyword.
314
+ * @param column - the shape column (string or dict)
315
+ * @returns true when the shape slot can hold the column
316
+ */
317
+ function allShapeKeywords(column: Column): boolean {
318
+ for (let r = 0; r < column.length; r++) {
319
+ if (column.isSet(r) && !SHAPES.has(column.value(r) as string)) {
320
+ return false;
321
+ }
322
+ }
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * Build a frozen LossNote.
328
+ * @param code - the code
329
+ * @param message - the message
330
+ * @param column - the column, or null
331
+ * @param count - the count, or null
332
+ * @returns the note
333
+ */
334
+ function note(code: string, message: string, column: string | null = null, count: number | null = null): LossNote {
335
+ return Object.freeze({ code, message, column, count });
336
+ }
337
+
338
+ /**
339
+ * The text of one numeric cell: integers as digits, f64 with a decimal point guaranteed so the
340
+ * importer's inference keeps the dtype, f32 as the shortest fround-round-trip decimal.
341
+ * @param value - the value
342
+ * @param dtype - the column dtype
343
+ * @returns the text
344
+ */
345
+ function numberText(value: number, dtype: "f32" | "f64" | "i32" | "u32" | "u8"): string {
346
+ switch (dtype) {
347
+ case "i32":
348
+ case "u32":
349
+ case "u8":
350
+ return formatInteger(value);
351
+ case "f32":
352
+ return formatF32(value);
353
+ case "f64":
354
+ return formatDecimal(value, "f64");
355
+ default: {
356
+ const name: string = dtype;
357
+ throw new Error(`unknown numeric dtype ${name}`);
358
+ }
359
+ }
360
+ }
361
+
362
+ /**
363
+ * The text of a set cell as a parameter value or label, before quoting.
364
+ * @param column - the column
365
+ * @param row - the row
366
+ * @returns the text
367
+ */
368
+ function cellText(column: Column, row: number): string {
369
+ switch (column.dtype) {
370
+ case "f32":
371
+ case "f64":
372
+ case "i32":
373
+ case "u32":
374
+ case "u8": {
375
+ const value = column.value(row);
376
+ if (typeof value === "number") {
377
+ return numberText(value, column.dtype);
378
+ }
379
+ const parts: string[] = [];
380
+ const vector = value as ArrayLike<number>;
381
+ for (let k = 0; k < vector.length; k++) {
382
+ parts.push(numberText(vector[k], column.dtype));
383
+ }
384
+ return parts.join(" ");
385
+ }
386
+ case "bool":
387
+ return column.value(row) === true ? "true" : "false";
388
+ case "dict":
389
+ case "string":
390
+ return column.value(row) as string;
391
+ case "list":
392
+ case "json":
393
+ return JSON.stringify(column.value(row));
394
+ default: {
395
+ const name: string = (column as Column).dtype;
396
+ throw new Error(`unknown dtype ${name}`);
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Count the cells of a plan that cannot be written (text with a quote or line break, non-finite
403
+ * f64) and add the notes.
404
+ * @param planned - the plan
405
+ * @param domain - node or edge
406
+ * @param notes - the note list
407
+ */
408
+ function checkCells(planned: readonly PlannedColumn[], domain: "node" | "edge", notes: LossNote[]): void {
409
+ for (const { column, slot } of planned) {
410
+ if (slot === "position" || slot === "spells") {
411
+ continue;
412
+ }
413
+ const { name } = column.meta;
414
+ let badText = 0;
415
+ let nonFinite = 0;
416
+ for (let r = 0; r < column.length; r++) {
417
+ if (!column.isSet(r)) {
418
+ continue;
419
+ }
420
+ if (column.dtype === "f64" || column.dtype === "f32") {
421
+ const value = column.value(r);
422
+ if (typeof value === "number") {
423
+ if (!Number.isFinite(value)) {
424
+ nonFinite++;
425
+ }
426
+ } else if (value !== undefined && !Array.from(value).every((v) => Number.isFinite(v))) {
427
+ nonFinite++;
428
+ }
429
+ } else if (column.dtype === "string" || column.dtype === "dict") {
430
+ if (!isPajekLabel(column.value(r) as string)) {
431
+ badText++;
432
+ }
433
+ }
434
+ }
435
+ if (badText > 0) {
436
+ notes.push(
437
+ note(
438
+ PAJEK_LOSS.TEXT,
439
+ `${badText} value(s) of ${domain} column "${name}" hold a double quote or a line break; Pajek cannot write them`,
440
+ name,
441
+ badText,
442
+ ),
443
+ );
444
+ }
445
+ if (nonFinite > 0) {
446
+ notes.push(
447
+ note(
448
+ PAJEK_LOSS.NONFINITE_AS_TEXT,
449
+ `${nonFinite} non-finite value(s) of ${domain} column "${name}" are written as text`,
450
+ name,
451
+ nonFinite,
452
+ ),
453
+ );
454
+ }
455
+ }
456
+ }
457
+
458
+ /**
459
+ * The first-mode count to write in `*Vertices N N1`, from meta.extra.pajek.firstMode.
460
+ * @param snapshot - the snapshot
461
+ * @returns the count, or null when absent or invalid
462
+ */
463
+ function firstModeOf(snapshot: GraphSnapshot): number | null {
464
+ const { pajek } = snapshot.meta.extra;
465
+ if (typeof pajek !== "object" || pajek === null) {
466
+ return null;
467
+ }
468
+ const value = (pajek as { firstMode?: unknown }).firstMode;
469
+ if (value === undefined) {
470
+ return null;
471
+ }
472
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= snapshot.nodeCount) {
473
+ return value;
474
+ }
475
+ return NaN;
476
+ }
477
+
478
+ /**
479
+ * The label of a vertex: the label column's value when set, the id's text when the id is not
480
+ * the vertex number, otherwise null (no label needed). A label is forced when something follows
481
+ * it on the line, because Pajek's grammar puts the label second.
482
+ * @param labels - the label column, or null
483
+ * @param ids - the sanitised ids
484
+ * @param i - the node index
485
+ * @param needed - whether the line has more after the label
486
+ * @returns the label text, or null
487
+ */
488
+ function labelOf(labels: Column | null, ids: SanitizedIds, i: number, needed: boolean): string | null {
489
+ if (labels !== null && labels.isSet(i)) {
490
+ return cellText(labels, i);
491
+ }
492
+ if (needed || ids.isChanged(i)) {
493
+ return idText(ids.originalAt(i));
494
+ }
495
+ return null;
496
+ }
497
+
498
+ /**
499
+ * The text of an id.
500
+ * @param id - the id
501
+ * @returns String(id)
502
+ */
503
+ function idText(id: NodeId): string {
504
+ return typeof id === "number" ? formatF64(id) : id;
505
+ }
506
+
507
+ /**
508
+ * The spells text of a row, or null when unset or empty.
509
+ * @param column - the spells list column
510
+ * @param row - the row
511
+ * @returns the interval token or null
512
+ */
513
+ function spellsText(column: Column, row: number): string | null {
514
+ if (column.dtype !== "list" || !column.isSet(row)) {
515
+ return null;
516
+ }
517
+ const items = column.sliceOf(row);
518
+ if (items.length === 0) {
519
+ return null;
520
+ }
521
+ const pairs: [number, number][] = [];
522
+ for (const item of items) {
523
+ const pair = item as ArrayLike<number>;
524
+ pairs.push([pair[0], pair[1]]);
525
+ }
526
+ return formatIntervals(pairs);
527
+ }
528
+
529
+ /**
530
+ * The coordinates text of a vertex, or null when unset.
531
+ * @param column - the position column (2 or 3 components)
532
+ * @param i - the node index
533
+ * @param writeZ - whether z is written for a 3-component column
534
+ * @returns "x y" or "x y z", or null
535
+ */
536
+ function coordinatesText(column: Column, i: number, writeZ: boolean): string | null {
537
+ if (!column.isSet(i)) {
538
+ return null;
539
+ }
540
+ const dtype = column.dtype as "f32" | "f64" | "i32" | "u32" | "u8";
541
+ const vector = column.value(i) as ArrayLike<number>;
542
+ const x = numberText(vector[0], dtype);
543
+ const y = numberText(vector[1], dtype);
544
+ if (column.meta.components === 3 && writeZ) {
545
+ return `${x} ${y} ${numberText(vector[2], dtype)}`;
546
+ }
547
+ return `${x} ${y}`;
548
+ }
549
+
550
+ /**
551
+ * Whether a 3-component position column needs its z written: the importer recorded sourceDims 3,
552
+ * or any set row has a non-zero z (never drop data because of a 2-D hint).
553
+ * @param column - the position column
554
+ * @returns true when z is written
555
+ */
556
+ function needsZ(column: Column): boolean {
557
+ if (column.meta.components !== 3) {
558
+ return false;
559
+ }
560
+ if (column.meta.extra.sourceDims !== 2) {
561
+ return true;
562
+ }
563
+ for (let i = 0; i < column.length; i++) {
564
+ if (column.isSet(i)) {
565
+ const vector = column.value(i) as ArrayLike<number>;
566
+ if (vector[2] !== 0) {
567
+ return true;
568
+ }
569
+ }
570
+ }
571
+ return false;
572
+ }
573
+
574
+ /**
575
+ * The text parts of a Pajek document, one per line.
576
+ * @param snapshot - the snapshot
577
+ * @param options - the resolved format options
578
+ * @param options.networkHeader - whether the `*Network` line is written
579
+ * @param options.mangle - whether the original ids are written as `graphty_originalId` parameters
580
+ * @yields lines with their terminator
581
+ * @returns nothing
582
+ */
583
+ function* writeParts(
584
+ snapshot: GraphSnapshot,
585
+ options: { networkHeader: boolean; mangle: boolean },
586
+ ): Generator<string, void, undefined> {
587
+ const ids = sanitizeIds(snapshot, CAPABILITIES.idCharset, options.mangle ? "mangle" : "error");
588
+ const nodePlan = withoutReservedKey(plan(snapshot.nodes, "node", null), options.mangle);
589
+ const edgePlan = plan(snapshot.edges, "edge", null);
590
+ // fail before writing anything when a text cannot be written (the check() E_ note)
591
+ const preflight: LossNote[] = [];
592
+ checkCells(nodePlan, "node", preflight);
593
+ checkCells(edgePlan, "edge", preflight);
594
+ checkIdTexts(snapshot.nodeCount, ids, options.mangle, preflight);
595
+ for (const n of preflight) {
596
+ if (n.code === PAJEK_LOSS.TEXT) {
597
+ throw new GraphFormatError("E_UNSUPPORTED", n.message, {
598
+ reason: "pajek label",
599
+ column: n.column,
600
+ count: n.count,
601
+ });
602
+ }
603
+ }
604
+
605
+ if (options.networkHeader && snapshot.meta.name !== null) {
606
+ yield `*Network ${snapshot.meta.name}\n`;
607
+ }
608
+ const firstMode = firstModeOf(snapshot);
609
+ yield firstMode !== null && !Number.isNaN(firstMode)
610
+ ? `*Vertices ${snapshot.nodeCount} ${firstMode}\n`
611
+ : `*Vertices ${snapshot.nodeCount}\n`;
612
+ yield* writeVertices(snapshot, ids, nodePlan, options.mangle);
613
+ yield* writeLines(snapshot, edgePlan);
614
+ }
615
+
616
+ /**
617
+ * The node plan without a user column named like the exporter's originalId key, which is
618
+ * reserved under "mangle" (check() reports it as not written).
619
+ * @param nodePlan - the node column plan
620
+ * @param mangle - whether the original ids are written as parameters
621
+ * @returns the plan to write
622
+ */
623
+ function withoutReservedKey(nodePlan: readonly PlannedColumn[], mangle: boolean): readonly PlannedColumn[] {
624
+ if (!mangle) {
625
+ return nodePlan;
626
+ }
627
+ return nodePlan.filter((p) => !(p.slot === "param" && p.column.meta.name === ORIGINAL_ID_KEY));
628
+ }
629
+
630
+ /**
631
+ * The notes about the id texts a vertex line carries: an id written as a label or, under
632
+ * "mangle", as the `graphty_originalId` parameter must be a Pajek label (E_PAJEK_TEXT otherwise),
633
+ * and under "mangle" an original id whose text reads back as the other type under ids
634
+ * "canonical" is reported (the importer coerces the parameter text like every id cell).
635
+ * @param nodeCount - the node count
636
+ * @param ids - the sanitised ids
637
+ * @param mangle - whether the original ids are written as parameters
638
+ * @param notes - where to record
639
+ */
640
+ function checkIdTexts(nodeCount: number, ids: SanitizedIds, mangle: boolean, notes: LossNote[]): void {
641
+ let badText = 0;
642
+ let typeChanged = 0;
643
+ for (let i = 0; i < nodeCount; i++) {
644
+ if (!ids.isChanged(i)) {
645
+ continue;
646
+ }
647
+ const original = ids.originalAt(i);
648
+ const text = idText(original);
649
+ if (!isPajekLabel(text)) {
650
+ badText++;
651
+ } else if (mangle && typeof canonicalId(text) !== typeof original) {
652
+ typeChanged++;
653
+ }
654
+ }
655
+ if (badText > 0) {
656
+ notes.push(
657
+ note(
658
+ PAJEK_LOSS.TEXT,
659
+ `${badText} node id(s) hold a double quote or a line break; Pajek cannot write them as labels or parameters`,
660
+ null,
661
+ badText,
662
+ ),
663
+ );
664
+ }
665
+ if (typeChanged > 0) {
666
+ notes.push(
667
+ note(
668
+ PAJEK_LOSS.ID_TEXT_TYPE,
669
+ `${typeChanged} original id(s) read back from ${ORIGINAL_ID_KEY} as the other type under ids: "canonical" (a string "1" becomes 1, a number 1.5 becomes "1.5")`,
670
+ null,
671
+ typeChanged,
672
+ ),
673
+ );
674
+ }
675
+ }
676
+
677
+ /**
678
+ * The vertex lines: number, label, coordinates, shape, parameters and interval.
679
+ * @param snapshot - the snapshot
680
+ * @param ids - the sanitised ids
681
+ * @param nodePlan - the node column plan
682
+ * @param mangle - whether a renumbered vertex carries its original id as a parameter
683
+ * @yields one line per vertex
684
+ * @returns nothing
685
+ */
686
+ function* writeVertices(
687
+ snapshot: GraphSnapshot,
688
+ ids: SanitizedIds,
689
+ nodePlan: readonly PlannedColumn[],
690
+ mangle: boolean,
691
+ ): Generator<string, void, undefined> {
692
+ let labels: Column | null = null;
693
+ let position: Column | null = null;
694
+ let shape: Column | null = null;
695
+ let nodeSpells: Column | null = null;
696
+ const nodeParams: Column[] = [];
697
+ for (const { column, slot } of nodePlan) {
698
+ switch (slot) {
699
+ case "label":
700
+ labels = column;
701
+ break;
702
+ case "position":
703
+ position = column;
704
+ break;
705
+ case "shape":
706
+ shape = column;
707
+ break;
708
+ case "spells":
709
+ nodeSpells = column;
710
+ break;
711
+ case "param":
712
+ nodeParams.push(column);
713
+ break;
714
+ case "relation":
715
+ case "skip":
716
+ break;
717
+ default: {
718
+ const name: string = slot;
719
+ throw new Error(`unknown slot ${name}`);
720
+ }
721
+ }
722
+ }
723
+ const writeZ = position !== null && needsZ(position);
724
+ for (let i = 0; i < snapshot.nodeCount; i++) {
725
+ const parts: string[] = [];
726
+ if (position !== null) {
727
+ const coordinates = coordinatesText(position, i, writeZ);
728
+ if (coordinates !== null) {
729
+ parts.push(coordinates);
730
+ }
731
+ }
732
+ if (shape !== null && shape.isSet(i)) {
733
+ const keyword = cellText(shape, i);
734
+ if (SHAPES.has(keyword)) {
735
+ parts.push(keyword);
736
+ } else {
737
+ parts.push(`${SHAPE_COLUMN} ${quotePajekLabel(keyword)}`);
738
+ }
739
+ }
740
+ for (const column of nodeParams) {
741
+ if (column.isSet(i)) {
742
+ parts.push(`${column.meta.name} ${quotePajekLabel(cellText(column, i))}`);
743
+ }
744
+ }
745
+ if (mangle && ids.isChanged(i)) {
746
+ parts.push(`${ORIGINAL_ID_KEY} ${quotePajekLabel(idText(ids.originalAt(i)))}`);
747
+ }
748
+ if (nodeSpells !== null) {
749
+ const spells = spellsText(nodeSpells, i);
750
+ if (spells !== null) {
751
+ parts.push(spells);
752
+ }
753
+ }
754
+ const label = labelOf(labels, ids, i, parts.length > 0);
755
+ let line = String(i + 1);
756
+ if (label !== null) {
757
+ line += ` ${quotePajekLabel(label)}`;
758
+ }
759
+ if (parts.length > 0) {
760
+ line += ` ${parts.join(" ")}`;
761
+ }
762
+ yield `${line}\n`;
763
+ }
764
+ }
765
+
766
+ /**
767
+ * The line sections: runs of `*Arcs` / `*Edges` (with a relation number and name when the edge
768
+ * has one), one line per written edge with its weight, parameters and interval.
769
+ * @param snapshot - the snapshot
770
+ * @param edgePlan - the edge column plan
771
+ * @yields one line per section header and edge
772
+ * @returns nothing
773
+ */
774
+ function* writeLines(snapshot: GraphSnapshot, edgePlan: readonly PlannedColumn[]): Generator<string, void, undefined> {
775
+ const folding = pairFolding(snapshot, { foldMutual: true });
776
+ const weights = explicitWeights(snapshot);
777
+ const { src, dst } = snapshot.edgeList();
778
+ let relation: Column | null = null;
779
+ let edgeSpells: Column | null = null;
780
+ const edgeParams: Column[] = [];
781
+ for (const { column, slot } of edgePlan) {
782
+ switch (slot) {
783
+ case "relation":
784
+ relation = column;
785
+ break;
786
+ case "spells":
787
+ edgeSpells = column;
788
+ break;
789
+ case "param":
790
+ edgeParams.push(column);
791
+ break;
792
+ case "label":
793
+ case "position":
794
+ case "shape":
795
+ case "skip":
796
+ break;
797
+ default: {
798
+ const name: string = slot;
799
+ throw new Error(`unknown slot ${name}`);
800
+ }
801
+ }
802
+ }
803
+ const relationNumbers = new Map<string, number>();
804
+ let currentKind: "arcs" | "edges" | null = null;
805
+ let currentRelation: string | null = null;
806
+ let sectionOpen = false;
807
+ for (let e = 0; e < snapshot.edgeCount; e++) {
808
+ if (folding.folded(e)) {
809
+ continue;
810
+ }
811
+ // a mutual pair (design section 3.6): both directions, written once as an undirected edge
812
+ const directed = snapshot.directed && folding.sourceDirected(e) && !folding.isMutual(e);
813
+ const kind = directed ? "arcs" : "edges";
814
+ const relationName = relation !== null && relation.isSet(e) ? cellText(relation, e) : null;
815
+ if (!sectionOpen || kind !== currentKind || relationName !== currentRelation) {
816
+ currentKind = kind;
817
+ currentRelation = relationName;
818
+ sectionOpen = true;
819
+ const keyword = kind === "arcs" ? "*Arcs" : "*Edges";
820
+ if (relationName === null) {
821
+ yield `${keyword}\n`;
822
+ } else {
823
+ let number = relationNumbers.get(relationName);
824
+ if (number === undefined) {
825
+ number = relationNumbers.size + 1;
826
+ relationNumbers.set(relationName, number);
827
+ }
828
+ yield `${keyword} :${number} ${quotePajekLabel(relationName)}\n`;
829
+ }
830
+ }
831
+ let line = `${src[e] + 1} ${dst[e] + 1}`;
832
+ const weight = weights.text(e);
833
+ if (weight !== null) {
834
+ line += ` ${weight}`;
835
+ }
836
+ for (const column of edgeParams) {
837
+ if (column.isSet(e)) {
838
+ line += ` ${column.meta.name} ${quotePajekLabel(cellText(column, e))}`;
839
+ }
840
+ }
841
+ if (edgeSpells !== null) {
842
+ const spells = spellsText(edgeSpells, e);
843
+ if (spells !== null) {
844
+ line += ` ${spells}`;
845
+ }
846
+ }
847
+ yield `${line}\n`;
848
+ }
849
+ if (!sectionOpen) {
850
+ yield snapshot.directed ? "*Arcs\n" : "*Edges\n";
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Resolve the Pajek export options.
856
+ * @param options - the caller's options
857
+ * @returns the resolved format options; the common options are checked by resolveExportOptions
858
+ */
859
+ function resolvePajekOptions(options: (PajekExportOptions & CommonExportOptions) | undefined): {
860
+ networkHeader: boolean;
861
+ mangle: boolean;
862
+ } {
863
+ const common = resolveExportOptions(options);
864
+ const value = options?.networkHeader;
865
+ if (value !== undefined && typeof value !== "boolean") {
866
+ throw new GraphFormatError("E_UNSUPPORTED", "option networkHeader: not a boolean", {
867
+ option: "networkHeader",
868
+ found: typeof value,
869
+ });
870
+ }
871
+ return { networkHeader: value ?? false, mangle: common.sanitizeIds === "mangle" };
872
+ }
873
+
874
+ /** The Pajek NET exporter. */
875
+ export const pajekExporter: GraphExporter<PajekExportOptions> = Object.freeze({
876
+ format: "pajek",
877
+ capabilities: CAPABILITIES,
878
+
879
+ /**
880
+ * Pre-flight: the generic capability gaps plus the Pajek-specific ones.
881
+ * @param snapshot - the snapshot
882
+ * @param options - export options
883
+ * @returns the loss notes, empty when the export is exact
884
+ */
885
+ check(snapshot: GraphSnapshot, options?: PajekExportOptions & CommonExportOptions): readonly LossNote[] {
886
+ const resolved = resolveExportOptions(options);
887
+ const { mangle } = resolvePajekOptions(options);
888
+ const notes: LossNote[] = [];
889
+ const planned = plan(snapshot.nodes, "node", notes);
890
+ const nodePlan = withoutReservedKey(planned, mangle);
891
+ const edgePlan = plan(snapshot.edges, "edge", notes);
892
+ const ids = sanitizeIds(snapshot, CAPABILITIES.idCharset, mangle ? "mangle" : "error");
893
+ checkIdTexts(snapshot.nodeCount, ids, mangle, notes);
894
+ if (nodePlan.length !== planned.length) {
895
+ const reserved = planned.find((p) => p.slot === "param" && p.column.meta.name === ORIGINAL_ID_KEY);
896
+ if (reserved !== undefined) {
897
+ notes.push(
898
+ note(
899
+ PAJEK_LOSS.KEY_DROPPED,
900
+ `node column "${ORIGINAL_ID_KEY}" is the parameter the exporter writes the original ids under (sanitizeIds "mangle"); it is not written`,
901
+ ORIGINAL_ID_KEY,
902
+ reserved.column.length - reserved.column.nullCount,
903
+ ),
904
+ );
905
+ }
906
+ }
907
+ // columns this exporter handles itself although the generic table says otherwise: the spells
908
+ // list (written as an interval token, or reported above with the spells code), the shape
909
+ // keyword and the relation name (dict columns written as keywords)
910
+ const owned = new Set<string>();
911
+ for (const { column, slot } of [...nodePlan, ...edgePlan]) {
912
+ if (slot === "shape" || slot === "relation") {
913
+ owned.add(`${column.meta.domain}:${column.meta.name}`);
914
+ } else if (slot === "param" && column.meta.domain === "node" && column.meta.name === SHAPE_COLUMN) {
915
+ // the shape-as-parameter note already says the column reads back as string
916
+ owned.add(`${column.meta.domain}:${column.meta.name}`);
917
+ }
918
+ }
919
+ for (const table of [snapshot.nodes, snapshot.edges]) {
920
+ for (const column of table) {
921
+ if (column.meta.role === "spells") {
922
+ owned.add(`${column.meta.domain}:${column.meta.name}`);
923
+ }
924
+ }
925
+ }
926
+ for (const n of checkCapabilities(snapshot, CAPABILITIES, resolved, {
927
+ roles: SLOT_ROLES,
928
+ roleNames: ROLE_NAMES,
929
+ })) {
930
+ if (n.column !== null && (n.code === LOSS.LIST || n.code === LOSS.DTYPE)) {
931
+ const domain = n.message.startsWith("node column") ? "node" : "edge";
932
+ if (owned.has(`${domain}:${n.column}`)) {
933
+ continue;
934
+ }
935
+ }
936
+ if (n.code === LOSS.COLUMN_NAME_CHANGED && n.message.startsWith("edge column")) {
937
+ // the label slot is a vertex slot; an edge label is a parameter under its own name
938
+ continue;
939
+ }
940
+ notes.push(n);
941
+ }
942
+ labelNotes(snapshot, nodePlan, notes);
943
+ for (const column of snapshot.edges) {
944
+ if (column.meta.role === "label") {
945
+ notes.push(
946
+ note(
947
+ LOSS.ROLE,
948
+ `edge column "${column.meta.name}" (label) is written as a plain parameter; Pajek labels vertices only and the role is lost`,
949
+ column.meta.name,
950
+ column.length - column.nullCount,
951
+ ),
952
+ );
953
+ }
954
+ }
955
+ checkCells(nodePlan, "node", notes);
956
+ checkCells(edgePlan, "edge", notes);
957
+ const folding = pairFolding(snapshot, { foldMutual: true });
958
+ if (folding.mutualCount > 0) {
959
+ notes.push(
960
+ note(
961
+ PAJEK_LOSS.MUTUAL_AS_UNDIRECTED,
962
+ `${folding.mutualCount} mutual pair(s) are written as undirected edges; the mutual mark is lost`,
963
+ snapshot.edges.byRole("mutual")?.meta.name ?? null,
964
+ folding.mutualCount,
965
+ ),
966
+ );
967
+ }
968
+ if (Number.isNaN(firstModeOf(snapshot))) {
969
+ notes.push(
970
+ note(
971
+ PAJEK_LOSS.FIRST_MODE_DROPPED,
972
+ "meta.extra.pajek.firstMode is not a count within 0..nodeCount; the two-mode header is not written",
973
+ ),
974
+ );
975
+ }
976
+ return notes;
977
+ },
978
+
979
+ /**
980
+ * Write the snapshot as UTF-8 chunks.
981
+ * @param snapshot - the snapshot
982
+ * @param options - export options
983
+ * @returns the chunks
984
+ */
985
+ export(snapshot: GraphSnapshot, options?: PajekExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
986
+ const resolved = resolvePajekOptions(options);
987
+ return encodeChunks(writeParts(snapshot, resolved));
988
+ },
989
+
990
+ /**
991
+ * Write the snapshot as one string.
992
+ * @param snapshot - the snapshot
993
+ * @param options - export options
994
+ * @returns the document
995
+ */
996
+ exportToString(snapshot: GraphSnapshot, options?: PajekExportOptions & CommonExportOptions): Promise<string> {
997
+ const resolved = resolvePajekOptions(options);
998
+ return joinText(writeParts(snapshot, resolved));
999
+ },
1000
+ });