keepset 0.37.1 → 0.38.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-CRr7GPD3.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-U2l4WT5T.js";
2
2
  import { RoundTripMismatchError, assertRoundTrip, encode } from "crossmap";
3
3
  import { z } from "zod";
4
4
  import { createHash } from "node:crypto";
@@ -136,34 +136,74 @@ function detect(dataLine) {
136
136
  return dataLine.includes(",") ? "comma" : "whitespace";
137
137
  }
138
138
  /**
139
- * Parses a delimited edge list: one edge per line, source and target in the configured columns, after `skipLines` header lines, with blank lines and lines whose first non-blank characters are a comment prefix skipped. A `detect` delimiter is fixed from the first data line. Nodes are the endpoints, in order of first appearance.
139
+ * Reads a line's multiplicity.
140
+ * @param fields - The line's fields.
141
+ * @param column - The multiplicity column.
142
+ * @param lineNumber - The 1-based line, for the error.
143
+ * @returns How many edges the line stands for.
144
+ * @throws NativeParseError when the field is not a whole number.
145
+ */
146
+ function multiplicity(fields, column, lineNumber) {
147
+ const value = fields[column]?.trim() ?? "";
148
+ if (!/^(?:0|[1-9]\d*)$/.test(value)) throw new NativeParseError("edge-list", lineNumber, `column ${String(column)} holds ${JSON.stringify(value)}, which is not a whole number of edges`);
149
+ return Number(value);
150
+ }
151
+ /**
152
+ * Parses a delimited edge list: one edge per line, source and target in the configured columns, after `skipLines` header lines, with blank lines and lines whose first non-blank characters are a comment prefix skipped. A `detect` delimiter is fixed from the first data line. A recipe that names a `keep` column reads only the lines whose field there is one of its values; one that names a `multiplicityColumn` emits as many edges for a line as the field says, none for a zero; one that says `mirrored` emits each pair of mirror-image lines once. Nodes are the endpoints of the lines read, in order of first appearance.
140
153
  * @param text - The file text.
141
154
  * @param options - The recipe's edge-list parser.
142
155
  * @returns The native graph.
143
- * @throws NativeParseError naming the line when a data line has too few fields or an empty endpoint, or when the file holds no edges.
156
+ * @throws NativeParseError naming the line when a data line has too few fields, an empty endpoint or a multiplicity that is not a whole number, when a mirrored table has a line without its mirror, or when the file holds no edges.
144
157
  */
145
158
  function parseEdgeList(text, options) {
146
159
  let split = options.delimiter === "detect" ? void 0 : splitters[options.delimiter];
147
- const needed = Math.max(options.sourceColumn, options.targetColumn) + 1;
160
+ const needed = Math.max(options.sourceColumn, options.targetColumn, options.keep?.column ?? 0, options.multiplicityColumn ?? 0) + 1;
148
161
  const order = new NodeOrder();
149
162
  const edges = [];
150
- for (const [index, line] of lines(text).entries()) {
163
+ const awaiting = /* @__PURE__ */ new Map();
164
+ const all = lines(text);
165
+ for (const [index, line] of all.entries()) {
151
166
  const lineNumber = index + 1;
152
167
  const content = line.trimStart();
153
168
  if (index < options.skipLines || content === "" || options.commentPrefixes.some((prefix) => content.startsWith(prefix))) continue;
154
169
  split ??= splitters[detect(line)];
155
170
  const fields = split(line);
156
171
  if (fields.length < needed) throw new NativeParseError("edge-list", lineNumber, `expected at least ${String(needed)} fields, found ${String(fields.length)}`);
172
+ if (options.keep !== void 0 && !options.keep.values.includes(fields[options.keep.column]?.trim() ?? "")) continue;
157
173
  const source = endpoint$1(fields, options.sourceColumn, lineNumber);
158
174
  const target = endpoint$1(fields, options.targetColumn, lineNumber);
175
+ const count = options.multiplicityColumn === void 0 ? 1 : multiplicity(fields, options.multiplicityColumn, lineNumber);
176
+ if (count === 0) continue;
159
177
  order.add(source);
160
178
  order.add(target);
161
- edges.push({
179
+ if (options.mirrored === true && source !== target) {
180
+ const key = JSON.stringify([
181
+ source,
182
+ target,
183
+ count
184
+ ]);
185
+ const waiting = awaiting.get(key);
186
+ if (waiting !== void 0 && waiting.length > 0) {
187
+ waiting.shift();
188
+ continue;
189
+ }
190
+ const mirror = JSON.stringify([
191
+ target,
192
+ source,
193
+ count
194
+ ]);
195
+ awaiting.set(mirror, [...awaiting.get(mirror) ?? [], lineNumber]);
196
+ }
197
+ for (let copy = 0; copy < count; copy += 1) edges.push({
162
198
  source,
163
199
  target
164
200
  });
165
201
  }
166
- if (edges.length === 0) throw new NativeParseError("edge-list", lines(text).length, "the file holds no edges");
202
+ for (const [key, waiting] of awaiting) {
203
+ const [first] = waiting;
204
+ if (first !== void 0) throw new NativeParseError("edge-list", first, `the table is mirrored but this line has no mirror image (${key} is never listed)`);
205
+ }
206
+ if (edges.length === 0) throw new NativeParseError("edge-list", all.length, "the file holds no edges");
167
207
  return {
168
208
  nodes: order.nodes(),
169
209
  edges
@@ -384,7 +424,7 @@ const FORMAT$13 = "hgb";
384
424
  /** A type code or node id as the HGB files write them: a non-negative decimal integer with no sign or leading zero. */
385
425
  const CODE = /^(?:0|[1-9]\d*)$/;
386
426
  /** A number as a decimal literal with an optional sign and exponent, so `Number` cannot read hex, an empty string or infinity. */
387
- const DECIMAL$4 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
427
+ const DECIMAL$5 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
388
428
  /** `node.dat` lines are the id, the name and the type, then the attributes on node types that have them. */
389
429
  const NODE_FIELDS = 3;
390
430
  const NODE_FIELDS_WITH_ATTRIBUTES = 4;
@@ -456,7 +496,7 @@ function parseHgb(parts, options) {
456
496
  if (actual === void 0) throw new NativeParseError(FORMAT$13, number, `link.dat: the ${end} ${JSON.stringify(id)} is not a node of node.dat`);
457
497
  if (actual !== expected) throw new NativeParseError(FORMAT$13, number, `link.dat: a ${linkType.name} link's ${end} must be a ${expected} node, and ${id} is a ${actual} node`);
458
498
  }
459
- if (!DECIMAL$4.test(weight) || Number(weight) !== 1) throw new NativeParseError(FORMAT$13, number, `link.dat: the weight ${JSON.stringify(weight)} is not 1; a weighted HGB file would lose its weights`);
499
+ if (!DECIMAL$5.test(weight) || Number(weight) !== 1) throw new NativeParseError(FORMAT$13, number, `link.dat: the weight ${JSON.stringify(weight)} is not 1; a weighted HGB file would lose its weights`);
460
500
  edges.push({
461
501
  source,
462
502
  target,
@@ -588,7 +628,7 @@ const COUNT_LINE = /^%\s+(\d+)\s+(\d+)\s+(\d+)\s*$/;
588
628
  /** A node id: KONECT numbers nodes from 1. */
589
629
  const NODE_ID = /^[1-9]\d*$/;
590
630
  /** A number as a decimal literal with an optional sign and exponent, so `Number` cannot read hex, an empty string or infinity. */
591
- const DECIMAL$3 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
631
+ const DECIMAL$4 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
592
632
  /** 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. */
593
633
  const ENDPOINT_COLUMNS = 2;
594
634
  const WEIGHTED_COLUMNS = 3;
@@ -686,9 +726,9 @@ function readEdge(content, number, context) {
686
726
  const target = endpoint(to, bipartite ? RIGHT_PREFIX : "", header.counts?.right, number);
687
727
  order.add(source);
688
728
  order.add(target);
689
- if (third !== void 0 && !options.weighted && (!DECIMAL$3.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$11, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
690
- if (third !== void 0 && options.weighted && !DECIMAL$3.test(third)) throw new NativeParseError(FORMAT$11, number, `the weight ${JSON.stringify(third)} is not a number`);
691
- if (time !== void 0 && !DECIMAL$3.test(time)) throw new NativeParseError(FORMAT$11, number, `the time ${JSON.stringify(time)} is not a number`);
729
+ if (third !== void 0 && !options.weighted && (!DECIMAL$4.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$11, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
730
+ if (third !== void 0 && options.weighted && !DECIMAL$4.test(third)) throw new NativeParseError(FORMAT$11, number, `the weight ${JSON.stringify(third)} is not a number`);
731
+ if (time !== void 0 && !DECIMAL$4.test(time)) throw new NativeParseError(FORMAT$11, number, `the time ${JSON.stringify(time)} is not a number`);
692
732
  const edge = {
693
733
  source,
694
734
  target
@@ -808,7 +848,7 @@ const DELIMITERS = {
808
848
  semicolon: ";"
809
849
  };
810
850
  /** A number as a decimal literal, with an optional sign and exponent. `Number` alone would also accept hex, binary, `Infinity` and the empty string. */
811
- const DECIMAL$2 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
851
+ const DECIMAL$3 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
812
852
  /** The byte order mark some exporters put before the first header. */
813
853
  const BYTE_ORDER_MARK = "";
814
854
  /**
@@ -952,7 +992,7 @@ var RecordScanner = class {
952
992
  * @returns The records in order, blank lines included as a record of one empty field.
953
993
  * @throws NativeParseError when a quoted field is never closed or a quote opens inside an unquoted field.
954
994
  */
955
- function* records(pieces, delimiter, firstLine) {
995
+ function* records$1(pieces, delimiter, firstLine) {
956
996
  const scanner = new RecordScanner(delimiter.charCodeAt(0), firstLine);
957
997
  let carry = "";
958
998
  for (const piece of pieces) {
@@ -971,7 +1011,7 @@ function* records(pieces, delimiter, firstLine) {
971
1011
  * @throws NativeParseError when a quoted field is never closed or a quote opens inside an unquoted field.
972
1012
  */
973
1013
  function csvGrid(text) {
974
- return [...records([text], ",", 1)].map((record) => record.fields);
1014
+ return [...records$1([text], ",", 1)].map((record) => record.fields);
975
1015
  }
976
1016
  /**
977
1017
  * Drops the first physical lines of a file given in pieces.
@@ -1020,7 +1060,7 @@ function* withoutByteOrderMark(text) {
1020
1060
  */
1021
1061
  function numeric$1(cell, factor, column, line) {
1022
1062
  if (cell === "") return null;
1023
- if (!DECIMAL$2.test(cell)) throw new NativeParseError("csv-table", line, `column ${column} is declared numeric but holds ${JSON.stringify(cell)}`);
1063
+ if (!DECIMAL$3.test(cell)) throw new NativeParseError("csv-table", line, `column ${column} is declared numeric but holds ${JSON.stringify(cell)}`);
1024
1064
  return Number(cell) * factor;
1025
1065
  }
1026
1066
  /**
@@ -1032,7 +1072,7 @@ function numeric$1(cell, factor, column, line) {
1032
1072
  */
1033
1073
  function* tableRecords(text, options) {
1034
1074
  const body = afterLines(withoutByteOrderMark(text), options.skipLines);
1035
- for (const record of records(body, DELIMITERS[options.delimiter], options.skipLines + 1)) if (record.fields.length > 1 || record.fields[0]?.trim() !== "") yield record;
1075
+ for (const record of records$1(body, DELIMITERS[options.delimiter], options.skipLines + 1)) if (record.fields.length > 1 || record.fields[0]?.trim() !== "") yield record;
1036
1076
  }
1037
1077
  /**
1038
1078
  * Parses a delimited table into rows (a cell whose text is one of the recipe's `nullValues` is empty, so null): the first record after `skipLines` lines is the header, each later record is a row, and every row carries every column but the id column (or, when the recipe lists `columns`, exactly those, so a wide table is not held cell for cell), an empty cell being null. Cells are trimmed. A column the recipe declares numeric is read as a decimal number times its factor and any other column stays a string. `id` is the row's id, so a column of that name which is not the id column is named by its position. A row's id is the cell of the recipe's id column, which is then not also a column, or its 1-based row number when the recipe names none. Blank lines are skipped; a row with a different number of fields than the header, two columns with the same name, and an id cell that is empty are errors.
@@ -2543,8 +2583,8 @@ const PREDEFINED = {
2543
2583
  apos: "'"
2544
2584
  };
2545
2585
  const CODE_POINT = /^#(?:x([0-9A-Fa-f]+)|(\d+))$/;
2546
- const HEXADECIMAL$1 = 16;
2547
- const DECIMAL$1 = 10;
2586
+ const HEXADECIMAL$2 = 16;
2587
+ const DECIMAL$2 = 10;
2548
2588
  /** The markup that runs from an opening marker to a closing one and holds no elements: comments, CDATA sections and processing instructions. */
2549
2589
  const COMMENT = {
2550
2590
  open: "<!--",
@@ -2591,7 +2631,7 @@ var XmlReader = class {
2591
2631
  if (predefined !== void 0) return predefined;
2592
2632
  const code = CODE_POINT.exec(reference);
2593
2633
  if (code === null) return this.#fail(index, `the entity ${whole} is not one of the five predefined ones`);
2594
- return String.fromCodePoint(code[1] === void 0 ? Number.parseInt(code[2] ?? "", DECIMAL$1) : Number.parseInt(code[1], HEXADECIMAL$1));
2634
+ return String.fromCodePoint(code[1] === void 0 ? Number.parseInt(code[2] ?? "", DECIMAL$2) : Number.parseInt(code[1], HEXADECIMAL$2));
2595
2635
  });
2596
2636
  }
2597
2637
  /**
@@ -2839,6 +2879,47 @@ function parseVrpRep(text) {
2839
2879
  };
2840
2880
  }
2841
2881
  //#endregion
2882
+ //#region src/parsers/witvliet-synapses.ts
2883
+ /** One chemical synapse of the file: the presynaptic neuron and every neuron it contacts. The file's other fields (CATMAID and VAST ids, the synapse's size and its partners' shares of it) are not read. */
2884
+ const SynapseSchema = z.object({
2885
+ pre: z.string().min(1),
2886
+ post: z.array(z.string().min(1)).min(1)
2887
+ });
2888
+ /**
2889
+ * Parses a Witvliet synapse file: a JSON array of chemical synapses, each a `pre` neuron and its `post` partners. A synapse is polyadic, one presynaptic site facing several partners, and each partner is one edge from `pre` to it, so a synapse with three partners is three edges and a partner listed twice is two. Nodes are the neurons in order of first appearance, `pre` before its partners.
2890
+ * @param text - The file text.
2891
+ * @returns The native graph.
2892
+ * @throws NativeParseError, numbering the synapse (from 1) in place of a line, when the text is not JSON, not an array, a synapse lacks its `pre` or `post` or names an empty neuron, or the file holds no synapse.
2893
+ */
2894
+ function parseWitvlietSynapses(text) {
2895
+ let document;
2896
+ try {
2897
+ document = JSON.parse(text);
2898
+ } catch (error) {
2899
+ throw new NativeParseError("witvliet-synapses", 1, `the file is not JSON: ${error instanceof Error ? error.message : String(error)}`);
2900
+ }
2901
+ if (!Array.isArray(document)) throw new NativeParseError("witvliet-synapses", 1, "the file is not a JSON array of synapses");
2902
+ const order = new NodeOrder();
2903
+ const edges = [];
2904
+ for (const [index, item] of document.entries()) {
2905
+ const synapse = SynapseSchema.safeParse(item);
2906
+ if (!synapse.success) throw new NativeParseError("witvliet-synapses", index + 1, `synapse ${String(index + 1)} is not a pre neuron with a non-empty list of post neurons: ${synapse.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}`);
2907
+ order.add(synapse.data.pre);
2908
+ for (const post of synapse.data.post) {
2909
+ order.add(post);
2910
+ edges.push({
2911
+ source: synapse.data.pre,
2912
+ target: post
2913
+ });
2914
+ }
2915
+ }
2916
+ if (edges.length === 0) throw new NativeParseError("witvliet-synapses", 1, "the file holds no synapses");
2917
+ return {
2918
+ nodes: order.nodes(),
2919
+ edges
2920
+ };
2921
+ }
2922
+ //#endregion
2842
2923
  //#region src/parsers/parse-native.ts
2843
2924
  /** Bytes converted per String.fromCharCode call: spreading a whole file would exceed the engine's argument-count limit, and this many stays well inside every engine's. */
2844
2925
  const LATIN1_CHUNK = 32768;
@@ -2904,6 +2985,7 @@ function parseNative(bytes, parser) {
2904
2985
  if (parser.format === "gml") return parseGml(text);
2905
2986
  if (parser.format === "ucinet-dl") return parseUcinetDl(text, parser);
2906
2987
  if (parser.format === "gd-contest") return parseGdContest(text);
2988
+ if (parser.format === "witvliet-synapses") return parseWitvlietSynapses(text);
2907
2989
  if (parser.format === "customer-points") return parseCustomerPoints(text);
2908
2990
  if (parser.format === "solomon") return parseSolomon(text);
2909
2991
  if (parser.format === "top") return parseTop(text);
@@ -3564,23 +3646,10 @@ function readTar(bytes) {
3564
3646
  return members;
3565
3647
  }
3566
3648
  //#endregion
3567
- //#region src/node/xlsx.ts
3568
- const HEXADECIMAL = 16;
3569
- const DECIMAL = 10;
3570
- const LETTERS = 26;
3571
- const FIRST_LETTER = "A".charCodeAt(0);
3572
- const SECONDS_PER_DAY = 86400;
3573
- const MILLISECONDS = 1e3;
3574
- /** The serial number Excel gives its 29th of February 1900, a day that never existed: serials above it count from 30 December 1899 and serials below it from 31 December 1899. */
3575
- const LEAP_BUG_SERIAL = 60;
3576
- /** The day a serial number counts from: 1 January 1904 in a workbook that says so, otherwise 31 December 1899 for the serials before Excel's false 29th of February 1900 and 30 December 1899 for those after it. */
3577
- const EPOCH_1904 = "1904-01-01T00:00:00Z";
3578
- const EPOCH_BEFORE_LEAP_BUG = "1899-12-31T00:00:00Z";
3579
- const EPOCH_AFTER_LEAP_BUG = "1899-12-30T00:00:00Z";
3580
- const DATE_LENGTH = 10;
3581
- const DATE_TIME_LENGTH = 19;
3582
- const SECONDS_PER_MINUTE = 60;
3583
- const MINUTES_PER_HOUR = 60;
3649
+ //#region src/node/xml-part.ts
3650
+ /** Helpers shared by the readers of spreadsheet formats that store their parts as XML (xlsx and OpenDocument): they read the flat, namespace-prefixed markup those parts are written in, without building a tree. */
3651
+ const HEXADECIMAL$1 = 16;
3652
+ const DECIMAL$1 = 10;
3584
3653
  /**
3585
3654
  * Replaces the XML entities and character references of a text.
3586
3655
  * @param text - Escaped text.
@@ -3588,8 +3657,8 @@ const MINUTES_PER_HOUR = 60;
3588
3657
  */
3589
3658
  function unescape(text) {
3590
3659
  return text.replaceAll(/&(#x[0-9A-Fa-f]+|#[0-9]+|amp|lt|gt|quot|apos);/g, (_whole, entity) => {
3591
- if (entity.startsWith("#x")) return String.fromCodePoint(Number.parseInt(entity.slice(2), HEXADECIMAL));
3592
- if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), DECIMAL));
3660
+ if (entity.startsWith("#x")) return String.fromCodePoint(Number.parseInt(entity.slice(2), HEXADECIMAL$1));
3661
+ if (entity.startsWith("#")) return String.fromCodePoint(Number.parseInt(entity.slice(1), DECIMAL$1));
3593
3662
  return {
3594
3663
  amp: "&",
3595
3664
  lt: "<",
@@ -3618,16 +3687,156 @@ function attributesOf(tag) {
3618
3687
  function elements(xml, name) {
3619
3688
  return [...xml.matchAll(new RegExp(`<${name}(?=[\\s/>])([^>]*)>`, "g"))].map((match) => attributesOf(match[1] ?? ""));
3620
3689
  }
3690
+ //#endregion
3691
+ //#region src/node/ods.ts
3692
+ /** The part of an OpenDocument spreadsheet that holds its tables ([ODF 1.3] part 2, 3.1.3). */
3693
+ const CONTENT_PART = "content.xml";
3694
+ const DECIMAL = 10;
3695
+ const SECONDS_PER_MINUTE$1 = 60;
3696
+ const MINUTES_PER_HOUR$1 = 60;
3697
+ /** An `office:time-value`, an ISO 8601 duration of hours, minutes and seconds such as `PT13H05M00S` (the form spreadsheet applications write for a time of day, [ODF 1.3] part 3, 19.389). */
3698
+ const TIME_VALUE = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/;
3699
+ /** The value types whose value is the number in `office:value` ([ODF 1.3] part 3, 19.387). */
3700
+ const NUMERIC_TYPES = /* @__PURE__ */ new Set([
3701
+ "float",
3702
+ "percentage",
3703
+ "currency"
3704
+ ]);
3621
3705
  /**
3622
- * The strings of a workbook's shared string table, in index order: each item's text runs joined, the phonetic runs of East Asian text left out.
3623
- * @param xml - The `sharedStrings.xml` text.
3624
- * @returns The strings.
3706
+ * The text of a cell's paragraphs, as a spreadsheet shows it: paragraphs joined by line breaks, `text:s` as its count of spaces, `text:tab` as a tab and `text:line-break` as a line break, the text of spans and links kept and annotations left out.
3707
+ * @param body - The XML between the cell's start and end tags.
3708
+ * @param row - The 1-based row, for the error.
3709
+ * @returns The text.
3710
+ * @throws ArchiveError when a run of spaces states a count that is not a positive whole number.
3625
3711
  */
3626
- function sharedStrings(xml) {
3627
- return [...xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)].map((item) => {
3628
- return [...(item[1] ?? "").replaceAll(/<rPh[\s\S]*?<\/rPh>/g, "").matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((text) => unescape(text[1] ?? "")).join("");
3629
- });
3712
+ function cellText(body, row) {
3713
+ const paragraphs = [];
3714
+ for (const paragraph of body.replaceAll(/<office:annotation[\s>][\s\S]*?<\/office:annotation>/g, "").matchAll(/<text:(?:p|h)(?=[\s/>])[^>]*?(?:\/>|>([\s\S]*?)<\/text:(?:p|h)>)/g)) {
3715
+ const inner = (paragraph[1] ?? "").replaceAll(/<text:s(?=[\s/>])([^>]*?)\/?>/g, (_whole, attributes) => " ".repeat(repeats(attributesOf(attributes), "text:c", row))).replaceAll(/<text:tab(?=[\s/>])[^>]*?\/?>/g, " ").replaceAll(/<text:line-break(?=[\s/>])[^>]*?\/?>/g, "\n").replaceAll(/<[^>]*>/g, "");
3716
+ paragraphs.push(unescape(inner));
3717
+ }
3718
+ return paragraphs.join("\n");
3630
3719
  }
3720
+ /**
3721
+ * A time value as the clock text the xlsx reader gives a time alone, `hh:mm:ss`, with the seconds rounded to whole seconds and an hour count past a day kept as it stands.
3722
+ * @param duration - The `office:time-value`.
3723
+ * @param row - The 1-based row, for the error.
3724
+ * @returns The text.
3725
+ * @throws ArchiveError when the value is not an hours, minutes and seconds duration.
3726
+ */
3727
+ function timeText(duration, row) {
3728
+ const match = TIME_VALUE.exec(duration);
3729
+ if (match === null) throw new ArchiveError(`row ${String(row)} has a time cell holding ${duration}, which is not a duration of hours, minutes and seconds`);
3730
+ const total = Math.round(Number.parseInt(match[1] ?? "0", DECIMAL) * MINUTES_PER_HOUR$1 * SECONDS_PER_MINUTE$1 + Number.parseInt(match[2] ?? "0", DECIMAL) * SECONDS_PER_MINUTE$1 + Number(match[3] ?? "0"));
3731
+ return [
3732
+ Math.floor(total / 3600),
3733
+ Math.floor(total / SECONDS_PER_MINUTE$1) % MINUTES_PER_HOUR$1,
3734
+ total % SECONDS_PER_MINUTE$1
3735
+ ].map((part) => String(part).padStart(2, "0")).join(":");
3736
+ }
3737
+ /**
3738
+ * The text a cell gives the CSV, by its value type: the stored number for a number, percentage or currency, the stored ISO 8601 date (or date and time) for a date, `hh:mm:ss` for a time, TRUE or FALSE for a boolean, the shown text for a string or an error, and nothing for a cell with no value.
3739
+ * @param attributes - The cell's attributes.
3740
+ * @param body - The XML between its tags.
3741
+ * @param row - The 1-based row, for errors.
3742
+ * @returns The text, empty for no value.
3743
+ * @throws ArchiveError when a typed cell lacks the attribute its type stores its value in.
3744
+ */
3745
+ function valueText(attributes, body, row) {
3746
+ const type = attributes["office:value-type"];
3747
+ const stored = (name) => {
3748
+ const value = attributes[name];
3749
+ if (value === void 0) throw new ArchiveError(`row ${String(row)} has a ${type ?? ""} cell with no ${name}`);
3750
+ return value;
3751
+ };
3752
+ if (type === void 0 || type === "string" || attributes["calcext:value-type"] === "error") return cellText(body, row);
3753
+ if (NUMERIC_TYPES.has(type)) return stored("office:value");
3754
+ if (type === "date") return stored("office:date-value");
3755
+ if (type === "time") return timeText(stored("office:time-value"), row);
3756
+ if (type === "boolean") return stored("office:boolean-value") === "true" ? "TRUE" : "FALSE";
3757
+ throw new ArchiveError(`row ${String(row)} has a cell of value type ${type}, which OpenDocument does not define`);
3758
+ }
3759
+ /**
3760
+ * How many times a row or cell repeats.
3761
+ * @param attributes - Its attributes.
3762
+ * @param name - The repeat attribute, which is absent for an element that appears once.
3763
+ * @param row - The 1-based row, for the error.
3764
+ * @returns The count, at least one.
3765
+ * @throws ArchiveError when the attribute is not a positive whole number.
3766
+ */
3767
+ function repeats(attributes, name, row) {
3768
+ const stated = attributes[name];
3769
+ if (stated === void 0) return 1;
3770
+ if (!/^[1-9]\d*$/.test(stated)) throw new ArchiveError(`row ${String(row)} states ${name} as ${stated}, which is not a positive whole number`);
3771
+ return Number(stated);
3772
+ }
3773
+ /**
3774
+ * Reads one table of an OpenDocument spreadsheet (`.ods`, a zip of XML parts) as the same text the xlsx reader gives (see `valueText`). A cell or row repeated by `table:number-columns-repeated` or `table:number-rows-repeated` is written as often as it repeats when it holds a value; an empty one only moves the position on, so the filler an application writes to the end of the sheet costs nothing. A covered cell (the hidden part of a merged range) is an empty cell.
3775
+ * @param bytes - The spreadsheet.
3776
+ * @param sheet - The table's name.
3777
+ * @returns The table's cells.
3778
+ * @throws ArchiveError when the bytes are not an OpenDocument spreadsheet, the table is missing, or a cell is malformed.
3779
+ */
3780
+ function odsGrid(bytes, sheet) {
3781
+ let content;
3782
+ try {
3783
+ content = getOwn(unzipSync(bytes, { filter: (file) => file.name === CONTENT_PART }), CONTENT_PART);
3784
+ } catch (error) {
3785
+ throw new ArchiveError(`not a valid OpenDocument spreadsheet: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
3786
+ }
3787
+ if (content === void 0) throw new ArchiveError(`the file is not an OpenDocument spreadsheet: it has no ${CONTENT_PART}`);
3788
+ const xml = new TextDecoder("utf-8", { fatal: true }).decode(content);
3789
+ const names = [];
3790
+ let table;
3791
+ for (const match of xml.matchAll(/<table:table(?=[\s>])([^>]*)>([\s\S]*?)<\/table:table>/g)) {
3792
+ const name = attributesOf(match[1] ?? "")["table:name"] ?? "";
3793
+ names.push(name);
3794
+ if (name === sheet) {
3795
+ table = match[2] ?? "";
3796
+ break;
3797
+ }
3798
+ }
3799
+ if (table === void 0) throw new ArchiveError(`the spreadsheet has no table ${sheet}; it has ${names.join(", ")}`);
3800
+ const rows = /* @__PURE__ */ new Map();
3801
+ let width = 0;
3802
+ let next = 1;
3803
+ for (const row of table.matchAll(/<table:table-row(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/table:table-row>)/g)) {
3804
+ const repeated = repeats(attributesOf(row[1] ?? ""), "table:number-rows-repeated", next);
3805
+ const cells = [];
3806
+ let column = 0;
3807
+ for (const cell of (row[2] ?? "").matchAll(/<table:(?:covered-)?table-cell(?=[\s/>])([^>]*?)(?:\/>|>([\s\S]*?)<\/table:(?:covered-)?table-cell>)/g)) {
3808
+ const attributes = attributesOf(cell[1] ?? "");
3809
+ const span = repeats(attributes, "table:number-columns-repeated", next);
3810
+ const text = valueText(attributes, cell[2] ?? "", next);
3811
+ if (text !== "") {
3812
+ for (let copy = 0; copy < span; copy += 1) cells[column + copy] = text;
3813
+ width = Math.max(width, column + span);
3814
+ }
3815
+ column += span;
3816
+ }
3817
+ if (cells.length > 0) for (let copy = 0; copy < repeated; copy += 1) rows.set(next + copy, cells);
3818
+ next += repeated;
3819
+ }
3820
+ return {
3821
+ rows,
3822
+ width
3823
+ };
3824
+ }
3825
+ //#endregion
3826
+ //#region src/node/sheet.ts
3827
+ /** What the spreadsheet readers (`xlsx`, `xls` and `ods`) share: the date arithmetic of the Excel serial number, the test for a number format that shows a date, and the CSV a sheet is written as, so the three formats give the table parser the same text for the same cells. */
3828
+ const SECONDS_PER_DAY = 86400;
3829
+ const MILLISECONDS = 1e3;
3830
+ /** The serial number Excel gives its 29th of February 1900, a day that never existed: serials above it count from 30 December 1899 and serials below it from 31 December 1899. */
3831
+ const LEAP_BUG_SERIAL = 60;
3832
+ /** The day a serial number counts from: 1 January 1904 in a workbook that says so, otherwise 31 December 1899 for the serials before Excel's false 29th of February 1900 and 30 December 1899 for those after it. */
3833
+ const EPOCH_1904 = "1904-01-01T00:00:00Z";
3834
+ const EPOCH_BEFORE_LEAP_BUG = "1899-12-31T00:00:00Z";
3835
+ const EPOCH_AFTER_LEAP_BUG = "1899-12-30T00:00:00Z";
3836
+ const DATE_LENGTH = 10;
3837
+ const DATE_TIME_LENGTH = 19;
3838
+ const SECONDS_PER_MINUTE = 60;
3839
+ const MINUTES_PER_HOUR = 60;
3631
3840
  /** The built-in number format ids that show a date or a time, as inclusive ranges (ECMA-376, 18.8.30): the short and long dates and times, the locale dates, and the elapsed and minute times. */
3632
3841
  const BUILT_IN_DATE_FORMATS = [
3633
3842
  {
@@ -3656,24 +3865,6 @@ function isDateFormat(code) {
3656
3865
  return /[ymdhs]/i.test(code.replaceAll(/"[^"]*"|\\.|\[[^\]]*\]/g, ""));
3657
3866
  }
3658
3867
  /**
3659
- * Which cell styles show dates, by the style's index.
3660
- * @param xml - The `styles.xml` text, or undefined when the workbook has none.
3661
- * @returns The style indexes whose number format is a date or a time.
3662
- */
3663
- function dateStyles(xml) {
3664
- if (xml === void 0) return /* @__PURE__ */ new Set();
3665
- const custom = /* @__PURE__ */ new Map();
3666
- for (const format of elements(xml, "numFmt")) if (format["numFmtId"] !== void 0 && format["formatCode"] !== void 0) custom.set(Number(format["numFmtId"]), format["formatCode"]);
3667
- const cellFormats = /<cellXfs(?:\s[^>]*)?>([\s\S]*?)<\/cellXfs>/.exec(xml)?.[1] ?? "";
3668
- const styles = /* @__PURE__ */ new Set();
3669
- for (const [index, format] of elements(cellFormats, "xf").entries()) {
3670
- const id = Number(format["numFmtId"] ?? "0");
3671
- const code = custom.get(id);
3672
- if (code === void 0 ? BUILT_IN_DATE_FORMATS.some((range) => id >= range.first && id <= range.last) : isDateFormat(code)) styles.add(index);
3673
- }
3674
- return styles;
3675
- }
3676
- /**
3677
3868
  * A date serial number as ISO 8601 text: a date when it has no time of day, a date and time when it has, and a time alone for a serial below one day in a 1900 workbook.
3678
3869
  * @param serial - The serial number, with the time of day as its fraction.
3679
3870
  * @param date1904 - Whether the workbook counts days from 1 January 1904.
@@ -3692,6 +3883,630 @@ function isoOf(serial, date1904) {
3692
3883
  return new Date(Date.parse(date1904 ? EPOCH_1904 : whole < LEAP_BUG_SERIAL ? EPOCH_BEFORE_LEAP_BUG : EPOCH_AFTER_LEAP_BUG) + (whole * SECONDS_PER_DAY + seconds) * MILLISECONDS).toISOString().slice(0, seconds === 0 ? DATE_LENGTH : DATE_TIME_LENGTH);
3693
3884
  }
3694
3885
  /**
3886
+ * Writes a sheet's cells as CSV text, so a recipe reads every spreadsheet format with the table parser: one line for every row number from the first to the last row that holds a value (a row with no value before it is an empty line, so the lines above the header are the rows an editor shows, and the formatted but empty rows a sheet carries after its data are left out), every line the width of the widest row.
3887
+ * @param grid - The sheet's cells.
3888
+ * @returns The CSV, in UTF-8.
3889
+ */
3890
+ function gridToCsv(grid) {
3891
+ let last = 0;
3892
+ for (const [number, cells] of grid.rows) if (cells.length > 0) last = Math.max(last, number);
3893
+ const lines = [];
3894
+ for (let number = 1; number <= last; number += 1) {
3895
+ const cells = grid.rows.get(number) ?? [];
3896
+ lines.push(Array.from({ length: grid.width }, (_empty, column) => csvField(cells[column] ?? "")).join(","));
3897
+ }
3898
+ return new TextEncoder().encode(lines.length === 0 ? "" : `${lines.join("\n")}\n`);
3899
+ }
3900
+ //#endregion
3901
+ //#region src/node/cfb.ts
3902
+ /** The signature every Compound File Binary file opens with ([MS-CFB] 2.2), the bytes D0 CF 11 E0 A1 B1 1A E1 read as one little-endian 64-bit number. */
3903
+ const SIGNATURE = 16220472316735377360n;
3904
+ const HEADER_BYTES = 512;
3905
+ /** The sector numbers that mark the end of a chain and a free sector; every regular sector number is below the first of them ([MS-CFB] 2.1). */
3906
+ const MAX_REGULAR_SECTOR = 4294967290;
3907
+ const END_OF_CHAIN = 4294967294;
3908
+ const FREE_SECTOR = 4294967295;
3909
+ /** The sibling or child id that names no directory entry. */
3910
+ const NO_STREAM = 4294967295;
3911
+ /** The DIFAT entries the header holds before the first DIFAT sector. */
3912
+ const HEADER_DIFAT_ENTRIES = 109;
3913
+ const HEADER_DIFAT_OFFSET = 76;
3914
+ const DIRECTORY_ENTRY_BYTES = 128;
3915
+ const DIRECTORY_NAME_BYTES = 64;
3916
+ const ENTRY_BYTES = 4;
3917
+ const STREAM_OBJECT = 2;
3918
+ const ROOT_OBJECT = 5;
3919
+ /** The header fields this reader uses, by byte offset ([MS-CFB] 2.2). */
3920
+ const FIELD = {
3921
+ sectorShift: 30,
3922
+ miniSectorShift: 32,
3923
+ fatSectors: 44,
3924
+ firstDirectorySector: 48,
3925
+ miniStreamCutoff: 56,
3926
+ firstMiniFatSector: 60,
3927
+ miniFatSectors: 64,
3928
+ firstDifatSector: 68,
3929
+ difatSectors: 72
3930
+ };
3931
+ /** The directory entry fields this reader uses, by byte offset within the entry ([MS-CFB] 2.6.1). */
3932
+ const ENTRY = {
3933
+ nameLength: 64,
3934
+ type: 66,
3935
+ left: 68,
3936
+ right: 72,
3937
+ child: 76,
3938
+ start: 116,
3939
+ size: 120
3940
+ };
3941
+ /** The sector sizes the format allows, as their power of two: 512 bytes for a version 3 file, 4096 for version 4. */
3942
+ const SECTOR_SHIFTS = {
3943
+ version3: 9,
3944
+ version4: 12
3945
+ };
3946
+ const MINI_SECTOR_SHIFT = 6;
3947
+ /**
3948
+ * Reads a stream that lives at the root of a Compound File Binary file (the container of the legacy Office formats, an xls workbook among them), following its sector chain in the file allocation table, or in the mini stream when it is shorter than the file's cutoff.
3949
+ * @param bytes - The file.
3950
+ * @param name - The stream's name, compared without regard to case as the format compares names.
3951
+ * @returns The stream's bytes, or undefined when the root storage holds no stream of that name.
3952
+ * @throws ArchiveError when the bytes are not a compound file, or a header field, a sector chain or a directory entry is malformed.
3953
+ */
3954
+ function readRootStream(bytes, name) {
3955
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3956
+ if (bytes.length < HEADER_BYTES || view.getBigUint64(0, true) !== SIGNATURE) throw new ArchiveError("the file is not a compound file: it does not open with the compound file signature");
3957
+ const u32 = (offset) => view.getUint32(offset, true);
3958
+ const sectorShift = view.getUint16(FIELD.sectorShift, true);
3959
+ if (sectorShift !== SECTOR_SHIFTS.version3 && sectorShift !== SECTOR_SHIFTS.version4 || view.getUint16(FIELD.miniSectorShift, true) !== MINI_SECTOR_SHIFT) throw new ArchiveError(`the compound file states a sector size of 2^${String(sectorShift)} bytes, which the format does not allow`);
3960
+ const sectorBytes = 2 ** sectorShift;
3961
+ const sectorCount = Math.floor((bytes.length - sectorBytes) / sectorBytes) + 1;
3962
+ const sector = (index) => {
3963
+ if (index >= MAX_REGULAR_SECTOR || index >= sectorCount) throw new ArchiveError(`the compound file names sector ${String(index)}, which it does not have`);
3964
+ const start = (index + 1) * sectorBytes;
3965
+ return bytes.subarray(start, Math.min(start + sectorBytes, bytes.length));
3966
+ };
3967
+ const fatSectors = [];
3968
+ for (let index = 0; index < HEADER_DIFAT_ENTRIES; index += 1) fatSectors.push(u32(HEADER_DIFAT_OFFSET + index * ENTRY_BYTES));
3969
+ const perDifatSector = sectorBytes / ENTRY_BYTES - 1;
3970
+ let difat = u32(FIELD.firstDifatSector);
3971
+ for (let read = 0; read < u32(FIELD.difatSectors); read += 1) {
3972
+ const difatSector = sector(difat);
3973
+ const entries = new DataView(difatSector.buffer, difatSector.byteOffset, difatSector.byteLength);
3974
+ for (let index = 0; index < perDifatSector; index += 1) fatSectors.push(entries.getUint32(index * ENTRY_BYTES, true));
3975
+ difat = entries.getUint32(perDifatSector * ENTRY_BYTES, true);
3976
+ }
3977
+ const fat = concatenateEntries(fatSectors.slice(0, u32(FIELD.fatSectors)).map(sector));
3978
+ const fatLimit = Math.min(fat.length, sectorCount);
3979
+ const chain = (start, table, limit) => {
3980
+ const sectors = [];
3981
+ let current = start;
3982
+ while (current !== END_OF_CHAIN) {
3983
+ const next = table[current];
3984
+ if (current >= limit || next === void 0 || sectors.length >= limit) throw new ArchiveError(`a sector chain of the compound file runs to ${current === FREE_SECTOR ? "a free sector" : `sector ${String(current)}`} instead of ending`);
3985
+ sectors.push(current);
3986
+ current = next;
3987
+ }
3988
+ return sectors;
3989
+ };
3990
+ const regularStream = (start, size) => {
3991
+ const joined = concatenate(chain(start, fat, fatLimit).map(sector));
3992
+ if (joined.length < size) throw new ArchiveError(`a stream of the compound file states ${String(size)} bytes but its sectors hold ${String(joined.length)}`);
3993
+ return joined.subarray(0, size);
3994
+ };
3995
+ const directory = concatenate(chain(u32(FIELD.firstDirectorySector), fat, fatLimit).map(sector));
3996
+ const entry = (id) => {
3997
+ const offset = id * DIRECTORY_ENTRY_BYTES;
3998
+ if (offset + DIRECTORY_ENTRY_BYTES > directory.length) throw new ArchiveError(`the compound file names directory entry ${String(id)}, which it does not have`);
3999
+ const fields = new DataView(directory.buffer, directory.byteOffset + offset, DIRECTORY_ENTRY_BYTES);
4000
+ const nameLength = fields.getUint16(ENTRY.nameLength, true);
4001
+ if (nameLength > DIRECTORY_NAME_BYTES || nameLength % 2 !== 0) throw new ArchiveError(`directory entry ${String(id)} of the compound file states a name of ${String(nameLength)} bytes`);
4002
+ return {
4003
+ name: new TextDecoder("utf-16le").decode(directory.subarray(offset, offset + Math.max(0, nameLength - 2))),
4004
+ type: fields.getUint8(ENTRY.type),
4005
+ left: fields.getUint32(ENTRY.left, true),
4006
+ right: fields.getUint32(ENTRY.right, true),
4007
+ child: fields.getUint32(ENTRY.child, true),
4008
+ start: fields.getUint32(ENTRY.start, true),
4009
+ size: fields.getUint32(ENTRY.size, true)
4010
+ };
4011
+ };
4012
+ const root = entry(0);
4013
+ if (root.type !== ROOT_OBJECT) throw new ArchiveError("the first directory entry of the compound file is not its root storage");
4014
+ const wanted = name.toUpperCase();
4015
+ const pending = root.child === NO_STREAM ? [] : [root.child];
4016
+ const visited = /* @__PURE__ */ new Set();
4017
+ let found;
4018
+ while (pending.length > 0 && found === void 0) {
4019
+ const id = pending.pop();
4020
+ if (id === void 0 || visited.has(id)) throw new ArchiveError("the directory tree of the compound file has a cycle");
4021
+ visited.add(id);
4022
+ const candidate = entry(id);
4023
+ if (candidate.type === STREAM_OBJECT && candidate.name.toUpperCase() === wanted) found = candidate;
4024
+ for (const sibling of [candidate.left, candidate.right]) if (sibling !== NO_STREAM) pending.push(sibling);
4025
+ }
4026
+ if (found === void 0) return;
4027
+ if (found.size >= u32(FIELD.miniStreamCutoff)) return regularStream(found.start, found.size);
4028
+ const miniStream = regularStream(root.start, root.size);
4029
+ const miniFat = u32(FIELD.miniFatSectors) === 0 ? /* @__PURE__ */ new Uint32Array() : concatenateEntries(chain(u32(FIELD.firstMiniFatSector), fat, fatLimit).map(sector));
4030
+ const miniBytes = 2 ** MINI_SECTOR_SHIFT;
4031
+ const joined = concatenate(chain(found.start, miniFat, Math.min(miniFat.length, Math.floor(miniStream.length / miniBytes))).map((index) => miniStream.subarray(index * miniBytes, (index + 1) * miniBytes)));
4032
+ if (joined.length < found.size) throw new ArchiveError(`a stream of the compound file states ${String(found.size)} bytes but its mini sectors hold ${String(joined.length)}`);
4033
+ return joined.subarray(0, found.size);
4034
+ }
4035
+ /**
4036
+ * Joins byte runs.
4037
+ * @param parts - The runs, in order.
4038
+ * @returns One array holding them all.
4039
+ */
4040
+ function concatenate(parts) {
4041
+ const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
4042
+ let offset = 0;
4043
+ for (const part of parts) {
4044
+ joined.set(part, offset);
4045
+ offset += part.length;
4046
+ }
4047
+ return joined;
4048
+ }
4049
+ /**
4050
+ * Reads sectors of an allocation table as one list of little-endian 32-bit entries.
4051
+ * @param sectors - The table's sectors, in order.
4052
+ * @returns The entries.
4053
+ */
4054
+ function concatenateEntries(sectors) {
4055
+ const joined = concatenate(sectors);
4056
+ const view = new DataView(joined.buffer, joined.byteOffset, joined.byteLength);
4057
+ return Uint32Array.from({ length: Math.floor(joined.length / ENTRY_BYTES) }, (_unused, index) => view.getUint32(index * ENTRY_BYTES, true));
4058
+ }
4059
+ //#endregion
4060
+ //#region src/node/xls.ts
4061
+ /** The BIFF record types this reader acts on ([MS-XLS] 2.3), by name. */
4062
+ const RECORD = {
4063
+ bof: 2057,
4064
+ eof: 10,
4065
+ filePass: 47,
4066
+ dateMode: 34,
4067
+ format: 1054,
4068
+ xf: 224,
4069
+ boundSheet: 133,
4070
+ sst: 252,
4071
+ continue: 60,
4072
+ labelSst: 253,
4073
+ label: 516,
4074
+ richString: 214,
4075
+ number: 515,
4076
+ rk: 638,
4077
+ mulRk: 189,
4078
+ boolErr: 517,
4079
+ formula: 6,
4080
+ string: 519,
4081
+ array: 545,
4082
+ sharedFormula: 1212,
4083
+ table: 566
4084
+ };
4085
+ /** The records that may stand between a formula and the STRING record holding its text result: the array, shared formula or data table the formula belongs to ([MS-XLS] 2.1.7.20.6, the CELL rule). */
4086
+ const FORMULA_COMPANIONS = /* @__PURE__ */ new Set([
4087
+ RECORD.array,
4088
+ RECORD.sharedFormula,
4089
+ RECORD.table
4090
+ ]);
4091
+ /** The BOF version of a BIFF8 stream, the format of Excel 97 to 2003; earlier BIFF versions store text in a code page and lay their records out differently. */
4092
+ const BIFF8 = 1536;
4093
+ const RECORD_HEADER_BYTES = 4;
4094
+ /** The widths of the unsigned integers a string's header holds. */
4095
+ const U8 = 1;
4096
+ const U16 = 2;
4097
+ const U32 = 4;
4098
+ /** The flag bits of a BIFF8 Unicode string ([MS-XLS] 2.5.293 and 2.5.294): two-byte characters, phonetic data after the runs, and formatting runs. */
4099
+ const HIGH_BYTE = 1;
4100
+ const EXTENDED = 4;
4101
+ const RICH = 8;
4102
+ const FORMATTING_RUN_BYTES = 4;
4103
+ /** A FORMULA record's cached result is not a number when its last two bytes are all ones; its first byte then says what it is ([MS-XLS] 2.5.133). */
4104
+ const NON_NUMERIC_RESULT = 65535;
4105
+ const FORMULA_RESULT = {
4106
+ string: 0,
4107
+ boolean: 1,
4108
+ error: 2,
4109
+ empty: 3
4110
+ };
4111
+ /** The error values a cell can hold, with the code BIFF stores for each ([MS-XLS] 2.5.97.2). */
4112
+ const ERRORS = [
4113
+ {
4114
+ code: 0,
4115
+ text: "#NULL!"
4116
+ },
4117
+ {
4118
+ code: 7,
4119
+ text: "#DIV/0!"
4120
+ },
4121
+ {
4122
+ code: 15,
4123
+ text: "#VALUE!"
4124
+ },
4125
+ {
4126
+ code: 23,
4127
+ text: "#REF!"
4128
+ },
4129
+ {
4130
+ code: 29,
4131
+ text: "#NAME?"
4132
+ },
4133
+ {
4134
+ code: 36,
4135
+ text: "#NUM!"
4136
+ },
4137
+ {
4138
+ code: 42,
4139
+ text: "#N/A"
4140
+ },
4141
+ {
4142
+ code: 43,
4143
+ text: "#GETTING_DATA"
4144
+ }
4145
+ ];
4146
+ const HEXADECIMAL = 16;
4147
+ /** The byte offsets of the fields this reader uses: a cell record's style after its row and column, the unique string count in the SST header, and the high half of a double, little-endian. */
4148
+ const STYLE_OFFSET = 4;
4149
+ const SST_UNIQUE_OFFSET = 4;
4150
+ const DOUBLE_HIGH_HALF = 4;
4151
+ /** An RK number's flags ([MS-XLS] 2.5.217): its value is a hundredth of what is stored, and what is stored is a signed integer rather than the high bits of a double. */
4152
+ const RK_HUNDREDTHS = 1;
4153
+ const RK_INTEGER = 2;
4154
+ const HUNDRED = 100;
4155
+ const RK_INTEGER_SHIFT = 2;
4156
+ const RK_DOUBLE_MASK = 4294967292;
4157
+ const MULRK_ENTRY_BYTES = 6;
4158
+ /** A MULRK's row and first column before its values, and its last column after them. */
4159
+ const MULRK_FIRST_ENTRY = 4;
4160
+ const MULRK_FIXED_BYTES = 6;
4161
+ /** A cell record's row, column and cell style, the first six bytes of every cell record. */
4162
+ const CELL_HEADER_BYTES = 6;
4163
+ const DOUBLE_BYTES = 8;
4164
+ const BOOLERR_VALUE_OFFSET = 6;
4165
+ const BOOLERR_KIND_OFFSET = 7;
4166
+ const SHEET_NAME_OFFSET = 6;
4167
+ const SST_STRINGS_OFFSET = 8;
4168
+ /** The records that hold a cell's value; a BLANK or MULBLANK holds only a style, so an empty cell, and is not among them. */
4169
+ const CELL_RECORDS = /* @__PURE__ */ new Set([
4170
+ RECORD.labelSst,
4171
+ RECORD.label,
4172
+ RECORD.richString,
4173
+ RECORD.number,
4174
+ RECORD.rk,
4175
+ RECORD.mulRk,
4176
+ RECORD.boolErr,
4177
+ RECORD.formula
4178
+ ]);
4179
+ /**
4180
+ * A boolean cell's text.
4181
+ * @param value - The stored byte: zero for false.
4182
+ * @returns TRUE or FALSE, as the xlsx reader writes them.
4183
+ */
4184
+ function booleanText(value) {
4185
+ return value === 0 ? "FALSE" : "TRUE";
4186
+ }
4187
+ /**
4188
+ * An error cell's text.
4189
+ * @param code - The stored error code.
4190
+ * @param row - The 0-based row, for the error.
4191
+ * @returns The error as a spreadsheet shows it, such as `#N/A`.
4192
+ * @throws ArchiveError for a code BIFF does not define.
4193
+ */
4194
+ function errorText(code, row) {
4195
+ const text = ERRORS.find((error) => error.code === code)?.text;
4196
+ if (text === void 0) throw new ArchiveError(`row ${String(row + 1)} holds the error code 0x${code.toString(HEXADECIMAL)}, which BIFF does not define`);
4197
+ return text;
4198
+ }
4199
+ /**
4200
+ * Splits a BIFF stream into records.
4201
+ * @param stream - The stream, or the part of it from a substream's BOF.
4202
+ * @returns The records, in order.
4203
+ * @throws ArchiveError when a record runs past the end of the stream.
4204
+ */
4205
+ function* records(stream) {
4206
+ const view = new DataView(stream.buffer, stream.byteOffset, stream.byteLength);
4207
+ for (let offset = 0; offset + RECORD_HEADER_BYTES <= stream.length;) {
4208
+ const type = view.getUint16(offset, true);
4209
+ const length = view.getUint16(offset + 2, true);
4210
+ const start = offset + RECORD_HEADER_BYTES;
4211
+ if (start + length > stream.length) throw new ArchiveError(`the workbook's record of type 0x${type.toString(HEXADECIMAL)} at byte ${String(offset)} runs past the end of its stream`);
4212
+ const body = stream.subarray(start, start + length);
4213
+ yield {
4214
+ type,
4215
+ body,
4216
+ view: new DataView(body.buffer, body.byteOffset, body.byteLength)
4217
+ };
4218
+ offset = start + length;
4219
+ }
4220
+ }
4221
+ /** Reads the characters of BIFF8 strings across a record and the CONTINUE records after it: a string whose characters cross into a CONTINUE record starts that record with a fresh flag byte saying how wide the rest of its characters are, while the other parts of a string cross without one ([MS-XLS] 2.5.293). */
4222
+ var StringReader = class {
4223
+ parts;
4224
+ part = 0;
4225
+ offset;
4226
+ constructor(parts, start) {
4227
+ this.parts = parts;
4228
+ this.offset = start;
4229
+ }
4230
+ /** @returns The current part, moving to the next one when the current one is spent. */
4231
+ current() {
4232
+ let body = this.parts[this.part];
4233
+ while (body !== void 0 && this.offset >= body.length) {
4234
+ this.part += 1;
4235
+ this.offset = 0;
4236
+ body = this.parts[this.part];
4237
+ }
4238
+ if (body === void 0) throw new ArchiveError("a string of the workbook runs past the end of its records");
4239
+ return body;
4240
+ }
4241
+ /**
4242
+ * Reads bytes that are not characters, which may cross from one part to the next.
4243
+ * @param count - How many.
4244
+ * @returns The bytes.
4245
+ */
4246
+ bytes(count) {
4247
+ const out = new Uint8Array(count);
4248
+ for (let filled = 0; filled < count;) {
4249
+ const body = this.current();
4250
+ const taken = Math.min(count - filled, body.length - this.offset);
4251
+ out.set(body.subarray(this.offset, this.offset + taken), filled);
4252
+ filled += taken;
4253
+ this.offset += taken;
4254
+ }
4255
+ return out;
4256
+ }
4257
+ /**
4258
+ * Reads a little-endian unsigned integer.
4259
+ * @param width - Its width in bytes.
4260
+ * @returns The integer.
4261
+ */
4262
+ uint(width) {
4263
+ const raw = this.bytes(width);
4264
+ const view = new DataView(raw.buffer);
4265
+ return width === U8 ? view.getUint8(0) : width === U16 ? view.getUint16(0, true) : view.getUint32(0, true);
4266
+ }
4267
+ /**
4268
+ * Reads characters, taking a fresh flag byte at the start of each later part they cross into.
4269
+ * @param count - How many characters.
4270
+ * @param high - Whether the characters start as two bytes each.
4271
+ * @returns The text.
4272
+ */
4273
+ characters(count, high) {
4274
+ let text = "";
4275
+ let wide = high;
4276
+ for (let left = count; left > 0;) {
4277
+ let body = this.parts[this.part];
4278
+ if (body !== void 0 && this.offset >= body.length) {
4279
+ this.part += 1;
4280
+ this.offset = 0;
4281
+ wide = (this.uint(U8) & HIGH_BYTE) !== 0;
4282
+ }
4283
+ body = this.current();
4284
+ const width = wide ? 2 : 1;
4285
+ const taken = Math.min(left, Math.floor((body.length - this.offset) / width));
4286
+ if (taken === 0) throw new ArchiveError("a character of a workbook string is split across two records");
4287
+ const run = body.subarray(this.offset, this.offset + taken * width);
4288
+ text += wide ? new TextDecoder("utf-16le").decode(run) : String.fromCharCode(...run);
4289
+ this.offset += taken * width;
4290
+ left -= taken;
4291
+ }
4292
+ return text;
4293
+ }
4294
+ /**
4295
+ * Reads one string: its character count (one or two bytes wide), its flags, the counts of any formatting runs and phonetic data, its characters, and then skips the runs and the phonetic data.
4296
+ * @param countWidth - The width of its character count.
4297
+ * @returns The text.
4298
+ */
4299
+ string(countWidth) {
4300
+ this.current();
4301
+ const count = this.uint(countWidth);
4302
+ const flags = this.uint(U8);
4303
+ const runs = (flags & RICH) === 0 ? 0 : this.uint(U16);
4304
+ const extended = (flags & EXTENDED) === 0 ? 0 : this.uint(U32);
4305
+ const text = this.characters(count, (flags & HIGH_BYTE) !== 0);
4306
+ this.bytes(runs * FORMATTING_RUN_BYTES + extended);
4307
+ return text;
4308
+ }
4309
+ };
4310
+ /**
4311
+ * Reads the workbook globals substream, up to its EOF.
4312
+ * @param stream - The Workbook stream.
4313
+ * @returns The globals.
4314
+ * @throws ArchiveError when the stream is not BIFF8, is encrypted or its shared string table is malformed.
4315
+ */
4316
+ function readGlobals(stream) {
4317
+ const iterator = records(stream);
4318
+ const first = iterator.next();
4319
+ if (first.done === true || first.value.type !== RECORD.bof || first.value.body.length < 2) throw new ArchiveError("the workbook stream does not open with a BOF record");
4320
+ const version = first.value.view.getUint16(0, true);
4321
+ if (version !== BIFF8) throw new ArchiveError(`the workbook is BIFF version 0x${version.toString(HEXADECIMAL)}; only BIFF8 (Excel 97 to 2003) is read`);
4322
+ const formats = /* @__PURE__ */ new Map();
4323
+ const styleFormats = [];
4324
+ const sheets = /* @__PURE__ */ new Map();
4325
+ let strings = [];
4326
+ let date1904 = false;
4327
+ let sst;
4328
+ const finishSst = () => {
4329
+ if (sst === void 0) return;
4330
+ const [head] = sst;
4331
+ if (head === void 0 || head.length < SST_STRINGS_OFFSET) throw new ArchiveError("the workbook's shared string table is shorter than its header");
4332
+ const unique = new DataView(head.buffer, head.byteOffset, head.byteLength).getUint32(SST_UNIQUE_OFFSET, true);
4333
+ const reader = new StringReader(sst, SST_STRINGS_OFFSET);
4334
+ strings = Array.from({ length: unique }, () => reader.string(U16));
4335
+ sst = void 0;
4336
+ };
4337
+ for (const record of iterator) {
4338
+ if (record.type !== RECORD.continue) finishSst();
4339
+ if (record.type === RECORD.eof) break;
4340
+ if (record.type === RECORD.filePass) throw new ArchiveError("the workbook is encrypted");
4341
+ if (record.type === RECORD.dateMode) date1904 = record.view.getUint16(0, true) === 1;
4342
+ else if (record.type === RECORD.format) formats.set(record.view.getUint16(0, true), new StringReader([record.body], 2).string(U16));
4343
+ else if (record.type === RECORD.xf) styleFormats.push(record.view.getUint16(2, true));
4344
+ else if (record.type === RECORD.boundSheet) sheets.set(new StringReader([record.body], SHEET_NAME_OFFSET).string(U8), record.view.getUint32(0, true));
4345
+ else if (record.type === RECORD.sst) sst = [record.body];
4346
+ else if (record.type === RECORD.continue && sst !== void 0) sst.push(record.body);
4347
+ }
4348
+ finishSst();
4349
+ const dateStyles = /* @__PURE__ */ new Set();
4350
+ for (const [index, id] of styleFormats.entries()) {
4351
+ const code = formats.get(id);
4352
+ if (code === void 0 ? BUILT_IN_DATE_FORMATS.some((range) => id >= range.first && id <= range.last) : isDateFormat(code)) dateStyles.add(index);
4353
+ }
4354
+ return {
4355
+ strings,
4356
+ dateStyles,
4357
+ date1904,
4358
+ sheets
4359
+ };
4360
+ }
4361
+ /**
4362
+ * Decodes an RK number ([MS-XLS] 2.5.217): a signed 30-bit integer or the high 30 bits of a double, either of them possibly a hundredth of the value.
4363
+ * @param rk - The stored 32 bits.
4364
+ * @returns The number.
4365
+ */
4366
+ function rkValue(rk) {
4367
+ let value;
4368
+ if ((rk & RK_INTEGER) === 0) {
4369
+ const bits = /* @__PURE__ */ new DataView(/* @__PURE__ */ new ArrayBuffer(DOUBLE_BYTES));
4370
+ bits.setUint32(DOUBLE_HIGH_HALF, (rk & RK_DOUBLE_MASK) >>> 0, true);
4371
+ value = bits.getFloat64(0, true);
4372
+ } else value = rk >> RK_INTEGER_SHIFT;
4373
+ return (rk & RK_HUNDREDTHS) === 0 ? value : value / HUNDRED;
4374
+ }
4375
+ /**
4376
+ * Reads one sheet of a legacy binary Excel workbook (`.xls`, BIFF8 in a compound file) as the same text the xlsx reader gives: a date cell as ISO 8601 text (recognised by its cell style's number format, in either date system), a boolean as TRUE or FALSE, an error as its text, a number as the shortest decimal that is the stored double, and a formula as its cached result.
4377
+ * @param bytes - The workbook.
4378
+ * @param sheet - The sheet's name.
4379
+ * @returns The sheet's cells.
4380
+ * @throws ArchiveError when the bytes are not a BIFF8 workbook, it is encrypted, the sheet is missing, or a record is malformed.
4381
+ */
4382
+ function xlsGrid(bytes, sheet) {
4383
+ const stream = readRootStream(bytes, "Workbook");
4384
+ if (stream === void 0) throw new ArchiveError(readRootStream(bytes, "Book") === void 0 ? "the compound file holds no Workbook stream, so it is not an Excel workbook" : "the workbook is BIFF5 or earlier (Excel 95), which stores text in a code page; only BIFF8 (Excel 97 to 2003) is read");
4385
+ const globals = readGlobals(stream);
4386
+ const start = globals.sheets.get(sheet);
4387
+ if (start === void 0) throw new ArchiveError(`the workbook has no sheet ${sheet}; it has ${[...globals.sheets.keys()].join(", ")}`);
4388
+ if (start >= stream.length) throw new ArchiveError(`the workbook places the sheet ${sheet} at byte ${String(start)}, past the end of its stream`);
4389
+ const rows = /* @__PURE__ */ new Map();
4390
+ let width = 0;
4391
+ const put = (row, column, text) => {
4392
+ if (text === "") return;
4393
+ const cells = rows.get(row + 1) ?? [];
4394
+ cells[column] = text;
4395
+ rows.set(row + 1, cells);
4396
+ width = Math.max(width, column + 1);
4397
+ };
4398
+ const numberText = (value, style, row) => {
4399
+ if (!globals.dateStyles.has(style)) return String(value);
4400
+ if (!Number.isFinite(value) || value < 0) throw new ArchiveError(`row ${String(row + 1)} has a date cell holding ${String(value)}`);
4401
+ return isoOf(value, globals.date1904);
4402
+ };
4403
+ let depth = 0;
4404
+ let pendingString;
4405
+ const finishString = () => {
4406
+ if (pendingString !== void 0) {
4407
+ put(pendingString.row, pendingString.column, new StringReader(pendingString.parts, 0).string(U16));
4408
+ pendingString = void 0;
4409
+ }
4410
+ };
4411
+ for (const record of records(stream.subarray(start))) {
4412
+ if (pendingString !== void 0) {
4413
+ if (pendingString.parts.length === 0) {
4414
+ if (FORMULA_COMPANIONS.has(record.type)) continue;
4415
+ if (record.type !== RECORD.string) throw new ArchiveError(`row ${String(pendingString.row + 1)} has a formula with a text result but no STRING record after it`);
4416
+ pendingString.parts.push(record.body);
4417
+ continue;
4418
+ }
4419
+ if (record.type === RECORD.continue) {
4420
+ pendingString.parts.push(record.body);
4421
+ continue;
4422
+ }
4423
+ finishString();
4424
+ }
4425
+ if (record.type === RECORD.bof) {
4426
+ depth += 1;
4427
+ continue;
4428
+ }
4429
+ if (record.type === RECORD.eof) {
4430
+ depth -= 1;
4431
+ if (depth === 0) break;
4432
+ continue;
4433
+ }
4434
+ if (record.type === RECORD.string && depth === 1) throw new ArchiveError("the sheet has a STRING record that follows no formula");
4435
+ if (depth !== 1 || !CELL_RECORDS.has(record.type)) continue;
4436
+ if (record.body.length < CELL_HEADER_BYTES) throw new ArchiveError(`the sheet has a cell record of type 0x${record.type.toString(HEXADECIMAL)} shorter than a cell's row, column and style`);
4437
+ const row = record.view.getUint16(0, true);
4438
+ const column = record.view.getUint16(2, true);
4439
+ const style = record.view.getUint16(STYLE_OFFSET, true);
4440
+ if (record.type === RECORD.labelSst) {
4441
+ const index = record.view.getUint32(CELL_HEADER_BYTES, true);
4442
+ const text = globals.strings[index];
4443
+ if (text === void 0) throw new ArchiveError(`row ${String(row + 1)} names the shared string ${String(index)}, which the workbook does not have`);
4444
+ put(row, column, text);
4445
+ } else if (record.type === RECORD.label || record.type === RECORD.richString) put(row, column, new StringReader([record.body], CELL_HEADER_BYTES).string(U16));
4446
+ else if (record.type === RECORD.number) put(row, column, numberText(record.view.getFloat64(CELL_HEADER_BYTES, true), style, row));
4447
+ else if (record.type === RECORD.rk) put(row, column, numberText(rkValue(record.view.getUint32(CELL_HEADER_BYTES, true)), style, row));
4448
+ else if (record.type === RECORD.mulRk) {
4449
+ const count = (record.body.length - MULRK_FIXED_BYTES) / MULRK_ENTRY_BYTES;
4450
+ if (!Number.isInteger(count)) throw new ArchiveError(`row ${String(row + 1)} has a MULRK record whose length is not a whole number of values`);
4451
+ for (let index = 0; index < count; index += 1) {
4452
+ const offset = MULRK_FIRST_ENTRY + index * MULRK_ENTRY_BYTES;
4453
+ put(row, column + index, numberText(rkValue(record.view.getUint32(offset + 2, true)), record.view.getUint16(offset, true), row));
4454
+ }
4455
+ } else if (record.type === RECORD.boolErr) {
4456
+ const value = record.view.getUint8(BOOLERR_VALUE_OFFSET);
4457
+ put(row, column, record.view.getUint8(BOOLERR_KIND_OFFSET) === 0 ? booleanText(value) : errorText(value, row));
4458
+ } else if (record.view.getUint16(12, true) === NON_NUMERIC_RESULT) {
4459
+ const kind = record.view.getUint8(CELL_HEADER_BYTES);
4460
+ const value = record.view.getUint8(8);
4461
+ if (kind === FORMULA_RESULT.string) pendingString = {
4462
+ row,
4463
+ column,
4464
+ parts: []
4465
+ };
4466
+ else if (kind === FORMULA_RESULT.boolean) put(row, column, booleanText(value));
4467
+ else if (kind === FORMULA_RESULT.error) put(row, column, errorText(value, row));
4468
+ else if (kind !== FORMULA_RESULT.empty) throw new ArchiveError(`row ${String(row + 1)} has a formula whose cached result is of unknown kind ${String(kind)}`);
4469
+ } else put(row, column, numberText(record.view.getFloat64(CELL_HEADER_BYTES, true), style, row));
4470
+ }
4471
+ finishString();
4472
+ return {
4473
+ rows,
4474
+ width
4475
+ };
4476
+ }
4477
+ //#endregion
4478
+ //#region src/node/xlsx.ts
4479
+ const LETTERS = 26;
4480
+ const FIRST_LETTER = "A".charCodeAt(0);
4481
+ /**
4482
+ * The strings of a workbook's shared string table, in index order: each item's text runs joined, the phonetic runs of East Asian text left out.
4483
+ * @param xml - The `sharedStrings.xml` text.
4484
+ * @returns The strings.
4485
+ */
4486
+ function sharedStrings(xml) {
4487
+ return [...xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)].map((item) => {
4488
+ return [...(item[1] ?? "").replaceAll(/<rPh[\s\S]*?<\/rPh>/g, "").matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)].map((text) => unescape(text[1] ?? "")).join("");
4489
+ });
4490
+ }
4491
+ /**
4492
+ * Which cell styles show dates, by the style's index.
4493
+ * @param xml - The `styles.xml` text, or undefined when the workbook has none.
4494
+ * @returns The style indexes whose number format is a date or a time.
4495
+ */
4496
+ function dateStyles(xml) {
4497
+ if (xml === void 0) return /* @__PURE__ */ new Set();
4498
+ const custom = /* @__PURE__ */ new Map();
4499
+ for (const format of elements(xml, "numFmt")) if (format["numFmtId"] !== void 0 && format["formatCode"] !== void 0) custom.set(Number(format["numFmtId"]), format["formatCode"]);
4500
+ const cellFormats = /<cellXfs(?:\s[^>]*)?>([\s\S]*?)<\/cellXfs>/.exec(xml)?.[1] ?? "";
4501
+ const styles = /* @__PURE__ */ new Set();
4502
+ for (const [index, format] of elements(cellFormats, "xf").entries()) {
4503
+ const id = Number(format["numFmtId"] ?? "0");
4504
+ const code = custom.get(id);
4505
+ if (code === void 0 ? BUILT_IN_DATE_FORMATS.some((range) => id >= range.first && id <= range.last) : isDateFormat(code)) styles.add(index);
4506
+ }
4507
+ return styles;
4508
+ }
4509
+ /**
3695
4510
  * The 0-based column of a cell reference such as `AB12`.
3696
4511
  * @param reference - The reference.
3697
4512
  * @returns The column index.
@@ -3785,13 +4600,13 @@ function sheetNames(bytes) {
3785
4600
  });
3786
4601
  }
3787
4602
  /**
3788
- * 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.
4603
+ * Reads one sheet of an xlsx workbook: a date cell as ISO 8601 text (recognised by its cell style's number format, in either date system), a boolean as TRUE or FALSE, a number as the text the workbook stores, and a formula as its stored result.
3789
4604
  * @param bytes - The workbook.
3790
4605
  * @param sheet - The sheet's name.
3791
- * @returns The CSV, in UTF-8.
4606
+ * @returns The sheet's cells.
3792
4607
  * @throws ArchiveError when the bytes are not a workbook, the sheet is missing, or a cell cannot be read.
3793
4608
  */
3794
- function sheetToCsv(bytes, sheet) {
4609
+ function xlsxGrid(bytes, sheet) {
3795
4610
  let parts;
3796
4611
  try {
3797
4612
  const wanted = /* @__PURE__ */ new Set([
@@ -3815,14 +4630,7 @@ function sheetToCsv(bytes, sheet) {
3815
4630
  const sheetBytes = getOwn(unzipSync(bytes, { filter: (file) => file.name === path }), path);
3816
4631
  if (sheetBytes === void 0) throw new ArchiveError(`the workbook names the sheet ${sheet} in ${path}, which the file does not have`);
3817
4632
  const strings = text("xl/sharedStrings.xml");
3818
- const { rows, width } = readRows(new TextDecoder("utf-8").decode(sheetBytes), strings === void 0 ? [] : sharedStrings(strings), dateStyles(text("xl/styles.xml")), /<workbookPr(?=[\s/>])[^>]*date1904="(?:1|true)"/.test(workbook));
3819
- const last = Math.max(0, ...rows.keys());
3820
- const lines = [];
3821
- for (let number = 1; number <= last; number += 1) {
3822
- const cells = rows.get(number) ?? [];
3823
- lines.push(Array.from({ length: width }, (_empty, column) => csvField(cells[column] ?? "")).join(","));
3824
- }
3825
- return new TextEncoder().encode(lines.length === 0 ? "" : `${lines.join("\n")}\n`);
4633
+ return readRows(new TextDecoder("utf-8").decode(sheetBytes), strings === void 0 ? [] : sharedStrings(strings), dateStyles(text("xl/styles.xml")), /<workbookPr(?=[\s/>])[^>]*date1904="(?:1|true)"/.test(workbook));
3826
4634
  }
3827
4635
  //#endregion
3828
4636
  //#region src/node/archive.ts
@@ -3882,7 +4690,7 @@ function pick(paths, member) {
3882
4690
  return only;
3883
4691
  }
3884
4692
  /**
3885
- * Unpacks downloaded bytes into the one file the parser reads. A member with an `inner` archive is unpacked again by that layout, and the path reported is the outer member's, joined by `/` to the inner member's when the inner layout selects one, as though the inner archive were a directory.
4693
+ * Unpacks downloaded bytes into the one file the parser reads. A member with an `inner` archive is unpacked again by that layout, and the path reported is the outer member's, joined by `/` to the inner member's or the inner sheet's name when the inner layout selects one, as though the inner archive were a directory.
3886
4694
  * @param bytes - The downloaded bytes.
3887
4695
  * @param archive - The recipe's archive layout.
3888
4696
  * @returns The file's bytes, and its member path for a multi-file archive.
@@ -3892,8 +4700,8 @@ function unpack(bytes, archive) {
3892
4700
  if (archive.kind === "none") return { bytes };
3893
4701
  if (archive.kind === "gzip") return { bytes: gunzip(bytes) };
3894
4702
  if (archive.kind === "bzip2") return { bytes: bunzip2(bytes) };
3895
- if (archive.kind === "xlsx") return {
3896
- bytes: sheetToCsv(bytes, archive.sheet),
4703
+ if ("sheet" in archive) return {
4704
+ bytes: sheetToCsv(bytes, archive),
3897
4705
  member: archive.sheet
3898
4706
  };
3899
4707
  if (archive.kind === "shapefile") return unpackShapefile(bytes, archive.member);
@@ -3903,6 +4711,10 @@ function unpack(bytes, archive) {
3903
4711
  bytes: gunzip(outer.bytes),
3904
4712
  member: outer.member
3905
4713
  };
4714
+ if ("sheet" in archive.inner) return {
4715
+ bytes: sheetToCsv(outer.bytes, archive.inner),
4716
+ member: `${outer.member}/${archive.inner.sheet}`
4717
+ };
3906
4718
  const inner = selectMember(outer.bytes, archive.inner);
3907
4719
  return {
3908
4720
  bytes: inner.bytes,
@@ -4007,7 +4819,10 @@ function unpackMembers(bytes, archive) {
4007
4819
  if (names.length === 0) throw new ArchiveError(`member ${member.path} has no sheet matching ${sheets ?? ""}`);
4008
4820
  return names.map((sheet) => ({
4009
4821
  path: `${member.path}/${sheet}`,
4010
- bytes: sheetToCsv(member.bytes, sheet)
4822
+ bytes: sheetToCsv(member.bytes, {
4823
+ kind: "xlsx",
4824
+ sheet
4825
+ })
4011
4826
  }));
4012
4827
  });
4013
4828
  }
@@ -4082,6 +4897,21 @@ function untarred(bytes, kind) {
4082
4897
  return kind === "tar.gz" ? gunzip(bytes) : bunzip2(bytes);
4083
4898
  }
4084
4899
  /**
4900
+ * Reads one sheet of a spreadsheet as CSV text, by the spreadsheet's format.
4901
+ * @param bytes - The spreadsheet.
4902
+ * @param archive - Its format and the sheet to read.
4903
+ * @returns The CSV, in UTF-8.
4904
+ * @throws ArchiveError when the bytes are not a spreadsheet of that format, the sheet is missing, or a cell cannot be read.
4905
+ */
4906
+ function sheetToCsv(bytes, archive) {
4907
+ const read = {
4908
+ xlsx: xlsxGrid,
4909
+ xls: xlsGrid,
4910
+ ods: odsGrid
4911
+ }[archive.kind];
4912
+ return gridToCsv(read(bytes, archive.sheet));
4913
+ }
4914
+ /**
4085
4915
  * Gunzips bytes, rethrowing zlib's failure in the package's taxonomy.
4086
4916
  * @param bytes - Gzip data.
4087
4917
  * @returns The decompressed bytes.