@graphty/graph-io 0.3.39 → 0.3.41

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 (213) hide show
  1. package/dist/chunks/{choose-CS3fvGT1.js → choose-CwFQamH9.js} +2 -2
  2. package/dist/chunks/{choose-CS3fvGT1.js.map → choose-CwFQamH9.js.map} +1 -1
  3. package/dist/chunks/{constants-DqKZT6cH.js → constants-Cf4Nn6tw.js} +2 -2
  4. package/dist/chunks/{constants-DqKZT6cH.js.map → constants-Cf4Nn6tw.js.map} +1 -1
  5. package/dist/chunks/{escape-BpYMQZVa.js → escape-DBb8zuNE.js} +4 -3
  6. package/dist/chunks/escape-DBb8zuNE.js.map +1 -0
  7. package/dist/chunks/{export-B-aVCwyG.js → export-DD3Lvw-F.js} +173 -116
  8. package/dist/chunks/export-DD3Lvw-F.js.map +1 -0
  9. package/dist/chunks/{exporter-CNJZF_BN.js → exporter-BqtmlxNr.js} +545 -459
  10. package/dist/chunks/exporter-BqtmlxNr.js.map +1 -0
  11. package/dist/chunks/{exporter-BqKs1Q12.js → exporter-CS35R6Fg.js} +6 -5
  12. package/dist/chunks/exporter-CS35R6Fg.js.map +1 -0
  13. package/dist/chunks/{exporter-DXqCPNrI.js → exporter-D8r1N2dU.js} +67 -42
  14. package/dist/chunks/exporter-D8r1N2dU.js.map +1 -0
  15. package/dist/chunks/{exporter-SFYkDzD0.js → exporter-NMplSnYn.js} +23 -20
  16. package/dist/chunks/{exporter-SFYkDzD0.js.map → exporter-NMplSnYn.js.map} +1 -1
  17. package/dist/chunks/{importer-CSt-gaYE.js → importer-BKC7AmCA.js} +42 -29
  18. package/dist/chunks/importer-BKC7AmCA.js.map +1 -0
  19. package/dist/chunks/{importer-DvFznt5-.js → importer-BdWHmlmG.js} +112 -62
  20. package/dist/chunks/importer-BdWHmlmG.js.map +1 -0
  21. package/dist/chunks/{importer-CqgYq4Ld.js → importer-BfbzWk6k.js} +202 -181
  22. package/dist/chunks/importer-BfbzWk6k.js.map +1 -0
  23. package/dist/chunks/{importer-PVXkeoaS.js → importer-BmaZcskq.js} +79 -66
  24. package/dist/chunks/importer-BmaZcskq.js.map +1 -0
  25. package/dist/chunks/{importer-B7Ir269L.js → importer-DviEsHy4.js} +118 -72
  26. package/dist/chunks/importer-DviEsHy4.js.map +1 -0
  27. package/dist/chunks/{importer--tQQsY4W.js → importer-S-abxJHn.js} +5 -5
  28. package/dist/chunks/{importer--tQQsY4W.js.map → importer-S-abxJHn.js.map} +1 -1
  29. package/dist/chunks/{importer-BfJ1jnNr.js → importer-r29cWnEf.js} +28 -13
  30. package/dist/chunks/importer-r29cWnEf.js.map +1 -0
  31. package/dist/chunks/{json-elements-CXs4z1iL.js → json-elements-DdF-etPT.js} +7 -5
  32. package/dist/chunks/{json-elements-CXs4z1iL.js.map → json-elements-DdF-etPT.js.map} +1 -1
  33. package/dist/chunks/{ontology-export-50L9DOV4.js → ontology-export-BC0zyPhj.js} +2 -2
  34. package/dist/chunks/{ontology-export-50L9DOV4.js.map → ontology-export-BC0zyPhj.js.map} +1 -1
  35. package/dist/chunks/{records-C9jd7yLD.js → records-Uc3LkABx.js} +150 -106
  36. package/dist/chunks/records-Uc3LkABx.js.map +1 -0
  37. package/dist/chunks/{text-Bjfa1xYL.js → text-Bh9SaAob.js} +20 -11
  38. package/dist/chunks/text-Bh9SaAob.js.map +1 -0
  39. package/dist/csv.js +67 -49
  40. package/dist/csv.js.map +1 -1
  41. package/dist/cx.js +1 -1
  42. package/dist/cx2.js +2 -2
  43. package/dist/cys.js +1 -1
  44. package/dist/dot.js +1 -1
  45. package/dist/gexf.js +199 -99
  46. package/dist/gexf.js.map +1 -1
  47. package/dist/gml.js +170 -149
  48. package/dist/gml.js.map +1 -1
  49. package/dist/graph-io.js +26 -26
  50. package/dist/graphml.js +2 -2
  51. package/dist/json.js +1 -1
  52. package/dist/neo4j.js +103 -65
  53. package/dist/neo4j.js.map +1 -1
  54. package/dist/obo.js +1 -1
  55. package/dist/pajek.js +1 -1
  56. package/dist/src/common/attributes.d.ts.map +1 -1
  57. package/dist/src/common/attributes.js +34 -9
  58. package/dist/src/common/attributes.js.map +1 -1
  59. package/dist/src/common/input.d.ts.map +1 -1
  60. package/dist/src/common/input.js +180 -102
  61. package/dist/src/common/input.js.map +1 -1
  62. package/dist/src/common/json-elements.d.ts.map +1 -1
  63. package/dist/src/common/json-elements.js +4 -2
  64. package/dist/src/common/json-elements.js.map +1 -1
  65. package/dist/src/common/text.d.ts +7 -0
  66. package/dist/src/common/text.d.ts.map +1 -1
  67. package/dist/src/common/text.js +15 -7
  68. package/dist/src/common/text.js.map +1 -1
  69. package/dist/src/common/xml.d.ts.map +1 -1
  70. package/dist/src/common/xml.js +2 -1
  71. package/dist/src/common/xml.js.map +1 -1
  72. package/dist/src/common/zip.d.ts.map +1 -1
  73. package/dist/src/common/zip.js +36 -19
  74. package/dist/src/common/zip.js.map +1 -1
  75. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  76. package/dist/src/formats/csv/exporter.js +77 -25
  77. package/dist/src/formats/csv/exporter.js.map +1 -1
  78. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  79. package/dist/src/formats/csv/importer.js +16 -6
  80. package/dist/src/formats/csv/importer.js.map +1 -1
  81. package/dist/src/formats/csv/records.d.ts.map +1 -1
  82. package/dist/src/formats/csv/records.js +156 -110
  83. package/dist/src/formats/csv/records.js.map +1 -1
  84. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  85. package/dist/src/formats/cx/importer.js +108 -53
  86. package/dist/src/formats/cx/importer.js.map +1 -1
  87. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/cx2/exporter.js +2 -1
  89. package/dist/src/formats/cx2/exporter.js.map +1 -1
  90. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  91. package/dist/src/formats/cx2/importer.js +28 -6
  92. package/dist/src/formats/cx2/importer.js.map +1 -1
  93. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  94. package/dist/src/formats/cys/tables.js +11 -8
  95. package/dist/src/formats/cys/tables.js.map +1 -1
  96. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  97. package/dist/src/formats/gexf/exporter.js +69 -39
  98. package/dist/src/formats/gexf/exporter.js.map +1 -1
  99. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  100. package/dist/src/formats/gexf/importer.js +138 -50
  101. package/dist/src/formats/gexf/importer.js.map +1 -1
  102. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  103. package/dist/src/formats/gml/exporter.js +51 -35
  104. package/dist/src/formats/gml/exporter.js.map +1 -1
  105. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  106. package/dist/src/formats/gml/importer.js +103 -58
  107. package/dist/src/formats/gml/importer.js.map +1 -1
  108. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  109. package/dist/src/formats/gml/syntax.js +17 -8
  110. package/dist/src/formats/gml/syntax.js.map +1 -1
  111. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  112. package/dist/src/formats/graphml/exporter.js +36 -25
  113. package/dist/src/formats/graphml/exporter.js.map +1 -1
  114. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  115. package/dist/src/formats/graphml/importer.js +20 -15
  116. package/dist/src/formats/graphml/importer.js.map +1 -1
  117. package/dist/src/formats/graphml/tree.js +23 -14
  118. package/dist/src/formats/graphml/tree.js.map +1 -1
  119. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  120. package/dist/src/formats/graphml/yfiles.js +22 -10
  121. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  122. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  123. package/dist/src/formats/json/exporter.js +38 -21
  124. package/dist/src/formats/json/exporter.js.map +1 -1
  125. package/dist/src/formats/json/importer.d.ts.map +1 -1
  126. package/dist/src/formats/json/importer.js +536 -343
  127. package/dist/src/formats/json/importer.js.map +1 -1
  128. package/dist/src/formats/json/obographs.js +29 -17
  129. package/dist/src/formats/json/obographs.js.map +1 -1
  130. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  131. package/dist/src/formats/neo4j/exporter.js +73 -43
  132. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  133. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  134. package/dist/src/formats/neo4j/header.js +37 -16
  135. package/dist/src/formats/neo4j/header.js.map +1 -1
  136. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  137. package/dist/src/formats/neo4j/importer.js +21 -11
  138. package/dist/src/formats/neo4j/importer.js.map +1 -1
  139. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  140. package/dist/src/formats/obo/exporter.js +18 -7
  141. package/dist/src/formats/obo/exporter.js.map +1 -1
  142. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  143. package/dist/src/formats/pajek/exporter.js +142 -73
  144. package/dist/src/formats/pajek/exporter.js.map +1 -1
  145. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  146. package/dist/src/formats/pajek/importer.js +26 -15
  147. package/dist/src/formats/pajek/importer.js.map +1 -1
  148. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  149. package/dist/src/formats/pajek/syntax.js +85 -58
  150. package/dist/src/formats/pajek/syntax.js.map +1 -1
  151. package/dist/src/formats/xgmml/emit.d.ts +13 -0
  152. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  153. package/dist/src/formats/xgmml/emit.js +112 -50
  154. package/dist/src/formats/xgmml/emit.js.map +1 -1
  155. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  156. package/dist/src/formats/xgmml/exporter.js +92 -34
  157. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  158. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  159. package/dist/src/formats/xgmml/importer.js +38 -18
  160. package/dist/src/formats/xgmml/importer.js.map +1 -1
  161. package/dist/src/sniff.js +109 -57
  162. package/dist/src/sniff.js.map +1 -1
  163. package/dist/xgmml.js +2 -2
  164. package/package.json +2 -2
  165. package/src/common/attributes.ts +59 -19
  166. package/src/common/input.ts +216 -102
  167. package/src/common/json-elements.ts +4 -2
  168. package/src/common/text.ts +24 -15
  169. package/src/common/xml.ts +2 -1
  170. package/src/common/zip.ts +47 -18
  171. package/src/formats/csv/exporter.ts +103 -39
  172. package/src/formats/csv/importer.ts +17 -6
  173. package/src/formats/csv/records.ts +162 -109
  174. package/src/formats/cx/importer.ts +140 -58
  175. package/src/formats/cx2/exporter.ts +2 -1
  176. package/src/formats/cx2/importer.ts +34 -10
  177. package/src/formats/cys/tables.ts +11 -10
  178. package/src/formats/gexf/exporter.ts +71 -38
  179. package/src/formats/gexf/importer.ts +173 -63
  180. package/src/formats/gml/exporter.ts +78 -82
  181. package/src/formats/gml/importer.ts +119 -68
  182. package/src/formats/gml/syntax.ts +18 -8
  183. package/src/formats/graphml/exporter.ts +46 -27
  184. package/src/formats/graphml/importer.ts +21 -13
  185. package/src/formats/graphml/tree.ts +22 -12
  186. package/src/formats/graphml/yfiles.ts +23 -10
  187. package/src/formats/json/exporter.ts +40 -21
  188. package/src/formats/json/importer.ts +701 -385
  189. package/src/formats/json/obographs.ts +35 -15
  190. package/src/formats/neo4j/exporter.ts +79 -41
  191. package/src/formats/neo4j/header.ts +50 -16
  192. package/src/formats/neo4j/importer.ts +21 -10
  193. package/src/formats/obo/exporter.ts +34 -17
  194. package/src/formats/pajek/exporter.ts +180 -117
  195. package/src/formats/pajek/importer.ts +27 -15
  196. package/src/formats/pajek/syntax.ts +99 -58
  197. package/src/formats/xgmml/emit.ts +122 -50
  198. package/src/formats/xgmml/exporter.ts +103 -35
  199. package/src/formats/xgmml/importer.ts +45 -23
  200. package/src/sniff.ts +115 -59
  201. package/dist/chunks/escape-BpYMQZVa.js.map +0 -1
  202. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  203. package/dist/chunks/exporter-BqKs1Q12.js.map +0 -1
  204. package/dist/chunks/exporter-CNJZF_BN.js.map +0 -1
  205. package/dist/chunks/exporter-DXqCPNrI.js.map +0 -1
  206. package/dist/chunks/importer-B7Ir269L.js.map +0 -1
  207. package/dist/chunks/importer-BfJ1jnNr.js.map +0 -1
  208. package/dist/chunks/importer-CSt-gaYE.js.map +0 -1
  209. package/dist/chunks/importer-CqgYq4Ld.js.map +0 -1
  210. package/dist/chunks/importer-DvFznt5-.js.map +0 -1
  211. package/dist/chunks/importer-PVXkeoaS.js.map +0 -1
  212. package/dist/chunks/records-C9jd7yLD.js.map +0 -1
  213. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -207,6 +207,16 @@ function localRef(href: string): string | null {
207
207
  return href.startsWith("#") ? href.slice(1) : null;
208
208
  }
209
209
 
210
+ /**
211
+ * The id a member names: its own id, else the id its local `#id` reference names.
212
+ * @param id - the member's id attribute, or null
213
+ * @param href - the member's xlink:href, or null
214
+ * @returns the id, or null when neither names one
215
+ */
216
+ function memberRef(id: string | null, href: string | null): string | null {
217
+ return id ?? (href === null ? null : localRef(href));
218
+ }
219
+
210
220
  /**
211
221
  * The nodes and edges a graph holds: its direct members, references resolved.
212
222
  * @param doc - the document
@@ -227,19 +237,7 @@ export function membersOf(
227
237
  const edges: EdgeRec[] = [];
228
238
  let dangling = 0;
229
239
  for (const member of graph.members) {
230
- if (member.href === null) {
231
- if (member.kind === "node") {
232
- nodes.push(member);
233
- } else {
234
- edges.push(member);
235
- }
236
- continue;
237
- }
238
- const id = localRef(member.href);
239
- let target: NodeRec | EdgeRec | undefined;
240
- if (id !== null) {
241
- target = member.kind === "node" ? nodeById.get(id) : edgeById.get(id);
242
- }
240
+ const target = resolveMember(member, nodeById, edgeById);
243
241
  if (target === undefined) {
244
242
  dangling++;
245
243
  } else if (target.kind === "node") {
@@ -251,6 +249,29 @@ export function membersOf(
251
249
  return { nodes, edges, dangling };
252
250
  }
253
251
 
252
+ /**
253
+ * The record a graph member stands for: itself when declared in place, else the node or edge its
254
+ * local `#id` reference names.
255
+ * @param member - the member
256
+ * @param nodeById - the first node declaration of each id
257
+ * @param edgeById - the first edge declaration of each id
258
+ * @returns the record, or undefined for a cross-file, malformed or dangling reference
259
+ */
260
+ function resolveMember(
261
+ member: NodeRec | EdgeRec,
262
+ nodeById: ReadonlyMap<string, NodeRec>,
263
+ edgeById: ReadonlyMap<string, EdgeRec>,
264
+ ): NodeRec | EdgeRec | undefined {
265
+ if (member.href === null) {
266
+ return member;
267
+ }
268
+ const id = localRef(member.href);
269
+ if (id === null) {
270
+ return undefined;
271
+ }
272
+ return member.kind === "node" ? nodeById.get(id) : edgeById.get(id);
273
+ }
274
+
254
275
  /**
255
276
  * The first declaration of each id.
256
277
  * @param records - node or edge records
@@ -266,6 +287,42 @@ function indexById<T extends NodeRec | EdgeRec>(records: readonly T[]): Map<stri
266
287
  return out;
267
288
  }
268
289
 
290
+ /**
291
+ * Whether an edge repeats one already seen, remembering it otherwise: a repeat has the same id,
292
+ * or without one the same label and endpoints.
293
+ * @param seen - the keys seen so far, or null when repeats are kept
294
+ * @param record - the edge
295
+ * @returns true for a repeat
296
+ */
297
+ function isRepeat(seen: Set<string> | null, record: EdgeRec): boolean {
298
+ if (seen === null) {
299
+ return false;
300
+ }
301
+ const key =
302
+ record.id ?? (record.label === null ? null : JSON.stringify([record.label, record.source, record.target]));
303
+ if (key === null) {
304
+ return false;
305
+ }
306
+ if (seen.has(key)) {
307
+ return true;
308
+ }
309
+ seen.add(key);
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * Whether a graph's att is one of its values: not RDF metadata, not the unnamed holder of a
315
+ * nested graph.
316
+ * @param att - the att
317
+ * @returns true to write it to the graph table
318
+ */
319
+ function isGraphValueAtt(att: AttRec): boolean {
320
+ if (att.name !== null && META_ATTS.has(att.name)) {
321
+ return false;
322
+ }
323
+ return !(att.hasGraph && att.name === null);
324
+ }
325
+
269
326
  /** Per-node state while emitting. */
270
327
  interface NodeRow {
271
328
  readonly id: string;
@@ -584,12 +641,16 @@ export class XgmmlEmitter {
584
641
  }
585
642
  const lower = t.toLowerCase();
586
643
  const readable = lower === "true" || lower === "yes" || lower === "false" || lower === "no";
644
+ let readAs: string;
645
+ if (readable) {
646
+ readAs = lower === "true" || lower === "yes" ? "1" : "0";
647
+ } else {
648
+ readAs = fallback ? "directed" : "undirected";
649
+ }
587
650
  this.report.warnOnce(
588
651
  "validation-error",
589
652
  XGMML_ISSUE.BAD_DIRECTED,
590
- readable
591
- ? `${this.prefix()}directed="${text}" is not 0 or 1; read as ${lower === "true" || lower === "yes" ? "1" : "0"}`
592
- : `${this.prefix()}directed="${text}" is not 0 or 1; read as ${fallback ? "directed" : "undirected"}`,
653
+ `${this.prefix()}directed="${text}" is not 0 or 1; read as ${readAs}`,
593
654
  where,
594
655
  `${XGMML_ISSUE.BAD_DIRECTED}:${text}`,
595
656
  );
@@ -717,16 +778,7 @@ export class XgmmlEmitter {
717
778
  if (graph.owner !== null && isGroup(graph.owner)) {
718
779
  continue;
719
780
  }
720
- for (const member of graph.members) {
721
- if (member.href === null) {
722
- continue;
723
- }
724
- const id = localRef(member.href);
725
- const known = id !== null && (member.kind === "node" ? this.rows.has(id) : edgeIds.has(id));
726
- if (!known) {
727
- dangling++;
728
- }
729
- }
781
+ dangling += this.danglingReferences(graph, edgeIds);
730
782
  }
731
783
  if (dangling > 0) {
732
784
  this.report.warning(
@@ -737,6 +789,27 @@ export class XgmmlEmitter {
737
789
  }
738
790
  }
739
791
 
792
+ /**
793
+ * How many `xlink:href` members of a graph name no node or edge of the document.
794
+ * @param graph - the graph
795
+ * @param edgeIds - the id of every edge
796
+ * @returns the count
797
+ */
798
+ private danglingReferences(graph: GraphRec, edgeIds: ReadonlySet<string | null>): number {
799
+ let dangling = 0;
800
+ for (const member of graph.members) {
801
+ if (member.href === null) {
802
+ continue;
803
+ }
804
+ const id = localRef(member.href);
805
+ const known = id !== null && (member.kind === "node" ? this.rows.has(id) : edgeIds.has(id));
806
+ if (!known) {
807
+ dangling++;
808
+ }
809
+ }
810
+ return dangling;
811
+ }
812
+
740
813
  /**
741
814
  * Write the per-node values: label, attributes, XML attributes, graphics, positions, nested
742
815
  * network pointers and subgraph memberships.
@@ -887,17 +960,17 @@ export class XgmmlEmitter {
887
960
  });
888
961
  }
889
962
 
890
- /** The `xgmml.networks` membership of nodes in root-level subgraphs of a generic document. */
891
- private writeNetworks(): void {
892
- const { subgraphs } = this.doc.root;
893
- if (subgraphs.length === 0) {
894
- return;
895
- }
963
+ /**
964
+ * The names of the subgraphs each node row is a member of.
965
+ * @param subgraphs - the root-level subgraphs
966
+ * @returns the subgraph names by node row index
967
+ */
968
+ private networkMembership(subgraphs: readonly GraphRec[]): Map<number, string[]> {
896
969
  const member = new Map<number, string[]>();
897
970
  for (const graph of subgraphs) {
898
971
  const name = graph.id ?? graph.label ?? "";
899
972
  for (const m of graph.members) {
900
- const id = m.kind === "node" ? (m.id ?? (m.href === null ? null : localRef(m.href))) : null;
973
+ const id = m.kind === "node" ? memberRef(m.id, m.href) : null;
901
974
  const row = id === null ? undefined : this.rows.get(id);
902
975
  if (row !== undefined && row.index >= 0) {
903
976
  const list = member.get(row.index) ?? [];
@@ -906,6 +979,16 @@ export class XgmmlEmitter {
906
979
  }
907
980
  }
908
981
  }
982
+ return member;
983
+ }
984
+
985
+ /** The `xgmml.networks` membership of nodes in root-level subgraphs of a generic document. */
986
+ private writeNetworks(): void {
987
+ const { subgraphs } = this.doc.root;
988
+ if (subgraphs.length === 0) {
989
+ return;
990
+ }
991
+ const member = this.networkMembership(subgraphs);
909
992
  if (member.size === 0) {
910
993
  return;
911
994
  }
@@ -942,17 +1025,10 @@ export class XgmmlEmitter {
942
1025
  this.decideNameLabel("edge", records);
943
1026
  for (const record of records) {
944
1027
  const where = { line: record.line, element: record.id ?? record.label };
945
- // a repeat has the same id, or without one the same label and endpoints
946
- const key =
947
- record.id ??
948
- (record.label === null ? null : JSON.stringify([record.label, record.source, record.target]));
949
- if (dedupe !== null && key !== null) {
950
- if (dedupe.has(key)) {
951
- repeats++;
952
- firstRepeat ??= where;
953
- continue;
954
- }
955
- dedupe.add(key);
1028
+ if (isRepeat(dedupe, record)) {
1029
+ repeats++;
1030
+ firstRepeat ??= where;
1031
+ continue;
956
1032
  }
957
1033
  const index = this.addEdge(record, header, ids, where);
958
1034
  this.checkAbort();
@@ -1220,13 +1296,9 @@ export class XgmmlEmitter {
1220
1296
  }
1221
1297
  }
1222
1298
  for (const att of g.atts) {
1223
- if (att.name !== null && META_ATTS.has(att.name)) {
1224
- continue;
1225
- }
1226
- if (att.hasGraph && att.name === null) {
1227
- continue;
1299
+ if (isGraphValueAtt(att)) {
1300
+ this.graphColumns.addAtt(0, att, where);
1228
1301
  }
1229
- this.graphColumns.addAtt(0, att, where);
1230
1302
  }
1231
1303
  this.graphColumns.write(this.sink, () => 0);
1232
1304
  const json: Record<string, unknown> = { ...this.extras.graphJson };
@@ -394,6 +394,17 @@ function slotOf(column: Column, domain: "graph" | "node" | "edge", hasLabelRole:
394
394
  if (domain !== "node") {
395
395
  return "att";
396
396
  }
397
+ return nodeSlotOf(column);
398
+ }
399
+
400
+ /**
401
+ * Where a node column goes when it is no XML attribute, label or graphics: one of the node-only
402
+ * slots, else a typed att.
403
+ * @param column - the column
404
+ * @returns the slot
405
+ */
406
+ function nodeSlotOf(column: Column): ColumnWrite["kind"] {
407
+ const { meta } = column;
397
408
  if (meta.name === Z_COLUMN && meta.dtype === "f64" && meta.role === null) {
398
409
  return "z";
399
410
  }
@@ -761,6 +772,25 @@ function countNumericIds(snapshot: GraphSnapshot): number {
761
772
  return n;
762
773
  }
763
774
 
775
+ /**
776
+ * ` name="value"`, or nothing for a null value. The value is written as given, not escaped.
777
+ * @param name - the attribute name
778
+ * @param value - the attribute text, or null
779
+ * @returns the attribute with a leading space, or ""
780
+ */
781
+ function optionalAttribute(name: string, value: string | null): string {
782
+ return value === null ? "" : ` ${name}="${value}"`;
783
+ }
784
+
785
+ /**
786
+ * ` k="v"` for each entry, each value as text and escaped for an XML attribute.
787
+ * @param entries - the attribute names and values
788
+ * @returns the attributes, each with a leading space
789
+ */
790
+ function attributeList(entries: readonly (readonly [string, unknown])[]): string {
791
+ return entries.map(([k, v]) => ` ${k}="${escapeXmlAttribute(String(v))}"`).join("");
792
+ }
793
+
764
794
  /**
765
795
  * The text of a scalar for an att value.
766
796
  * @param value - the value
@@ -846,10 +876,10 @@ function* attsOf(
846
876
  continue;
847
877
  }
848
878
  const name = `${indent}<att name="${escapeXmlAttribute(w.name)}"`;
849
- const types = `${w.type === null ? "" : ` type="${w.type}"`}${w.cyType === null ? "" : ` cy:type="${w.cyType}"`}`;
879
+ const types = optionalAttribute("type", w.type) + optionalAttribute("cy:type", w.cyType);
850
880
  const extra = `${column.meta.extra.hidden === true ? ' cy:hidden="1"' : ""}${column.meta.extra.equation === true ? ' cy:equation="1"' : ""}`;
851
881
  if (!isSet) {
852
- yield `${name}${types}${w.elementType === null ? "" : ` cy:elementType="${w.elementType}"`}${extra}/>\n`;
882
+ yield `${name}${types}${optionalAttribute("cy:elementType", w.elementType)}${extra}/>\n`;
853
883
  continue;
854
884
  }
855
885
  if (column.dtype === "list") {
@@ -895,7 +925,7 @@ function* graphicsOf(
895
925
  if (attrs.length === 0 && nested.length === 0) {
896
926
  return;
897
927
  }
898
- const head = `${indent}<graphics${attrs.map(([k, v]) => ` ${k}="${escapeXmlAttribute(v)}"`).join("")}`;
928
+ const head = `${indent}<graphics${attributeList(attrs)}`;
899
929
  if (nested.length === 0) {
900
930
  yield `${head}/>\n`;
901
931
  return;
@@ -907,7 +937,7 @@ function* graphicsOf(
907
937
  for (const point of value) {
908
938
  const entries =
909
939
  typeof point === "object" && point !== null ? Object.entries(point as Record<string, unknown>) : [];
910
- yield `${indent} <point${entries.map(([k, v]) => ` ${k}="${escapeXmlAttribute(String(v))}"`).join("")}/>\n`;
940
+ yield `${indent} <point${attributeList(entries)}/>\n`;
911
941
  }
912
942
  yield `${indent} </Line>\n`;
913
943
  } else {
@@ -942,7 +972,7 @@ function* jsonAtt(name: string | null, value: unknown, indent: string): Generato
942
972
  entries.length <= 3 &&
943
973
  entries.every(([k]) => /^[a-z]$/.test(k))
944
974
  ) {
945
- yield `${indent}<att${nameAttr}${entries.map(([k, v]) => ` ${k}="${escapeXmlAttribute(v as string)}"`).join("")}/>\n`;
975
+ yield `${indent}<att${nameAttr}${attributeList(entries)}/>\n`;
946
976
  return;
947
977
  }
948
978
  yield `${indent}<att${nameAttr}>\n`;
@@ -985,18 +1015,7 @@ function cellOf(column: Column | null, row: number): unknown {
985
1015
  * @returns the coordinates as XML attributes
986
1016
  */
987
1017
  function coordsOf(plan: Plan, zColumn: Column | null, node: number): [string, string][] {
988
- const out: [string, string][] = [];
989
- const xyz = cellOf(plan.position, node);
990
- if (xyz !== null) {
991
- const [x, y, z] = Array.from(xyz as ArrayLike<number>);
992
- const fmt = plan.position?.dtype === "f32" ? formatF32 : formatF64;
993
- if (Number.isFinite(x) && Number.isFinite(y)) {
994
- out.push(["x", fmt(x)], ["y", fmt(y === 0 ? 0 : -y)]);
995
- if (zColumn === null && z !== undefined && z !== 0 && Number.isFinite(z)) {
996
- out.push(["z", fmt(z)]);
997
- }
998
- }
999
- }
1018
+ const out = positionCoordsOf(plan, zColumn, node);
1000
1019
  const z = cellOf(zColumn, node);
1001
1020
  if (typeof z === "number") {
1002
1021
  out.push(["z", formatF64(z)]);
@@ -1004,6 +1023,65 @@ function coordsOf(plan: Plan, zColumn: Column | null, node: number): [string, st
1004
1023
  return out;
1005
1024
  }
1006
1025
 
1026
+ /**
1027
+ * The graphics coordinates a node's position gives: x, y negated back to screen coordinates, and
1028
+ * z when no z column carries it.
1029
+ * @param plan - the plan
1030
+ * @param zColumn - the z column, or null
1031
+ * @param node - the node
1032
+ * @returns the coordinates as XML attributes (none for an unset or non-finite position)
1033
+ */
1034
+ function positionCoordsOf(plan: Plan, zColumn: Column | null, node: number): [string, string][] {
1035
+ const xyz = cellOf(plan.position, node);
1036
+ if (xyz === null) {
1037
+ return [];
1038
+ }
1039
+ const [x, y, z] = Array.from(xyz as ArrayLike<number>);
1040
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1041
+ return [];
1042
+ }
1043
+ const fmt = plan.position?.dtype === "f32" ? formatF32 : formatF64;
1044
+ const out: [string, string][] = [
1045
+ ["x", fmt(x)],
1046
+ ["y", fmt(y === 0 ? 0 : -y)],
1047
+ ];
1048
+ if (zColumn === null && z !== undefined && z !== 0 && Number.isFinite(z)) {
1049
+ out.push(["z", fmt(z)]);
1050
+ }
1051
+ return out;
1052
+ }
1053
+
1054
+ /**
1055
+ * The group marker and the nested `<graph>` of a group node: its own id, label and atts, and an
1056
+ * `xlink:href` to each child.
1057
+ * @param snapshot - the snapshot
1058
+ * @param sub - the group's subgraph record (empty when it has none)
1059
+ * @param children - the child node indices
1060
+ * @param inner - the indentation inside the node
1061
+ * @yields the group's att elements
1062
+ * @returns nothing
1063
+ */
1064
+ function* groupAttOf(
1065
+ snapshot: GraphSnapshot,
1066
+ sub: Record<string, unknown>,
1067
+ children: ArrayLike<number> & Iterable<number>,
1068
+ inner: string,
1069
+ ): Generator<string, void, undefined> {
1070
+ yield `${inner}<att name="__isGroup" value="1" type="boolean" cy:type="Boolean" cy:hidden="1"/>\n`;
1071
+ const id = typeof sub.id === "string" ? ` id="${escapeXmlAttribute(sub.id)}"` : "";
1072
+ const lbl = typeof sub.label === "string" ? ` label="${escapeXmlAttribute(sub.label)}"` : "";
1073
+ yield `${inner}<att>\n${inner} <graph${id}${lbl}>\n`;
1074
+ const atts =
1075
+ typeof sub.atts === "object" && sub.atts !== null ? Object.entries(sub.atts as Record<string, unknown>) : [];
1076
+ for (const [key, value] of atts) {
1077
+ yield* jsonAtt(key, value, `${inner} `);
1078
+ }
1079
+ for (const child of children) {
1080
+ yield `${inner} <node xlink:href="#${escapeXmlAttribute(idText(snapshot.ids.idOf(child)))}"/>\n`;
1081
+ }
1082
+ yield `${inner} </graph>\n${inner}</att>\n`;
1083
+ }
1084
+
1007
1085
  /**
1008
1086
  * The XML of one node and its group graph, whose members are `xlink:href` references (every
1009
1087
  * node is declared at the top level, in index order).
@@ -1022,7 +1100,8 @@ function* nodeOf(
1022
1100
  ): Generator<string, void, undefined> {
1023
1101
  const labelCol = slot(plan.nodes, "label");
1024
1102
  const label = cellOf(labelCol, node);
1025
- yield `${indent}<node id="${escapeXmlAttribute(idText(snapshot.ids.idOf(node)))}"${label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`}${xmlAttributesOf(plan.nodes, node)}>\n`;
1103
+ const labelAttr = label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`;
1104
+ yield `${indent}<node id="${escapeXmlAttribute(idText(snapshot.ids.idOf(node)))}"${labelAttr}${xmlAttributesOf(plan.nodes, node)}>\n`;
1026
1105
  const inner = `${indent} `;
1027
1106
  yield* attsOf(plan.nodes, node, node === 0, plan, inner);
1028
1107
  const children = plan.reachable[node] === 1 ? plan.hierarchy.childrenOf(node) : [];
@@ -1030,22 +1109,7 @@ function* nodeOf(
1030
1109
  const pointer = cellOf(slot(plan.nodes, "pointer"), node);
1031
1110
  const subgraph = cellOf(slot(plan.nodes, "subgraph"), node);
1032
1111
  if (children.length > 0 || subgraph !== null) {
1033
- const sub = (subgraph ?? {}) as Record<string, unknown>;
1034
- yield `${inner}<att name="__isGroup" value="1" type="boolean" cy:type="Boolean" cy:hidden="1"/>\n`;
1035
- const id = typeof sub.id === "string" ? ` id="${escapeXmlAttribute(sub.id)}"` : "";
1036
- const lbl = typeof sub.label === "string" ? ` label="${escapeXmlAttribute(sub.label)}"` : "";
1037
- yield `${inner}<att>\n${inner} <graph${id}${lbl}>\n`;
1038
- const atts =
1039
- typeof sub.atts === "object" && sub.atts !== null
1040
- ? Object.entries(sub.atts as Record<string, unknown>)
1041
- : [];
1042
- for (const [key, value] of atts) {
1043
- yield* jsonAtt(key, value, `${inner} `);
1044
- }
1045
- for (const child of children) {
1046
- yield `${inner} <node xlink:href="#${escapeXmlAttribute(idText(snapshot.ids.idOf(child)))}"/>\n`;
1047
- }
1048
- yield `${inner} </graph>\n${inner}</att>\n`;
1112
+ yield* groupAttOf(snapshot, (subgraph ?? {}) as Record<string, unknown>, children, inner);
1049
1113
  } else if (typeof nested === "string") {
1050
1114
  yield `${inner}<att>\n${inner} <graph label="${escapeXmlAttribute(nested)}"/>\n${inner}</att>\n`;
1051
1115
  } else if (typeof pointer === "string") {
@@ -1171,7 +1235,11 @@ function* edgesOf(snapshot: GraphSnapshot, plan: Plan): Generator<string, void,
1171
1235
  const id = cellOf(plan.edgeId, e);
1172
1236
  const label = cellOf(labelCol, e);
1173
1237
  const weight = weights.text(e);
1174
- yield ` <edge${id === null ? "" : ` id="${escapeXmlAttribute(scalarText(id))}"`}${label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`} source="${escapeXmlAttribute(idText(snapshot.ids.idOf(u)))}" target="${escapeXmlAttribute(idText(snapshot.ids.idOf(v)))}" cy:directed="${directed ? "1" : "0"}"${weight === null ? "" : ` weight="${weight}"`}${xmlAttributesOf(plan.edges, e)}>\n`;
1238
+ const idAttr = id === null ? "" : ` id="${escapeXmlAttribute(scalarText(id))}"`;
1239
+ const labelAttr = label === null ? "" : ` label="${escapeXmlAttribute(scalarText(label))}"`;
1240
+ const source = escapeXmlAttribute(idText(snapshot.ids.idOf(u)));
1241
+ const target = escapeXmlAttribute(idText(snapshot.ids.idOf(v)));
1242
+ yield ` <edge${idAttr}${labelAttr} source="${source}" target="${target}" cy:directed="${directed ? "1" : "0"}"${optionalAttribute("weight", weight)}${xmlAttributesOf(plan.edges, e)}>\n`;
1175
1243
  yield* attsOf(plan.edges, e, first, plan, " ");
1176
1244
  yield* graphicsOf(cellOf(graphicsCol, e) as Record<string, unknown> | null, [], " ");
1177
1245
  yield " </edge>\n";
@@ -174,23 +174,7 @@ export async function parseXgmml(
174
174
  common: ResolvedImportOptions,
175
175
  options: XgmmlImportOptions | undefined,
176
176
  ): Promise<XgmmlDocument> {
177
- const repairs: XmlRepairs = {
178
- bareAmpersand: booleanOption("repairBareAmpersands", options?.repairBareAmpersands, false)
179
- ? (line): void => {
180
- report.warning("parse-error", XGMML_ISSUE.AMPERSAND_REPAIRED, "a bare & was read as &amp;", { line });
181
- }
182
- : undefined,
183
- surrogatePair: booleanOption("pairSurrogateReferences", options?.pairSurrogateReferences, false)
184
- ? (line): void => {
185
- report.warning(
186
- "parse-error",
187
- XGMML_ISSUE.SURROGATE_PAIRED,
188
- "two surrogate character references were joined into one character",
189
- { line },
190
- );
191
- }
192
- : undefined,
193
- };
177
+ const repairs = repairsOf(options, report);
194
178
  const parser = new XgmmlParser(report);
195
179
  const seen = { content: false };
196
180
  try {
@@ -200,12 +184,7 @@ export async function parseXgmml(
200
184
  repairs,
201
185
  );
202
186
  } catch (err) {
203
- if (err instanceof XmlSyntaxError) {
204
- if (!seen.content) {
205
- report.fail(XGMML_ISSUE.EMPTY_INPUT, "the input is empty");
206
- }
207
- report.fail(XGMML_ISSUE.XML_SYNTAX, err.message, { line: err.line });
208
- }
187
+ failOnSyntax(err, seen.content, report);
209
188
  throw err;
210
189
  }
211
190
  const doc = parser.document();
@@ -220,6 +199,49 @@ export async function parseXgmml(
220
199
  return doc;
221
200
  }
222
201
 
202
+ /**
203
+ * The XML repairs the options switch on, each reporting where it repaired.
204
+ * @param options - the XGMML options
205
+ * @param report - the report
206
+ * @returns the repairs
207
+ */
208
+ function repairsOf(options: XgmmlImportOptions | undefined, report: ImportReportBuilder): XmlRepairs {
209
+ return {
210
+ bareAmpersand: booleanOption("repairBareAmpersands", options?.repairBareAmpersands, false)
211
+ ? (line): void => {
212
+ report.warning("parse-error", XGMML_ISSUE.AMPERSAND_REPAIRED, "a bare & was read as &amp;", { line });
213
+ }
214
+ : undefined,
215
+ surrogatePair: booleanOption("pairSurrogateReferences", options?.pairSurrogateReferences, false)
216
+ ? (line): void => {
217
+ report.warning(
218
+ "parse-error",
219
+ XGMML_ISSUE.SURROGATE_PAIRED,
220
+ "two surrogate character references were joined into one character",
221
+ { line },
222
+ );
223
+ }
224
+ : undefined,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Fail the report on an XML syntax error: an input with no content is empty, any other is
230
+ * malformed. Any other error is left to the caller.
231
+ * @param err - the error the tokenizer threw
232
+ * @param content - whether non-whitespace text was seen
233
+ * @param report - the report
234
+ */
235
+ function failOnSyntax(err: unknown, content: boolean, report: ImportReportBuilder): void {
236
+ if (!(err instanceof XmlSyntaxError)) {
237
+ return;
238
+ }
239
+ if (!content) {
240
+ report.fail(XGMML_ISSUE.EMPTY_INPUT, "the input is empty");
241
+ }
242
+ report.fail(XGMML_ISSUE.XML_SYNTAX, err.message, { line: err.line });
243
+ }
244
+
223
245
  /**
224
246
  * Pass text chunks through while noting non-whitespace content.
225
247
  * @param chunks - the chunks