keepset 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
- import { $ as ArchiveError, H as getCollectionTarget, L as getOwn, U as getTarget, at as RecipeError, it as NativeParseError, rt as KeepsetError, t as getDataset, z as countTarget } from "../bundled-pT7q4sWm.js";
1
+ import { $ as ArchiveError, H as getCollectionTarget, L as getOwn, U as getTarget, at as RecipeError, it as NativeParseError, rt as KeepsetError, t as getDataset, z as countTarget } from "../bundled-OGg9e42x.js";
2
2
  import { RoundTripMismatchError, assertRoundTrip, encode } from "crossmap";
3
3
  import { z } from "zod";
4
4
  import { createHash } from "node:crypto";
5
5
  import { gunzipSync } from "node:zlib";
6
6
  import { unzipSync } from "fflate";
7
+ import unbzip2 from "unbzip2-stream";
7
8
  import { request } from "node:http";
8
9
  import { request as request$1 } from "node:https";
9
10
  //#region src/parsers/native.ts
@@ -36,6 +37,30 @@ var NodeOrder = class {
36
37
  function lines(text) {
37
38
  return text.split(/\r?\n/);
38
39
  }
40
+ const CARRIAGE_RETURN$1 = 13;
41
+ /**
42
+ * Visits the lines of a file read in pieces, accepting both LF and CRLF endings, holding only the unfinished end of a piece between pieces so a file larger than one string can be read.
43
+ * @param pieces - The file's text, in order, split anywhere (even between a carriage return and its line feed).
44
+ * @param visit - Called with each line, without its terminator, and its 1-based number. A final line without a terminator is visited; the empty text after a final terminator is not a line.
45
+ */
46
+ function forEachLine(pieces, visit) {
47
+ let carry = "";
48
+ let number = 0;
49
+ const emit = (text, start, end) => {
50
+ number += 1;
51
+ visit(end > start && text.charCodeAt(end - 1) === CARRIAGE_RETURN$1 ? text.slice(start, end - 1) : text.slice(start, end), number);
52
+ };
53
+ for (const piece of pieces) {
54
+ const text = carry + piece;
55
+ let start = 0;
56
+ for (let end = text.indexOf("\n", start); end !== -1; end = text.indexOf("\n", start)) {
57
+ emit(text, start, end);
58
+ start = end + 1;
59
+ }
60
+ carry = text.slice(start);
61
+ }
62
+ if (carry !== "") emit(carry, 0, carry.length);
63
+ }
39
64
  //#endregion
40
65
  //#region src/parsers/edge-list.ts
41
66
  /** How each fixed delimiter option splits a line into fields. */
@@ -53,7 +78,7 @@ const splitters = {
53
78
  * @returns The endpoint id.
54
79
  * @throws NativeParseError when the column is absent or empty.
55
80
  */
56
- function endpoint(fields, column, lineNumber) {
81
+ function endpoint$1(fields, column, lineNumber) {
57
82
  const value = fields[column];
58
83
  if (value === void 0 || value.trim() === "") throw new NativeParseError("edge-list", lineNumber, `column ${String(column)} holds no node id`);
59
84
  return value.trim();
@@ -86,8 +111,8 @@ function parseEdgeList(text, options) {
86
111
  split ??= splitters[detect(line)];
87
112
  const fields = split(line);
88
113
  if (fields.length < needed) throw new NativeParseError("edge-list", lineNumber, `expected at least ${String(needed)} fields, found ${String(fields.length)}`);
89
- const source = endpoint(fields, options.sourceColumn, lineNumber);
90
- const target = endpoint(fields, options.targetColumn, lineNumber);
114
+ const source = endpoint$1(fields, options.sourceColumn, lineNumber);
115
+ const target = endpoint$1(fields, options.targetColumn, lineNumber);
91
116
  order.add(source);
92
117
  order.add(target);
93
118
  edges.push({
@@ -103,8 +128,8 @@ function parseEdgeList(text, options) {
103
128
  }
104
129
  //#endregion
105
130
  //#region src/parsers/gd-contest.ts
106
- const FORMAT$8 = "gd-contest";
107
- const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
131
+ const FORMAT$11 = "gd-contest";
132
+ const NUMBER$9 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
108
133
  /**
109
134
  * Reads a vertex number, which the format numbers from 0 to N - 1.
110
135
  * @param token - The token.
@@ -112,7 +137,7 @@ const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
112
137
  * @returns The vertex id as text.
113
138
  */
114
139
  function vertex$1(token, count) {
115
- if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$8, token.line, `expected a vertex number from 0 to ${String(count - 1)}, found ${JSON.stringify(token.text)}`);
140
+ if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$11, token.line, `expected a vertex number from 0 to ${String(count - 1)}, found ${JSON.stringify(token.text)}`);
116
141
  return token.text;
117
142
  }
118
143
  /**
@@ -132,17 +157,17 @@ function parseGdContest(text) {
132
157
  });
133
158
  }
134
159
  const [first] = tokens;
135
- if (first === void 0) throw new NativeParseError(FORMAT$8, 1, "the file holds no vertex count");
136
- if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$8, first.line, `the vertex count must be a non-negative integer, found ${JSON.stringify(first.text)}`);
160
+ if (first === void 0) throw new NativeParseError(FORMAT$11, 1, "the file holds no vertex count");
161
+ if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$11, first.line, `the vertex count must be a non-negative integer, found ${JSON.stringify(first.text)}`);
137
162
  const count = Number(first.text);
138
163
  const coordinates = tokens.slice(1, 1 + 2 * count);
139
- if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$8, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
140
- for (const coordinate of coordinates) if (!NUMBER$8.test(coordinate.text)) throw new NativeParseError(FORMAT$8, coordinate.line, `coordinate ${JSON.stringify(coordinate.text)} is not a number`);
164
+ if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$11, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
165
+ for (const coordinate of coordinates) if (!NUMBER$9.test(coordinate.text)) throw new NativeParseError(FORMAT$11, coordinate.line, `coordinate ${JSON.stringify(coordinate.text)} is not a number`);
141
166
  const ends = tokens.slice(1 + 2 * count);
142
167
  const edges = [];
143
168
  for (let source = ends.shift(); source !== void 0; source = ends.shift()) {
144
169
  const target = ends.shift();
145
- if (target === void 0) throw new NativeParseError(FORMAT$8, source.line, `edge from vertex ${source.text} has no second vertex`);
170
+ if (target === void 0) throw new NativeParseError(FORMAT$11, source.line, `edge from vertex ${source.text} has no second vertex`);
146
171
  edges.push({
147
172
  source: vertex$1(source, count),
148
173
  target: vertex$1(target, count)
@@ -156,7 +181,7 @@ function parseGdContest(text) {
156
181
  //#endregion
157
182
  //#region src/parsers/gml.ts
158
183
  const KEY = /[A-Za-z_][A-Za-z0-9_]*/y;
159
- const NUMBER$7 = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/y;
184
+ const NUMBER$8 = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/y;
160
185
  /**
161
186
  * Matches a sticky pattern at a position.
162
187
  * @param pattern - A sticky regular expression.
@@ -207,7 +232,7 @@ function tokenise(text) {
207
232
  position = end + 1;
208
233
  } else {
209
234
  const key = matchAt(KEY, text, position);
210
- const number = key === void 0 ? matchAt(NUMBER$7, text, position) : void 0;
235
+ const number = key === void 0 ? matchAt(NUMBER$8, text, position) : void 0;
211
236
  const match = key ?? number;
212
237
  if (match === void 0) throw new NativeParseError("gml", line, `unexpected character ${JSON.stringify(char)}`);
213
238
  tokens.push({
@@ -311,6 +336,155 @@ function parseGml(text) {
311
336
  };
312
337
  }
313
338
  //#endregion
339
+ //#region src/parsers/konect.ts
340
+ const FORMAT$10 = "konect";
341
+ /** The network formats of KONECT's first line (Kunegis, "The KONECT Handbook", table 1): undirected, directed and bipartite. */
342
+ const NETWORK_FORMATS = /* @__PURE__ */ new Set([
343
+ "sym",
344
+ "asym",
345
+ "bip"
346
+ ]);
347
+ /** The weight types whose edges carry no weight: a third column, where the file has one, counts the edge's repetitions and is 1 for a single edge. */
348
+ const UNWEIGHTED_TYPES = /* @__PURE__ */ new Set(["unweighted", "positive"]);
349
+ /** The weight types whose third column is the edge's weight (handbook table 2). `dynamic` is not among them: its third column is +1 for an added edge and -1 for a removed one, which is not a weight. */
350
+ const WEIGHTED_TYPES = /* @__PURE__ */ new Set([
351
+ "posweighted",
352
+ "signed",
353
+ "multisigned",
354
+ "weighted",
355
+ "multiweighted",
356
+ "multiposweighted"
357
+ ]);
358
+ /** The first line, `% FORMAT WEIGHTS`. */
359
+ const FORMAT_LINE = /^%\s+(\S+)\s+(\S+)\s*$/;
360
+ /** The optional second line, `% EDGES SUBJECTS OBJECTS`: the number of edges and the number of nodes in each of the two sets (equal in a network that is not bipartite). */
361
+ const COUNT_LINE = /^%\s+(\d+)\s+(\d+)\s+(\d+)\s*$/;
362
+ /** A node id: KONECT numbers nodes from 1. */
363
+ const NODE_ID = /^[1-9]\d*$/;
364
+ /** A number as a decimal literal with an optional sign and exponent, so `Number` cannot read hex, an empty string or infinity. */
365
+ const DECIMAL$3 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
366
+ /** The columns of an edge line (handbook section 9.1): the two nodes, then optionally the weight, and the time only when the weight is also given. */
367
+ const ENDPOINT_COLUMNS = 2;
368
+ const WEIGHTED_COLUMNS = 3;
369
+ const TIMESTAMPED_COLUMNS = 4;
370
+ /** The prefix of the node ids in a bipartite network's left and right set, whose numbering each starts from 1. */
371
+ const LEFT_PREFIX = "l:";
372
+ const RIGHT_PREFIX = "r:";
373
+ /**
374
+ * Parses a KONECT TSV file (`out.<network>` in a KONECT download, in the format of section 9.1 of the KONECT Handbook, Kunegis 2017): a first line `% FORMAT WEIGHTS`, an optional second line `% EDGES SUBJECTS OBJECTS`, then one edge per line as two to four whitespace-separated numbers, the source node, the target node, the weight and the time. Other `%` lines are comments.
375
+ *
376
+ * Nodes are the endpoints in order of first appearance, as they are numbered in the file, except in a bipartite network, whose two node sets are numbered independently from 1: there a left node is `l:<n>` and a right node `r:<n>`, so left node 1 and right node 1 are two nodes. Edges are in file order. An edge carries `weight` when the recipe says the file's weights are read, and `timestamp` when it says the file has a time column. The recipe's `weighted` must agree with the file's weight type (`posweighted`, `signed`, `weighted` and their multi-edge forms are weighted; `unweighted` and `positive` are not, and an edge's repetition count in their third column must be 1) and `timestamped` with the file having four columns, so a recipe cannot silently drop a weight or a time the file holds. When the second line is present, its edge count must equal the number of edge lines and every node id must lie within its declared set.
377
+ * @param pieces - The file's text, in order.
378
+ * @param options - The recipe's parser.
379
+ * @returns The native graph.
380
+ * @throws NativeParseError naming the line for a missing or unknown first line, a `dynamic` network, a recipe that disagrees with the file, a malformed edge line, a node id out of its declared range, an edge count that differs from the declared one, and when the file holds no edges.
381
+ */
382
+ function parseKonect(pieces, options) {
383
+ const order = new NodeOrder();
384
+ const edges = [];
385
+ const state = {};
386
+ let lastLine = 0;
387
+ forEachLine(pieces, (line, number) => {
388
+ lastLine = number;
389
+ const content = line.trim();
390
+ if (content === "") return;
391
+ const { header } = state;
392
+ if (header === void 0) {
393
+ state.header = readHeader(content, number, options);
394
+ return;
395
+ }
396
+ if (content.startsWith("%")) {
397
+ const counts = number === header.line + 1 ? COUNT_LINE.exec(content) : null;
398
+ if (counts?.[1] !== void 0 && counts[2] !== void 0 && counts[3] !== void 0) state.header = {
399
+ ...header,
400
+ counts: {
401
+ edges: Number(counts[1]),
402
+ left: Number(counts[2]),
403
+ right: Number(counts[3])
404
+ }
405
+ };
406
+ return;
407
+ }
408
+ edges.push(readEdge(content, number, {
409
+ header,
410
+ options,
411
+ order
412
+ }));
413
+ });
414
+ const { header } = state;
415
+ if (header === void 0) throw new NativeParseError(FORMAT$10, lastLine, "the file has no first line naming its format and weights");
416
+ if (edges.length === 0) throw new NativeParseError(FORMAT$10, lastLine, "the file holds no edges");
417
+ if (header.counts !== void 0 && header.counts.edges !== edges.length) throw new NativeParseError(FORMAT$10, lastLine, `the second line declares ${String(header.counts.edges)} edges, the file holds ${String(edges.length)}`);
418
+ return {
419
+ nodes: order.nodes(),
420
+ edges
421
+ };
422
+ }
423
+ /**
424
+ * Reads the first line and checks the recipe against it.
425
+ * @param content - The trimmed first non-blank line.
426
+ * @param number - Its 1-based number.
427
+ * @param options - The recipe's parser.
428
+ * @returns The format and weight type it declares.
429
+ * @throws NativeParseError when it is not `% FORMAT WEIGHTS` with a known format, when the network is dynamic, or when the recipe's `weighted` disagrees with the weight type.
430
+ */
431
+ function readHeader(content, number, options) {
432
+ const match = FORMAT_LINE.exec(content);
433
+ const network = match?.[1];
434
+ const weights = match?.[2];
435
+ if (network === void 0 || weights === void 0 || !NETWORK_FORMATS.has(network)) throw new NativeParseError(FORMAT$10, number, `expected a first line "% FORMAT WEIGHTS" with FORMAT one of ${[...NETWORK_FORMATS].join(", ")}, found ${JSON.stringify(content)}`);
436
+ if (!WEIGHTED_TYPES.has(weights) && !UNWEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$10, number, `the weight type ${JSON.stringify(weights)} is not one this parser reads (its third column is not an edge weight or a repetition count)`);
437
+ if (options.weighted !== WEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$10, number, `the file's weight type is ${weights}, so a recipe with weighted ${String(WEIGHTED_TYPES.has(weights))} reads it, not one with weighted ${String(options.weighted)}`);
438
+ return {
439
+ line: number,
440
+ network
441
+ };
442
+ }
443
+ /**
444
+ * Reads one edge line.
445
+ * @param content - The trimmed line.
446
+ * @param number - Its 1-based number.
447
+ * @param context - What the first lines declare (`header`), the recipe's parser (`options`) and the `order` that receives the edge's endpoints.
448
+ * @returns The edge.
449
+ * @throws NativeParseError when the line has the wrong number of columns, a node id that is not a positive integer or is outside its declared set, a weight or time that is not a number, or a repetition count other than 1.
450
+ */
451
+ function readEdge(content, number, context) {
452
+ const { header, options, order } = context;
453
+ const fields = content.split(/\s+/);
454
+ const [from, to, third, time] = fields;
455
+ const expected = options.timestamped ? TIMESTAMPED_COLUMNS : options.weighted ? WEIGHTED_COLUMNS : void 0;
456
+ const fits = expected === void 0 ? fields.length === ENDPOINT_COLUMNS || fields.length === WEIGHTED_COLUMNS : fields.length === expected;
457
+ if (from === void 0 || to === void 0 || !fits) throw new NativeParseError(FORMAT$10, number, `expected ${expected === void 0 ? `${String(ENDPOINT_COLUMNS)} or ${String(WEIGHTED_COLUMNS)}` : String(expected)} columns for this recipe, found ${String(fields.length)}`);
458
+ const bipartite = header.network === "bip";
459
+ const source = endpoint(from, bipartite ? LEFT_PREFIX : "", header.counts?.left, number);
460
+ const target = endpoint(to, bipartite ? RIGHT_PREFIX : "", header.counts?.right, number);
461
+ order.add(source);
462
+ order.add(target);
463
+ if (third !== void 0 && !options.weighted && (!DECIMAL$3.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$10, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
464
+ if (third !== void 0 && options.weighted && !DECIMAL$3.test(third)) throw new NativeParseError(FORMAT$10, number, `the weight ${JSON.stringify(third)} is not a number`);
465
+ if (time !== void 0 && !DECIMAL$3.test(time)) throw new NativeParseError(FORMAT$10, number, `the time ${JSON.stringify(time)} is not a number`);
466
+ const edge = {
467
+ source,
468
+ target
469
+ };
470
+ if (options.weighted && third !== void 0) edge.weight = Number(third);
471
+ if (options.timestamped && time !== void 0) edge.timestamp = Number(time);
472
+ return edge;
473
+ }
474
+ /**
475
+ * Reads a node id.
476
+ * @param token - The id as written.
477
+ * @param prefix - The prefix of the node's set in a bipartite network, empty otherwise.
478
+ * @param size - The declared size of the node's set, when the file declares one.
479
+ * @param number - The 1-based line, for the error.
480
+ * @returns The node id.
481
+ * @throws NativeParseError when the token is not a positive integer or exceeds the set's size.
482
+ */
483
+ function endpoint(token, prefix, size, number) {
484
+ if (!NODE_ID.test(token) || size !== void 0 && Number(token) > size) throw new NativeParseError(FORMAT$10, number, `${JSON.stringify(token)} is not a node id from 1${size === void 0 ? "" : ` to ${String(size)}`}`);
485
+ return `${prefix}${token}`;
486
+ }
487
+ //#endregion
314
488
  //#region src/parsers/matrix-market.ts
315
489
  /** How many tokens an entry line of each field type holds: the coordinates, then the value's parts (none for a pattern, a real and an imaginary part for a complex value). */
316
490
  const tokensPerEntry = /* @__PURE__ */ new Map([
@@ -565,6 +739,15 @@ function* records(pieces, delimiter, firstLine) {
565
739
  if (last !== void 0) yield last;
566
740
  }
567
741
  /**
742
+ * Splits comma-separated text into its records' fields by RFC 4180, as `records` does, keeping every record (blank lines included) at its position: the grid a workbook sheet's CSV makes, for a parser that reads a layout of blocks and not one table.
743
+ * @param text - The text.
744
+ * @returns Each record's fields, in order.
745
+ * @throws NativeParseError when a quoted field is never closed or a quote opens inside an unquoted field.
746
+ */
747
+ function csvGrid(text) {
748
+ return [...records([text], ",", 1)].map((record) => record.fields);
749
+ }
750
+ /**
568
751
  * Drops the first physical lines of a file given in pieces.
569
752
  * @param pieces - The file text in order.
570
753
  * @param count - How many physical lines to drop.
@@ -711,7 +894,7 @@ function pointOf(geometry) {
711
894
  * @returns The cell: null for a missing, null or empty value.
712
895
  * @throws NativeParseError when the value is not the kind the recipe declares.
713
896
  */
714
- function cellOf(value, column, feature, kind) {
897
+ function cellOf$1(value, column, feature, kind) {
715
898
  if (value === void 0 || value === null) return null;
716
899
  if (kind === "date") {
717
900
  if (typeof value !== "number" || !Number.isFinite(value)) throw new NativeParseError("geojson", feature, `column ${column} is declared a date but holds ${JSON.stringify(value)}`);
@@ -755,7 +938,7 @@ function parseGeojson(text, options) {
755
938
  const properties = feature.properties ?? {};
756
939
  const point = pointOf(feature.geometry);
757
940
  const own = (column) => Object.hasOwn(properties, column) ? properties[column] : void 0;
758
- const id = options.idColumn === null ? String(number) : cellOf(own(options.idColumn), options.idColumn, number, "text");
941
+ const id = options.idColumn === null ? String(number) : cellOf$1(own(options.idColumn), options.idColumn, number, "text");
759
942
  if (id === null) throw new NativeParseError("geojson", number, `the id column ${options.idColumn ?? ""} is empty`);
760
943
  const cells = kinds.map(({ column, kind }) => {
761
944
  if (column === POINT_COLUMNS.latitude || column === POINT_COLUMNS.longitude) {
@@ -763,7 +946,7 @@ function parseGeojson(text, options) {
763
946
  const position = column === POINT_COLUMNS.latitude ? point?.latitude : point?.longitude;
764
947
  return [column, position === void 0 ? null : position * (typeof kind === "object" ? kind.factor : 1)];
765
948
  }
766
- return [column, cellOf(own(column), column, number, kind)];
949
+ return [column, cellOf$1(own(column), column, number, kind)];
767
950
  });
768
951
  return {
769
952
  ...Object.fromEntries(cells),
@@ -772,6 +955,205 @@ function parseGeojson(text, options) {
772
955
  }) };
773
956
  }
774
957
  //#endregion
958
+ //#region src/parsers/hhcrsp.ts
959
+ const FORMAT$9 = "hhcrsp";
960
+ /** The labels of the sheet's blocks, each the top-left cell of a block whose header runs to its right and whose rows run below it. */
961
+ const LABEL = {
962
+ patients: "Patients",
963
+ requests: "Pat / Ser",
964
+ times: "P/SerTime",
965
+ skills: "CgSkills",
966
+ caregiverWindows: "TwCaregivers",
967
+ patientWindows: "Tw Patients"
968
+ };
969
+ /** A service's header, `Service <k>`, a patient's row label and a caregiver's. */
970
+ const SERVICE = /^Service (\d+)$/;
971
+ const NUMBER$7 = /^-?\d+(?:\.\d+)?$/;
972
+ /** How many required services a patient may have: the instances ask for one, or two that must be synchronised, and the parser has a duration column for each. */
973
+ const MOST_SERVICES = 2;
974
+ /**
975
+ * Finds a block by its label: the one cell of the sheet that holds it, its header the cells to its right up to the first empty one, and its rows those below it up to the first whose label and cells are all empty. Past its header a block has an empty column, so the cell there must be empty in every row and a value the header does not label is refused rather than left unread; `Pat / Ser` instead keeps its count there, with the next block starting right after it, and its count is checked on its own.
976
+ * @param grid - The sheet's cells by row.
977
+ * @param label - The block's label.
978
+ * @param pastHeader - What the column past the header holds: nothing, or the block's count.
979
+ * @returns The block.
980
+ * @throws NativeParseError when the label is in no cell or in more than one, or a row has a value in the empty column past the header.
981
+ */
982
+ function blockOf(grid, label, pastHeader = "empty") {
983
+ const found = grid.flatMap((row, line) => row.flatMap((cell, column) => cell.trim() === label ? [{
984
+ line,
985
+ column
986
+ }] : []));
987
+ const [only] = found;
988
+ if (only === void 0 || found.length !== 1) throw new NativeParseError(FORMAT$9, 1, `expected one block labelled ${JSON.stringify(label)}, found ${String(found.length)}`);
989
+ const headerRow = grid[only.line] ?? [];
990
+ const end = headerRow.findIndex((cell, column) => column > only.column && cell.trim() === "");
991
+ const header = headerRow.slice(only.column + 1, end === -1 ? headerRow.length : end).map((cell) => cell.trim());
992
+ const rows = [];
993
+ for (let line = only.line + 1; line < grid.length; line += 1) {
994
+ const cells = (grid[line] ?? []).slice(only.column, only.column + 1 + header.length).map((cell) => cell.trim());
995
+ if (cells.every((cell) => cell === "")) break;
996
+ const past = (grid[line] ?? [])[only.column + 1 + header.length]?.trim();
997
+ if (pastHeader === "empty" && past !== void 0 && past !== "") throw new NativeParseError(FORMAT$9, line + 1, `the ${label} block's row holds ${JSON.stringify(past)} past its header ${JSON.stringify(header)}, which does not label it`);
998
+ rows.push({
999
+ line: line + 1,
1000
+ label: cells[0] ?? "",
1001
+ cells: cells.slice(1)
1002
+ });
1003
+ }
1004
+ return {
1005
+ line: only.line + 1,
1006
+ column: only.column,
1007
+ header,
1008
+ rows
1009
+ };
1010
+ }
1011
+ /**
1012
+ * Reads a cell as a finite number.
1013
+ * @param cell - The trimmed cell.
1014
+ * @param line - The 1-based line, for the error.
1015
+ * @param what - What the cell should be, for the error.
1016
+ * @returns The number.
1017
+ * @throws NativeParseError when the cell is not a decimal number.
1018
+ */
1019
+ function numberOf$4(cell, line, what) {
1020
+ if (cell === void 0 || !NUMBER$7.test(cell)) throw new NativeParseError(FORMAT$9, line, `expected ${what}, found ${JSON.stringify(cell)}`);
1021
+ return Number(cell);
1022
+ }
1023
+ /**
1024
+ * Checks that a block's rows are labelled `<noun> 1` to `<noun> <count>` in order.
1025
+ * @param block - The block.
1026
+ * @param noun - The row label's word.
1027
+ * @param label - The block's label, for the error.
1028
+ * @throws NativeParseError naming the first row out of order.
1029
+ */
1030
+ function checkNumbered(block, noun, label) {
1031
+ block.rows.forEach((row, index) => {
1032
+ if (row.label !== `${noun} ${String(index + 1)}`) throw new NativeParseError(FORMAT$9, row.line, `the ${label} block's row ${String(index + 1)} is labelled ${JSON.stringify(row.label)}, not ${noun} ${String(index + 1)}`);
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Checks that a block's header names the services 1 to k in order.
1037
+ * @param block - The block.
1038
+ * @param label - The block's label, for the error.
1039
+ * @returns The number of services, k.
1040
+ * @throws NativeParseError when a header cell is not the next service.
1041
+ */
1042
+ function serviceCount(block, label) {
1043
+ block.header.forEach((cell, index) => {
1044
+ if (SERVICE.exec(cell)?.[1] !== String(index + 1)) throw new NativeParseError(FORMAT$9, block.line, `the ${label} block's header cell ${String(index + 1)} is ${JSON.stringify(cell)}, not Service ${String(index + 1)}`);
1045
+ });
1046
+ return block.header.length;
1047
+ }
1048
+ /**
1049
+ * Parses one sheet of a home health care routing and scheduling workbook (the layout of Bazirha's multiple-time-window, multiple-service instances, generated from Mankowska et al. 2014), read as the CSV of the sheet. The sheet holds labelled blocks side by side: `Patients` gives each node's X and Y, the care centre as node 0 first and again as the last row (the route's end, which must repeat the centre's position); `Pat / Ser` marks which of the services each patient requires with a 1 (a 0 or an empty cell is a service not required) and counts them in the column after the services, and the count must agree with the marks; `P/SerTime` gives each patient's duration for every service, after a row of zeros for the centre whose label may be empty; `CgSkills` and `TwCaregivers` give the caregivers' skills and working windows; and `Tw Patients` gives each patient's time windows by the headers `ETW 1` and `LTW 1`, and `ETW 2` and `LTW 2` where there is a second, in whatever order the sheet puts them. The travel time block, the caregivers' skills and their windows are not carried, since tasks/v1 has neither arcs nor resources: the caregivers are counted in `meta`. The sheets name no unit for their times.
1050
+ * @param text - The sheet's CSV text.
1051
+ * @returns The instance: its patient, caregiver and service counts as `meta`, and its nodes, the centre (id `0`) first and the patients (ids `1` to n) after it. A patient's `services` names its required services joined by a comma and a space (`Service 1, Service 5`), and its `duration1` and `duration2` are those services' durations in that order.
1052
+ * @throws NativeParseError when a block is missing or repeated, its rows or header are not the numbered patients, caregivers or services it should hold, the blocks disagree on the number of patients or caregivers, the centre's closing row does not repeat it, a patient requires no service or more than two, a requirement is not 0, 1 or empty or the count beside the marks disagrees with them, or a cell that should be a number is not.
1053
+ */
1054
+ function parseHhcrsp(text) {
1055
+ const grid = csvGrid(text);
1056
+ const positions = blockOf(grid, LABEL.patients);
1057
+ const requests = blockOf(grid, LABEL.requests, "count");
1058
+ const times = blockOf(grid, LABEL.times);
1059
+ const skills = blockOf(grid, LABEL.skills);
1060
+ const caregiverWindows = blockOf(grid, LABEL.caregiverWindows);
1061
+ const windows = blockOf(grid, LABEL.patientWindows);
1062
+ const serviceKinds = serviceCount(requests, LABEL.requests);
1063
+ if (serviceCount(times, LABEL.times) !== serviceKinds || serviceCount(skills, LABEL.skills) !== serviceKinds) throw new NativeParseError(FORMAT$9, times.line, `the ${LABEL.requests}, ${LABEL.times} and ${LABEL.skills} blocks name different numbers of services`);
1064
+ checkNumbered(requests, "Patient", LABEL.requests);
1065
+ checkNumbered(windows, "Patient", LABEL.patientWindows);
1066
+ checkNumbered(skills, "Caregiver", LABEL.skills);
1067
+ checkNumbered(caregiverWindows, "Caregiver", LABEL.caregiverWindows);
1068
+ const patients = requests.rows.length;
1069
+ const [centreTimes, ...patientTimes] = times.rows;
1070
+ checkNumbered({
1071
+ ...times,
1072
+ rows: patientTimes
1073
+ }, "Patient", LABEL.times);
1074
+ if (centreTimes === void 0 || ![
1075
+ "",
1076
+ "center",
1077
+ "centre"
1078
+ ].includes(centreTimes.label.toLowerCase()) || windows.rows.length !== patients || patientTimes.length !== patients || skills.rows.length !== caregiverWindows.rows.length) throw new NativeParseError(FORMAT$9, requests.line, `the blocks disagree: ${String(patients)} patients requesting services, ${String(patientTimes.length)} with durations after the centre's row, ${String(windows.rows.length)} with time windows, and ${String(skills.rows.length)} caregivers with skills against ${String(caregiverWindows.rows.length)} with windows`);
1079
+ if (positions.header.join(",") !== "X,Y" || positions.rows.length !== patients + 2 || positions.rows.some((row, index) => row.label !== String(index))) throw new NativeParseError(FORMAT$9, positions.line, `the ${LABEL.patients} block must give X and Y for nodes 0 to ${String(patients + 1)}, the centre, ${String(patients)} patients and the centre again`);
1080
+ const position = (index) => {
1081
+ const row = positions.rows[index];
1082
+ if (row === void 0) throw new NativeParseError(FORMAT$9, positions.line, `the ${LABEL.patients} block has no node ${String(index)}`);
1083
+ return {
1084
+ x: numberOf$4(row.cells[0], row.line, "an X"),
1085
+ y: numberOf$4(row.cells[1], row.line, "a Y"),
1086
+ line: row.line
1087
+ };
1088
+ };
1089
+ const { x, y } = position(0);
1090
+ const closing = position(patients + 1);
1091
+ if (closing.x !== x || closing.y !== y) throw new NativeParseError(FORMAT$9, closing.line, `the last node of the ${LABEL.patients} block is not at the centre's position, so it is not the route's end`);
1092
+ const windowColumn = (name) => {
1093
+ const index = windows.header.indexOf(name);
1094
+ return index === -1 ? void 0 : index;
1095
+ };
1096
+ const columns = {
1097
+ earliest1: windowColumn("ETW 1"),
1098
+ latest1: windowColumn("LTW 1"),
1099
+ earliest2: windowColumn("ETW 2"),
1100
+ latest2: windowColumn("LTW 2")
1101
+ };
1102
+ if (columns.earliest1 === void 0 || columns.latest1 === void 0 || columns.earliest2 === void 0 !== (columns.latest2 === void 0) || windows.header.length !== (columns.earliest2 === void 0 ? 2 : 4)) throw new NativeParseError(FORMAT$9, windows.line, `the ${LABEL.patientWindows} header ${JSON.stringify(windows.header)} is not ETW 1 and LTW 1, with or without ETW 2 and LTW 2`);
1103
+ const nodes = [{
1104
+ id: "0",
1105
+ role: "centre",
1106
+ x,
1107
+ y,
1108
+ services: null,
1109
+ duration1: null,
1110
+ duration2: null,
1111
+ earliest1: null,
1112
+ latest1: null,
1113
+ earliest2: null,
1114
+ latest2: null
1115
+ }];
1116
+ requests.rows.forEach((request, index) => {
1117
+ const required = request.cells.flatMap((cell, service) => {
1118
+ if (cell !== "0" && cell !== "1" && cell !== "") throw new NativeParseError(FORMAT$9, request.line, `${request.label} has ${JSON.stringify(cell)} for Service ${String(service + 1)}, not 0, 1 or empty`);
1119
+ return cell === "1" ? [service] : [];
1120
+ });
1121
+ const stated = (grid[request.line - 1] ?? [])[requests.column + serviceKinds + 1]?.trim();
1122
+ if (stated !== String(required.length)) throw new NativeParseError(FORMAT$9, request.line, `${request.label} requires ${String(required.length)} services by its marks, and the count beside them is ${JSON.stringify(stated)}`);
1123
+ if (required.length === 0 || required.length > MOST_SERVICES) throw new NativeParseError(FORMAT$9, request.line, `${request.label} requires ${String(required.length)} services, and a patient requires one or two`);
1124
+ const timeRow = patientTimes[index];
1125
+ const windowRow = windows.rows[index];
1126
+ if (timeRow === void 0 || windowRow === void 0) throw new NativeParseError(FORMAT$9, request.line, `${request.label} has no durations or time windows`);
1127
+ const duration = (which) => {
1128
+ const service = required[which];
1129
+ return service === void 0 ? null : numberOf$4(timeRow.cells[service], timeRow.line, `the duration of Service ${String(service + 1)}`);
1130
+ };
1131
+ const window = (column, what) => column === void 0 ? null : numberOf$4(windowRow.cells[column], windowRow.line, what);
1132
+ const at = position(index + 1);
1133
+ nodes.push({
1134
+ id: String(index + 1),
1135
+ role: "patient",
1136
+ x: at.x,
1137
+ y: at.y,
1138
+ services: required.map((service) => `Service ${String(service + 1)}`).join(", "),
1139
+ duration1: duration(0),
1140
+ duration2: duration(1),
1141
+ earliest1: window(columns.earliest1, "the opening of the first window"),
1142
+ latest1: window(columns.latest1, "the closing of the first window"),
1143
+ earliest2: window(columns.earliest2, "the opening of the second window"),
1144
+ latest2: window(columns.latest2, "the closing of the second window")
1145
+ });
1146
+ });
1147
+ return {
1148
+ meta: {
1149
+ patients,
1150
+ caregivers: skills.rows.length,
1151
+ serviceKinds
1152
+ },
1153
+ nodes
1154
+ };
1155
+ }
1156
+ //#endregion
775
1157
  //#region src/parsers/pajek.ts
776
1158
  /** Section keywords, lower-cased, mapped to how their lines are read. `*Network` names the network and starts no section. `*Partition` and `*Vector` hold node attributes this version does not carry, so their lines are skipped, as are the `*Vertices` lines inside them. */
777
1159
  const sections = /* @__PURE__ */ new Map([
@@ -885,7 +1267,7 @@ function parsePajek(text) {
885
1267
  }
886
1268
  //#endregion
887
1269
  //#region src/parsers/customer-points.ts
888
- const FORMAT$7 = "customer-points";
1270
+ const FORMAT$8 = "customer-points";
889
1271
  const NUMBER$6 = /^-?\d+(?:\.\d+)?$/;
890
1272
  const WHOLE$2 = /^\d+$/;
891
1273
  /**
@@ -901,12 +1283,12 @@ function parseCustomerPoints(text) {
901
1283
  })).filter((row) => row.fields.length > 0);
902
1284
  const [first, ...body] = rows;
903
1285
  const [key, count, ...extra] = first?.fields ?? [];
904
- if (first === void 0 || key !== "customers" || count === void 0 || !WHOLE$2.test(count) || extra.length > 0) throw new NativeParseError(FORMAT$7, first?.line ?? 1, `expected "customers <count>", found ${JSON.stringify(first?.fields.join(" "))}`);
1286
+ if (first === void 0 || key !== "customers" || count === void 0 || !WHOLE$2.test(count) || extra.length > 0) throw new NativeParseError(FORMAT$8, first?.line ?? 1, `expected "customers <count>", found ${JSON.stringify(first?.fields.join(" "))}`);
905
1287
  const seen = /* @__PURE__ */ new Set();
906
1288
  const nodes = body.map((row) => {
907
1289
  const [id, x, y, ...rest] = row.fields;
908
- if (id === void 0 || x === void 0 || y === void 0 || rest.length > 0 || !WHOLE$2.test(id) || !NUMBER$6.test(x) || !NUMBER$6.test(y)) throw new NativeParseError(FORMAT$7, row.line, `expected a customer id and two numbers, found ${JSON.stringify(row.fields.join(" "))}`);
909
- if (seen.has(id)) throw new NativeParseError(FORMAT$7, row.line, `customer ${id} appears twice`);
1290
+ if (id === void 0 || x === void 0 || y === void 0 || rest.length > 0 || !WHOLE$2.test(id) || !NUMBER$6.test(x) || !NUMBER$6.test(y)) throw new NativeParseError(FORMAT$8, row.line, `expected a customer id and two numbers, found ${JSON.stringify(row.fields.join(" "))}`);
1291
+ if (seen.has(id)) throw new NativeParseError(FORMAT$8, row.line, `customer ${id} appears twice`);
910
1292
  seen.add(id);
911
1293
  return {
912
1294
  id,
@@ -914,7 +1296,7 @@ function parseCustomerPoints(text) {
914
1296
  y: Number(y)
915
1297
  };
916
1298
  });
917
- if (nodes.length !== Number(count)) throw new NativeParseError(FORMAT$7, rows[rows.length - 1]?.line ?? first.line, `the file announces ${count} customers, found ${String(nodes.length)}`);
1299
+ if (nodes.length !== Number(count)) throw new NativeParseError(FORMAT$8, rows[rows.length - 1]?.line ?? first.line, `the file announces ${count} customers, found ${String(nodes.length)}`);
918
1300
  return {
919
1301
  meta: { customers: Number(count) },
920
1302
  nodes
@@ -922,7 +1304,7 @@ function parseCustomerPoints(text) {
922
1304
  }
923
1305
  //#endregion
924
1306
  //#region src/parsers/solomon.ts
925
- const FORMAT$6 = "solomon";
1307
+ const FORMAT$7 = "solomon";
926
1308
  /** A line of integers or decimals separated by any run of spaces and tabs, as the files write rows (Solomon's with padded spaces, the Tenerife set with tabs). */
927
1309
  const NUMBER$5 = /^-?\d+(?:\.\d+)?$/;
928
1310
  /**
@@ -934,7 +1316,7 @@ const NUMBER$5 = /^-?\d+(?:\.\d+)?$/;
934
1316
  * @throws NativeParseError when the token is not a decimal number.
935
1317
  */
936
1318
  function numberOf$3(token, line, what) {
937
- if (!NUMBER$5.test(token)) throw new NativeParseError(FORMAT$6, line, `expected ${what}, found ${JSON.stringify(token)}`);
1319
+ if (!NUMBER$5.test(token)) throw new NativeParseError(FORMAT$7, line, `expected ${what}, found ${JSON.stringify(token)}`);
938
1320
  return Number(token);
939
1321
  }
940
1322
  /**
@@ -947,7 +1329,7 @@ function parseSolomon(text) {
947
1329
  const all = lines(text);
948
1330
  const present = (index) => (all[index] ?? "").trim();
949
1331
  const name = present(0);
950
- if (name === "") throw new NativeParseError(FORMAT$6, 1, "expected the instance name on the first line");
1332
+ if (name === "") throw new NativeParseError(FORMAT$7, 1, "expected the instance name on the first line");
951
1333
  const meta = { name };
952
1334
  const nodes = [];
953
1335
  const seen = /* @__PURE__ */ new Set();
@@ -963,7 +1345,7 @@ function parseSolomon(text) {
963
1345
  awaitingHeader = true;
964
1346
  continue;
965
1347
  }
966
- if (section === "preamble") throw new NativeParseError(FORMAT$6, line, `expected VEHICLE or CUSTOMER, found ${JSON.stringify(trimmed)}`);
1348
+ if (section === "preamble") throw new NativeParseError(FORMAT$7, line, `expected VEHICLE or CUSTOMER, found ${JSON.stringify(trimmed)}`);
967
1349
  if (awaitingHeader) {
968
1350
  awaitingHeader = false;
969
1351
  continue;
@@ -971,15 +1353,15 @@ function parseSolomon(text) {
971
1353
  const tokens = trimmed.split(/\s+/);
972
1354
  if (section === "vehicle") {
973
1355
  const [vehicles, capacity, ...extra] = tokens;
974
- if (vehicles === void 0 || capacity === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$6, line, `expected the vehicle count and capacity, found ${JSON.stringify(trimmed)}`);
1356
+ if (vehicles === void 0 || capacity === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$7, line, `expected the vehicle count and capacity, found ${JSON.stringify(trimmed)}`);
975
1357
  meta.vehicles = numberOf$3(vehicles, line, "the vehicle count");
976
1358
  meta.capacity = numberOf$3(capacity, line, "the vehicle capacity");
977
1359
  continue;
978
1360
  }
979
1361
  const [id, x, y, demand, ready, due, service, ...extra] = tokens;
980
- if (id === void 0 || x === void 0 || y === void 0 || demand === void 0 || ready === void 0 || due === void 0 || service === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$6, line, `expected seven numbers (id, x, y, demand, ready, due, service), found ${JSON.stringify(trimmed)}`);
981
- if (!/^\d+$/.test(id)) throw new NativeParseError(FORMAT$6, line, `expected a whole number node id, found ${JSON.stringify(id)}`);
982
- if (seen.has(id)) throw new NativeParseError(FORMAT$6, line, `node ${id} appears twice`);
1362
+ if (id === void 0 || x === void 0 || y === void 0 || demand === void 0 || ready === void 0 || due === void 0 || service === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$7, line, `expected seven numbers (id, x, y, demand, ready, due, service), found ${JSON.stringify(trimmed)}`);
1363
+ if (!/^\d+$/.test(id)) throw new NativeParseError(FORMAT$7, line, `expected a whole number node id, found ${JSON.stringify(id)}`);
1364
+ if (seen.has(id)) throw new NativeParseError(FORMAT$7, line, `node ${id} appears twice`);
983
1365
  seen.add(id);
984
1366
  nodes.push({
985
1367
  id,
@@ -991,9 +1373,9 @@ function parseSolomon(text) {
991
1373
  service: numberOf$3(service, line, "the service time")
992
1374
  });
993
1375
  }
994
- if (section !== "customer") throw new NativeParseError(FORMAT$6, all.length, "expected a CUSTOMER section");
995
- if (nodes.length === 0) throw new NativeParseError(FORMAT$6, all.length, "the CUSTOMER section has no rows");
996
- if (meta.vehicles === void 0 || meta.capacity === void 0) throw new NativeParseError(FORMAT$6, all.length, "expected a VEHICLE section with the vehicle count and capacity");
1376
+ if (section !== "customer") throw new NativeParseError(FORMAT$7, all.length, "expected a CUSTOMER section");
1377
+ if (nodes.length === 0) throw new NativeParseError(FORMAT$7, all.length, "the CUSTOMER section has no rows");
1378
+ if (meta.vehicles === void 0 || meta.capacity === void 0) throw new NativeParseError(FORMAT$7, all.length, "expected a VEHICLE section with the vehicle count and capacity");
997
1379
  return {
998
1380
  meta: {
999
1381
  name: meta.name,
@@ -1005,7 +1387,7 @@ function parseSolomon(text) {
1005
1387
  }
1006
1388
  //#endregion
1007
1389
  //#region src/parsers/top.ts
1008
- const FORMAT$5 = "top";
1390
+ const FORMAT$6 = "top";
1009
1391
  /** The header lines before the first point: `n`, `m` and `tmax`. */
1010
1392
  const HEADER_LINES = 3;
1011
1393
  const NUMBER$4 = /^-?\d+(?:\.\d+)?$/;
@@ -1019,7 +1401,7 @@ const NUMBER$4 = /^-?\d+(?:\.\d+)?$/;
1019
1401
  */
1020
1402
  function headerOf(text, key, line) {
1021
1403
  const [name, value, ...extra] = (text ?? "").trim().split(/\s+/);
1022
- if (name !== key || value === void 0 || extra.length > 0 || !NUMBER$4.test(value)) throw new NativeParseError(FORMAT$5, line, `expected "${key} <number>", found ${JSON.stringify((text ?? "").trim())}`);
1404
+ if (name !== key || value === void 0 || extra.length > 0 || !NUMBER$4.test(value)) throw new NativeParseError(FORMAT$6, line, `expected "${key} <number>", found ${JSON.stringify((text ?? "").trim())}`);
1023
1405
  return Number(value);
1024
1406
  }
1025
1407
  /**
@@ -1038,11 +1420,11 @@ function parseTop(text) {
1038
1420
  const points = headerOf(first?.text, "n", first?.line ?? 1);
1039
1421
  const paths = headerOf(second?.text, "m", second?.line ?? 2);
1040
1422
  const budget = headerOf(third?.text, "tmax", third?.line ?? HEADER_LINES);
1041
- if (!Number.isInteger(points) || points < 2) throw new NativeParseError(FORMAT$5, first?.line ?? 1, `expected at least a start and an end point, found n ${String(points)}`);
1042
- if (rows.length !== points) throw new NativeParseError(FORMAT$5, all[all.length - 1]?.line ?? 1, `the header gives ${String(points)} points, found ${String(rows.length)} rows`);
1423
+ if (!Number.isInteger(points) || points < 2) throw new NativeParseError(FORMAT$6, first?.line ?? 1, `expected at least a start and an end point, found n ${String(points)}`);
1424
+ if (rows.length !== points) throw new NativeParseError(FORMAT$6, all[all.length - 1]?.line ?? 1, `the header gives ${String(points)} points, found ${String(rows.length)} rows`);
1043
1425
  const nodes = rows.map((row, index) => {
1044
1426
  const [x, y, score, ...extra] = row.text.split(/\s+/);
1045
- if (x === void 0 || y === void 0 || score === void 0 || extra.length > 0 || !NUMBER$4.test(x) || !NUMBER$4.test(y) || !NUMBER$4.test(score)) throw new NativeParseError(FORMAT$5, row.line, `expected three numbers (x, y, score), found ${JSON.stringify(row.text)}`);
1427
+ if (x === void 0 || y === void 0 || score === void 0 || extra.length > 0 || !NUMBER$4.test(x) || !NUMBER$4.test(y) || !NUMBER$4.test(score)) throw new NativeParseError(FORMAT$6, row.line, `expected three numbers (x, y, score), found ${JSON.stringify(row.text)}`);
1046
1428
  return {
1047
1429
  id: String(index),
1048
1430
  x: Number(x),
@@ -1062,7 +1444,7 @@ function parseTop(text) {
1062
1444
  }
1063
1445
  //#endregion
1064
1446
  //#region src/parsers/toptw.ts
1065
- const FORMAT$4 = "toptw";
1447
+ const FORMAT$5 = "toptw";
1066
1448
  /** How many numbers the first header line holds (k, paths, customers, t) and the second (D, capacity). */
1067
1449
  const FIRST_HEADER_NUMBERS = 4;
1068
1450
  const SECOND_HEADER_NUMBERS = 2;
@@ -1080,7 +1462,7 @@ const NUMBER$3 = /^-?\d+(?:\.\d+)?$/;
1080
1462
  * @throws NativeParseError when the token is not a decimal number.
1081
1463
  */
1082
1464
  function numberOf$2(token, line, what) {
1083
- if (token === void 0 || !NUMBER$3.test(token)) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(token)}`);
1465
+ if (token === void 0 || !NUMBER$3.test(token)) throw new NativeParseError(FORMAT$5, line, `expected ${what}, found ${JSON.stringify(token)}`);
1084
1466
  return Number(token);
1085
1467
  }
1086
1468
  /**
@@ -1094,7 +1476,7 @@ function numberOf$2(token, line, what) {
1094
1476
  */
1095
1477
  function numbersOf(text, count, line, what) {
1096
1478
  const tokens = text.trim().split(/\s+/);
1097
- if (tokens.length !== count) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(text.trim())}`);
1479
+ if (tokens.length !== count) throw new NativeParseError(FORMAT$5, line, `expected ${what}, found ${JSON.stringify(text.trim())}`);
1098
1480
  return tokens.map((token) => numberOf$2(token, line, what));
1099
1481
  }
1100
1482
  /**
@@ -1109,18 +1491,18 @@ function parseToptw(text) {
1109
1491
  text: raw.trim()
1110
1492
  })).filter((entry) => entry.text !== "");
1111
1493
  const [first, second, ...rows] = all;
1112
- if (first === void 0 || second === void 0) throw new NativeParseError(FORMAT$4, 1, "expected the two header lines");
1494
+ if (first === void 0 || second === void 0) throw new NativeParseError(FORMAT$5, 1, "expected the two header lines");
1113
1495
  const [, paths, customers] = numbersOf(first.text, FIRST_HEADER_NUMBERS, first.line, "four numbers (k, paths, customers, t)");
1114
1496
  const [, capacity] = numbersOf(second.text, SECOND_HEADER_NUMBERS, second.line, "two numbers (D, capacity)");
1115
- if (paths === void 0 || customers === void 0 || capacity === void 0) throw new NativeParseError(FORMAT$4, first.line, "the header lacks a number");
1497
+ if (paths === void 0 || customers === void 0 || capacity === void 0) throw new NativeParseError(FORMAT$5, first.line, "the header lacks a number");
1116
1498
  const seen = /* @__PURE__ */ new Set();
1117
1499
  const nodes = rows.map((row) => {
1118
1500
  const tokens = row.text.split(/\s+/);
1119
1501
  const listLength = numberOf$2(tokens[LIST_LENGTH_AT], row.line, "the length of the list after the unused number");
1120
- if (!Number.isInteger(listLength) || listLength < 0 || tokens.length !== ROW_NUMBERS_BESIDE_LIST + listLength) throw new NativeParseError(FORMAT$4, row.line, `expected ${String(ROW_NUMBERS_BESIDE_LIST + listLength)} numbers for a list of ${String(listLength)}, found ${String(tokens.length)}`);
1502
+ if (!Number.isInteger(listLength) || listLength < 0 || tokens.length !== ROW_NUMBERS_BESIDE_LIST + listLength) throw new NativeParseError(FORMAT$5, row.line, `expected ${String(ROW_NUMBERS_BESIDE_LIST + listLength)} numbers for a list of ${String(listLength)}, found ${String(tokens.length)}`);
1121
1503
  const [id] = tokens;
1122
- if (id === void 0 || !/^\d+$/.test(id)) throw new NativeParseError(FORMAT$4, row.line, `expected a whole number node id, found ${JSON.stringify(id)}`);
1123
- if (seen.has(id)) throw new NativeParseError(FORMAT$4, row.line, `node ${id} appears twice`);
1504
+ if (id === void 0 || !/^\d+$/.test(id)) throw new NativeParseError(FORMAT$5, row.line, `expected a whole number node id, found ${JSON.stringify(id)}`);
1505
+ if (seen.has(id)) throw new NativeParseError(FORMAT$5, row.line, `node ${id} appears twice`);
1124
1506
  seen.add(id);
1125
1507
  return {
1126
1508
  id,
@@ -1132,7 +1514,7 @@ function parseToptw(text) {
1132
1514
  close: numberOf$2(tokens[8 + listLength], row.line, "the closing of the window")
1133
1515
  };
1134
1516
  });
1135
- if (nodes.length !== customers + 1) throw new NativeParseError(FORMAT$4, all.length === 0 ? 1 : all[all.length - 1]?.line ?? 1, `the header gives ${String(customers)} customers, so expected ${String(customers + 1)} rows with the depot, found ${String(nodes.length)}`);
1517
+ if (nodes.length !== customers + 1) throw new NativeParseError(FORMAT$5, all.length === 0 ? 1 : all[all.length - 1]?.line ?? 1, `the header gives ${String(customers)} customers, so expected ${String(customers + 1)} rows with the depot, found ${String(nodes.length)}`);
1136
1518
  return {
1137
1519
  meta: {
1138
1520
  paths,
@@ -1144,7 +1526,7 @@ function parseToptw(text) {
1144
1526
  }
1145
1527
  //#endregion
1146
1528
  //#region src/parsers/tsplib.ts
1147
- const FORMAT$3 = "tsplib";
1529
+ const FORMAT$4 = "tsplib";
1148
1530
  /** The instance types the parser reads: the probabilistic and the clustered orienteering problem. */
1149
1531
  const TYPES = /* @__PURE__ */ new Set(["POP", "COP"]);
1150
1532
  /** The edge weight types a coordinate section is read under: planar ones keep their coordinates as `x` and `y`, `GEO` converts them to degrees, and `EXPLICIT` has no coordinates. */
@@ -1175,7 +1557,7 @@ function degrees(value, line) {
1175
1557
  const whole = Math.trunc(magnitude);
1176
1558
  const hundredths = (magnitude - whole) * MINUTE_SCALE;
1177
1559
  const minutes = Math.round(hundredths);
1178
- if (Math.abs(hundredths - minutes) > MINUTE_TOLERANCE || minutes >= MINUTES_PER_DEGREE) throw new NativeParseError(FORMAT$3, line, `expected a DDD.MM coordinate with whole minutes below 60, found ${String(value)}`);
1560
+ if (Math.abs(hundredths - minutes) > MINUTE_TOLERANCE || minutes >= MINUTES_PER_DEGREE) throw new NativeParseError(FORMAT$4, line, `expected a DDD.MM coordinate with whole minutes below 60, found ${String(value)}`);
1179
1561
  return Math.sign(value) * (whole + minutes / MINUTES_PER_DEGREE);
1180
1562
  }
1181
1563
  /**
@@ -1187,7 +1569,7 @@ function degrees(value, line) {
1187
1569
  * @throws NativeParseError when the token is not a decimal number.
1188
1570
  */
1189
1571
  function numberOf$1(token, line, what) {
1190
- if (token === void 0 || !NUMBER$2.test(token)) throw new NativeParseError(FORMAT$3, line, `expected ${what}, found ${JSON.stringify(token)}`);
1572
+ if (token === void 0 || !NUMBER$2.test(token)) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(token)}`);
1191
1573
  return Number(token);
1192
1574
  }
1193
1575
  /**
@@ -1199,7 +1581,7 @@ function numberOf$1(token, line, what) {
1199
1581
  * @throws NativeParseError when the token is not a whole number.
1200
1582
  */
1201
1583
  function wholeOf(token, line, what) {
1202
- if (token === void 0 || !WHOLE$1.test(token)) throw new NativeParseError(FORMAT$3, line, `expected ${what}, found ${JSON.stringify(token)}`);
1584
+ if (token === void 0 || !WHOLE$1.test(token)) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(token)}`);
1203
1585
  return Number(token);
1204
1586
  }
1205
1587
  /**
@@ -1227,7 +1609,7 @@ function collect(text) {
1227
1609
  continue;
1228
1610
  }
1229
1611
  if (keyed !== null && key !== void 0) {
1230
- if (collected.header.has(key)) throw new NativeParseError(FORMAT$3, line, `${key} appears twice`);
1612
+ if (collected.header.has(key)) throw new NativeParseError(FORMAT$4, line, `${key} appears twice`);
1231
1613
  collected.header.set(key, (keyed[2] ?? "").trim());
1232
1614
  section = void 0;
1233
1615
  continue;
@@ -1239,9 +1621,9 @@ function collect(text) {
1239
1621
  const tokens = trimmed.split(/\s+/);
1240
1622
  if (section === "coords") {
1241
1623
  const [id, x, y, ...extra] = tokens;
1242
- if (extra.length > 0) throw new NativeParseError(FORMAT$3, line, `expected "id x y", found ${JSON.stringify(trimmed)}`);
1624
+ if (extra.length > 0) throw new NativeParseError(FORMAT$4, line, `expected "id x y", found ${JSON.stringify(trimmed)}`);
1243
1625
  const node = wholeOf(id, line, "a node id");
1244
- if (collected.coords.has(node)) throw new NativeParseError(FORMAT$3, line, `node ${String(node)} has two coordinate rows`);
1626
+ if (collected.coords.has(node)) throw new NativeParseError(FORMAT$4, line, `node ${String(node)} has two coordinate rows`);
1245
1627
  collected.coords.set(node, {
1246
1628
  x: numberOf$1(x, line, "x"),
1247
1629
  y: numberOf$1(y, line, "y"),
@@ -1249,9 +1631,9 @@ function collect(text) {
1249
1631
  });
1250
1632
  } else if (section === "prizes") {
1251
1633
  const [id, prize, probability, ...extra] = tokens;
1252
- if (extra.length > 0) throw new NativeParseError(FORMAT$3, line, `expected "id prize probability", found ${JSON.stringify(trimmed)}`);
1634
+ if (extra.length > 0) throw new NativeParseError(FORMAT$4, line, `expected "id prize probability", found ${JSON.stringify(trimmed)}`);
1253
1635
  const node = wholeOf(id, line, "a node id");
1254
- if (collected.prizes.has(node)) throw new NativeParseError(FORMAT$3, line, `node ${String(node)} has two prize rows`);
1636
+ if (collected.prizes.has(node)) throw new NativeParseError(FORMAT$4, line, `node ${String(node)} has two prize rows`);
1255
1637
  collected.prizes.set(node, {
1256
1638
  prize: numberOf$1(prize, line, "the prize"),
1257
1639
  probability: numberOf$1(probability, line, "the probability")
@@ -1259,13 +1641,13 @@ function collect(text) {
1259
1641
  } else if (section === "sets") {
1260
1642
  const [id, prize, ...members] = tokens;
1261
1643
  const setId = wholeOf(id, line, "a set id");
1262
- if (collected.sets.some((set) => set.id === setId)) throw new NativeParseError(FORMAT$3, line, `set ${String(setId)} appears twice`);
1644
+ if (collected.sets.some((set) => set.id === setId)) throw new NativeParseError(FORMAT$4, line, `set ${String(setId)} appears twice`);
1263
1645
  collected.sets.push({
1264
1646
  id: setId,
1265
1647
  prize: numberOf$1(prize, line, "the set prize"),
1266
1648
  members: members.map((member) => wholeOf(member, line, "a node id in a set"))
1267
1649
  });
1268
- } else if (section === void 0) throw new NativeParseError(FORMAT$3, line, `expected a KEY : value line or a section, found ${JSON.stringify(trimmed)}`);
1650
+ } else if (section === void 0) throw new NativeParseError(FORMAT$4, line, `expected a KEY : value line or a section, found ${JSON.stringify(trimmed)}`);
1269
1651
  }
1270
1652
  return collected;
1271
1653
  }
@@ -1278,7 +1660,7 @@ function collect(text) {
1278
1660
  */
1279
1661
  function required(header, key) {
1280
1662
  const value = header.get(key);
1281
- if (value === void 0) throw new NativeParseError(FORMAT$3, 1, `expected a ${key} line`);
1663
+ if (value === void 0) throw new NativeParseError(FORMAT$4, 1, `expected a ${key} line`);
1282
1664
  return value;
1283
1665
  }
1284
1666
  /**
@@ -1290,23 +1672,23 @@ function required(header, key) {
1290
1672
  function parseTsplib(text) {
1291
1673
  const { header, coords, prizes, sets } = collect(text);
1292
1674
  const type = required(header, "TYPE");
1293
- if (!TYPES.has(type)) throw new NativeParseError(FORMAT$3, 1, `expected TYPE POP or COP, found ${JSON.stringify(type)}`);
1675
+ if (!TYPES.has(type)) throw new NativeParseError(FORMAT$4, 1, `expected TYPE POP or COP, found ${JSON.stringify(type)}`);
1294
1676
  const weightType = required(header, "EDGE_WEIGHT_TYPE");
1295
1677
  const geographic = weightType === "GEO";
1296
- if (!geographic && !PLANAR.has(weightType) && weightType !== "EXPLICIT") throw new NativeParseError(FORMAT$3, 1, `expected EDGE_WEIGHT_TYPE EUC_2D, CEIL_2D, ATT, GEO or EXPLICIT, found ${JSON.stringify(weightType)}`);
1678
+ if (!geographic && !PLANAR.has(weightType) && weightType !== "EXPLICIT") throw new NativeParseError(FORMAT$4, 1, `expected EDGE_WEIGHT_TYPE EUC_2D, CEIL_2D, ATT, GEO or EXPLICIT, found ${JSON.stringify(weightType)}`);
1297
1679
  const dimension = wholeOf(required(header, "DIMENSION"), 1, "DIMENSION");
1298
1680
  const budget = numberOf$1(required(header, "TMAX"), 1, "TMAX");
1299
- if (weightType === "EXPLICIT" ? coords.size > 0 : coords.size !== dimension) throw new NativeParseError(FORMAT$3, 1, weightType === "EXPLICIT" ? "an EXPLICIT file has no NODE_COORD_SECTION" : `expected ${String(dimension)} coordinate rows, found ${String(coords.size)}`);
1681
+ if (weightType === "EXPLICIT" ? coords.size > 0 : coords.size !== dimension) throw new NativeParseError(FORMAT$4, 1, weightType === "EXPLICIT" ? "an EXPLICIT file has no NODE_COORD_SECTION" : `expected ${String(dimension)} coordinate rows, found ${String(coords.size)}`);
1300
1682
  const inRange = (id) => id >= 1 && id <= dimension;
1301
1683
  for (const id of [
1302
1684
  ...coords.keys(),
1303
1685
  ...prizes.keys(),
1304
1686
  ...sets.flatMap((set) => set.members)
1305
- ]) if (!inRange(id)) throw new NativeParseError(FORMAT$3, 1, `node ${String(id)} is outside 1 to ${String(dimension)}`);
1306
- if (type === "POP" && prizes.size !== dimension) throw new NativeParseError(FORMAT$3, 1, `expected ${String(dimension)} prize rows, found ${String(prizes.size)}`);
1687
+ ]) if (!inRange(id)) throw new NativeParseError(FORMAT$4, 1, `node ${String(id)} is outside 1 to ${String(dimension)}`);
1688
+ if (type === "POP" && prizes.size !== dimension) throw new NativeParseError(FORMAT$4, 1, `expected ${String(dimension)} prize rows, found ${String(prizes.size)}`);
1307
1689
  if (type === "COP") {
1308
1690
  const declared = wholeOf(required(header, "SETS"), 1, "SETS");
1309
- if (sets.length !== declared) throw new NativeParseError(FORMAT$3, 1, `expected ${String(declared)} sets, found ${String(sets.length)}`);
1691
+ if (sets.length !== declared) throw new NativeParseError(FORMAT$4, 1, `expected ${String(declared)} sets, found ${String(sets.length)}`);
1310
1692
  }
1311
1693
  const clustersOf = (node) => {
1312
1694
  const ids = sets.filter((set) => set.members.includes(node)).map((set) => String(set.id));
@@ -1344,7 +1726,7 @@ function parseTsplib(text) {
1344
1726
  }
1345
1727
  //#endregion
1346
1728
  //#region src/parsers/ucinet-dl.ts
1347
- const FORMAT$2 = "ucinet-dl";
1729
+ const FORMAT$3 = "ucinet-dl";
1348
1730
  /** A label section's keyword, upper-cased with its spacing collapsed, mapped to which labels it gives: `LABELS:` names the rows and columns of a one-mode matrix at once, `LEVEL LABELS:` (or `MATRIX LABELS:`) names the matrices. */
1349
1731
  const LABEL_SECTIONS = /* @__PURE__ */ new Map([
1350
1732
  ["LABELS", "both"],
@@ -1373,7 +1755,7 @@ function labelsOf(text) {
1373
1755
  * @returns The dimension.
1374
1756
  */
1375
1757
  function dimension(value, key, line) {
1376
- if (!/^[1-9]\d*$/.test(value)) throw new NativeParseError(FORMAT$2, line, `${key} must be a positive integer, found ${JSON.stringify(value)}`);
1758
+ if (!/^[1-9]\d*$/.test(value)) throw new NativeParseError(FORMAT$3, line, `${key} must be a positive integer, found ${JSON.stringify(value)}`);
1377
1759
  return Number(value);
1378
1760
  }
1379
1761
  /** The dimension parameters, upper-cased, mapped to the header field each sets: N actors of a one-mode matrix, NR rows and NC columns of a two-mode one, NM matrices. */
@@ -1405,7 +1787,7 @@ function readParameters(text, line) {
1405
1787
  tokens.shift();
1406
1788
  continue;
1407
1789
  }
1408
- throw new NativeParseError(FORMAT$2, line, `unknown header token ${JSON.stringify(token)}`);
1790
+ throw new NativeParseError(FORMAT$3, line, `unknown header token ${JSON.stringify(token)}`);
1409
1791
  }
1410
1792
  const key = upper.slice(0, equals);
1411
1793
  const value = token.slice(equals + 1);
@@ -1420,7 +1802,7 @@ function readParameters(text, line) {
1420
1802
  kind: "format",
1421
1803
  value: value.toUpperCase()
1422
1804
  });
1423
- else throw new NativeParseError(FORMAT$2, line, `unknown header parameter ${key}`);
1805
+ else throw new NativeParseError(FORMAT$3, line, `unknown header parameter ${key}`);
1424
1806
  }
1425
1807
  return parameters;
1426
1808
  }
@@ -1433,10 +1815,10 @@ function readParameters(text, line) {
1433
1815
  */
1434
1816
  function checked(labels, count, what) {
1435
1817
  if (labels === void 0) return;
1436
- if (labels.values.length !== count) throw new NativeParseError(FORMAT$2, labels.line, `expected ${String(count)} ${what} labels, found ${String(labels.values.length)}`);
1818
+ if (labels.values.length !== count) throw new NativeParseError(FORMAT$3, labels.line, `expected ${String(count)} ${what} labels, found ${String(labels.values.length)}`);
1437
1819
  const seen = /* @__PURE__ */ new Set();
1438
1820
  for (const label of labels.values) {
1439
- if (seen.has(label)) throw new NativeParseError(FORMAT$2, labels.line, `${what} label ${JSON.stringify(label)} appears twice`);
1821
+ if (seen.has(label)) throw new NativeParseError(FORMAT$3, labels.line, `${what} label ${JSON.stringify(label)} appears twice`);
1440
1822
  seen.add(label);
1441
1823
  }
1442
1824
  return labels.values;
@@ -1455,7 +1837,7 @@ function parseUcinetDl(text, options) {
1455
1837
  const header = { labels: {} };
1456
1838
  const setParameters = (parameterText, line) => {
1457
1839
  for (const parameter of readParameters(parameterText, line)) if (parameter.kind === "dimension") {
1458
- if (header[parameter.field] !== void 0) throw new NativeParseError(FORMAT$2, line, `${parameter.key} is declared twice`);
1840
+ if (header[parameter.field] !== void 0) throw new NativeParseError(FORMAT$3, line, `${parameter.key} is declared twice`);
1459
1841
  header[parameter.field] = parameter.value;
1460
1842
  } else if (parameter.kind === "format") header.format = parameter.value;
1461
1843
  else header.diagonal = parameter.value;
@@ -1477,7 +1859,7 @@ function parseUcinetDl(text, options) {
1477
1859
  if (content === "") continue;
1478
1860
  if (!started) {
1479
1861
  const match = /^DL(?:\s+(.*))?$/i.exec(content);
1480
- if (match === null) throw new NativeParseError(FORMAT$2, lineNumber, "a DL file starts with the keyword DL");
1862
+ if (match === null) throw new NativeParseError(FORMAT$3, lineNumber, "a DL file starts with the keyword DL");
1481
1863
  started = true;
1482
1864
  if (match[1] !== void 0) setParameters(match[1], lineNumber);
1483
1865
  continue;
@@ -1495,7 +1877,7 @@ function parseUcinetDl(text, options) {
1495
1877
  }
1496
1878
  const target = keyword === void 0 ? void 0 : LABEL_SECTIONS.get(keyword);
1497
1879
  if (target !== void 0) {
1498
- if (header.labels[target] !== void 0) throw new NativeParseError(FORMAT$2, lineNumber, `${String(keyword)} is given twice`);
1880
+ if (header.labels[target] !== void 0) throw new NativeParseError(FORMAT$3, lineNumber, `${String(keyword)} is given twice`);
1499
1881
  section = target;
1500
1882
  header.labels[target] = {
1501
1883
  values: labelsOf(rest),
@@ -1503,31 +1885,31 @@ function parseUcinetDl(text, options) {
1503
1885
  };
1504
1886
  continue;
1505
1887
  }
1506
- if (/^LABELS\s+EMBEDDED\b/i.test(content)) throw new NativeParseError(FORMAT$2, lineNumber, "labels embedded in the data are not read in this version");
1888
+ if (/^LABELS\s+EMBEDDED\b/i.test(content)) throw new NativeParseError(FORMAT$3, lineNumber, "labels embedded in the data are not read in this version");
1507
1889
  if (section === void 0) setParameters(content, lineNumber);
1508
1890
  else header.labels[section]?.values.push(...labelsOf(content));
1509
1891
  }
1510
- if (!started) throw new NativeParseError(FORMAT$2, 1, "the file is empty");
1511
- if (dataLine === void 0) throw new NativeParseError(FORMAT$2, all.length, "the file has no DATA: section");
1892
+ if (!started) throw new NativeParseError(FORMAT$3, 1, "the file is empty");
1893
+ if (dataLine === void 0) throw new NativeParseError(FORMAT$3, all.length, "the file has no DATA: section");
1512
1894
  const format = header.format ?? "FULLMATRIX";
1513
- if (!FULL_MATRIX.has(format)) throw new NativeParseError(FORMAT$2, dataLine, `format ${format} is not read in this version; only the full matrix layout is`);
1514
- if (header.diagonal === "ABSENT") throw new NativeParseError(FORMAT$2, dataLine, "a full matrix with its diagonal absent is not read in this version");
1895
+ if (!FULL_MATRIX.has(format)) throw new NativeParseError(FORMAT$3, dataLine, `format ${format} is not read in this version; only the full matrix layout is`);
1896
+ if (header.diagonal === "ABSENT") throw new NativeParseError(FORMAT$3, dataLine, "a full matrix with its diagonal absent is not read in this version");
1515
1897
  const twoMode = header.nr !== void 0 || header.nc !== void 0;
1516
- if (twoMode === (header.n !== void 0) || twoMode && (header.nr === void 0 || header.nc === void 0)) throw new NativeParseError(FORMAT$2, dataLine, "the header must give either N, or both NR and NC");
1898
+ if (twoMode === (header.n !== void 0) || twoMode && (header.nr === void 0 || header.nc === void 0)) throw new NativeParseError(FORMAT$3, dataLine, "the header must give either N, or both NR and NC");
1517
1899
  const rows = header.n ?? header.nr ?? 0;
1518
1900
  const columns = header.n ?? header.nc ?? 0;
1519
1901
  const matrices = header.nm ?? 1;
1520
1902
  checked(header.labels.matrices, matrices, "matrix");
1521
- if (options.matrix > matrices) throw new NativeParseError(FORMAT$2, dataLine, `the recipe reads matrix ${String(options.matrix)} but the file holds ${String(matrices)}`);
1903
+ if (options.matrix > matrices) throw new NativeParseError(FORMAT$3, dataLine, `the recipe reads matrix ${String(options.matrix)} but the file holds ${String(matrices)}`);
1522
1904
  let rowIds;
1523
1905
  let columnIds;
1524
1906
  if (twoMode) {
1525
- if (header.labels.both !== void 0) throw new NativeParseError(FORMAT$2, header.labels.both.line, "a two-mode file names its rows and columns with ROW LABELS: and COLUMN LABELS:, not LABELS:");
1907
+ if (header.labels.both !== void 0) throw new NativeParseError(FORMAT$3, header.labels.both.line, "a two-mode file names its rows and columns with ROW LABELS: and COLUMN LABELS:, not LABELS:");
1526
1908
  rowIds = checked(header.labels.rows, rows, "row") ?? Array.from({ length: rows }, (_, row) => `r${String(row + 1)}`);
1527
1909
  columnIds = checked(header.labels.columns, columns, "column") ?? Array.from({ length: columns }, (_, column) => `c${String(column + 1)}`);
1528
1910
  const rowSet = new Set(rowIds);
1529
1911
  const shared = columnIds.find((id) => rowSet.has(id));
1530
- if (shared !== void 0) throw new NativeParseError(FORMAT$2, dataLine, `the row and column label ${JSON.stringify(shared)} names two different entities`);
1912
+ if (shared !== void 0) throw new NativeParseError(FORMAT$3, dataLine, `the row and column label ${JSON.stringify(shared)} names two different entities`);
1531
1913
  } else {
1532
1914
  const given = [
1533
1915
  checked(header.labels.both, rows, "actor"),
@@ -1535,18 +1917,18 @@ function parseUcinetDl(text, options) {
1535
1917
  checked(header.labels.columns, columns, "column")
1536
1918
  ].filter((labels) => labels !== void 0);
1537
1919
  const [first] = given;
1538
- if (first !== void 0 && given.some((labels) => labels.some((label, position) => label !== first[position]))) throw new NativeParseError(FORMAT$2, dataLine, "a one-mode file's row and column labels must be the same actors in the same order");
1920
+ if (first !== void 0 && given.some((labels) => labels.some((label, position) => label !== first[position]))) throw new NativeParseError(FORMAT$3, dataLine, "a one-mode file's row and column labels must be the same actors in the same order");
1539
1921
  rowIds = first ?? Array.from({ length: rows }, (_, row) => String(row + 1));
1540
1922
  columnIds = rowIds;
1541
1923
  }
1542
1924
  const perMatrix = rows * columns;
1543
- if (cells.length !== perMatrix * matrices) throw new NativeParseError(FORMAT$2, cells.at(-1)?.line ?? dataLine, `expected ${String(matrices)} full ${String(rows)} by ${String(columns)} matrices (${String(perMatrix * matrices)} cells), found ${String(cells.length)} cells`);
1544
- for (const cell of cells) if (!NUMBER$1.test(cell.text)) throw new NativeParseError(FORMAT$2, cell.line, `cell ${JSON.stringify(cell.text)} is not a number`);
1925
+ if (cells.length !== perMatrix * matrices) throw new NativeParseError(FORMAT$3, cells.at(-1)?.line ?? dataLine, `expected ${String(matrices)} full ${String(rows)} by ${String(columns)} matrices (${String(perMatrix * matrices)} cells), found ${String(cells.length)} cells`);
1926
+ for (const cell of cells) if (!NUMBER$1.test(cell.text)) throw new NativeParseError(FORMAT$3, cell.line, `cell ${JSON.stringify(cell.text)} is not a number`);
1545
1927
  const chosen = cells.slice((options.matrix - 1) * perMatrix, options.matrix * perMatrix);
1546
1928
  const edges = [];
1547
1929
  for (const [row, source] of rowIds.entries()) for (const [column, target] of columnIds.entries()) {
1548
1930
  const cell = chosen[row * columns + column];
1549
- if (cell === void 0) throw new NativeParseError(FORMAT$2, dataLine, `matrix ${String(options.matrix)} has no cell at row ${String(row + 1)}, column ${String(column + 1)}`);
1931
+ if (cell === void 0) throw new NativeParseError(FORMAT$3, dataLine, `matrix ${String(options.matrix)} has no cell at row ${String(row + 1)}, column ${String(column + 1)}`);
1550
1932
  if (Number(cell.text) !== 0) edges.push({
1551
1933
  source,
1552
1934
  target
@@ -1559,7 +1941,7 @@ function parseUcinetDl(text, options) {
1559
1941
  }
1560
1942
  //#endregion
1561
1943
  //#region src/parsers/verolog-2019.ts
1562
- const FORMAT$1 = "verolog-2019";
1944
+ const FORMAT$2 = "verolog-2019";
1563
1945
  /** The header lines, in the order the file has them, with the key of `meta` each fills and whether its value is text. */
1564
1946
  const HEADER$1 = [
1565
1947
  [
@@ -1658,9 +2040,9 @@ function rowsOf(text) {
1658
2040
  * @returns The number.
1659
2041
  * @throws NativeParseError when the field is absent or not a decimal number.
1660
2042
  */
1661
- function numberAt(row, index, what) {
2043
+ function numberAt$1(row, index, what) {
1662
2044
  const field = row.fields[index];
1663
- if (field === void 0 || !NUMBER.test(field)) throw new NativeParseError(FORMAT$1, row.line, `expected ${what}, found ${JSON.stringify(field)}`);
2045
+ if (field === void 0 || !NUMBER.test(field)) throw new NativeParseError(FORMAT$2, row.line, `expected ${what}, found ${JSON.stringify(field)}`);
1664
2046
  return Number(field);
1665
2047
  }
1666
2048
  /**
@@ -1684,8 +2066,8 @@ function keyed(row) {
1684
2066
  */
1685
2067
  function checkRows(name, rows, length) {
1686
2068
  for (const [index, row] of rows.entries()) {
1687
- if (row.fields[0] !== String(index + 1)) throw new NativeParseError(FORMAT$1, row.line, `expected ${name} row ${String(index + 1)} to start with that id, found ${JSON.stringify(row.fields[0])}`);
1688
- if (length !== void 0 && row.fields.length !== length) throw new NativeParseError(FORMAT$1, row.line, `expected ${String(length)} fields in a ${name} row, found ${String(row.fields.length)}`);
2069
+ if (row.fields[0] !== String(index + 1)) throw new NativeParseError(FORMAT$2, row.line, `expected ${name} row ${String(index + 1)} to start with that id, found ${JSON.stringify(row.fields[0])}`);
2070
+ if (length !== void 0 && row.fields.length !== length) throw new NativeParseError(FORMAT$2, row.line, `expected ${String(length)} fields in a ${name} row, found ${String(row.fields.length)}`);
1689
2071
  }
1690
2072
  }
1691
2073
  /**
@@ -1700,15 +2082,15 @@ function sectionsOf(rows) {
1700
2082
  for (const name of SECTIONS) {
1701
2083
  const start = rows[at];
1702
2084
  const announced = start === void 0 ? void 0 : keyed(start);
1703
- if (start === void 0 || announced?.key !== name || announced.value.length !== 1 || !WHOLE.test(announced.value[0] ?? "")) throw new NativeParseError(FORMAT$1, start?.line ?? 1, `expected "${name} = <count>", found ${JSON.stringify(start?.fields.join(" "))}`);
2085
+ if (start === void 0 || announced?.key !== name || announced.value.length !== 1 || !WHOLE.test(announced.value[0] ?? "")) throw new NativeParseError(FORMAT$2, start?.line ?? 1, `expected "${name} = <count>", found ${JSON.stringify(start?.fields.join(" "))}`);
1704
2086
  const count = Number(announced.value[0]);
1705
2087
  const body = rows.slice(at + 1, at + 1 + count);
1706
- if (body.length !== count) throw new NativeParseError(FORMAT$1, start.line, `${name} announces ${String(count)} rows, found ${String(body.length)}`);
2088
+ if (body.length !== count) throw new NativeParseError(FORMAT$2, start.line, `${name} announces ${String(count)} rows, found ${String(body.length)}`);
1707
2089
  found[name] = body;
1708
2090
  at += 1 + count;
1709
2091
  }
1710
2092
  const extra = rows[at];
1711
- if (extra !== void 0) throw new NativeParseError(FORMAT$1, extra.line, `expected the end of the file after TECHNICIANS, found ${JSON.stringify(extra.fields.join(" "))}`);
2093
+ if (extra !== void 0) throw new NativeParseError(FORMAT$2, extra.line, `expected the end of the file after TECHNICIANS, found ${JSON.stringify(extra.fields.join(" "))}`);
1712
2094
  return {
1713
2095
  MACHINES: found.MACHINES ?? [],
1714
2096
  LOCATIONS: found.LOCATIONS ?? [],
@@ -1729,8 +2111,8 @@ function parseVerolog2019(text) {
1729
2111
  for (const [position, [key, name, kind]] of HEADER$1.entries()) {
1730
2112
  const row = rows[position];
1731
2113
  const header = row === void 0 ? void 0 : keyed(row);
1732
- if (row === void 0 || header?.key !== key || header.value.length === 0) throw new NativeParseError(FORMAT$1, row?.line ?? 1, `expected "${key} = <value>", found ${JSON.stringify(row?.fields.join(" "))}`);
1733
- meta[name] = kind === "text" ? header.value.join(" ") : numberAt({
2114
+ if (row === void 0 || header?.key !== key || header.value.length === 0) throw new NativeParseError(FORMAT$2, row?.line ?? 1, `expected "${key} = <value>", found ${JSON.stringify(row?.fields.join(" "))}`);
2115
+ meta[name] = kind === "text" ? header.value.join(" ") : numberAt$1({
1734
2116
  line: row.line,
1735
2117
  fields: header.value
1736
2118
  }, 0, `a number for ${key}`);
@@ -1749,8 +2131,8 @@ function parseVerolog2019(text) {
1749
2131
  x: NaN,
1750
2132
  y: NaN
1751
2133
  } : {
1752
- x: numberAt(row, 1, "x"),
1753
- y: numberAt(row, 2, "y")
2134
+ x: numberAt$1(row, 1, "x"),
2135
+ y: numberAt$1(row, 2, "y")
1754
2136
  };
1755
2137
  };
1756
2138
  const depot = {
@@ -1764,15 +2146,15 @@ function parseVerolog2019(text) {
1764
2146
  amount: null
1765
2147
  };
1766
2148
  const requests = sections.REQUESTS.map((row) => {
1767
- const location = numberAt(row, REQUEST_AT.location, "a location id");
1768
- const fromDay = numberAt(row, REQUEST_AT.fromDay, "the first day");
1769
- const toDay = numberAt(row, REQUEST_AT.toDay, "the last day");
1770
- const machine = numberAt(row, REQUEST_AT.machine, "a machine id");
1771
- const amount = numberAt(row, REQUEST_AT.amount, "the amount");
1772
- if (!Number.isInteger(location) || location < 1 || location > locations.length) throw new NativeParseError(FORMAT$1, row.line, `location ${String(location)} is outside 1 to ${String(locations.length)}`);
1773
- if (!Number.isInteger(machine) || machine < 1 || machine > machines.length) throw new NativeParseError(FORMAT$1, row.line, `machine ${String(machine)} is outside 1 to ${String(machines.length)}`);
1774
- if (fromDay < 1 || fromDay > toDay || toDay > days) throw new NativeParseError(FORMAT$1, row.line, `the days ${String(fromDay)} to ${String(toDay)} are not within 1 to ${String(days)}`);
1775
- if (amount < 1) throw new NativeParseError(FORMAT$1, row.line, `expected an amount of at least 1, found ${String(amount)}`);
2149
+ const location = numberAt$1(row, REQUEST_AT.location, "a location id");
2150
+ const fromDay = numberAt$1(row, REQUEST_AT.fromDay, "the first day");
2151
+ const toDay = numberAt$1(row, REQUEST_AT.toDay, "the last day");
2152
+ const machine = numberAt$1(row, REQUEST_AT.machine, "a machine id");
2153
+ const amount = numberAt$1(row, REQUEST_AT.amount, "the amount");
2154
+ if (!Number.isInteger(location) || location < 1 || location > locations.length) throw new NativeParseError(FORMAT$2, row.line, `location ${String(location)} is outside 1 to ${String(locations.length)}`);
2155
+ if (!Number.isInteger(machine) || machine < 1 || machine > machines.length) throw new NativeParseError(FORMAT$2, row.line, `machine ${String(machine)} is outside 1 to ${String(machines.length)}`);
2156
+ if (fromDay < 1 || fromDay > toDay || toDay > days) throw new NativeParseError(FORMAT$2, row.line, `the days ${String(fromDay)} to ${String(toDay)} are not within 1 to ${String(days)}`);
2157
+ if (amount < 1) throw new NativeParseError(FORMAT$2, row.line, `expected an amount of at least 1, found ${String(amount)}`);
1776
2158
  return {
1777
2159
  id: row.fields[0] ?? "",
1778
2160
  ...position(location),
@@ -1793,6 +2175,98 @@ function parseVerolog2019(text) {
1793
2175
  };
1794
2176
  }
1795
2177
  //#endregion
2178
+ //#region src/parsers/user-item-ratings.ts
2179
+ const FORMAT$1 = "user-item-ratings";
2180
+ /** The text each delimiter option separates a line's fields with. */
2181
+ const SEPARATORS = {
2182
+ comma: ",",
2183
+ tab: " ",
2184
+ "double-colon": "::"
2185
+ };
2186
+ /** The prefix an id takes from the set it is in, so that user 1 and movie 1, which a source numbers independently, are two nodes: `u:` for a user and the first letter of the item type and a colon for an item. */
2187
+ const USER_PREFIX = "u:";
2188
+ const ITEM_PREFIXES = {
2189
+ movie: "m:",
2190
+ product: "p:"
2191
+ };
2192
+ /** The node type of a user, and the edge type of a rating. */
2193
+ const USER_TYPE = "user";
2194
+ const RATING_TYPE = "rating";
2195
+ /** A rating as a decimal literal: digits with an optional fraction, no sign, so `Number` cannot read it as hex, as an empty string or as infinity. */
2196
+ const RATING = /^\d+(?:\.\d+)?$/;
2197
+ /** A time as a count of whole units since the epoch, in digits only. */
2198
+ const TIMESTAMP = /^\d+$/;
2199
+ /** The fields on a rating's line: the user, the item, the rating and when it was given. */
2200
+ const FIELDS = 4;
2201
+ /**
2202
+ * Parses a file of ratings, one per line as `user`, `item`, `rating` and `timestamp` separated by the recipe's delimiter, after `skipLines` header lines, into a bipartite graph. This is the layout of an Amazon ratings-only file (comma, no header) and of each MovieLens release (tab for `u.data`, `::` for `ratings.dat`, comma with a header line for `ratings.csv`).
2203
+ *
2204
+ * Nodes are the users and the items in order of first appearance, a user as `u:<id>` of type `user` and an item as `m:<id>` or `p:<id>` of the recipe's item type. Each line is one edge from its user to its item, in file order, of type `rating`, with `weight` the rating over the recipe's `maxRating` (so a weight is in (0, 1]) and `timestamp` the time field as the file writes it. Blank lines are skipped. A user who rates an item twice gives two edges.
2205
+ *
2206
+ * The file is read a piece of text at a time and each distinct id is held once, however many edges name it.
2207
+ * @param pieces - The file's text, in order.
2208
+ * @param options - The recipe's parser.
2209
+ * @returns The native graph.
2210
+ * @throws NativeParseError naming the line for a line without exactly four fields, an empty id, a rating that is not a positive number or exceeds `maxRating`, or a timestamp that is not a whole number, and when the file holds no ratings.
2211
+ */
2212
+ function parseUserItemRatings(pieces, options) {
2213
+ const separator = SEPARATORS[options.delimiter];
2214
+ const itemPrefix = ITEM_PREFIXES[options.itemType];
2215
+ const users = /* @__PURE__ */ new Map();
2216
+ const items = /* @__PURE__ */ new Map();
2217
+ const nodes = [];
2218
+ const edges = [];
2219
+ /** The ids of one node set seen so far, by source id, so each distinct id is held once however many edges name it. */
2220
+ const sets = {
2221
+ user: {
2222
+ known: users,
2223
+ prefix: USER_PREFIX,
2224
+ type: USER_TYPE
2225
+ },
2226
+ item: {
2227
+ known: items,
2228
+ prefix: itemPrefix,
2229
+ type: options.itemType
2230
+ }
2231
+ };
2232
+ /** Returns the node id of a source id in a set, adding the node when the id has not appeared in it. */
2233
+ const idOf = (set, raw, number) => {
2234
+ if (raw === "") throw new NativeParseError(FORMAT$1, number, `the ${set.type} id is empty`);
2235
+ let id = set.known.get(raw);
2236
+ if (id === void 0) {
2237
+ id = `${set.prefix}${raw}`;
2238
+ set.known.set(raw, id);
2239
+ nodes.push({
2240
+ id,
2241
+ type: set.type
2242
+ });
2243
+ }
2244
+ return id;
2245
+ };
2246
+ let lastLine = 0;
2247
+ forEachLine(pieces, (line, number) => {
2248
+ lastLine = number;
2249
+ if (number <= options.skipLines || line.trim() === "") return;
2250
+ const fields = line.split(separator);
2251
+ const [user, item, rating, timestamp] = fields;
2252
+ if (fields.length !== FIELDS || user === void 0 || item === void 0 || rating === void 0 || timestamp === void 0) throw new NativeParseError(FORMAT$1, number, `expected ${String(FIELDS)} fields (user, item, rating, timestamp), found ${String(fields.length)}`);
2253
+ if (!RATING.test(rating) || Number(rating) <= 0 || Number(rating) > options.maxRating) throw new NativeParseError(FORMAT$1, number, `the rating ${JSON.stringify(rating)} is not a number above 0 and at most ${String(options.maxRating)}`);
2254
+ if (!TIMESTAMP.test(timestamp) || !Number.isSafeInteger(Number(timestamp))) throw new NativeParseError(FORMAT$1, number, `the timestamp ${JSON.stringify(timestamp)} is not a whole number`);
2255
+ edges.push({
2256
+ source: idOf(sets.user, user, number),
2257
+ target: idOf(sets.item, item, number),
2258
+ type: RATING_TYPE,
2259
+ weight: Number(rating) / options.maxRating,
2260
+ timestamp: Number(timestamp)
2261
+ });
2262
+ });
2263
+ if (edges.length === 0) throw new NativeParseError(FORMAT$1, lastLine, "the file holds no ratings");
2264
+ return {
2265
+ nodes,
2266
+ edges
2267
+ };
2268
+ }
2269
+ //#endregion
1796
2270
  //#region src/parsers/xml.ts
1797
2271
  /** The characters an XML name may contain, narrowed to what the formats read use. */
1798
2272
  const NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
@@ -2151,6 +2625,8 @@ function* decodePieces(bytes, parser, pieceBytes = DECODE_PIECE_BYTES) {
2151
2625
  }
2152
2626
  function parseNative(bytes, parser) {
2153
2627
  if (parser.format === "csv-table") return parseCsvTable(decodePieces(bytes, parser), parser);
2628
+ if (parser.format === "user-item-ratings") return parseUserItemRatings(decodePieces(bytes, parser), parser);
2629
+ if (parser.format === "konect") return parseKonect(decodePieces(bytes, parser), parser);
2154
2630
  const text = decode(bytes, parser);
2155
2631
  if (parser.format === "geojson") return parseGeojson(text, parser);
2156
2632
  if (parser.format === "edge-list") return parseEdgeList(text, parser);
@@ -2165,8 +2641,510 @@ function parseNative(bytes, parser) {
2165
2641
  if (parser.format === "tsplib") return parseTsplib(text);
2166
2642
  if (parser.format === "verolog-2019") return parseVerolog2019(text);
2167
2643
  if (parser.format === "vrp-rep") return parseVrpRep(text);
2644
+ if (parser.format === "hhcrsp") return parseHhcrsp(text);
2168
2645
  return parsePajek(text);
2169
2646
  }
2647
+ //#endregion
2648
+ //#region src/node/bzip2.ts
2649
+ /** The four bytes every bzip2 stream starts with, as ASCII: `BZh` and the block size digit, `1` to `9`. */
2650
+ const MAGIC = /^BZh[1-9]$/;
2651
+ const MAGIC_LENGTH = 4;
2652
+ /**
2653
+ * Whether the bytes start a bzip2 stream.
2654
+ * @param bytes - The data.
2655
+ * @returns True when they begin with `BZh` and a block size digit.
2656
+ */
2657
+ function startsStream(bytes) {
2658
+ return MAGIC.test(String.fromCharCode(...bytes.subarray(0, MAGIC_LENGTH)));
2659
+ }
2660
+ /**
2661
+ * Decompresses bzip2 data: one stream or several concatenated ones, as `bzip2 -d` reads them. The decoder verifies every block's CRC and the stream's, and fails on input that ends inside a stream or holds anything after the last one, so a download cut short or corrupted never yields a prefix of the file.
2662
+ *
2663
+ * `unbzip2-stream` is a stream interface, but its decoder runs inside `write` and `end` and emits every `data`, `error` and `end` event before they return, so one write of the whole input and an `end` give the whole output synchronously; the check that `end` was emitted guards that.
2664
+ * @param bytes - The compressed data.
2665
+ * @returns The decompressed bytes.
2666
+ * @throws ArchiveError when the data does not start with a bzip2 header, fails a CRC, ends early or holds trailing bytes that are not another stream.
2667
+ */
2668
+ function bunzip2(bytes) {
2669
+ if (!startsStream(bytes)) throw new ArchiveError("not valid bzip2 data: it does not start with the BZh header and a block size digit");
2670
+ const decoder = unbzip2();
2671
+ const chunks = [];
2672
+ const outcome = { finished: false };
2673
+ decoder.on("data", (chunk) => {
2674
+ chunks.push(chunk);
2675
+ });
2676
+ decoder.on("error", (error) => {
2677
+ outcome.failure ??= error;
2678
+ });
2679
+ decoder.on("end", () => {
2680
+ outcome.finished = true;
2681
+ });
2682
+ decoder.write(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
2683
+ decoder.end();
2684
+ if (outcome.failure !== void 0) throw new ArchiveError(`not valid bzip2 data: ${outcome.failure instanceof Error ? outcome.failure.message : JSON.stringify(outcome.failure)}`, { cause: outcome.failure });
2685
+ if (!outcome.finished) throw new ArchiveError("the bzip2 decoder did not finish when its input ended");
2686
+ const joined = Buffer.concat(chunks);
2687
+ return new Uint8Array(joined.buffer, joined.byteOffset, joined.byteLength);
2688
+ }
2689
+ //#endregion
2690
+ //#region src/node/csv-text.ts
2691
+ /**
2692
+ * One CSV field as the table parser reads it: quoted, with its quotes doubled, when it holds a comma, a quote or a line break, and as it is otherwise.
2693
+ * @param text - The cell text.
2694
+ * @returns The field.
2695
+ */
2696
+ function csvField(text) {
2697
+ return /[",\r\n]/.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
2698
+ }
2699
+ //#endregion
2700
+ //#region src/node/projection.ts
2701
+ const HALF_TURN_DEGREES = 180;
2702
+ const DEGREES_PER_RADIAN = HALF_TURN_DEGREES / Math.PI;
2703
+ /** An eighth of a turn, the angle from which `t` (Snyder 15-9) takes half the latitude. */
2704
+ const EIGHTH_TURN = Math.PI / 4;
2705
+ /** How close, in radians, two successive estimates of the latitude must be for the inverse projection to stop: far below a millimetre on the ground. */
2706
+ const LATITUDE_TOLERANCE = 1e-12;
2707
+ /** The most refinements the latitude takes; the series converges in a handful for any point a conic projection covers, so reaching this means the input is not a point the projection maps. */
2708
+ const LATITUDE_ITERATIONS = 32;
2709
+ /** The relative tolerance within which a unit's stated factor must equal the one keepset reads: a degree in radians, or the metre. */
2710
+ const UNIT_TOLERANCE = 1e-12;
2711
+ const DEGREE_IN_RADIANS = Math.PI / HALF_TURN_DEGREES;
2712
+ /**
2713
+ * The geodetic datums whose coordinates keepset takes as WGS84 without a shift: WGS84 itself, and ETRS89 and RGF93 (France's realisation of ETRS89), which were defined to coincide with WGS84 and have drifted from it by under a metre, below the precision of the geocoded addresses such data carries. Any other datum needs a transformation keepset does not make, so a coordinate system on it is refused. Names are compared without case, spaces or underscores, and both the ESRI spelling (`D_RGF_1993`) and the EPSG one (`Reseau_Geodesique_Francais_1993`) are listed.
2714
+ */
2715
+ const WGS84_COINCIDENT_DATUMS = /* @__PURE__ */ new Set([
2716
+ "dwgs1984",
2717
+ "wgs1984",
2718
+ "worldgeodeticsystem1984",
2719
+ "drgf1993",
2720
+ "reseaugeodesiquefrancais1993",
2721
+ "detrs1989",
2722
+ "europeanterrestrialreferencesystem1989"
2723
+ ]);
2724
+ /**
2725
+ * Parses well-known text into its tree.
2726
+ * @param text - A `.prj` file's text.
2727
+ * @returns The root node.
2728
+ * @throws ArchiveError when the text is not well-formed WKT.
2729
+ */
2730
+ function parseWkt(text) {
2731
+ const tokenPattern = /\s*("(?:[^"]|"")*"|[A-Za-z_][A-Za-z0-9_]*|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?|[[\](),])\s*/y;
2732
+ const tokens = [];
2733
+ while (tokenPattern.lastIndex < text.length) {
2734
+ const at = tokenPattern.lastIndex;
2735
+ const match = tokenPattern.exec(text);
2736
+ if (match?.[1] === void 0) throw new ArchiveError(`the .prj is not well-known text: it has ${JSON.stringify(text.slice(at).trimStart().slice(0, 1))} where a keyword, text, number or bracket should be`);
2737
+ tokens.push(match[1]);
2738
+ }
2739
+ let position = 0;
2740
+ const node = () => {
2741
+ const keyword = tokens[position];
2742
+ if (keyword === void 0 || !/^[A-Za-z_]/.test(keyword) || !["[", "("].includes(tokens[position + 1] ?? "")) throw new ArchiveError(`the .prj is not well-known text: expected a keyword and a bracket at token ${String(position + 1)}`);
2743
+ position += 2;
2744
+ const values = [];
2745
+ for (;;) {
2746
+ const token = tokens[position];
2747
+ if (token === void 0) throw new ArchiveError(`the .prj is not well-known text: ${keyword} is not closed`);
2748
+ if (token.startsWith("\"")) {
2749
+ values.push(token.slice(1, -1).replaceAll("\"\"", "\""));
2750
+ position += 1;
2751
+ } else if (/^[-+.\d]/.test(token)) {
2752
+ values.push(Number(token));
2753
+ position += 1;
2754
+ } else values.push(node());
2755
+ const separator = tokens[position];
2756
+ position += 1;
2757
+ if (separator === "]" || separator === ")") return {
2758
+ keyword: keyword.toUpperCase(),
2759
+ values
2760
+ };
2761
+ if (separator !== ",") throw new ArchiveError(`the .prj is not well-known text: expected a comma or a closing bracket in ${keyword}`);
2762
+ }
2763
+ };
2764
+ const root = node();
2765
+ if (position !== tokens.length) throw new ArchiveError("the .prj is not well-known text: it continues after its definition");
2766
+ return root;
2767
+ }
2768
+ /**
2769
+ * The child nodes of a node with a keyword.
2770
+ * @param node - The parent.
2771
+ * @param keyword - The keyword, upper case.
2772
+ * @returns The children with that keyword, in order.
2773
+ */
2774
+ function children(node, keyword) {
2775
+ return node.values.filter((value) => typeof value === "object" && value.keyword === keyword);
2776
+ }
2777
+ /**
2778
+ * The one child node of a node with a keyword.
2779
+ * @param node - The parent.
2780
+ * @param keyword - The keyword, upper case.
2781
+ * @returns The child.
2782
+ * @throws ArchiveError when there is not exactly one.
2783
+ */
2784
+ function child(node, keyword) {
2785
+ const found = children(node, keyword);
2786
+ const [only] = found;
2787
+ if (only === void 0 || found.length !== 1) throw new ArchiveError(`the .prj's ${node.keyword} has ${String(found.length)} ${keyword} definitions, not one`);
2788
+ return only;
2789
+ }
2790
+ /**
2791
+ * The number a node gives at a position.
2792
+ * @param node - The node.
2793
+ * @param index - The position of the number among its values.
2794
+ * @returns The number.
2795
+ * @throws ArchiveError when the value there is not a number.
2796
+ */
2797
+ function numberAt(node, index) {
2798
+ const value = node.values[index];
2799
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ArchiveError(`the .prj's ${node.keyword} has no number at position ${String(index + 1)}`);
2800
+ return value;
2801
+ }
2802
+ /**
2803
+ * The text a node names itself by, its first value.
2804
+ * @param node - The node.
2805
+ * @returns The name.
2806
+ * @throws ArchiveError when the node does not start with a name.
2807
+ */
2808
+ function nameOf(node) {
2809
+ const [name] = node.values;
2810
+ if (typeof name !== "string") throw new ArchiveError(`the .prj's ${node.keyword} has no name`);
2811
+ return name;
2812
+ }
2813
+ /**
2814
+ * A name compared without case, spaces or underscores.
2815
+ * @param name - The name.
2816
+ * @returns Its comparison key.
2817
+ */
2818
+ const keyOf = (name) => name.toLowerCase().replaceAll(/[\s_]+/g, "");
2819
+ /**
2820
+ * Checks that a factor is the one expected within a relative tolerance.
2821
+ * @param factor - The stated factor.
2822
+ * @param expected - The factor keepset reads.
2823
+ * @returns Whether they agree.
2824
+ */
2825
+ const agrees = (factor, expected) => Math.abs(factor - expected) <= UNIT_TOLERANCE * expected;
2826
+ /**
2827
+ * Reads a geographic coordinate system: its datum must coincide with WGS84, its prime meridian must be Greenwich and its angular unit the degree.
2828
+ * @param geogcs - The `GEOGCS` node.
2829
+ * @returns The ellipsoid's semi-major axis in metres and its inverse flattening.
2830
+ * @throws ArchiveError naming what is not read.
2831
+ */
2832
+ function readGeographic(geogcs) {
2833
+ const datum = child(geogcs, "DATUM");
2834
+ if (!WGS84_COINCIDENT_DATUMS.has(keyOf(nameOf(datum)))) throw new ArchiveError(`the .prj's datum is ${nameOf(datum)}, which needs a transformation to WGS84 that keepset does not make`);
2835
+ const primeMeridian = numberAt(child(geogcs, "PRIMEM"), 1);
2836
+ if (primeMeridian !== 0) throw new ArchiveError(`the .prj's prime meridian is ${String(primeMeridian)} degrees from Greenwich, which keepset does not read`);
2837
+ const unit = numberAt(child(geogcs, "UNIT"), 1);
2838
+ if (!agrees(unit, DEGREE_IN_RADIANS)) throw new ArchiveError(`the .prj's angular unit is ${String(unit)} radians, not the degree`);
2839
+ const spheroid = child(datum, "SPHEROID");
2840
+ return {
2841
+ semiMajorAxis: numberAt(spheroid, 1),
2842
+ inverseFlattening: numberAt(spheroid, 2)
2843
+ };
2844
+ }
2845
+ /**
2846
+ * The inverse of the ellipsoidal Lambert conformal conic projection (Snyder, Map Projections: A Working Manual, USGS Professional Paper 1395, 1987, equations 15-1 to 15-11 and 7-9), which takes projected metres back to geodetic longitude and latitude.
2847
+ * @param projection - The projection's parameters.
2848
+ * @returns The inverse, which gives degrees.
2849
+ * @throws ArchiveError, when the returned function is called, for a point whose latitude does not converge.
2850
+ */
2851
+ function inverseLambertConformalConic(projection) {
2852
+ const flattening = 1 / projection.inverseFlattening;
2853
+ const eccentricity = Math.sqrt(2 * flattening - flattening * flattening);
2854
+ const m = (latitude) => Math.cos(latitude) / Math.sqrt(1 - (eccentricity * Math.sin(latitude)) ** 2);
2855
+ const t = (latitude) => Math.tan(EIGHTH_TURN - latitude / 2) / ((1 - eccentricity * Math.sin(latitude)) / (1 + eccentricity * Math.sin(latitude))) ** (eccentricity / 2);
2856
+ const { standardParallel1: phi1, standardParallel2: phi2 } = projection;
2857
+ const n = phi1 === phi2 ? Math.sin(phi1) : (Math.log(m(phi1)) - Math.log(m(phi2))) / (Math.log(t(phi1)) - Math.log(t(phi2)));
2858
+ const scaledF = projection.scaleFactor * m(phi1) / (n * t(phi1) ** n);
2859
+ const rho0 = projection.semiMajorAxis * scaledF * t(projection.latitudeOfOrigin) ** n;
2860
+ const sign = Math.sign(n);
2861
+ return (x, y) => {
2862
+ const easting = x - projection.falseEasting;
2863
+ const northing = rho0 - (y - projection.falseNorthing);
2864
+ const rho = sign * Math.hypot(easting, northing);
2865
+ const theta = Math.atan2(sign * easting, sign * northing);
2866
+ const tOfPoint = (rho / (projection.semiMajorAxis * scaledF)) ** (1 / n);
2867
+ let latitude = Math.PI / 2 - 2 * Math.atan(tOfPoint);
2868
+ for (let iteration = 0; iteration < LATITUDE_ITERATIONS; iteration += 1) {
2869
+ const sine = eccentricity * Math.sin(latitude);
2870
+ const next = Math.PI / 2 - 2 * Math.atan(tOfPoint * ((1 - sine) / (1 + sine)) ** (eccentricity / 2));
2871
+ if (Math.abs(next - latitude) < LATITUDE_TOLERANCE) return {
2872
+ longitude: (theta / n + projection.centralMeridian) * DEGREES_PER_RADIAN,
2873
+ latitude: next * DEGREES_PER_RADIAN
2874
+ };
2875
+ latitude = next;
2876
+ }
2877
+ throw new ArchiveError(`the point (${String(x)}, ${String(y)}) is not one the projection maps: its latitude does not converge`);
2878
+ };
2879
+ }
2880
+ /** The ESRI and OGC names of the Lambert conformal conic projection with two standard parallels, compared as `keyOf` compares them. */
2881
+ const LAMBERT_CONFORMAL_CONIC = /* @__PURE__ */ new Set(["lambertconformalconic", "lambertconformalconic2sp"]);
2882
+ /**
2883
+ * Reads a projected coordinate system on a Lambert conformal conic projection.
2884
+ * @param projcs - The `PROJCS` node.
2885
+ * @returns The inverse projection.
2886
+ * @throws ArchiveError naming what is not read.
2887
+ */
2888
+ function readProjected(projcs) {
2889
+ const ellipsoid = readGeographic(child(projcs, "GEOGCS"));
2890
+ const projection = nameOf(child(projcs, "PROJECTION"));
2891
+ if (!LAMBERT_CONFORMAL_CONIC.has(keyOf(projection))) throw new ArchiveError(`the .prj's projection is ${projection}, which keepset does not invert (it reads the Lambert conformal conic with two standard parallels)`);
2892
+ const metresPerUnit = numberAt(child(projcs, "UNIT"), 1);
2893
+ if (!agrees(metresPerUnit, 1)) throw new ArchiveError(`the .prj's linear unit is ${String(metresPerUnit)} metres, not the metre`);
2894
+ const parameters = new Map(children(projcs, "PARAMETER").map((parameter) => [keyOf(nameOf(parameter)), numberAt(parameter, 1)]));
2895
+ const parameter = (name, absent) => {
2896
+ const value = parameters.get(keyOf(name)) ?? absent;
2897
+ if (value === void 0) throw new ArchiveError(`the .prj's projection has no ${name} parameter`);
2898
+ return value;
2899
+ };
2900
+ const known = new Set([
2901
+ "False_Easting",
2902
+ "False_Northing",
2903
+ "Central_Meridian",
2904
+ "Standard_Parallel_1",
2905
+ "Standard_Parallel_2",
2906
+ "Latitude_Of_Origin",
2907
+ "Scale_Factor"
2908
+ ].map(keyOf));
2909
+ const unknown = [...parameters.keys()].filter((name) => !known.has(name));
2910
+ if (unknown.length > 0) throw new ArchiveError(`the .prj's projection has parameters keepset does not read: ${unknown.join(", ")}`);
2911
+ return inverseLambertConformalConic({
2912
+ ...ellipsoid,
2913
+ falseEasting: parameter("False_Easting"),
2914
+ falseNorthing: parameter("False_Northing"),
2915
+ centralMeridian: parameter("Central_Meridian") * DEGREE_IN_RADIANS,
2916
+ latitudeOfOrigin: parameter("Latitude_Of_Origin") * DEGREE_IN_RADIANS,
2917
+ standardParallel1: parameter("Standard_Parallel_1") * DEGREE_IN_RADIANS,
2918
+ standardParallel2: parameter("Standard_Parallel_2") * DEGREE_IN_RADIANS,
2919
+ scaleFactor: parameter("Scale_Factor", 1)
2920
+ });
2921
+ }
2922
+ /**
2923
+ * The conversion a shapefile's `.prj` states from its coordinates to WGS84 longitude and latitude in degrees. A geographic system on a datum that coincides with WGS84 is read as it is; a projected one must use the Lambert conformal conic projection with two standard parallels in metres on such a datum. Every other system is refused, so a position is never given in the wrong place.
2924
+ * @param text - The `.prj` file's text.
2925
+ * @returns The conversion.
2926
+ * @throws ArchiveError naming the part of the definition keepset does not read.
2927
+ */
2928
+ function toWgs84Of(text) {
2929
+ const root = parseWkt(text);
2930
+ if (root.keyword === "GEOGCS") {
2931
+ readGeographic(root);
2932
+ return (x, y) => ({
2933
+ longitude: x,
2934
+ latitude: y
2935
+ });
2936
+ }
2937
+ if (root.keyword === "PROJCS") return readProjected(root);
2938
+ throw new ArchiveError(`the .prj defines a ${root.keyword}, not a GEOGCS or a PROJCS`);
2939
+ }
2940
+ //#endregion
2941
+ //#region src/node/shapefile.ts
2942
+ /** The columns a point's position fills after the attribute table's: its coordinates as the shapefile stores them, then its WGS84 latitude and longitude, the same names the geojson parser gives a point feature's position. */
2943
+ const SHAPEFILE_POINT_COLUMNS = [
2944
+ "geometry_x",
2945
+ "geometry_y",
2946
+ POINT_COLUMNS.latitude,
2947
+ POINT_COLUMNS.longitude
2948
+ ];
2949
+ /** The `.shp` file code and version every shapefile starts with (ESRI Shapefile Technical Description, 1998). */
2950
+ const SHP_FILE_CODE = 9994;
2951
+ const SHP_VERSION = 1e3;
2952
+ const SHP_HEADER_BYTES = 100;
2953
+ const SHP_RECORD_HEADER_BYTES = 8;
2954
+ /** Bytes in the 16-bit word the `.shp` measures lengths in. */
2955
+ const SHP_WORD_BYTES = 2;
2956
+ const SHP_VERSION_OFFSET = 28;
2957
+ const SHP_TYPE_OFFSET = 32;
2958
+ /** The shape types of a point layer: the null shape (no position), and the point with x and y, with a measure (`PointM`) or with z and a measure (`PointZ`), whose x and y come first. */
2959
+ const SHAPE = {
2960
+ null: 0,
2961
+ point: 1,
2962
+ pointZ: 11,
2963
+ pointM: 21
2964
+ };
2965
+ const POINT_TYPES = /* @__PURE__ */ new Set([
2966
+ SHAPE.point,
2967
+ SHAPE.pointZ,
2968
+ SHAPE.pointM
2969
+ ]);
2970
+ const COORDINATE_BYTES = 8;
2971
+ const SHAPE_TYPE_BYTES = 4;
2972
+ /** The dBASE header's offsets: the record count, the header's length and one record's length, then the field descriptors from byte 32, each 32 bytes, ended by a carriage return (dBASE III and later). */
2973
+ const DBF = {
2974
+ recordCount: 4,
2975
+ headerLength: 8,
2976
+ recordLength: 10,
2977
+ firstField: 32,
2978
+ fieldBytes: 32,
2979
+ nameBytes: 11,
2980
+ typeOffset: 11,
2981
+ lengthOffset: 16,
2982
+ terminator: 13
2983
+ };
2984
+ /** The first byte of a dBASE record: a space for a live record and an asterisk for a deleted one. */
2985
+ const LIVE_RECORD = 32;
2986
+ const DATE_DIGITS = 8;
2987
+ const YEAR_DIGITS = 4;
2988
+ const MONTH_END = 6;
2989
+ /** The text encodings a `.cpg` may name, by the names a `.cpg` writes them with, compared without case, spaces, hyphens or underscores. ISO-8859-1 maps each byte to the code point of the same value and is decoded so, not with `TextDecoder`'s "latin1" label, which the WHATWG Encoding Standard maps to windows-1252 and so reads bytes 0x80 to 0x9F differently. */
2990
+ const CPG_ENCODINGS = /* @__PURE__ */ new Map([
2991
+ ["utf8", () => {
2992
+ const decoder = new TextDecoder("utf-8", { fatal: true });
2993
+ return (bytes) => decoder.decode(bytes);
2994
+ }],
2995
+ ["88591", () => (bytes) => String.fromCharCode(...bytes)],
2996
+ ["iso88591", () => (bytes) => String.fromCharCode(...bytes)]
2997
+ ]);
2998
+ /**
2999
+ * The decoder of the text encoding a `.cpg` names.
3000
+ * @param cpg - The `.cpg` file.
3001
+ * @returns The decoder, strict for UTF-8 so bytes that are not valid fail rather than becoming replacement characters.
3002
+ * @throws ArchiveError when it names an encoding keepset does not decode.
3003
+ */
3004
+ function decoderOf(cpg) {
3005
+ const named = new TextDecoder("utf-8").decode(cpg).trim();
3006
+ const encoding = CPG_ENCODINGS.get(named.toLowerCase().replaceAll(/[\s_-]+/g, ""));
3007
+ if (encoding === void 0) throw new ArchiveError(`the .cpg names the encoding ${JSON.stringify(named)}, and keepset decodes a shapefile's attributes only in UTF-8 or ISO-8859-1`);
3008
+ return encoding();
3009
+ }
3010
+ /**
3011
+ * Reads a dBASE table's field descriptors.
3012
+ * @param dbf - The `.dbf` file.
3013
+ * @param decode - Decodes the field names.
3014
+ * @returns The record count, the header and record lengths, and the fields in order.
3015
+ * @throws ArchiveError when the header is cut short, a field is a memo or other type kept outside the table, or the fields do not fill a record.
3016
+ */
3017
+ function dbfLayout(dbf, decode) {
3018
+ if (dbf.length < DBF.firstField + 1) throw new ArchiveError("the .dbf is shorter than a dBASE header");
3019
+ const view = new DataView(dbf.buffer, dbf.byteOffset, dbf.byteLength);
3020
+ const headerLength = view.getUint16(DBF.headerLength, true);
3021
+ const recordLength = view.getUint16(DBF.recordLength, true);
3022
+ const fields = [];
3023
+ let offset = 1;
3024
+ for (let at = DBF.firstField; dbf[at] !== DBF.terminator; at += DBF.fieldBytes) {
3025
+ if (at + DBF.fieldBytes > headerLength) throw new ArchiveError("the .dbf's field descriptors run past its header without the terminator");
3026
+ const nameBytes = dbf.subarray(at, at + DBF.nameBytes);
3027
+ const end = nameBytes.indexOf(0);
3028
+ const name = decode(end === -1 ? nameBytes : nameBytes.subarray(0, end));
3029
+ const type = String.fromCharCode(dbf[at + DBF.typeOffset] ?? 0);
3030
+ const length = dbf[at + DBF.lengthOffset] ?? 0;
3031
+ if (![
3032
+ "C",
3033
+ "N",
3034
+ "F",
3035
+ "D",
3036
+ "L"
3037
+ ].includes(type)) throw new ArchiveError(`the .dbf field ${name} has the type ${JSON.stringify(type)}, which keeps its value outside the table or is not one keepset reads (it reads C, N, F, D and L)`);
3038
+ fields.push({
3039
+ name,
3040
+ type,
3041
+ offset,
3042
+ length
3043
+ });
3044
+ offset += length;
3045
+ }
3046
+ if (offset !== recordLength) throw new ArchiveError(`the .dbf's fields fill ${String(offset)} bytes of a record its header says is ${String(recordLength)}`);
3047
+ return {
3048
+ records: view.getUint32(DBF.recordCount, true),
3049
+ headerLength,
3050
+ recordLength,
3051
+ fields
3052
+ };
3053
+ }
3054
+ /**
3055
+ * One dBASE value as the text of a CSV cell: character text trimmed, a number as written (an overflowed field of asterisks is empty, as dBASE writes no value that way), a date `YYYYMMDD` as ISO 8601 (all zeros or blank is empty), and a logical as `true` or `false` (`?` or blank is empty).
3056
+ * @param bytes - The field's bytes in the record.
3057
+ * @param field - The field.
3058
+ * @param decode - Decodes character text.
3059
+ * @param record - The 1-based record number, for errors.
3060
+ * @returns The cell text, empty for no value.
3061
+ * @throws ArchiveError when a date or logical value is malformed.
3062
+ */
3063
+ function cellOf(bytes, field, decode, record) {
3064
+ const text = decode(bytes).trim();
3065
+ if (field.type === "C") return text;
3066
+ if (field.type === "N" || field.type === "F") return /^\**$/.test(text) ? "" : text;
3067
+ if (field.type === "D") {
3068
+ if (text === "" || /^0+$/.test(text)) return "";
3069
+ if (!new RegExp(`^\\d{${String(DATE_DIGITS)}}$`).test(text)) throw new ArchiveError(`record ${String(record)} has the date ${JSON.stringify(text)} in ${field.name}, not YYYYMMDD`);
3070
+ return `${text.slice(0, YEAR_DIGITS)}-${text.slice(YEAR_DIGITS, MONTH_END)}-${text.slice(MONTH_END)}`;
3071
+ }
3072
+ if (text === "" || text === "?") return "";
3073
+ if (/^[TtYy]$/.test(text)) return "true";
3074
+ if (/^[FfNn]$/.test(text)) return "false";
3075
+ throw new ArchiveError(`record ${String(record)} has the logical value ${JSON.stringify(text)} in ${field.name}`);
3076
+ }
3077
+ /**
3078
+ * Reads a point shapefile's positions in record order.
3079
+ * @param shp - The `.shp` file.
3080
+ * @returns Each record's x and y, or undefined for a null shape.
3081
+ * @throws ArchiveError when the file is not a shapefile, a record is not a point or the null shape, or the records are not numbered in order.
3082
+ */
3083
+ function pointsOf(shp) {
3084
+ const view = new DataView(shp.buffer, shp.byteOffset, shp.byteLength);
3085
+ if (shp.length < SHP_HEADER_BYTES || view.getInt32(0, false) !== SHP_FILE_CODE || view.getInt32(SHP_VERSION_OFFSET, true) !== SHP_VERSION) throw new ArchiveError("the .shp is not a shapefile: it lacks the file code and version a shapefile starts with");
3086
+ const layerType = view.getInt32(SHP_TYPE_OFFSET, true);
3087
+ if (!POINT_TYPES.has(layerType) && layerType !== SHAPE.null) throw new ArchiveError(`the .shp holds shapes of type ${String(layerType)}, not points, and keepset reads only point shapefiles`);
3088
+ const points = [];
3089
+ for (let at = SHP_HEADER_BYTES; at < shp.length;) {
3090
+ if (at + SHP_RECORD_HEADER_BYTES + SHAPE_TYPE_BYTES > shp.length) throw new ArchiveError(`the .shp ends inside the header of record ${String(points.length + 1)}`);
3091
+ const number = view.getInt32(at, false);
3092
+ const length = view.getInt32(at + SHAPE_TYPE_BYTES, false) * SHP_WORD_BYTES;
3093
+ if (number !== points.length + 1) throw new ArchiveError(`the .shp's record ${String(points.length + 1)} is numbered ${String(number)}`);
3094
+ const content = at + SHP_RECORD_HEADER_BYTES;
3095
+ if (content + length > shp.length) throw new ArchiveError(`the .shp ends inside record ${String(number)}`);
3096
+ const type = view.getInt32(content, true);
3097
+ if (type === SHAPE.null) points.push(void 0);
3098
+ else if (POINT_TYPES.has(type) && length >= 20) points.push({
3099
+ x: view.getFloat64(content + SHAPE_TYPE_BYTES, true),
3100
+ y: view.getFloat64(content + SHAPE_TYPE_BYTES + COORDINATE_BYTES, true)
3101
+ });
3102
+ else throw new ArchiveError(`the .shp's record ${String(number)} is a shape of type ${String(type)}, not a point`);
3103
+ at = content + length;
3104
+ }
3105
+ return points;
3106
+ }
3107
+ /**
3108
+ * The position cells of a point, in the order of `SHAPEFILE_POINT_COLUMNS`.
3109
+ * @param x - The stored x.
3110
+ * @param y - The stored y.
3111
+ * @param toWgs84 - The conversion the `.prj` defines.
3112
+ * @returns The stored x and y, then the WGS84 latitude and longitude.
3113
+ */
3114
+ function positionCells(x, y, toWgs84) {
3115
+ const { latitude, longitude } = toWgs84(x, y);
3116
+ return [
3117
+ String(x),
3118
+ String(y),
3119
+ String(latitude),
3120
+ String(longitude)
3121
+ ];
3122
+ }
3123
+ /**
3124
+ * Writes a point shapefile as CSV text, so a recipe reads it with the table parser: a header of the attribute table's field names followed by `geometry_x`, `geometry_y`, `geometry_latitude` and `geometry_longitude`, then one line per record in file order with its attributes (`cellOf` says how each is written), its coordinates as the shapefile stores them, and its position in WGS84 degrees, converted by the coordinate system the `.prj` defines (`toWgs84Of`). A record whose shape is null has empty position cells.
3125
+ * @param parts - The shapefile's parts.
3126
+ * @returns The CSV, in UTF-8.
3127
+ * @throws ArchiveError when a part is malformed, the geometry is not points, the geometry and the attribute table hold different numbers of records, an attribute record is marked deleted (it would leave the table and the geometry paired by nothing), a field is named like a position column, or the coordinate system is not one keepset converts.
3128
+ */
3129
+ function pointShapefileToCsv(parts) {
3130
+ const toWgs84 = toWgs84Of(new TextDecoder("utf-8").decode(parts.prj));
3131
+ const decode = decoderOf(parts.cpg);
3132
+ const layout = dbfLayout(parts.dbf, decode);
3133
+ const points = pointsOf(parts.shp);
3134
+ if (points.length !== layout.records) throw new ArchiveError(`the .shp holds ${String(points.length)} records and the .dbf ${String(layout.records)}, so they do not pair`);
3135
+ if (parts.dbf.length < layout.headerLength + layout.records * layout.recordLength) throw new ArchiveError(`the .dbf ends before its ${String(layout.records)} records`);
3136
+ const clash = layout.fields.find((field) => SHAPEFILE_POINT_COLUMNS.some((column) => column === field.name));
3137
+ if (clash !== void 0) throw new ArchiveError(`the .dbf has a field named ${clash.name}, which is a column of the point's position`);
3138
+ const lines = [[...layout.fields.map((field) => field.name), ...SHAPEFILE_POINT_COLUMNS].map(csvField).join(",")];
3139
+ points.forEach((point, index) => {
3140
+ const start = layout.headerLength + index * layout.recordLength;
3141
+ if (parts.dbf[start] !== LIVE_RECORD) throw new ArchiveError(`the .dbf's record ${String(index + 1)} is marked deleted`);
3142
+ const cells = layout.fields.map((field) => cellOf(parts.dbf.subarray(start + field.offset, start + field.offset + field.length), field, decode, index + 1));
3143
+ const geometry = point === void 0 ? SHAPEFILE_POINT_COLUMNS.map(() => "") : positionCells(point.x, point.y, toWgs84);
3144
+ lines.push([...cells, ...geometry].map(csvField).join(","));
3145
+ });
3146
+ return new TextEncoder().encode(`${lines.join("\n")}\n`);
3147
+ }
2170
3148
  /** The ustar header layout (POSIX.1-1988, pax(1) "ustar Interchange Format"): each field's byte offset and length. */
2171
3149
  const HEADER = {
2172
3150
  name: {
@@ -2213,7 +3191,7 @@ const BYTE_RADIX = 256;
2213
3191
  * @param length - Field length.
2214
3192
  * @returns The field text.
2215
3193
  */
2216
- function field$1(bytes, offset, length) {
3194
+ function field(bytes, offset, length) {
2217
3195
  const slice = bytes.subarray(offset, offset + length);
2218
3196
  const end = slice.indexOf(0);
2219
3197
  return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end));
@@ -2232,7 +3210,7 @@ function numeric(bytes, offset, length) {
2232
3210
  for (const byte of bytes.subarray(offset + 1, offset + length)) value = value * BYTE_RADIX + byte;
2233
3211
  return value;
2234
3212
  }
2235
- const text = field$1(bytes, offset, length).trim();
3213
+ const text = field(bytes, offset, length).trim();
2236
3214
  if (!/^[0-7]*$/.test(text)) throw new ArchiveError(`tar header field is not octal: ${JSON.stringify(text)}`);
2237
3215
  return text === "" ? 0 : Number.parseInt(text, 8);
2238
3216
  }
@@ -2268,21 +3246,21 @@ function readTar(bytes) {
2268
3246
  for (const [index, byte] of header.entries()) sum += index >= checksum.offset && index < checksum.offset + checksum.length ? 32 : byte;
2269
3247
  if (sum !== numeric(header, checksum.offset, checksum.length)) throw new ArchiveError(`tar header at byte ${String(offset)} fails its checksum`);
2270
3248
  const size = numeric(header, HEADER.size.offset, HEADER.size.length);
2271
- const type = field$1(header, HEADER.type.offset, HEADER.type.length);
3249
+ const type = field(header, HEADER.type.offset, HEADER.type.length);
2272
3250
  const dataStart = offset + 512;
2273
3251
  if (dataStart + size > bytes.length) throw new ArchiveError(`tar member at byte ${String(offset)} runs past the end of the archive`);
2274
3252
  const data = bytes.subarray(dataStart, dataStart + size);
2275
3253
  offset = dataStart + Math.ceil(size / 512) * 512;
2276
3254
  if (type === "L") {
2277
- longName = field$1(data, 0, data.length);
3255
+ longName = field(data, 0, data.length);
2278
3256
  continue;
2279
3257
  }
2280
3258
  if (type === "x") {
2281
3259
  longName = paxPath(data);
2282
3260
  continue;
2283
3261
  }
2284
- const name = field$1(header, HEADER.name.offset, HEADER.name.length);
2285
- const prefix = field$1(header, HEADER.magic.offset, HEADER.magic.length).startsWith("ustar") ? field$1(header, HEADER.prefix.offset, HEADER.prefix.length) : "";
3262
+ const name = field(header, HEADER.name.offset, HEADER.name.length);
3263
+ const prefix = field(header, HEADER.magic.offset, HEADER.magic.length).startsWith("ustar") ? field(header, HEADER.prefix.offset, HEADER.prefix.length) : "";
2286
3264
  const path = longName ?? (prefix === "" ? name : `${prefix}/${name}`);
2287
3265
  longName = void 0;
2288
3266
  if (type === "0" || type === "" || type === "7") members.push({
@@ -2431,14 +3409,6 @@ function columnOf(reference) {
2431
3409
  return index - 1;
2432
3410
  }
2433
3411
  /**
2434
- * One CSV field: quoted when it holds a comma, a quote or a line break.
2435
- * @param text - The cell text.
2436
- * @returns The field.
2437
- */
2438
- function field(text) {
2439
- return /[",\r\n]/.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
2440
- }
2441
- /**
2442
3412
  * Finds the part that holds a named sheet.
2443
3413
  * @param workbook - The `workbook.xml` text.
2444
3414
  * @param rels - The workbook's relationships text.
@@ -2502,6 +3472,26 @@ function readRows(xml, strings, dates, date1904) {
2502
3472
  };
2503
3473
  }
2504
3474
  /**
3475
+ * The names of a workbook's sheets, in the order the workbook lists them, which is the order of the tabs an editor shows.
3476
+ * @param bytes - The workbook.
3477
+ * @returns The sheet names.
3478
+ * @throws ArchiveError when the bytes are not a workbook.
3479
+ */
3480
+ function sheetNames(bytes) {
3481
+ let part;
3482
+ try {
3483
+ part = getOwn(unzipSync(bytes, { filter: (file) => file.name === "xl/workbook.xml" }), "xl/workbook.xml");
3484
+ } catch (error) {
3485
+ throw new ArchiveError(`not a valid workbook: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3486
+ }
3487
+ if (part === void 0) throw new ArchiveError("the file is not an xlsx workbook: it has no xl/workbook.xml");
3488
+ return elements(new TextDecoder("utf-8").decode(part), "sheet").map((sheet, index) => {
3489
+ const name = sheet["name"];
3490
+ if (name === void 0) throw new ArchiveError(`the workbook's sheet ${String(index + 1)} has no name`);
3491
+ return name;
3492
+ });
3493
+ }
3494
+ /**
2505
3495
  * Writes one sheet of an xlsx workbook as CSV text, so a recipe reads it with the table parser: one line for every row number from the first to the last (a row the sheet leaves out is an empty line, so the lines above the header are the rows an editor shows), every line the width of the sheet's widest row, a date cell as ISO 8601 text, a boolean as TRUE or FALSE, a number as the text the workbook stores, and a formula as its stored result.
2506
3496
  * @param bytes - The workbook.
2507
3497
  * @param sheet - The sheet's name.
@@ -2537,7 +3527,7 @@ function sheetToCsv(bytes, sheet) {
2537
3527
  const lines = [];
2538
3528
  for (let number = 1; number <= last; number += 1) {
2539
3529
  const cells = rows.get(number) ?? [];
2540
- lines.push(Array.from({ length: width }, (_empty, column) => field(cells[column] ?? "")).join(","));
3530
+ lines.push(Array.from({ length: width }, (_empty, column) => csvField(cells[column] ?? "")).join(","));
2541
3531
  }
2542
3532
  return new TextEncoder().encode(lines.length === 0 ? "" : `${lines.join("\n")}\n`);
2543
3533
  }
@@ -2608,10 +3598,12 @@ function pick(paths, member) {
2608
3598
  function unpack(bytes, archive) {
2609
3599
  if (archive.kind === "none") return { bytes };
2610
3600
  if (archive.kind === "gzip") return { bytes: gunzip(bytes) };
3601
+ if (archive.kind === "bzip2") return { bytes: bunzip2(bytes) };
2611
3602
  if (archive.kind === "xlsx") return {
2612
3603
  bytes: sheetToCsv(bytes, archive.sheet),
2613
3604
  member: archive.sheet
2614
3605
  };
3606
+ if (archive.kind === "shapefile") return unpackShapefile(bytes, archive.member);
2615
3607
  const outer = selectMember(bytes, archive);
2616
3608
  if (archive.inner === void 0) return outer;
2617
3609
  if (archive.inner.kind === "gzip") return {
@@ -2646,7 +3638,7 @@ function selectMember(bytes, archive) {
2646
3638
  member: path
2647
3639
  };
2648
3640
  }
2649
- const members = readTar(archive.kind === "tar" ? bytes : gunzip(bytes));
3641
+ const members = readTar(untarred(bytes, archive.kind));
2650
3642
  const path = pick(members.map((member) => member.path), archive.member);
2651
3643
  const found = members.find((member) => member.path === path);
2652
3644
  if (found === void 0) throw new ArchiveError(`tar member ${path} could not be read`);
@@ -2656,13 +3648,84 @@ function selectMember(bytes, archive) {
2656
3648
  };
2657
3649
  }
2658
3650
  /**
2659
- * Unpacks downloaded bytes into every file a collection's selector matches.
3651
+ * Unpacks a zipped point shapefile into the CSV its attribute table and positions make. The `.dbf`, `.prj` and `.cpg` are the members with the `.shp`'s path and their own extension, matched without case as GIS tools write extensions either way.
3652
+ * @param bytes - The zip's bytes.
3653
+ * @param member - The selector of the `.shp`.
3654
+ * @returns The CSV, and the `.shp`'s path as the member read.
3655
+ * @throws ArchiveError when the bytes are not a zip, the selector does not match exactly one `.shp`, a part beside it is missing or there is more than one of it, or the shapefile cannot be read.
3656
+ */
3657
+ function unpackShapefile(bytes, member) {
3658
+ const names = [];
3659
+ unzip(bytes, (file) => {
3660
+ names.push(file.name);
3661
+ return false;
3662
+ });
3663
+ const shp = pick(names, member);
3664
+ if (!shp.toLowerCase().endsWith(".shp")) throw new ArchiveError(`the shapefile member ${shp} is not a .shp`);
3665
+ const stem = shp.slice(0, -4);
3666
+ const beside = (extension) => {
3667
+ const matched = names.filter((name) => name.length === stem.length + extension.length && name.startsWith(stem) && name.toLowerCase().endsWith(extension));
3668
+ const [only] = matched;
3669
+ if (only === void 0 || matched.length !== 1) throw new ArchiveError(`expected one ${extension} beside ${shp}, found ${String(matched.length)} among: ${names.join(", ")}`);
3670
+ return only;
3671
+ };
3672
+ const paths = {
3673
+ shp,
3674
+ dbf: beside(".dbf"),
3675
+ prj: beside(".prj"),
3676
+ cpg: beside(".cpg")
3677
+ };
3678
+ const wanted = new Set(Object.values(paths));
3679
+ const files = unzip(bytes, (candidate) => wanted.has(candidate.name));
3680
+ const read = (path) => {
3681
+ const file = getOwn(files, path);
3682
+ if (file === void 0) throw new ArchiveError(`zip member ${path} could not be read`);
3683
+ return file;
3684
+ };
3685
+ return {
3686
+ bytes: pointShapefileToCsv({
3687
+ shp: read(paths.shp),
3688
+ dbf: read(paths.dbf),
3689
+ prj: read(paths.prj),
3690
+ cpg: read(paths.cpg)
3691
+ }),
3692
+ member: shp
3693
+ };
3694
+ }
3695
+ /**
3696
+ * Unpacks downloaded bytes into every file a collection's selector matches, or, with an `xlsx` inner layout, into every sheet of every matched workbook that its `sheets` pattern matches as the sheet's CSV, at the member's path and the sheet's name joined by `/`, members in path order and sheets in the workbook's order.
2660
3697
  * @param bytes - The downloaded bytes.
2661
3698
  * @param archive - The recipe's archive layout, which selects members with `members`.
2662
- * @returns Each matching file, in path order.
2663
- * @throws ArchiveError when the bytes are not a well-formed archive of the stated kind or the selector matches no member.
3699
+ * @returns Each file.
3700
+ * @throws ArchiveError when the bytes are not a well-formed archive of the stated kind, the selector matches no member, or a member an `xlsx` layout reads is not a workbook or has no sheet the pattern matches.
2664
3701
  */
2665
3702
  function unpackMembers(bytes, archive) {
3703
+ const members = selectMembers(bytes, archive);
3704
+ if (archive.inner === void 0) return members;
3705
+ const { sheets } = archive.inner;
3706
+ const wanted = sheets === void 0 ? void 0 : new RegExp(`^(?:${sheets})$`, "u");
3707
+ return members.flatMap((member) => {
3708
+ let names;
3709
+ try {
3710
+ names = sheetNames(member.bytes).filter((sheet) => wanted === void 0 || wanted.test(sheet));
3711
+ } catch (error) {
3712
+ throw new ArchiveError(`member ${member.path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3713
+ }
3714
+ if (names.length === 0) throw new ArchiveError(`member ${member.path} has no sheet matching ${sheets ?? ""}`);
3715
+ return names.map((sheet) => ({
3716
+ path: `${member.path}/${sheet}`,
3717
+ bytes: sheetToCsv(member.bytes, sheet)
3718
+ }));
3719
+ });
3720
+ }
3721
+ /**
3722
+ * Unpacks every member a collection's selector matches.
3723
+ * @param bytes - The downloaded bytes.
3724
+ * @param archive - The recipe's archive layout.
3725
+ * @returns Each matching member, in path order.
3726
+ * @throws ArchiveError when the bytes are not a well-formed archive of the stated kind or the selector matches no member.
3727
+ */
3728
+ function selectMembers(bytes, archive) {
2666
3729
  if (archive.kind === "zip") {
2667
3730
  const names = [];
2668
3731
  unzip(bytes, (file) => {
@@ -2681,7 +3744,7 @@ function unpackMembers(bytes, archive) {
2681
3744
  };
2682
3745
  });
2683
3746
  }
2684
- const members = readTar(archive.kind === "tar" ? bytes : gunzip(bytes));
3747
+ const members = readTar(untarred(bytes, archive.kind));
2685
3748
  return pickAll(members.map((member) => member.path), archive.members).map((path) => {
2686
3749
  const found = members.find((member) => member.path === path);
2687
3750
  if (found === void 0) throw new ArchiveError(`tar member ${path} could not be read`);
@@ -2692,6 +3755,16 @@ function unpackMembers(bytes, archive) {
2692
3755
  });
2693
3756
  }
2694
3757
  /**
3758
+ * Removes the compression a tar archive kind states, leaving the tar bytes.
3759
+ * @param bytes - The downloaded bytes.
3760
+ * @param kind - The tar archive kind: `tar` is uncompressed, `tar.gz` is gzip and `tar.bz2` is bzip2.
3761
+ * @returns The tar archive.
3762
+ */
3763
+ function untarred(bytes, kind) {
3764
+ if (kind === "tar") return bytes;
3765
+ return kind === "tar.gz" ? gunzip(bytes) : bunzip2(bytes);
3766
+ }
3767
+ /**
2695
3768
  * Gunzips bytes, rethrowing zlib's failure in the package's taxonomy.
2696
3769
  * @param bytes - Gzip data.
2697
3770
  * @returns The decompressed bytes.