keepset 0.34.0 → 0.36.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,4 +1,4 @@
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-CPSe9InV.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-BrTVG8j1.js";
2
2
  import { RoundTripMismatchError, assertRoundTrip, encode } from "crossmap";
3
3
  import { z } from "zod";
4
4
  import { createHash } from "node:crypto";
@@ -128,8 +128,8 @@ function parseEdgeList(text, options) {
128
128
  }
129
129
  //#endregion
130
130
  //#region src/parsers/gd-contest.ts
131
- const FORMAT$10 = "gd-contest";
132
- const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
131
+ const FORMAT$11 = "gd-contest";
132
+ const NUMBER$9 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
133
133
  /**
134
134
  * Reads a vertex number, which the format numbers from 0 to N - 1.
135
135
  * @param token - The token.
@@ -137,7 +137,7 @@ const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
137
137
  * @returns The vertex id as text.
138
138
  */
139
139
  function vertex$1(token, count) {
140
- if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$10, 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)}`);
141
141
  return token.text;
142
142
  }
143
143
  /**
@@ -157,17 +157,17 @@ function parseGdContest(text) {
157
157
  });
158
158
  }
159
159
  const [first] = tokens;
160
- if (first === void 0) throw new NativeParseError(FORMAT$10, 1, "the file holds no vertex count");
161
- if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$10, 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)}`);
162
162
  const count = Number(first.text);
163
163
  const coordinates = tokens.slice(1, 1 + 2 * count);
164
- if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$10, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
165
- for (const coordinate of coordinates) if (!NUMBER$8.test(coordinate.text)) throw new NativeParseError(FORMAT$10, 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`);
166
166
  const ends = tokens.slice(1 + 2 * count);
167
167
  const edges = [];
168
168
  for (let source = ends.shift(); source !== void 0; source = ends.shift()) {
169
169
  const target = ends.shift();
170
- if (target === void 0) throw new NativeParseError(FORMAT$10, 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`);
171
171
  edges.push({
172
172
  source: vertex$1(source, count),
173
173
  target: vertex$1(target, count)
@@ -181,7 +181,7 @@ function parseGdContest(text) {
181
181
  //#endregion
182
182
  //#region src/parsers/gml.ts
183
183
  const KEY = /[A-Za-z_][A-Za-z0-9_]*/y;
184
- const NUMBER$7 = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/y;
184
+ const NUMBER$8 = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?/y;
185
185
  /**
186
186
  * Matches a sticky pattern at a position.
187
187
  * @param pattern - A sticky regular expression.
@@ -232,7 +232,7 @@ function tokenise(text) {
232
232
  position = end + 1;
233
233
  } else {
234
234
  const key = matchAt(KEY, text, position);
235
- 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;
236
236
  const match = key ?? number;
237
237
  if (match === void 0) throw new NativeParseError("gml", line, `unexpected character ${JSON.stringify(char)}`);
238
238
  tokens.push({
@@ -337,7 +337,7 @@ function parseGml(text) {
337
337
  }
338
338
  //#endregion
339
339
  //#region src/parsers/konect.ts
340
- const FORMAT$9 = "konect";
340
+ const FORMAT$10 = "konect";
341
341
  /** The network formats of KONECT's first line (Kunegis, "The KONECT Handbook", table 1): undirected, directed and bipartite. */
342
342
  const NETWORK_FORMATS = /* @__PURE__ */ new Set([
343
343
  "sym",
@@ -412,9 +412,9 @@ function parseKonect(pieces, options) {
412
412
  }));
413
413
  });
414
414
  const { header } = state;
415
- if (header === void 0) throw new NativeParseError(FORMAT$9, lastLine, "the file has no first line naming its format and weights");
416
- if (edges.length === 0) throw new NativeParseError(FORMAT$9, lastLine, "the file holds no edges");
417
- if (header.counts !== void 0 && header.counts.edges !== edges.length) throw new NativeParseError(FORMAT$9, lastLine, `the second line declares ${String(header.counts.edges)} edges, the file holds ${String(edges.length)}`);
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
418
  return {
419
419
  nodes: order.nodes(),
420
420
  edges
@@ -432,9 +432,9 @@ function readHeader(content, number, options) {
432
432
  const match = FORMAT_LINE.exec(content);
433
433
  const network = match?.[1];
434
434
  const weights = match?.[2];
435
- if (network === void 0 || weights === void 0 || !NETWORK_FORMATS.has(network)) throw new NativeParseError(FORMAT$9, 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$9, 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$9, 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)}`);
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
438
  return {
439
439
  line: number,
440
440
  network
@@ -454,15 +454,15 @@ function readEdge(content, number, context) {
454
454
  const [from, to, third, time] = fields;
455
455
  const expected = options.timestamped ? TIMESTAMPED_COLUMNS : options.weighted ? WEIGHTED_COLUMNS : void 0;
456
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$9, number, `expected ${expected === void 0 ? `${String(ENDPOINT_COLUMNS)} or ${String(WEIGHTED_COLUMNS)}` : String(expected)} columns for this recipe, found ${String(fields.length)}`);
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
458
  const bipartite = header.network === "bip";
459
459
  const source = endpoint(from, bipartite ? LEFT_PREFIX : "", header.counts?.left, number);
460
460
  const target = endpoint(to, bipartite ? RIGHT_PREFIX : "", header.counts?.right, number);
461
461
  order.add(source);
462
462
  order.add(target);
463
- if (third !== void 0 && !options.weighted && (!DECIMAL$3.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$9, 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$9, number, `the weight ${JSON.stringify(third)} is not a number`);
465
- if (time !== void 0 && !DECIMAL$3.test(time)) throw new NativeParseError(FORMAT$9, number, `the time ${JSON.stringify(time)} is not a number`);
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
466
  const edge = {
467
467
  source,
468
468
  target
@@ -481,7 +481,7 @@ function readEdge(content, number, context) {
481
481
  * @throws NativeParseError when the token is not a positive integer or exceeds the set's size.
482
482
  */
483
483
  function endpoint(token, prefix, size, number) {
484
- if (!NODE_ID.test(token) || size !== void 0 && Number(token) > size) throw new NativeParseError(FORMAT$9, number, `${JSON.stringify(token)} is not a node id from 1${size === void 0 ? "" : ` to ${String(size)}`}`);
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
485
  return `${prefix}${token}`;
486
486
  }
487
487
  //#endregion
@@ -739,6 +739,15 @@ function* records(pieces, delimiter, firstLine) {
739
739
  if (last !== void 0) yield last;
740
740
  }
741
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
+ /**
742
751
  * Drops the first physical lines of a file given in pieces.
743
752
  * @param pieces - The file text in order.
744
753
  * @param count - How many physical lines to drop.
@@ -885,7 +894,7 @@ function pointOf(geometry) {
885
894
  * @returns The cell: null for a missing, null or empty value.
886
895
  * @throws NativeParseError when the value is not the kind the recipe declares.
887
896
  */
888
- function cellOf(value, column, feature, kind) {
897
+ function cellOf$1(value, column, feature, kind) {
889
898
  if (value === void 0 || value === null) return null;
890
899
  if (kind === "date") {
891
900
  if (typeof value !== "number" || !Number.isFinite(value)) throw new NativeParseError("geojson", feature, `column ${column} is declared a date but holds ${JSON.stringify(value)}`);
@@ -929,7 +938,7 @@ function parseGeojson(text, options) {
929
938
  const properties = feature.properties ?? {};
930
939
  const point = pointOf(feature.geometry);
931
940
  const own = (column) => Object.hasOwn(properties, column) ? properties[column] : void 0;
932
- 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");
933
942
  if (id === null) throw new NativeParseError("geojson", number, `the id column ${options.idColumn ?? ""} is empty`);
934
943
  const cells = kinds.map(({ column, kind }) => {
935
944
  if (column === POINT_COLUMNS.latitude || column === POINT_COLUMNS.longitude) {
@@ -937,7 +946,7 @@ function parseGeojson(text, options) {
937
946
  const position = column === POINT_COLUMNS.latitude ? point?.latitude : point?.longitude;
938
947
  return [column, position === void 0 ? null : position * (typeof kind === "object" ? kind.factor : 1)];
939
948
  }
940
- return [column, cellOf(own(column), column, number, kind)];
949
+ return [column, cellOf$1(own(column), column, number, kind)];
941
950
  });
942
951
  return {
943
952
  ...Object.fromEntries(cells),
@@ -946,6 +955,205 @@ function parseGeojson(text, options) {
946
955
  }) };
947
956
  }
948
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
949
1157
  //#region src/parsers/pajek.ts
950
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. */
951
1159
  const sections = /* @__PURE__ */ new Map([
@@ -1832,7 +2040,7 @@ function rowsOf(text) {
1832
2040
  * @returns The number.
1833
2041
  * @throws NativeParseError when the field is absent or not a decimal number.
1834
2042
  */
1835
- function numberAt(row, index, what) {
2043
+ function numberAt$1(row, index, what) {
1836
2044
  const field = row.fields[index];
1837
2045
  if (field === void 0 || !NUMBER.test(field)) throw new NativeParseError(FORMAT$2, row.line, `expected ${what}, found ${JSON.stringify(field)}`);
1838
2046
  return Number(field);
@@ -1904,7 +2112,7 @@ function parseVerolog2019(text) {
1904
2112
  const row = rows[position];
1905
2113
  const header = row === void 0 ? void 0 : keyed(row);
1906
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(" "))}`);
1907
- meta[name] = kind === "text" ? header.value.join(" ") : numberAt({
2115
+ meta[name] = kind === "text" ? header.value.join(" ") : numberAt$1({
1908
2116
  line: row.line,
1909
2117
  fields: header.value
1910
2118
  }, 0, `a number for ${key}`);
@@ -1923,8 +2131,8 @@ function parseVerolog2019(text) {
1923
2131
  x: NaN,
1924
2132
  y: NaN
1925
2133
  } : {
1926
- x: numberAt(row, 1, "x"),
1927
- y: numberAt(row, 2, "y")
2134
+ x: numberAt$1(row, 1, "x"),
2135
+ y: numberAt$1(row, 2, "y")
1928
2136
  };
1929
2137
  };
1930
2138
  const depot = {
@@ -1938,11 +2146,11 @@ function parseVerolog2019(text) {
1938
2146
  amount: null
1939
2147
  };
1940
2148
  const requests = sections.REQUESTS.map((row) => {
1941
- const location = numberAt(row, REQUEST_AT.location, "a location id");
1942
- const fromDay = numberAt(row, REQUEST_AT.fromDay, "the first day");
1943
- const toDay = numberAt(row, REQUEST_AT.toDay, "the last day");
1944
- const machine = numberAt(row, REQUEST_AT.machine, "a machine id");
1945
- const amount = numberAt(row, REQUEST_AT.amount, "the 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");
1946
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)}`);
1947
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)}`);
1948
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)}`);
@@ -2433,6 +2641,7 @@ function parseNative(bytes, parser) {
2433
2641
  if (parser.format === "tsplib") return parseTsplib(text);
2434
2642
  if (parser.format === "verolog-2019") return parseVerolog2019(text);
2435
2643
  if (parser.format === "vrp-rep") return parseVrpRep(text);
2644
+ if (parser.format === "hhcrsp") return parseHhcrsp(text);
2436
2645
  return parsePajek(text);
2437
2646
  }
2438
2647
  //#endregion
@@ -2477,6 +2686,465 @@ function bunzip2(bytes) {
2477
2686
  const joined = Buffer.concat(chunks);
2478
2687
  return new Uint8Array(joined.buffer, joined.byteOffset, joined.byteLength);
2479
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
+ }
2480
3148
  /** The ustar header layout (POSIX.1-1988, pax(1) "ustar Interchange Format"): each field's byte offset and length. */
2481
3149
  const HEADER = {
2482
3150
  name: {
@@ -2523,7 +3191,7 @@ const BYTE_RADIX = 256;
2523
3191
  * @param length - Field length.
2524
3192
  * @returns The field text.
2525
3193
  */
2526
- function field$1(bytes, offset, length) {
3194
+ function field(bytes, offset, length) {
2527
3195
  const slice = bytes.subarray(offset, offset + length);
2528
3196
  const end = slice.indexOf(0);
2529
3197
  return new TextDecoder().decode(end === -1 ? slice : slice.subarray(0, end));
@@ -2542,7 +3210,7 @@ function numeric(bytes, offset, length) {
2542
3210
  for (const byte of bytes.subarray(offset + 1, offset + length)) value = value * BYTE_RADIX + byte;
2543
3211
  return value;
2544
3212
  }
2545
- const text = field$1(bytes, offset, length).trim();
3213
+ const text = field(bytes, offset, length).trim();
2546
3214
  if (!/^[0-7]*$/.test(text)) throw new ArchiveError(`tar header field is not octal: ${JSON.stringify(text)}`);
2547
3215
  return text === "" ? 0 : Number.parseInt(text, 8);
2548
3216
  }
@@ -2578,21 +3246,21 @@ function readTar(bytes) {
2578
3246
  for (const [index, byte] of header.entries()) sum += index >= checksum.offset && index < checksum.offset + checksum.length ? 32 : byte;
2579
3247
  if (sum !== numeric(header, checksum.offset, checksum.length)) throw new ArchiveError(`tar header at byte ${String(offset)} fails its checksum`);
2580
3248
  const size = numeric(header, HEADER.size.offset, HEADER.size.length);
2581
- const type = field$1(header, HEADER.type.offset, HEADER.type.length);
3249
+ const type = field(header, HEADER.type.offset, HEADER.type.length);
2582
3250
  const dataStart = offset + 512;
2583
3251
  if (dataStart + size > bytes.length) throw new ArchiveError(`tar member at byte ${String(offset)} runs past the end of the archive`);
2584
3252
  const data = bytes.subarray(dataStart, dataStart + size);
2585
3253
  offset = dataStart + Math.ceil(size / 512) * 512;
2586
3254
  if (type === "L") {
2587
- longName = field$1(data, 0, data.length);
3255
+ longName = field(data, 0, data.length);
2588
3256
  continue;
2589
3257
  }
2590
3258
  if (type === "x") {
2591
3259
  longName = paxPath(data);
2592
3260
  continue;
2593
3261
  }
2594
- const name = field$1(header, HEADER.name.offset, HEADER.name.length);
2595
- 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) : "";
2596
3264
  const path = longName ?? (prefix === "" ? name : `${prefix}/${name}`);
2597
3265
  longName = void 0;
2598
3266
  if (type === "0" || type === "" || type === "7") members.push({
@@ -2741,14 +3409,6 @@ function columnOf(reference) {
2741
3409
  return index - 1;
2742
3410
  }
2743
3411
  /**
2744
- * One CSV field: quoted when it holds a comma, a quote or a line break.
2745
- * @param text - The cell text.
2746
- * @returns The field.
2747
- */
2748
- function field(text) {
2749
- return /[",\r\n]/.test(text) ? `"${text.replaceAll("\"", "\"\"")}"` : text;
2750
- }
2751
- /**
2752
3412
  * Finds the part that holds a named sheet.
2753
3413
  * @param workbook - The `workbook.xml` text.
2754
3414
  * @param rels - The workbook's relationships text.
@@ -2812,6 +3472,26 @@ function readRows(xml, strings, dates, date1904) {
2812
3472
  };
2813
3473
  }
2814
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
+ /**
2815
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.
2816
3496
  * @param bytes - The workbook.
2817
3497
  * @param sheet - The sheet's name.
@@ -2847,7 +3527,7 @@ function sheetToCsv(bytes, sheet) {
2847
3527
  const lines = [];
2848
3528
  for (let number = 1; number <= last; number += 1) {
2849
3529
  const cells = rows.get(number) ?? [];
2850
- 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(","));
2851
3531
  }
2852
3532
  return new TextEncoder().encode(lines.length === 0 ? "" : `${lines.join("\n")}\n`);
2853
3533
  }
@@ -2923,6 +3603,7 @@ function unpack(bytes, archive) {
2923
3603
  bytes: sheetToCsv(bytes, archive.sheet),
2924
3604
  member: archive.sheet
2925
3605
  };
3606
+ if (archive.kind === "shapefile") return unpackShapefile(bytes, archive.member);
2926
3607
  const outer = selectMember(bytes, archive);
2927
3608
  if (archive.inner === void 0) return outer;
2928
3609
  if (archive.inner.kind === "gzip") return {
@@ -2967,13 +3648,84 @@ function selectMember(bytes, archive) {
2967
3648
  };
2968
3649
  }
2969
3650
  /**
2970
- * 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.
2971
3697
  * @param bytes - The downloaded bytes.
2972
3698
  * @param archive - The recipe's archive layout, which selects members with `members`.
2973
- * @returns Each matching file, in path order.
2974
- * @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.
2975
3701
  */
2976
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) {
2977
3729
  if (archive.kind === "zip") {
2978
3730
  const names = [];
2979
3731
  unzip(bytes, (file) => {