@graphty/graph-io 0.3.19 → 0.3.21

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 (201) hide show
  1. package/README.md +49 -4
  2. package/dist/chunks/{escape-CWExcecC.js → escape-B-tkk_Cl.js} +74 -16
  3. package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
  4. package/dist/chunks/{writer-DQiKgQJc.js → export-Bh60Dv-n.js} +5 -79
  5. package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
  6. package/dist/chunks/{exporter-CbMZyVt-.js → exporter-BGrsWamJ.js} +7 -6
  7. package/dist/chunks/{exporter-CbMZyVt-.js.map → exporter-BGrsWamJ.js.map} +1 -1
  8. package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
  9. package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
  10. package/dist/chunks/{importer-BNFuV1-K.js → importer-BWY2FFc5.js} +28 -7
  11. package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
  12. package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
  13. package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
  14. package/dist/chunks/{importer-BAP4PrxR.js → importer-DET1tg6s.js} +30 -20
  15. package/dist/chunks/importer-DET1tg6s.js.map +1 -0
  16. package/dist/chunks/{importer-ByPGO-09.js → importer-DEcKhsmQ.js} +2 -2
  17. package/dist/chunks/{importer-ByPGO-09.js.map → importer-DEcKhsmQ.js.map} +1 -1
  18. package/dist/chunks/{importer-BW-Ft2ps.js → importer-DIrbbnAf.js} +6 -5
  19. package/dist/chunks/{importer-BW-Ft2ps.js.map → importer-DIrbbnAf.js.map} +1 -1
  20. package/dist/chunks/importer-D_e7e7LX.js +3055 -0
  21. package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
  22. package/dist/chunks/{importer-Bm1_5vPS.js → importer-eUREhBGj.js} +11 -8
  23. package/dist/chunks/importer-eUREhBGj.js.map +1 -0
  24. package/dist/chunks/{importer-DOepkgnG.js → importer-vi3bSdtw.js} +4 -4
  25. package/dist/chunks/{importer-DOepkgnG.js.map → importer-vi3bSdtw.js.map} +1 -1
  26. package/dist/chunks/{importer-BVGtU1NA.js → importer-zGLo8Dg_.js} +6 -5
  27. package/dist/chunks/{importer-BVGtU1NA.js.map → importer-zGLo8Dg_.js.map} +1 -1
  28. package/dist/chunks/{json-elements-CZY1wiZh.js → json-elements-DDS8N2Dd.js} +2 -2
  29. package/dist/chunks/{json-elements-CZY1wiZh.js.map → json-elements-DDS8N2Dd.js.map} +1 -1
  30. package/dist/chunks/{records-BzNicMsf.js → records-dbRkxwaq.js} +2 -2
  31. package/dist/chunks/{records-BzNicMsf.js.map → records-dbRkxwaq.js.map} +1 -1
  32. package/dist/chunks/{report-BcWboivV.js → report-B1z4WT9e.js} +143 -111
  33. package/dist/chunks/{report-BcWboivV.js.map → report-B1z4WT9e.js.map} +1 -1
  34. package/dist/chunks/{weights-CwISIpCP.js → weights-Dzba96G3.js} +5 -5
  35. package/dist/chunks/{weights-CwISIpCP.js.map → weights-Dzba96G3.js.map} +1 -1
  36. package/dist/chunks/writer-C7flM-Ih.js +77 -0
  37. package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
  38. package/dist/csv.js +6 -5
  39. package/dist/csv.js.map +1 -1
  40. package/dist/cx.js +1 -1
  41. package/dist/cx2.js +2 -2
  42. package/dist/cys.d.ts +1 -0
  43. package/dist/cys.js +6 -0
  44. package/dist/cys.js.map +1 -0
  45. package/dist/dot.js +1 -1
  46. package/dist/gexf.js +5 -4
  47. package/dist/gexf.js.map +1 -1
  48. package/dist/gml.js +12 -14
  49. package/dist/gml.js.map +1 -1
  50. package/dist/graph-io.js +288 -141
  51. package/dist/graph-io.js.map +1 -1
  52. package/dist/graphml.js +1 -1
  53. package/dist/json.js +1 -1
  54. package/dist/neo4j.js +6 -5
  55. package/dist/neo4j.js.map +1 -1
  56. package/dist/obo.js +1 -1
  57. package/dist/pajek.js +1 -1
  58. package/dist/src/common/cell-budget.d.ts +84 -0
  59. package/dist/src/common/cell-budget.d.ts.map +1 -0
  60. package/dist/src/common/cell-budget.js +138 -0
  61. package/dist/src/common/cell-budget.js.map +1 -0
  62. package/dist/src/common/input.d.ts +10 -0
  63. package/dist/src/common/input.d.ts.map +1 -1
  64. package/dist/src/common/input.js +39 -0
  65. package/dist/src/common/input.js.map +1 -1
  66. package/dist/src/common/options.d.ts +6 -0
  67. package/dist/src/common/options.d.ts.map +1 -1
  68. package/dist/src/common/options.js +1 -1
  69. package/dist/src/common/options.js.map +1 -1
  70. package/dist/src/common/xml.d.ts +37 -3
  71. package/dist/src/common/xml.d.ts.map +1 -1
  72. package/dist/src/common/xml.js +86 -6
  73. package/dist/src/common/xml.js.map +1 -1
  74. package/dist/src/common/zip.d.ts +92 -0
  75. package/dist/src/common/zip.d.ts.map +1 -0
  76. package/dist/src/common/zip.js +399 -0
  77. package/dist/src/common/zip.js.map +1 -0
  78. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  79. package/dist/src/formats/cx/importer.js +26 -17
  80. package/dist/src/formats/cx/importer.js.map +1 -1
  81. package/dist/src/formats/cys/constants.d.ts +68 -0
  82. package/dist/src/formats/cys/constants.d.ts.map +1 -0
  83. package/dist/src/formats/cys/constants.js +76 -0
  84. package/dist/src/formats/cys/constants.js.map +1 -0
  85. package/dist/src/formats/cys/importer.d.ts +38 -0
  86. package/dist/src/formats/cys/importer.d.ts.map +1 -0
  87. package/dist/src/formats/cys/importer.js +830 -0
  88. package/dist/src/formats/cys/importer.js.map +1 -0
  89. package/dist/src/formats/cys/index.d.ts +7 -0
  90. package/dist/src/formats/cys/index.d.ts.map +1 -0
  91. package/dist/src/formats/cys/index.js +7 -0
  92. package/dist/src/formats/cys/index.js.map +1 -0
  93. package/dist/src/formats/cys/session.d.ts +132 -0
  94. package/dist/src/formats/cys/session.d.ts.map +1 -0
  95. package/dist/src/formats/cys/session.js +315 -0
  96. package/dist/src/formats/cys/session.js.map +1 -0
  97. package/dist/src/formats/cys/tables.d.ts +90 -0
  98. package/dist/src/formats/cys/tables.d.ts.map +1 -0
  99. package/dist/src/formats/cys/tables.js +293 -0
  100. package/dist/src/formats/cys/tables.js.map +1 -0
  101. package/dist/src/formats/dot/exporter.d.ts +2 -0
  102. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  103. package/dist/src/formats/dot/exporter.js +12 -0
  104. package/dist/src/formats/dot/exporter.js.map +1 -1
  105. package/dist/src/formats/dot/importer.js +6 -2
  106. package/dist/src/formats/dot/importer.js.map +1 -1
  107. package/dist/src/formats/gexf/importer.d.ts +2 -2
  108. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  109. package/dist/src/formats/gexf/importer.js +1 -1
  110. package/dist/src/formats/gexf/importer.js.map +1 -1
  111. package/dist/src/formats/gml/exporter.d.ts +0 -8
  112. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  113. package/dist/src/formats/gml/exporter.js +7 -17
  114. package/dist/src/formats/gml/exporter.js.map +1 -1
  115. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  116. package/dist/src/formats/json/exporter.js +3 -1
  117. package/dist/src/formats/json/exporter.js.map +1 -1
  118. package/dist/src/formats/json/importer.d.ts.map +1 -1
  119. package/dist/src/formats/json/importer.js +3 -2
  120. package/dist/src/formats/json/importer.js.map +1 -1
  121. package/dist/src/formats/xgmml/columns.d.ts +152 -0
  122. package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
  123. package/dist/src/formats/xgmml/columns.js +593 -0
  124. package/dist/src/formats/xgmml/columns.js.map +1 -0
  125. package/dist/src/formats/xgmml/constants.d.ts +196 -0
  126. package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
  127. package/dist/src/formats/xgmml/constants.js +197 -0
  128. package/dist/src/formats/xgmml/constants.js.map +1 -0
  129. package/dist/src/formats/xgmml/document.d.ts +361 -0
  130. package/dist/src/formats/xgmml/document.d.ts.map +1 -0
  131. package/dist/src/formats/xgmml/document.js +695 -0
  132. package/dist/src/formats/xgmml/document.js.map +1 -0
  133. package/dist/src/formats/xgmml/emit.d.ts +341 -0
  134. package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
  135. package/dist/src/formats/xgmml/emit.js +1275 -0
  136. package/dist/src/formats/xgmml/emit.js.map +1 -0
  137. package/dist/src/formats/xgmml/exporter.d.ts +24 -0
  138. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
  139. package/dist/src/formats/xgmml/exporter.js +999 -0
  140. package/dist/src/formats/xgmml/exporter.js.map +1 -0
  141. package/dist/src/formats/xgmml/importer.d.ts +58 -0
  142. package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
  143. package/dist/src/formats/xgmml/importer.js +360 -0
  144. package/dist/src/formats/xgmml/importer.js.map +1 -0
  145. package/dist/src/formats/xgmml/index.d.ts +8 -0
  146. package/dist/src/formats/xgmml/index.d.ts.map +1 -0
  147. package/dist/src/formats/xgmml/index.js +8 -0
  148. package/dist/src/formats/xgmml/index.js.map +1 -0
  149. package/dist/src/formats/xgmml/values.d.ts +60 -0
  150. package/dist/src/formats/xgmml/values.d.ts.map +1 -0
  151. package/dist/src/formats/xgmml/values.js +148 -0
  152. package/dist/src/formats/xgmml/values.js.map +1 -0
  153. package/dist/src/index.d.ts +2 -0
  154. package/dist/src/index.d.ts.map +1 -1
  155. package/dist/src/index.js +2 -0
  156. package/dist/src/index.js.map +1 -1
  157. package/dist/src/registry.d.ts +7 -0
  158. package/dist/src/registry.d.ts.map +1 -1
  159. package/dist/src/registry.js +25 -10
  160. package/dist/src/registry.js.map +1 -1
  161. package/dist/src/sniff.d.ts +1 -1
  162. package/dist/src/sniff.d.ts.map +1 -1
  163. package/dist/src/sniff.js +2 -0
  164. package/dist/src/sniff.js.map +1 -1
  165. package/dist/xgmml.d.ts +1 -0
  166. package/dist/xgmml.js +9 -0
  167. package/dist/xgmml.js.map +1 -0
  168. package/package.json +15 -2
  169. package/src/common/cell-budget.ts +170 -0
  170. package/src/common/input.ts +40 -0
  171. package/src/common/options.ts +1 -1
  172. package/src/common/xml.ts +127 -6
  173. package/src/common/zip.ts +472 -0
  174. package/src/formats/cx/importer.ts +27 -19
  175. package/src/formats/cys/constants.ts +99 -0
  176. package/src/formats/cys/importer.ts +1100 -0
  177. package/src/formats/cys/index.ts +7 -0
  178. package/src/formats/cys/session.ts +428 -0
  179. package/src/formats/cys/tables.ts +400 -0
  180. package/src/formats/dot/exporter.ts +17 -0
  181. package/src/formats/dot/importer.ts +6 -2
  182. package/src/formats/gexf/importer.ts +0 -1
  183. package/src/formats/gml/exporter.ts +7 -18
  184. package/src/formats/json/exporter.ts +3 -1
  185. package/src/formats/json/importer.ts +3 -2
  186. package/src/formats/xgmml/columns.ts +747 -0
  187. package/src/formats/xgmml/constants.ts +265 -0
  188. package/src/formats/xgmml/document.ts +975 -0
  189. package/src/formats/xgmml/emit.ts +1563 -0
  190. package/src/formats/xgmml/exporter.ts +1215 -0
  191. package/src/formats/xgmml/importer.ts +491 -0
  192. package/src/formats/xgmml/index.ts +8 -0
  193. package/src/formats/xgmml/values.ts +183 -0
  194. package/src/index.ts +9 -0
  195. package/src/registry.ts +41 -15
  196. package/src/sniff.ts +5 -1
  197. package/dist/chunks/escape-CWExcecC.js.map +0 -1
  198. package/dist/chunks/importer-BAP4PrxR.js.map +0 -1
  199. package/dist/chunks/importer-BNFuV1-K.js.map +0 -1
  200. package/dist/chunks/importer-Bm1_5vPS.js.map +0 -1
  201. package/dist/chunks/writer-DQiKgQJc.js.map +0 -1
@@ -0,0 +1,170 @@
1
+ /**
2
+ * A memory guard for the builder importGraph() fills. graph-format stores every attribute as a dense
3
+ * column, one slot per node (or edge) whether or not that element has a value, so a file whose nodes
4
+ * each carry a differently named attribute holds n values but allocates n x n slots: a few hundred
5
+ * kilobytes of input can need gigabytes. The guard counts the slots that hold no value and stops the
6
+ * import once they pass a limit. A dense file has almost no empty slots, so it is never stopped
7
+ * however large it is.
8
+ */
9
+
10
+ import {
11
+ type ColumnDecl,
12
+ type ColumnHandle,
13
+ GraphBuilder,
14
+ type GraphBuilderOptions,
15
+ GraphFormatError,
16
+ type NodeId,
17
+ } from "@graphty/graph-format";
18
+
19
+ import { describe } from "./options.js";
20
+ import { ImportReportBuilder } from "./report.js";
21
+
22
+ /** The issue code of an import stopped for allocating too many empty attribute slots. */
23
+ export const TOO_MANY_EMPTY_CELLS_CODE = "E_TOO_MANY_EMPTY_CELLS";
24
+
25
+ /** The default limit: 2^24 empty slots, about 130 MB of f64 columns. */
26
+ const DEFAULT_MAX_EMPTY_CELLS = 2 ** 24;
27
+
28
+ /**
29
+ * Resolve the maxEmptyCells option.
30
+ * @param value - the caller's value
31
+ * @returns a non-negative integer or Infinity
32
+ * @throws GraphFormatError E_UNSUPPORTED for anything else
33
+ */
34
+ export function maxEmptyCellsOption(value: unknown): number {
35
+ if (value === undefined) {
36
+ return DEFAULT_MAX_EMPTY_CELLS;
37
+ }
38
+ if (typeof value === "number" && value >= 0 && (Number.isInteger(value) || value === Infinity)) {
39
+ return value;
40
+ }
41
+ throw new GraphFormatError(
42
+ "E_UNSUPPORTED",
43
+ `option maxEmptyCells: ${describe(value)} is not a non-negative integer or Infinity`,
44
+ { option: "maxEmptyCells", found: value },
45
+ );
46
+ }
47
+
48
+ /** A GraphBuilder that throws an ImportError once its attribute columns hold too many empty slots. */
49
+ export class CellBudgetBuilder extends GraphBuilder {
50
+ private readonly nodeColumns = new Set<number>();
51
+ private readonly edgeColumns = new Set<number>();
52
+ private values = 0;
53
+
54
+ /**
55
+ * Create a builder with a limit.
56
+ * @param options - the builder options
57
+ * @param format - the format being read, for the error's report
58
+ * @param maxEmptyCells - the most empty slots allowed
59
+ */
60
+ constructor(
61
+ options: GraphBuilderOptions,
62
+ private readonly format: string,
63
+ private readonly maxEmptyCells: number,
64
+ ) {
65
+ super(options);
66
+ }
67
+
68
+ /**
69
+ * Add a node, then check the limit (its row adds a slot to every node column).
70
+ * @param id - the node id
71
+ * @returns the node index
72
+ */
73
+ override addNode(id: NodeId): number {
74
+ const index = super.addNode(id);
75
+ this.checkBudget();
76
+ return index;
77
+ }
78
+
79
+ /**
80
+ * Add an edge, then check the limit (its row adds a slot to every edge column).
81
+ * @param source - source id
82
+ * @param target - target id
83
+ * @param weight - the weight
84
+ * @returns the edge index
85
+ */
86
+ override addEdge(source: NodeId, target: NodeId, weight?: number): number {
87
+ const edge = super.addEdge(source, target, weight);
88
+ this.checkBudget();
89
+ return edge;
90
+ }
91
+
92
+ /**
93
+ * Declare a node column, then check the limit.
94
+ * @param decl - the declaration
95
+ * @returns the column handle
96
+ */
97
+ override declareNodeColumn(decl: ColumnDecl): ColumnHandle {
98
+ const handle = super.declareNodeColumn(decl);
99
+ this.nodeColumns.add(handle);
100
+ this.checkBudget();
101
+ return handle;
102
+ }
103
+
104
+ /**
105
+ * Declare an edge column, then check the limit.
106
+ * @param decl - the declaration
107
+ * @returns the column handle
108
+ */
109
+ override declareEdgeColumn(decl: ColumnDecl): ColumnHandle {
110
+ const handle = super.declareEdgeColumn(decl);
111
+ this.edgeColumns.add(handle);
112
+ this.checkBudget();
113
+ return handle;
114
+ }
115
+
116
+ /**
117
+ * Set a node cell (a new name declares a column), then check the limit.
118
+ * @param column - the handle or name
119
+ * @param index - the node index
120
+ * @param value - the value
121
+ */
122
+ override setNodeValue(column: ColumnHandle | string, index: number, value: unknown): void {
123
+ super.setNodeValue(column, index, value);
124
+ this.nodeColumns.add(typeof column === "string" ? this.nodeColumn(column) : column);
125
+ this.count(value);
126
+ }
127
+
128
+ /**
129
+ * Set an edge cell (a new name declares a column), then check the limit.
130
+ * @param column - the handle or name
131
+ * @param edge - the edge index
132
+ * @param value - the value
133
+ */
134
+ override setEdgeValue(column: ColumnHandle | string, edge: number, value: unknown): void {
135
+ super.setEdgeValue(column, edge, value);
136
+ this.edgeColumns.add(typeof column === "string" ? this.edgeColumn(column) : column);
137
+ this.count(value);
138
+ }
139
+
140
+ /**
141
+ * Count a written value and check the budget.
142
+ * @param value - the value; null and undefined unset a slot and do not count
143
+ */
144
+ private count(value: unknown): void {
145
+ if (value !== null && value !== undefined) {
146
+ this.values++;
147
+ }
148
+ this.checkBudget();
149
+ }
150
+
151
+ /**
152
+ * Throw when the columns' slots minus the values written pass the limit.
153
+ * @throws ImportError E_TOO_MANY_EMPTY_CELLS
154
+ */
155
+ private checkBudget(): void {
156
+ const slots = this.nodeColumns.size * this.nodeBound + this.edgeColumns.size * this.edgeBound;
157
+ const empty = slots - this.values;
158
+ if (empty > this.maxEmptyCells) {
159
+ new ImportReportBuilder(this.format, Infinity).fail(
160
+ TOO_MANY_EMPTY_CELLS_CODE,
161
+ `the attributes are too sparse: ${this.nodeColumns.size} node and ${this.edgeColumns.size} edge ` +
162
+ `attribute column(s) over ${this.nodeBound} node(s) and ${this.edgeBound} edge(s) would ` +
163
+ `allocate more than ${this.maxEmptyCells} slots that hold no value; pass a larger ` +
164
+ `maxEmptyCells (or Infinity) to read the file anyway`,
165
+ undefined,
166
+ { emptyCells: empty, maxEmptyCells: this.maxEmptyCells },
167
+ );
168
+ }
169
+ }
170
+ }
@@ -529,6 +529,46 @@ async function* streamChunks(
529
529
  }
530
530
  }
531
531
 
532
+ /**
533
+ * Read the whole input as bytes, for a binary format (a zip): nothing is decoded. The signal is
534
+ * checked before every chunk and progress reported after it; a stream is cancelled when the read
535
+ * stops early.
536
+ * @param input - the input
537
+ * @param options - cancellation and progress (the encoding is ignored)
538
+ * @returns the bytes, or null when the input is text (a string, or a chunk that is a string),
539
+ * which a binary format cannot read
540
+ */
541
+ export async function readBytes(input: ImportInput, options: ReadOptions = {}): Promise<Uint8Array | null> {
542
+ const signal = options.signal ?? null;
543
+ throwIfAborted(signal);
544
+ if (typeof input === "string") {
545
+ return null;
546
+ }
547
+ if (input instanceof Uint8Array) {
548
+ options.onProgress?.(input.byteLength, input.byteLength);
549
+ return input;
550
+ }
551
+ const parts: Uint8Array[] = [];
552
+ let done = 0;
553
+ for await (const chunk of isReadableStream(input) ? streamChunks(input, signal) : input) {
554
+ throwIfAborted(signal);
555
+ if (typeof chunk === "string") {
556
+ return null;
557
+ }
558
+ if (!(chunk instanceof Uint8Array)) {
559
+ throw new GraphFormatError("E_UNSUPPORTED", "an input chunk must be a string or a Uint8Array", {
560
+ reason: "chunk type",
561
+ found: typeof chunk,
562
+ });
563
+ }
564
+ parts.push(chunk);
565
+ done += chunk.byteLength;
566
+ options.onProgress?.(done);
567
+ }
568
+ options.onProgress?.(done, done);
569
+ return parts.length === 0 ? new Uint8Array(0) : concatBytes(parts);
570
+ }
571
+
532
572
  /**
533
573
  * Read the whole input as one string (the GML / DOT / JSON path, design section 8.4).
534
574
  * @param input - the input
@@ -455,7 +455,7 @@ function encodingOption(value: unknown): string | null {
455
455
  * @param value - the value
456
456
  * @returns the JSON text of a primitive, or the type name otherwise
457
457
  */
458
- function describe(value: unknown): string {
458
+ export function describe(value: unknown): string {
459
459
  switch (typeof value) {
460
460
  case "string":
461
461
  return JSON.stringify(value);
package/src/common/xml.ts CHANGED
@@ -72,6 +72,38 @@ export class XmlSyntaxError extends Error {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * Opt-in repairs of two defects the Cytoscape XGMML writer is known to produce (research note
77
+ * `research-xgmml.md` 3.8 and 5). Each is off unless its callback is given; GEXF and GraphML never
78
+ * pass them, so their documents stay strictly well-formed. The callback is told the line of each
79
+ * repair, so the importer can warn per occurrence.
80
+ */
81
+ export interface XmlRepairs {
82
+ /**
83
+ * Read an `&` that is not followed by a `;` within the next 7 characters as `&amp;` (Cytoscape's
84
+ * `cytoscape.xgmml.repair.bare.ampersands` lookahead); an `&name;` that does end in time is
85
+ * still decoded, and still fatal when the entity is unknown. A complete numeric character
86
+ * reference is decoded whatever its length (`&#128512;`), where Cytoscape's byte lookahead
87
+ * would turn it into text.
88
+ */
89
+ readonly bareAmpersand?: ((line: number) => void) | undefined;
90
+ /**
91
+ * Join a high-surrogate character reference immediately followed by a low-surrogate one
92
+ * (`&#xd83d;&#xde00;`, which XML 1.0 forbids but Cytoscape writes for astral characters) into
93
+ * the one character they encode; a lone surrogate stays fatal.
94
+ */
95
+ readonly surrogatePair?: ((line: number) => void) | undefined;
96
+ }
97
+
98
+ /** How far Cytoscape's bare-ampersand repair looks for the `;` that ends an entity reference. */
99
+ const BARE_AMPERSAND_LOOKAHEAD = 7;
100
+
101
+ /** A numeric character reference at the start of a text: `&#123;` or `&#x1F;`. */
102
+ const CHAR_REFERENCE = /^&#(?:[xX]([0-9a-fA-F]{1,6})|([0-9]{1,7}));/;
103
+
104
+ /** A numeric character reference at the end of a text. */
105
+ const TRAILING_CHAR_REFERENCE = /&#(?:[xX]([0-9a-fA-F]{1,6})|([0-9]{1,7}));$/;
106
+
75
107
  const NAMED_ENTITIES: Readonly<Record<string, string>> = {
76
108
  lt: "<",
77
109
  gt: ">",
@@ -280,9 +312,10 @@ function isXmlChar(cp: number): boolean {
280
312
  * Decode the predefined entities and character references of a text.
281
313
  * @param raw - the text as written
282
314
  * @param line - the line, for errors
315
+ * @param repairs - the opt-in repairs (XGMML only); none by default
283
316
  * @returns the decoded text
284
317
  */
285
- export function decodeEntities(raw: string, line: number): string {
318
+ export function decodeEntities(raw: string, line: number, repairs?: XmlRepairs): string {
286
319
  let amp = raw.indexOf("&");
287
320
  if (amp < 0) {
288
321
  return raw;
@@ -292,6 +325,18 @@ export function decodeEntities(raw: string, line: number): string {
292
325
  while (amp >= 0) {
293
326
  out += raw.slice(start, amp);
294
327
  const semi = raw.indexOf(";", amp + 1);
328
+ if (
329
+ repairs?.bareAmpersand !== undefined &&
330
+ (semi < 0 || semi - amp > BARE_AMPERSAND_LOOKAHEAD) &&
331
+ // a well-formed character reference longer than the lookahead (&#128512;) is not bare
332
+ !CHAR_REFERENCE.test(raw.slice(amp, amp + 12))
333
+ ) {
334
+ repairs.bareAmpersand(lineAt(raw, amp, line));
335
+ out += "&";
336
+ start = amp + 1;
337
+ amp = raw.indexOf("&", start);
338
+ continue;
339
+ }
295
340
  if (semi < 0) {
296
341
  throw new XmlSyntaxError("unterminated entity reference", line);
297
342
  }
@@ -301,6 +346,14 @@ export function decodeEntities(raw: string, line: number): string {
301
346
  const digits = name.slice(hex ? 2 : 1);
302
347
  const ok = hex ? /^[0-9a-fA-F]{1,6}$/.test(digits) : /^[0-9]{1,7}$/.test(digits);
303
348
  const cp = ok ? Number.parseInt(digits, hex ? 16 : 10) : -1;
349
+ const low = cp >= 0xd800 && cp <= 0xdbff ? lowSurrogateAfter(raw, semi + 1, repairs) : null;
350
+ if (low !== null) {
351
+ repairs?.surrogatePair?.(lineAt(raw, amp, line));
352
+ out += String.fromCharCode(cp, low.unit);
353
+ start = low.end;
354
+ amp = raw.indexOf("&", start);
355
+ continue;
356
+ }
304
357
  if (cp < 0 || !isXmlChar(cp)) {
305
358
  throw new XmlSyntaxError(`invalid character reference &${name};`, line);
306
359
  }
@@ -318,6 +371,45 @@ export function decodeEntities(raw: string, line: number): string {
318
371
  return out + raw.slice(start);
319
372
  }
320
373
 
374
+ /**
375
+ * The line of a position inside a text that starts on a known line.
376
+ * @param raw - the text
377
+ * @param at - the position
378
+ * @param line - the line the text starts on
379
+ * @returns the line of the position
380
+ */
381
+ function lineAt(raw: string, at: number, line: number): number {
382
+ let n = line;
383
+ for (let i = raw.indexOf("\n"); i >= 0 && i < at; i = raw.indexOf("\n", i + 1)) {
384
+ n++;
385
+ }
386
+ return n;
387
+ }
388
+
389
+ /**
390
+ * The low-surrogate character reference that may follow a high-surrogate one, when the
391
+ * surrogate-pair repair is on.
392
+ * @param raw - the text
393
+ * @param at - where the next reference would start
394
+ * @param repairs - the repairs in force
395
+ * @returns the low surrogate code unit and the index after its reference, or null
396
+ */
397
+ function lowSurrogateAfter(
398
+ raw: string,
399
+ at: number,
400
+ repairs: XmlRepairs | undefined,
401
+ ): { readonly unit: number; readonly end: number } | null {
402
+ if (repairs?.surrogatePair === undefined || raw.charCodeAt(at) !== 38) {
403
+ return null;
404
+ }
405
+ const match = CHAR_REFERENCE.exec(raw.slice(at, at + 12));
406
+ if (match === null) {
407
+ return null;
408
+ }
409
+ const unit = match[1] === undefined ? Number.parseInt(match[2], 10) : Number.parseInt(match[1], 16);
410
+ return unit >= 0xdc00 && unit <= 0xdfff ? { unit, end: at + match[0].length } : null;
411
+ }
412
+
321
413
  /**
322
414
  * Whether a text is whitespace only.
323
415
  * @param text - the text
@@ -347,9 +439,14 @@ export function localName(name: string): string {
347
439
  * malformed input; any error the handler throws propagates unchanged.
348
440
  * @param chunks - the text (already UTF-8 decoded, BOM removed)
349
441
  * @param handler - the event sink
442
+ * @param repairs - the opt-in repairs (XGMML only); none by default
350
443
  */
351
- export async function tokenizeXml(chunks: AsyncIterable<string>, handler: XmlHandler): Promise<void> {
352
- const tokenizer = new XmlTokenizer(handler);
444
+ export async function tokenizeXml(
445
+ chunks: AsyncIterable<string>,
446
+ handler: XmlHandler,
447
+ repairs?: XmlRepairs,
448
+ ): Promise<void> {
449
+ const tokenizer = new XmlTokenizer(handler, repairs);
353
450
  for await (const chunk of chunks) {
354
451
  tokenizer.push(chunk);
355
452
  }
@@ -435,12 +532,16 @@ export class XmlTokenizer {
435
532
 
436
533
  private rootClosed = false;
437
534
 
535
+ private readonly repairs: XmlRepairs | undefined;
536
+
438
537
  /**
439
538
  * Create a tokenizer.
440
539
  * @param handler - the event sink
540
+ * @param repairs - the opt-in repairs (XGMML only); none by default
441
541
  */
442
- constructor(handler: XmlHandler) {
542
+ constructor(handler: XmlHandler, repairs?: XmlRepairs) {
443
543
  this.handler = handler;
544
+ this.repairs = repairs;
444
545
  }
445
546
 
446
547
  /**
@@ -677,6 +778,9 @@ export class XmlTokenizer {
677
778
  if (amp >= pos && amp >= length - MAX_ENTITY_LENGTH && buffer.indexOf(";", amp) < 0) {
678
779
  end = amp;
679
780
  }
781
+ if (this.repairs?.surrogatePair !== undefined) {
782
+ end = this.holdHighSurrogate(pos, end);
783
+ }
680
784
  }
681
785
  this.takeText(pos, end);
682
786
  pos = end;
@@ -928,7 +1032,7 @@ export class XmlTokenizer {
928
1032
  this.line,
929
1033
  );
930
1034
  }
931
- attrs.set(attrName, decodeEntities(normalizeAttributeValue(raw), this.line));
1035
+ attrs.set(attrName, decodeEntities(normalizeAttributeValue(raw), this.line, this.repairs));
932
1036
  i = close + 1;
933
1037
  }
934
1038
  }
@@ -962,6 +1066,23 @@ export class XmlTokenizer {
962
1066
  return j >= length && !this.final ? -1 : j;
963
1067
  }
964
1068
 
1069
+ /**
1070
+ * Under the surrogate-pair repair, hold back a high-surrogate character reference that ends
1071
+ * the text taken so far, so it is decoded together with the low one the next chunk may start with.
1072
+ * @param pos - the start of the text
1073
+ * @param end - the end of the text that would be taken
1074
+ * @returns the end to take up to
1075
+ */
1076
+ private holdHighSurrogate(pos: number, end: number): number {
1077
+ const tail = this.buffer.slice(Math.max(pos, end - 12), end);
1078
+ const match = TRAILING_CHAR_REFERENCE.exec(tail);
1079
+ if (match === null) {
1080
+ return end;
1081
+ }
1082
+ const unit = match[1] === undefined ? Number.parseInt(match[2], 10) : Number.parseInt(match[1], 16);
1083
+ return unit >= 0xd800 && unit <= 0xdbff ? end - match[0].length : end;
1084
+ }
1085
+
965
1086
  /**
966
1087
  * Move text from the buffer into the current run.
967
1088
  * @param start - the start index
@@ -978,7 +1099,7 @@ export class XmlTokenizer {
978
1099
  if (hasIllegalXmlChar(raw)) {
979
1100
  throw new XmlSyntaxError("a character XML 1.0 forbids appears in character data", this.line);
980
1101
  }
981
- this.text += decodeEntities(raw, this.line);
1102
+ this.text += decodeEntities(raw, this.line, this.repairs);
982
1103
  this.advanceLine(start, end);
983
1104
  }
984
1105