@graphty/graph-io 0.3.40 → 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 (189) 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-BJRLd3vJ.js → escape-DBb8zuNE.js} +2 -2
  6. package/dist/chunks/{escape-BJRLd3vJ.js.map → escape-DBb8zuNE.js.map} +1 -1
  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-Bcp-l4Bo.js → exporter-BqtmlxNr.js} +545 -459
  10. package/dist/chunks/exporter-BqtmlxNr.js.map +1 -0
  11. package/dist/chunks/{exporter-6ocL_9x_.js → exporter-CS35R6Fg.js} +4 -4
  12. package/dist/chunks/{exporter-6ocL_9x_.js.map → exporter-CS35R6Fg.js.map} +1 -1
  13. package/dist/chunks/{exporter-BVXnV9-9.js → exporter-D8r1N2dU.js} +28 -16
  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-CPqnIY8m.js → importer-BKC7AmCA.js} +42 -29
  18. package/dist/chunks/importer-BKC7AmCA.js.map +1 -0
  19. package/dist/chunks/{importer-D_srHyj6.js → importer-BdWHmlmG.js} +4 -4
  20. package/dist/chunks/{importer-D_srHyj6.js.map → importer-BdWHmlmG.js.map} +1 -1
  21. package/dist/chunks/{importer-AjwOJrn2.js → importer-BfbzWk6k.js} +202 -181
  22. package/dist/chunks/importer-BfbzWk6k.js.map +1 -0
  23. package/dist/chunks/{importer-BpuS3J8C.js → importer-BmaZcskq.js} +73 -60
  24. package/dist/chunks/importer-BmaZcskq.js.map +1 -0
  25. package/dist/chunks/{importer-DEp_fYjf.js → importer-DviEsHy4.js} +92 -64
  26. package/dist/chunks/importer-DviEsHy4.js.map +1 -0
  27. package/dist/chunks/{importer-DcKB97kW.js → importer-S-abxJHn.js} +5 -5
  28. package/dist/chunks/{importer-DcKB97kW.js.map → importer-S-abxJHn.js.map} +1 -1
  29. package/dist/chunks/{importer-BJlGAOht.js → importer-r29cWnEf.js} +3 -3
  30. package/dist/chunks/{importer-BJlGAOht.js.map → importer-r29cWnEf.js.map} +1 -1
  31. package/dist/chunks/{json-elements-DpO6AXIc.js → json-elements-DdF-etPT.js} +3 -3
  32. package/dist/chunks/{json-elements-DpO6AXIc.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-r3SFHz11.js → records-Uc3LkABx.js} +15 -12
  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 +195 -97
  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/text.d.ts +7 -0
  63. package/dist/src/common/text.d.ts.map +1 -1
  64. package/dist/src/common/text.js +15 -7
  65. package/dist/src/common/text.js.map +1 -1
  66. package/dist/src/common/zip.d.ts.map +1 -1
  67. package/dist/src/common/zip.js +36 -19
  68. package/dist/src/common/zip.js.map +1 -1
  69. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  70. package/dist/src/formats/csv/exporter.js +77 -25
  71. package/dist/src/formats/csv/exporter.js.map +1 -1
  72. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  73. package/dist/src/formats/csv/importer.js +16 -6
  74. package/dist/src/formats/csv/importer.js.map +1 -1
  75. package/dist/src/formats/csv/records.d.ts.map +1 -1
  76. package/dist/src/formats/csv/records.js +18 -10
  77. package/dist/src/formats/csv/records.js.map +1 -1
  78. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  79. package/dist/src/formats/cys/tables.js +11 -8
  80. package/dist/src/formats/cys/tables.js.map +1 -1
  81. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  82. package/dist/src/formats/gexf/exporter.js +65 -37
  83. package/dist/src/formats/gexf/exporter.js.map +1 -1
  84. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  85. package/dist/src/formats/gexf/importer.js +138 -50
  86. package/dist/src/formats/gexf/importer.js.map +1 -1
  87. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/gml/exporter.js +51 -35
  89. package/dist/src/formats/gml/exporter.js.map +1 -1
  90. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  91. package/dist/src/formats/gml/importer.js +103 -58
  92. package/dist/src/formats/gml/importer.js.map +1 -1
  93. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  94. package/dist/src/formats/gml/syntax.js +17 -8
  95. package/dist/src/formats/gml/syntax.js.map +1 -1
  96. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  97. package/dist/src/formats/graphml/exporter.js +34 -24
  98. package/dist/src/formats/graphml/exporter.js.map +1 -1
  99. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  100. package/dist/src/formats/graphml/importer.js +17 -13
  101. package/dist/src/formats/graphml/importer.js.map +1 -1
  102. package/dist/src/formats/graphml/tree.js +23 -14
  103. package/dist/src/formats/graphml/tree.js.map +1 -1
  104. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  105. package/dist/src/formats/graphml/yfiles.js +22 -10
  106. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  107. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  108. package/dist/src/formats/json/exporter.js +38 -21
  109. package/dist/src/formats/json/exporter.js.map +1 -1
  110. package/dist/src/formats/json/importer.d.ts.map +1 -1
  111. package/dist/src/formats/json/importer.js +536 -343
  112. package/dist/src/formats/json/importer.js.map +1 -1
  113. package/dist/src/formats/json/obographs.js +29 -17
  114. package/dist/src/formats/json/obographs.js.map +1 -1
  115. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  116. package/dist/src/formats/neo4j/exporter.js +73 -43
  117. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  118. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  119. package/dist/src/formats/neo4j/header.js +37 -16
  120. package/dist/src/formats/neo4j/header.js.map +1 -1
  121. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  122. package/dist/src/formats/neo4j/importer.js +21 -11
  123. package/dist/src/formats/neo4j/importer.js.map +1 -1
  124. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  125. package/dist/src/formats/obo/exporter.js +18 -7
  126. package/dist/src/formats/obo/exporter.js.map +1 -1
  127. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  128. package/dist/src/formats/pajek/exporter.js +142 -73
  129. package/dist/src/formats/pajek/exporter.js.map +1 -1
  130. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  131. package/dist/src/formats/pajek/importer.js +26 -15
  132. package/dist/src/formats/pajek/importer.js.map +1 -1
  133. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  134. package/dist/src/formats/pajek/syntax.js +85 -58
  135. package/dist/src/formats/pajek/syntax.js.map +1 -1
  136. package/dist/src/formats/xgmml/emit.d.ts +7 -0
  137. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  138. package/dist/src/formats/xgmml/emit.js +79 -40
  139. package/dist/src/formats/xgmml/emit.js.map +1 -1
  140. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  141. package/dist/src/formats/xgmml/exporter.js +38 -12
  142. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  143. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  144. package/dist/src/formats/xgmml/importer.js +38 -18
  145. package/dist/src/formats/xgmml/importer.js.map +1 -1
  146. package/dist/src/sniff.js +109 -57
  147. package/dist/src/sniff.js.map +1 -1
  148. package/dist/xgmml.js +2 -2
  149. package/package.json +2 -2
  150. package/src/common/attributes.ts +59 -19
  151. package/src/common/input.ts +216 -102
  152. package/src/common/text.ts +24 -15
  153. package/src/common/zip.ts +47 -18
  154. package/src/formats/csv/exporter.ts +103 -39
  155. package/src/formats/csv/importer.ts +17 -6
  156. package/src/formats/csv/records.ts +19 -10
  157. package/src/formats/cys/tables.ts +11 -10
  158. package/src/formats/gexf/exporter.ts +67 -36
  159. package/src/formats/gexf/importer.ts +173 -63
  160. package/src/formats/gml/exporter.ts +78 -82
  161. package/src/formats/gml/importer.ts +119 -68
  162. package/src/formats/gml/syntax.ts +18 -8
  163. package/src/formats/graphml/exporter.ts +44 -24
  164. package/src/formats/graphml/importer.ts +18 -11
  165. package/src/formats/graphml/tree.ts +22 -12
  166. package/src/formats/graphml/yfiles.ts +23 -10
  167. package/src/formats/json/exporter.ts +40 -21
  168. package/src/formats/json/importer.ts +701 -385
  169. package/src/formats/json/obographs.ts +35 -15
  170. package/src/formats/neo4j/exporter.ts +79 -41
  171. package/src/formats/neo4j/header.ts +50 -16
  172. package/src/formats/neo4j/importer.ts +21 -10
  173. package/src/formats/obo/exporter.ts +34 -17
  174. package/src/formats/pajek/exporter.ts +180 -117
  175. package/src/formats/pajek/importer.ts +27 -15
  176. package/src/formats/pajek/syntax.ts +99 -58
  177. package/src/formats/xgmml/emit.ts +88 -40
  178. package/src/formats/xgmml/exporter.ts +40 -12
  179. package/src/formats/xgmml/importer.ts +45 -23
  180. package/src/sniff.ts +115 -59
  181. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  182. package/dist/chunks/exporter-BVXnV9-9.js.map +0 -1
  183. package/dist/chunks/exporter-Bcp-l4Bo.js.map +0 -1
  184. package/dist/chunks/importer-AjwOJrn2.js.map +0 -1
  185. package/dist/chunks/importer-BpuS3J8C.js.map +0 -1
  186. package/dist/chunks/importer-CPqnIY8m.js.map +0 -1
  187. package/dist/chunks/importer-DEp_fYjf.js.map +0 -1
  188. package/dist/chunks/records-r3SFHz11.js.map +0 -1
  189. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -1232,130 +1232,244 @@ export async function* textChunks(
1232
1232
  ): AsyncGenerator<string, void, undefined> {
1233
1233
  const input = normalizeInput(rawInput);
1234
1234
  const signal = options.signal ?? null;
1235
- const onProgress = options.onProgress ?? null;
1236
1235
  const filter = new TextFilter(report, options);
1237
1236
  throwIfAborted(signal);
1238
1237
  if (typeof input === "string") {
1239
1238
  reportTextEncoding(report, options.encoding);
1240
- const total = onProgress === null ? 0 : utf8Length(input);
1241
- let done = 0;
1242
- let start = 0;
1243
- while (start < input.length) {
1244
- throwIfAborted(signal);
1245
- let end = Math.min(start + DECODE_SLICE, input.length);
1246
- const highSurrogate = (input.charCodeAt(end - 1) & 0xfc00) === 0xd800; // NOSONAR(S7758): reads UTF-16 code units on purpose
1247
- if (end < input.length && highSurrogate) {
1248
- end++; // never split a surrogate pair
1249
- }
1250
- const piece = start === 0 && end === input.length ? input : input.slice(start, end);
1251
- start = end;
1252
- const text = filter.push(piece);
1253
- if (text.length > 0) {
1254
- yield text;
1255
- }
1256
- if (onProgress !== null) {
1257
- done += utf8Length(piece);
1258
- onProgress(done, total);
1259
- }
1260
- }
1261
- if (input.length === 0) {
1262
- onProgress?.(0, 0);
1263
- }
1264
- filter.end();
1239
+ yield* stringTextChunks(input, filter, options);
1265
1240
  return;
1266
1241
  }
1267
1242
  const decoder = new ByteDecoder(report, options);
1268
- const headBytes = Math.max(options.declarationBytes ?? HEAD_BYTES, BOM_BYTES);
1269
1243
  if (input instanceof Uint8Array) {
1270
- const length = input.byteLength;
1271
- decoder.start(input.subarray(0, headBytes));
1272
- for (let offset = 0; offset < length; offset += DECODE_SLICE) {
1273
- throwIfAborted(signal);
1274
- if (input.byteLength !== length) {
1275
- throw new GraphFormatError(
1276
- "E_UNSUPPORTED",
1277
- "the input's buffer was detached (transferred) or resized during the import",
1278
- { reason: "detached" },
1279
- );
1280
- }
1281
- const slice = input.subarray(offset, Math.min(offset + DECODE_SLICE, length));
1282
- const text = filter.push(decoder.decode(slice, offset + DECODE_SLICE < length));
1283
- if (text.length > 0) {
1284
- yield text;
1285
- }
1286
- onProgress?.(Math.min(offset + DECODE_SLICE, length), length);
1287
- }
1288
- if (length === 0) {
1289
- onProgress?.(0, 0);
1290
- }
1291
- filter.end();
1244
+ yield* byteTextChunks(input, filter, decoder, options);
1292
1245
  return;
1293
1246
  }
1294
- // the first bytes of a stream are held back until the BOM check (and the declaration check,
1295
- // when the format has one) can see enough of them, or the input ended, or text arrived, so a
1296
- // BOM or a declaration split across small chunks is still seen
1297
- const needed = options.declaredEncoding === undefined ? BOM_BYTES : headBytes;
1247
+ // the stream's loop stays here rather than in a delegated generator: a yield* layer costs
1248
+ // time on every chunk, and a stream may arrive in many small ones
1249
+ const stream = new StreamText(filter, decoder, options);
1250
+ for await (const chunk of isReadableStream(input) ? streamChunks(input, signal) : input) {
1251
+ throwIfAborted(signal);
1252
+ const text = stream.push(chunk);
1253
+ if (text.length > 0) {
1254
+ yield text;
1255
+ }
1256
+ options.onProgress?.(stream.done);
1257
+ }
1258
+ const tail = stream.end();
1259
+ if (tail.length > 0) {
1260
+ yield tail;
1261
+ }
1262
+ filter.end();
1263
+ options.onProgress?.(stream.done, stream.done);
1264
+ }
1265
+
1266
+ /**
1267
+ * The end of the slice of a string input that starts at start: DECODE_SLICE code units, one more
1268
+ * when the slice would end between the two halves of a surrogate pair.
1269
+ * @param input - the string
1270
+ * @param start - where the slice starts
1271
+ * @returns where it ends
1272
+ */
1273
+ function stringSliceEnd(input: string, start: number): number {
1274
+ const end = Math.min(start + DECODE_SLICE, input.length);
1275
+ const highSurrogate = (input.charCodeAt(end - 1) & 0xfc00) === 0xd800; // NOSONAR(S7758): reads UTF-16 code units on purpose
1276
+ return end < input.length && highSurrogate ? end + 1 : end;
1277
+ }
1278
+
1279
+ /**
1280
+ * textChunks() of a string input: slices of it, through the text filter.
1281
+ * @param input - the string
1282
+ * @param filter - the text checks
1283
+ * @param options - cancellation and progress
1284
+ * @yields the filtered slices
1285
+ * @returns nothing
1286
+ */
1287
+ function* stringTextChunks(
1288
+ input: string,
1289
+ filter: TextFilter,
1290
+ options: ReadOptions,
1291
+ ): Generator<string, void, undefined> {
1292
+ const signal = options.signal ?? null;
1293
+ const onProgress = options.onProgress ?? null;
1294
+ const total = onProgress === null ? 0 : utf8Length(input);
1298
1295
  let done = 0;
1299
- let head: Uint8Array[] = [];
1300
- let headLength = 0;
1301
- const flushHead = (stream: boolean): string => {
1302
- const bytes = concatBytes(head);
1303
- head = [];
1304
- headLength = 0;
1305
- decoder.start(bytes.subarray(0, headBytes));
1306
- return decoder.decode(bytes, stream);
1307
- };
1308
- const chunks = isReadableStream(input) ? streamChunks(input, signal) : input;
1309
- for await (const chunk of chunks) {
1296
+ let start = 0;
1297
+ while (start < input.length) {
1310
1298
  throwIfAborted(signal);
1311
- let text: string;
1312
- const bytes = typeof chunk === "string" ? null : asBytes(chunk);
1299
+ const end = stringSliceEnd(input, start);
1300
+ const piece = start === 0 && end === input.length ? input : input.slice(start, end);
1301
+ start = end;
1302
+ const text = filter.push(piece);
1303
+ if (text.length > 0) {
1304
+ yield text;
1305
+ }
1306
+ if (onProgress !== null) {
1307
+ done += utf8Length(piece);
1308
+ onProgress(done, total);
1309
+ }
1310
+ }
1311
+ if (input.length === 0) {
1312
+ onProgress?.(0, 0);
1313
+ }
1314
+ filter.end();
1315
+ }
1316
+
1317
+ /**
1318
+ * textChunks() of a Uint8Array input: slices of it, decoded, through the text filter.
1319
+ * @param input - the bytes
1320
+ * @param filter - the text checks
1321
+ * @param decoder - the byte decoder
1322
+ * @param options - cancellation, progress and the declaration size
1323
+ * @yields the decoded, filtered slices
1324
+ * @returns nothing
1325
+ */
1326
+ function* byteTextChunks(
1327
+ input: Uint8Array,
1328
+ filter: TextFilter,
1329
+ decoder: ByteDecoder,
1330
+ options: ReadOptions,
1331
+ ): Generator<string, void, undefined> {
1332
+ const signal = options.signal ?? null;
1333
+ const length = input.byteLength;
1334
+ decoder.start(input.subarray(0, headByteCount(options)));
1335
+ for (let offset = 0; offset < length; offset += DECODE_SLICE) {
1336
+ throwIfAborted(signal);
1337
+ if (input.byteLength !== length) {
1338
+ throw new GraphFormatError(
1339
+ "E_UNSUPPORTED",
1340
+ "the input's buffer was detached (transferred) or resized during the import",
1341
+ { reason: "detached" },
1342
+ );
1343
+ }
1344
+ const slice = input.subarray(offset, Math.min(offset + DECODE_SLICE, length));
1345
+ const text = filter.push(decoder.decode(slice, offset + DECODE_SLICE < length));
1346
+ if (text.length > 0) {
1347
+ yield text;
1348
+ }
1349
+ options.onProgress?.(Math.min(offset + DECODE_SLICE, length), length);
1350
+ }
1351
+ if (length === 0) {
1352
+ options.onProgress?.(0, 0);
1353
+ }
1354
+ filter.end();
1355
+ }
1356
+
1357
+ /**
1358
+ * How many leading bytes the encoding is chosen from: the format's declaration size, at least a BOM.
1359
+ * @param options - the declaration size
1360
+ * @returns the byte count
1361
+ */
1362
+ function headByteCount(options: ReadOptions): number {
1363
+ return Math.max(options.declarationBytes ?? HEAD_BYTES, BOM_BYTES);
1364
+ }
1365
+
1366
+ /**
1367
+ * The chunks of a stream input (a ReadableStream or an async iterable of strings and bytes) turned
1368
+ * into filtered text. The first bytes are held back until the BOM check (and the declaration
1369
+ * check, when the format has one) can see enough of them, or the input ended, or text arrived, so
1370
+ * a BOM or a declaration split across small chunks is still seen.
1371
+ */
1372
+ class StreamText {
1373
+ /** Bytes read so far (text counted as UTF-8 when progress is reported), for progress. */
1374
+ done = 0;
1375
+
1376
+ private head: Uint8Array[] = [];
1377
+
1378
+ private headLength = 0;
1379
+
1380
+ private readonly headBytes: number;
1381
+
1382
+ /** How many bytes to hold back before choosing the encoding. */
1383
+ private readonly needed: number;
1384
+
1385
+ /** Whether progress is reported, so text chunks are measured. */
1386
+ private readonly counting: boolean;
1387
+
1388
+ /**
1389
+ * Create the reader of one stream input.
1390
+ * @param filter - the text checks
1391
+ * @param decoder - the byte decoder
1392
+ * @param options - progress, the declaration reader and its size
1393
+ */
1394
+ constructor(
1395
+ private readonly filter: TextFilter,
1396
+ private readonly decoder: ByteDecoder,
1397
+ options: ReadOptions,
1398
+ ) {
1399
+ this.headBytes = headByteCount(options);
1400
+ this.needed = options.declaredEncoding === undefined ? BOM_BYTES : this.headBytes;
1401
+ this.counting = (options.onProgress ?? null) !== null;
1402
+ }
1403
+
1404
+ /**
1405
+ * Read the next chunk.
1406
+ * @param chunk - a string or a Uint8Array (anything else is E_UNSUPPORTED)
1407
+ * @returns its filtered text; empty while bytes are held back
1408
+ */
1409
+ push(chunk: unknown): string {
1313
1410
  if (typeof chunk === "string") {
1314
- // finish any byte sequence still pending in the decoder before switching to text; the
1315
- // BOM and declaration checks apply to the start of the input only, so bytes after text
1316
- // are never taken for a BOM
1317
- let pending = "";
1318
- if (headLength > 0) {
1319
- pending = decoder.flushBeforeText(() => flushHead(false));
1320
- } else if (decoder.started) {
1321
- pending = decoder.flushBeforeText(() => decoder.decode(new Uint8Array(0), false));
1322
- } else {
1323
- decoder.start(new Uint8Array(0));
1324
- }
1325
- text = filter.push(pending + chunk);
1326
- done += onProgress === null ? 0 : utf8Length(chunk);
1327
- } else if (bytes !== null) {
1328
- done += bytes.byteLength;
1329
- if (decoder.started) {
1330
- text = filter.push(decoder.decode(bytes, true));
1331
- } else {
1332
- head.push(bytes);
1333
- headLength += bytes.byteLength;
1334
- text = headLength >= needed ? filter.push(flushHead(true)) : "";
1335
- }
1336
- } else {
1411
+ this.done += this.counting ? utf8Length(chunk) : 0;
1412
+ return this.filter.push(this.beforeText() + chunk);
1413
+ }
1414
+ const bytes = asBytes(chunk);
1415
+ if (bytes === null) {
1337
1416
  throw new GraphFormatError("E_UNSUPPORTED", "an input chunk must be a string or a Uint8Array", {
1338
1417
  reason: "chunk type",
1339
1418
  found: typeof chunk,
1340
1419
  });
1341
1420
  }
1342
- if (text.length > 0) {
1343
- yield text;
1421
+ this.done += bytes.byteLength;
1422
+ if (this.decoder.started) {
1423
+ return this.filter.push(this.decoder.decode(bytes, true));
1344
1424
  }
1345
- onProgress?.(done);
1425
+ this.head.push(bytes);
1426
+ this.headLength += bytes.byteLength;
1427
+ return this.headLength >= this.needed ? this.filter.push(this.flushHead(true)) : "";
1346
1428
  }
1347
- let tail = "";
1348
- if (headLength > 0) {
1349
- tail = flushHead(false);
1350
- } else if (decoder.started) {
1351
- tail = decoder.decode(new Uint8Array(0), false);
1429
+
1430
+ /**
1431
+ * The input ended: the filtered text of whatever is still held or pending.
1432
+ * @returns the text
1433
+ */
1434
+ end(): string {
1435
+ let tail = "";
1436
+ if (this.headLength > 0) {
1437
+ tail = this.flushHead(false);
1438
+ } else if (this.decoder.started) {
1439
+ tail = this.decoder.decode(new Uint8Array(0), false);
1440
+ }
1441
+ return this.filter.push(tail);
1352
1442
  }
1353
- tail = filter.push(tail);
1354
- if (tail.length > 0) {
1355
- yield tail;
1443
+
1444
+ /**
1445
+ * Start the decoder on the held bytes and decode them.
1446
+ * @param stream - whether more bytes follow
1447
+ * @returns their text
1448
+ */
1449
+ private flushHead(stream: boolean): string {
1450
+ const bytes = concatBytes(this.head);
1451
+ this.head = [];
1452
+ this.headLength = 0;
1453
+ this.decoder.start(bytes.subarray(0, this.headBytes));
1454
+ return this.decoder.decode(bytes, stream);
1455
+ }
1456
+
1457
+ /**
1458
+ * A text chunk follows: finish any byte sequence still pending in the decoder before switching
1459
+ * to text. The BOM and declaration checks apply to the start of the input only, so bytes after
1460
+ * text are never taken for a BOM.
1461
+ * @returns the text of the pending bytes
1462
+ */
1463
+ private beforeText(): string {
1464
+ if (this.headLength > 0) {
1465
+ return this.decoder.flushBeforeText(() => this.flushHead(false));
1466
+ }
1467
+ if (this.decoder.started) {
1468
+ return this.decoder.flushBeforeText(() => this.decoder.decode(new Uint8Array(0), false));
1469
+ }
1470
+ this.decoder.start(new Uint8Array(0));
1471
+ return "";
1356
1472
  }
1357
- filter.end();
1358
- onProgress?.(done, done);
1359
1473
  }
1360
1474
 
1361
1475
  /**
@@ -202,21 +202,7 @@ export class TextCellWriter {
202
202
  write(row: number, text: string): void {
203
203
  const kind = inferTextDtype(text);
204
204
  const value = parseTextCell(text);
205
- if (
206
- typeof value === "number" &&
207
- !Number.isSafeInteger(value) &&
208
- I32_TEXT.test(text) &&
209
- BigInt(text) !== BigInt(value)
210
- ) {
211
- // an integer beyond 2^53 is stored as the nearest double; say so once per column
212
- this.report.warnOnce(
213
- "precision",
214
- PRECISION_CODE,
215
- `${this.domain} column "${this.name}": integers beyond 2^53 are stored as the nearest double (first: ${text})`,
216
- { element: this.name },
217
- `${PRECISION_CODE}:${this.domain}:${this.name}`,
218
- );
219
- }
205
+ this.warnIfImprecise(text, value);
220
206
  const wasString = this.textDtype === "string";
221
207
  const textDtype =
222
208
  this.textDtype === null || textDtypeRank(kind) > textDtypeRank(this.textDtype) ? kind : this.textDtype;
@@ -250,6 +236,29 @@ export class TextCellWriter {
250
236
  }
251
237
  }
252
238
 
239
+ /**
240
+ * Warn, once per column, when the cell is an integer beyond 2^53 that is stored as the nearest
241
+ * double.
242
+ * @param text - the cell text
243
+ * @param value - the parsed value
244
+ */
245
+ private warnIfImprecise(text: string, value: unknown): void {
246
+ if (
247
+ typeof value === "number" &&
248
+ !Number.isSafeInteger(value) &&
249
+ I32_TEXT.test(text) &&
250
+ BigInt(text) !== BigInt(value)
251
+ ) {
252
+ this.report.warnOnce(
253
+ "precision",
254
+ PRECISION_CODE,
255
+ `${this.domain} column "${this.name}": integers beyond 2^53 are stored as the nearest double (first: ${text})`,
256
+ { element: this.name },
257
+ `${PRECISION_CODE}:${this.domain}:${this.name}`,
258
+ );
259
+ }
260
+ }
261
+
253
262
  /**
254
263
  * Write a value (the parsed scalar, or a text into a widened column).
255
264
  * @param row - the row
package/src/common/zip.ts CHANGED
@@ -134,6 +134,52 @@ export function startsLikeZip(bytes: Uint8Array): boolean {
134
134
  return bytes.byteLength >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 3 && bytes[3] === 4;
135
135
  }
136
136
 
137
+ /**
138
+ * Read the end of central directory record, following the zip64 locator when there is one.
139
+ * @param view - the whole archive
140
+ * @param eocd - the offset of the end record
141
+ * @returns the disk numbers, the entry count and where the central directory is; ZipError
142
+ * "corrupt" when the end record needs a zip64 record the archive does not have
143
+ */
144
+ function readEndRecord(
145
+ view: DataView,
146
+ eocd: number,
147
+ ): {
148
+ disk: number;
149
+ directoryDisk: number;
150
+ count: number;
151
+ directorySize: number;
152
+ directoryOffset: number;
153
+ directoryEnd: number;
154
+ } {
155
+ const locator = eocd - ZIP64_LOCATOR_SIZE;
156
+ if (locator >= 0 && view.getUint32(locator, true) === ZIP64_LOCATOR_SIGNATURE) {
157
+ const record = zip64EndRecord(view, locator);
158
+ return {
159
+ disk: view.getUint32(record + 16, true),
160
+ directoryDisk: view.getUint32(record + 20, true),
161
+ count: readU64(view, record + 32),
162
+ directorySize: readU64(view, record + 40),
163
+ directoryOffset: readU64(view, record + 48),
164
+ directoryEnd: record,
165
+ };
166
+ }
167
+ const count = view.getUint16(eocd + 10, true);
168
+ const directorySize = view.getUint32(eocd + 12, true);
169
+ const directoryOffset = view.getUint32(eocd + 16, true);
170
+ if (count === U16_MAX || directorySize === U32_MAX || directoryOffset === U32_MAX) {
171
+ throw new ZipError("corrupt", "the end record needs a zip64 record the archive does not have");
172
+ }
173
+ return {
174
+ disk: view.getUint16(eocd + 4, true),
175
+ directoryDisk: view.getUint16(eocd + 6, true),
176
+ count,
177
+ directorySize,
178
+ directoryOffset,
179
+ directoryEnd: eocd,
180
+ };
181
+ }
182
+
137
183
  /**
138
184
  * Read the central directory.
139
185
  * @param bytes - the whole archive
@@ -150,24 +196,7 @@ export function readZipDirectory(bytes: Uint8Array): ZipEntry[] {
150
196
  ? new ZipError("corrupt", "the archive has no end of central directory record (truncated?)")
151
197
  : new ZipError("not-zip", "the input is not a zip archive");
152
198
  }
153
- let disk = view.getUint16(eocd + 4, true);
154
- let directoryDisk = view.getUint16(eocd + 6, true);
155
- let count = view.getUint16(eocd + 10, true);
156
- let directorySize = view.getUint32(eocd + 12, true);
157
- let directoryOffset = view.getUint32(eocd + 16, true);
158
- let directoryEnd = eocd;
159
- const locator = eocd - ZIP64_LOCATOR_SIZE;
160
- if (locator >= 0 && view.getUint32(locator, true) === ZIP64_LOCATOR_SIGNATURE) {
161
- const record = zip64EndRecord(view, locator);
162
- disk = view.getUint32(record + 16, true);
163
- directoryDisk = view.getUint32(record + 20, true);
164
- count = readU64(view, record + 32);
165
- directorySize = readU64(view, record + 40);
166
- directoryOffset = readU64(view, record + 48);
167
- directoryEnd = record;
168
- } else if (count === U16_MAX || directorySize === U32_MAX || directoryOffset === U32_MAX) {
169
- throw new ZipError("corrupt", "the end record needs a zip64 record the archive does not have");
170
- }
199
+ const { disk, directoryDisk, count, directorySize, directoryOffset, directoryEnd } = readEndRecord(view, eocd);
171
200
  if (disk !== 0 || directoryDisk !== 0) {
172
201
  throw new ZipError("unsupported", "a split (multi-disk) archive cannot be read");
173
202
  }
@@ -407,6 +407,25 @@ function nodeTableNote(names: readonly string[], table: string, notes: LossNote[
407
407
  }
408
408
  }
409
409
 
410
+ /**
411
+ * The number of node ids whose text an earlier id already has (a number and a string).
412
+ * @param ids - the snapshot's ids
413
+ * @returns the collisions
414
+ */
415
+ function idTextCollisions(ids: GraphSnapshot["ids"]): number {
416
+ const seen = new Set<string>();
417
+ let collisions = 0;
418
+ for (let i = 0; i < ids.size; i++) {
419
+ const text = String(ids.idOf(i));
420
+ if (seen.has(text)) {
421
+ collisions++;
422
+ } else {
423
+ seen.add(text);
424
+ }
425
+ }
426
+ return collisions;
427
+ }
428
+
410
429
  /**
411
430
  * The id notes: text collisions are fatal (export() throws E_INVALID_ID); type changes under the
412
431
  * canonical re-read are reported.
@@ -421,16 +440,7 @@ function idNotes(
421
440
  ): void {
422
441
  const { ids } = snapshot;
423
442
  if (ids.kind === "mixed") {
424
- const seen = new Set<string>();
425
- let collisions = 0;
426
- for (let i = 0; i < ids.size; i++) {
427
- const text = String(ids.idOf(i));
428
- if (seen.has(text)) {
429
- collisions++;
430
- } else {
431
- seen.add(text);
432
- }
433
- }
443
+ const collisions = idTextCollisions(ids);
434
444
  if (collisions > 0) {
435
445
  note(
436
446
  CSV_LOSS.ID_TEXT_COLLISION,
@@ -699,40 +709,81 @@ function attributeColumns(
699
709
  if (role !== null && SKIPPED_ROLES.has(role)) {
700
710
  continue;
701
711
  }
702
- const low = lower(name);
703
712
  const set = column.length - column.nullCount;
704
- const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
705
- if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
706
- note?.(
707
- CSV_LOSS.RESERVED_NAME,
708
- `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
709
- name,
710
- set,
711
- );
713
+ if (isReservedName(name, domain, reserved, idColumn, set, note)) {
712
714
  continue;
713
715
  }
714
- if (findColumn([name], LABEL_NAMES) >= 0) {
715
- if (labelColumn !== null) {
716
- note?.(
717
- CSV_LOSS.RESERVED_NAME,
718
- `${domain} column "${name}" is not written: the name is reserved for the label column`,
719
- name,
720
- set,
721
- );
722
- continue;
723
- }
724
- note?.(
725
- CSV_LOSS.ROLE_ASSUMED,
726
- `${domain} column "${name}" reads back with the label role (string)`,
727
- name,
728
- set,
729
- );
716
+ if (findColumn([name], LABEL_NAMES) >= 0 && !labelNameWritten(name, domain, labelColumn, set, note)) {
717
+ continue;
730
718
  }
731
719
  out.push({ column, header: name });
732
720
  }
733
721
  return out;
734
722
  }
735
723
 
724
+ /**
725
+ * Whether an attribute column's name is reserved for a structural column (a dialect header, an
726
+ * edge id name, the id column's name), with its note.
727
+ * @param name - the column name
728
+ * @param domain - node or edge, for messages
729
+ * @param reserved - lower-case header names the dialect writes for structure
730
+ * @param idColumn - the table's id role column, or null
731
+ * @param set - the column's set cells
732
+ * @param note - the recorder, or null when this table's notes are not wanted
733
+ * @returns true when the column is not written
734
+ */
735
+ function isReservedName(
736
+ name: string,
737
+ domain: "node" | "edge",
738
+ reserved: ReadonlySet<string>,
739
+ idColumn: Column | null,
740
+ set: number,
741
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
742
+ ): boolean {
743
+ const low = lower(name);
744
+ const isIdName = domain === "edge" ? findColumn([name], EDGE_ID_NAMES) >= 0 : false;
745
+ if (reserved.has(low) || isIdName || (idColumn !== null && lower(idColumn.meta.name) === low)) {
746
+ note?.(
747
+ CSV_LOSS.RESERVED_NAME,
748
+ `${domain} column "${name}" is not written: the name is reserved for the ${isIdName ? "edge id" : low} column`,
749
+ name,
750
+ set,
751
+ );
752
+ return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ /**
758
+ * Whether an attribute column with a label header name is written, with its note: not when the
759
+ * table has a label column (the name is reserved for it), else it reads back with the label role.
760
+ * @param name - the column name
761
+ * @param domain - node or edge, for messages
762
+ * @param labelColumn - the table's label role column, or null
763
+ * @param set - the column's set cells
764
+ * @param note - the recorder, or null when this table's notes are not wanted
765
+ * @returns true when the column is written
766
+ */
767
+ function labelNameWritten(
768
+ name: string,
769
+ domain: "node" | "edge",
770
+ labelColumn: Column | null,
771
+ set: number,
772
+ note: ((code: string, message: string, column?: string | null, count?: number | null) => void) | null,
773
+ ): boolean {
774
+ if (labelColumn !== null) {
775
+ note?.(
776
+ CSV_LOSS.RESERVED_NAME,
777
+ `${domain} column "${name}" is not written: the name is reserved for the label column`,
778
+ name,
779
+ set,
780
+ );
781
+ return false;
782
+ }
783
+ note?.(CSV_LOSS.ROLE_ASSUMED, `${domain} column "${name}" reads back with the label role (string)`, name, set);
784
+ return true;
785
+ }
786
+
736
787
  /**
737
788
  * Notes about a role column (id or label): a name the importer does not map to the role, and a
738
789
  * dtype other than string / dict (read back as string).
@@ -912,6 +963,22 @@ function neighbourCell(id: string, weight: string | null): string {
912
963
  return id.includes(":") ? `${id}:` : id;
913
964
  }
914
965
 
966
+ /**
967
+ * The end of the run of consecutive edge rows that share the source of row `r`.
968
+ * @param src - the edge sources by edge index
969
+ * @param edgeRows - the edges in write order
970
+ * @param r - the run's first row
971
+ * @returns the index after the run's last row
972
+ */
973
+ function sourceRunEnd(src: ArrayLike<number>, edgeRows: ArrayLike<number>, r: number): number {
974
+ const source = src[edgeRows[r]];
975
+ let end = r;
976
+ while (end < edgeRows.length && src[edgeRows[end]] === source) {
977
+ end++;
978
+ }
979
+ return end;
980
+ }
981
+
915
982
  /**
916
983
  * The rows of the adjacency table (design section 8.5: the order survives the importer's first-
917
984
  * appearance numbering). Consecutive edges with one source share a row, so the edge order is kept;
@@ -953,10 +1020,7 @@ function* adjacencyLines(snapshot: GraphSnapshot, plan: Plan): Generator<string,
953
1020
  let r = 0;
954
1021
  while (r < edgeRows.length) {
955
1022
  const source = list.src[edgeRows[r]];
956
- let end = r;
957
- while (end < edgeRows.length && list.src[edgeRows[end]] === source) {
958
- end++;
959
- }
1023
+ const end = sourceRunEnd(list.src, edgeRows, r);
960
1024
  // the nodes this row introduces, in the order the importer meets them; in index order from
961
1025
  // `next` they need nothing more
962
1026
  const fresh: number[] = [];