documonster 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/dist/esm/modules/archive/unzip/parser-core.js +10 -18
  4. package/dist/esm/modules/archive/zip-spec/binary.js +24 -67
  5. package/dist/esm/modules/csv/parse/async.js +32 -0
  6. package/dist/esm/modules/csv/parse/config.js +22 -23
  7. package/dist/esm/modules/csv/parse/delimiter-detector.js +276 -0
  8. package/dist/esm/modules/csv/parse/scanner/scanner.js +357 -76
  9. package/dist/esm/modules/csv/parse/scanner/types.js +2 -2
  10. package/dist/esm/modules/csv/stream/parser.js +285 -86
  11. package/dist/esm/modules/csv/utils/detect.js +30 -152
  12. package/dist/esm/modules/csv/worker/worker-script.generated.js +1 -1
  13. package/dist/esm/modules/excel/core/opaque-part.js +16 -0
  14. package/dist/esm/modules/excel/core/workbook.browser.js +10 -1
  15. package/dist/esm/modules/excel/core/worksheet.js +2 -0
  16. package/dist/esm/modules/excel/utils/ooxml-paths.js +4 -0
  17. package/dist/esm/modules/excel/xlsx/opaque-parts.js +427 -0
  18. package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +53 -8
  19. package/dist/esm/modules/excel/xlsx/xform/sheet/page-setup-xform.js +4 -2
  20. package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +37 -0
  21. package/dist/esm/modules/excel/xlsx/xlsx.browser.js +222 -30
  22. package/dist/esm/utils/binary.js +295 -0
  23. package/dist/iife/documonster.archive.iife.min.js +4 -4
  24. package/dist/iife/documonster.csv.iife.min.js +25 -28
  25. package/dist/iife/documonster.draw.iife.min.js +1 -1
  26. package/dist/iife/documonster.excel.iife.min.js +53 -53
  27. package/dist/iife/documonster.formula.iife.min.js +1 -1
  28. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  29. package/dist/iife/documonster.mermaid.iife.min.js +1 -1
  30. package/dist/iife/documonster.pdf.iife.min.js +2 -2
  31. package/dist/iife/documonster.stream.iife.min.js +1 -1
  32. package/dist/iife/documonster.word.iife.min.js +61 -61
  33. package/dist/iife/documonster.xml.iife.min.js +1 -1
  34. package/dist/types/modules/archive/zip-spec/binary.d.ts +18 -20
  35. package/dist/types/modules/csv/parse/delimiter-detector.d.ts +53 -0
  36. package/dist/types/modules/csv/parse/scanner/scanner.d.ts +17 -5
  37. package/dist/types/modules/csv/parse/scanner/types.d.ts +5 -5
  38. package/dist/types/modules/csv/stream/parser.d.ts +80 -0
  39. package/dist/types/modules/csv/utils/detect.d.ts +9 -0
  40. package/dist/types/modules/csv/worker/worker-script.generated.d.ts +1 -1
  41. package/dist/types/modules/excel/core/opaque-part.d.ts +70 -0
  42. package/dist/types/modules/excel/core/workbook-core.d.ts +6 -0
  43. package/dist/types/modules/excel/core/workbook.browser.d.ts +28 -0
  44. package/dist/types/modules/excel/core/worksheet-core.d.ts +10 -0
  45. package/dist/types/modules/excel/core/worksheet.d.ts +6 -0
  46. package/dist/types/modules/excel/index.base.d.ts +1 -0
  47. package/dist/types/modules/excel/utils/ooxml-paths.d.ts +2 -0
  48. package/dist/types/modules/excel/xlsx/opaque-parts.d.ts +195 -0
  49. package/dist/types/modules/excel/xlsx/xform/core/content-types-xform.d.ts +13 -0
  50. package/dist/types/modules/excel/xlsx/xform/sheet/page-setup-xform.d.ts +11 -0
  51. package/dist/types/modules/excel/xlsx/xlsx.browser.d.ts +51 -20
  52. package/dist/types/utils/binary.d.ts +108 -0
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -211,7 +211,7 @@ const buffer = await Workbook.toBuffer(wb);
211
211
 
212
212
  ```html
213
213
  <!-- Script tag (no bundler) — one IIFE per module, each under the shared `Documonster` global -->
214
- <script src="https://unpkg.com/documonster@0.11.0/dist/iife/documonster.excel.iife.min.js"></script>
214
+ <script src="https://unpkg.com/documonster@0.11.1/dist/iife/documonster.excel.iife.min.js"></script>
215
215
  <script>
216
216
  const { Workbook, Cell } = Documonster.Excel;
217
217
  const wb = Workbook.create();
package/README_zh.md CHANGED
@@ -198,7 +198,7 @@ const buffer = await Workbook.toBuffer(wb);
198
198
 
199
199
  ```html
200
200
  <!-- Script 标签(无需打包工具)— 每个模块一个 IIFE,共享同一个 `Documonster` 全局 -->
201
- <script src="https://unpkg.com/documonster@0.11.0/dist/iife/documonster.excel.iife.min.js"></script>
201
+ <script src="https://unpkg.com/documonster@0.11.1/dist/iife/documonster.excel.iife.min.js"></script>
202
202
  <script>
203
203
  const { Workbook, Cell } = Documonster.Excel;
204
204
  const wb = Workbook.create();
@@ -154,22 +154,14 @@ export function isValidZipRecordSignature(sig) {
154
154
  return false;
155
155
  }
156
156
  }
157
- function readUint32LEFromBytes(view, offset) {
158
- return ((view[offset] |
159
- 0 |
160
- ((view[offset + 1] | 0) << 8) |
161
- ((view[offset + 2] | 0) << 16) |
162
- ((view[offset + 3] | 0) << 24)) >>>
163
- 0);
164
- }
165
157
  function readUint16LEFromBytes(view, offset) {
166
158
  return (view[offset] | ((view[offset + 1] | 0) << 8)) >>> 0;
167
159
  }
168
160
  function parseCrxHeaderFast(data) {
169
161
  return {
170
- version: readUint32LEFromBytes(data, 0),
171
- pubKeyLength: readUint32LEFromBytes(data, 4),
172
- signatureLength: readUint32LEFromBytes(data, 8)
162
+ version: readUint32LE(data, 0),
163
+ pubKeyLength: readUint32LE(data, 4),
164
+ signatureLength: readUint32LE(data, 8)
173
165
  };
174
166
  }
175
167
  function parseLocalFileHeaderVarsFast(data) {
@@ -179,19 +171,19 @@ function parseLocalFileHeaderVarsFast(data) {
179
171
  compressionMethod: readUint16LEFromBytes(data, 4),
180
172
  lastModifiedTime: readUint16LEFromBytes(data, 6),
181
173
  lastModifiedDate: readUint16LEFromBytes(data, 8),
182
- crc32: readUint32LEFromBytes(data, 10),
183
- compressedSize: readUint32LEFromBytes(data, 14),
184
- uncompressedSize: readUint32LEFromBytes(data, 18),
174
+ crc32: readUint32LE(data, 10),
175
+ compressedSize: readUint32LE(data, 14),
176
+ uncompressedSize: readUint32LE(data, 18),
185
177
  fileNameLength: readUint16LEFromBytes(data, 22),
186
178
  extraFieldLength: readUint16LEFromBytes(data, 24)
187
179
  };
188
180
  }
189
181
  function parseDataDescriptorVarsFast(data) {
190
182
  return {
191
- dataDescriptorSignature: readUint32LEFromBytes(data, 0),
192
- crc32: readUint32LEFromBytes(data, 4),
193
- compressedSize: readUint32LEFromBytes(data, 8),
194
- uncompressedSize: readUint32LEFromBytes(data, 12)
183
+ dataDescriptorSignature: readUint32LE(data, 0),
184
+ crc32: readUint32LE(data, 4),
185
+ compressedSize: readUint32LE(data, 8),
186
+ uncompressedSize: readUint32LE(data, 12)
195
187
  };
196
188
  }
197
189
  export const DEFAULT_PARSE_THRESHOLD_BYTES = 5 * 1024 * 1024;
@@ -1,72 +1,30 @@
1
1
  /**
2
- * Tiny binary reader for Uint8Array-backed DataView.
3
- * Shared by ZIP parsers.
2
+ * ZIP-specific binary helpers.
3
+ *
4
+ * The little-endian scalar reader, writer and free functions live in
5
+ * `@utils/binary` (Layer 0) because ZIP is not the only binary container in this
6
+ * library — BIFF12 in `excel/` and CFB in `word/` need the same primitives, and
7
+ * none of those modules may import each other. What stays here is the part that
8
+ * is genuinely about ZIP: CP437 filename decoding, and the declarative
9
+ * fixed-width header parser the unzip path is written against.
4
10
  */
5
11
  import { decodeCp437 } from "../core/text.js";
6
- import { uint8ArrayToString as decodeUtf8 } from "../../../utils/binary.js";
7
- export function writeUint32LE(value) {
8
- const out = new Uint8Array(4);
9
- const view = new DataView(out.buffer);
10
- view.setUint32(0, value >>> 0, true);
11
- return out;
12
- }
13
- export function readUint32LE(data, offset) {
14
- const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
15
- return view.getUint32(offset, true);
16
- }
17
- export class BinaryReader {
18
- constructor(data, offset = 0) {
19
- this.data = data;
20
- this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
21
- this.offset = offset;
22
- }
23
- get position() {
24
- return this.offset;
25
- }
26
- set position(value) {
27
- this.offset = value;
28
- }
29
- get remaining() {
30
- return this.data.length - this.offset;
31
- }
32
- readUint8() {
33
- const value = this.view.getUint8(this.offset);
34
- this.offset += 1;
35
- return value;
36
- }
37
- readUint16() {
38
- const value = this.view.getUint16(this.offset, true);
39
- this.offset += 2;
40
- return value;
41
- }
42
- readUint32() {
43
- const value = this.view.getUint32(this.offset, true);
44
- this.offset += 4;
45
- return value;
46
- }
47
- readBigUint64() {
48
- const value = this.view.getBigUint64(this.offset, true);
49
- this.offset += 8;
50
- return value;
51
- }
52
- readBytes(length) {
53
- const bytes = this.data.subarray(this.offset, this.offset + length);
54
- this.offset += length;
55
- return bytes;
12
+ import { BinaryReader as LittleEndianReader, readUint16LE, readUint32LE, uint8ArrayToString as decodeUtf8 } from "../../../utils/binary.js";
13
+ export { readUint16LE, readUint32LE, writeUint16LE, writeUint32LE } from "../../../utils/binary.js";
14
+ /**
15
+ * Little-endian reader with ZIP's two filename encodings.
16
+ *
17
+ * ZIP stores names as CP437 unless the general-purpose UTF-8 flag is set, so the
18
+ * decode choice belongs to the caller reading the header, not to the reader.
19
+ */
20
+ export class BinaryReader extends LittleEndianReader {
21
+ constructor(data, offset = 0, label = "zip stream") {
22
+ super(data, offset, label);
56
23
  }
57
24
  readString(length, utf8 = true) {
58
25
  const bytes = this.readBytes(length);
59
26
  return utf8 ? decodeUtf8(bytes) : decodeCp437(bytes);
60
27
  }
61
- skip(length) {
62
- this.offset += length;
63
- }
64
- slice(start, end) {
65
- return this.data.subarray(start, end);
66
- }
67
- peekUint32(offset) {
68
- return this.view.getUint32(offset, true);
69
- }
70
28
  }
71
29
  // =============================================================================
72
30
  // Format-based parsing (legacy-style declarative parser)
@@ -89,24 +47,23 @@ export class BinaryReader {
89
47
  */
90
48
  export function parseFormatted(buffer, format) {
91
49
  const result = {};
92
- const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
93
50
  let offset = 0;
94
51
  for (const [key, size] of format) {
95
52
  if (buffer.length >= offset + size) {
96
53
  switch (size) {
97
54
  case 1:
98
- result[key] = view.getUint8(offset);
55
+ result[key] = buffer[offset];
99
56
  break;
100
57
  case 2:
101
- result[key] = view.getUint16(offset, true);
58
+ result[key] = readUint16LE(buffer, offset);
102
59
  break;
103
60
  case 4:
104
- result[key] = view.getUint32(offset, true);
61
+ result[key] = readUint32LE(buffer, offset);
105
62
  break;
106
63
  case 8: {
107
64
  // Keep behavior (Number) while avoiding BigInt costs.
108
- const low = view.getUint32(offset, true);
109
- const high = view.getUint32(offset + 4, true);
65
+ const low = readUint32LE(buffer, offset);
66
+ const high = readUint32LE(buffer, offset + 4);
110
67
  result[key] = high * 0x100000000 + low;
111
68
  break;
112
69
  }
@@ -23,6 +23,14 @@ import { parseCsv } from "./sync.js";
23
23
  import { CsvParserStream } from "../stream/parser.js";
24
24
  import { isReadableStreamLike, readableStreamToAsyncIterable } from "../../stream/utils.base.js";
25
25
  import { toError } from "../../../utils/errors.js";
26
+ /**
27
+ * Rows buffered for the generator's consumer before the parser is paused.
28
+ *
29
+ * Small enough that a slow consumer cannot accumulate the whole input, large enough that a
30
+ * fast one is never throttled between pulls.
31
+ */
32
+ const ROW_QUEUE_HIGH_WATER_MARK = 1024;
33
+ const ROW_QUEUE_LOW_WATER_MARK = 256;
26
34
  function isAsyncIterable(value) {
27
35
  return Boolean(value &&
28
36
  typeof value[Symbol.asyncIterator] === "function");
@@ -148,6 +156,7 @@ export async function* parseCsvRows(input, options = {}) {
148
156
  let ended = false;
149
157
  let streamError = null;
150
158
  let aborted = false;
159
+ let paused = false;
151
160
  const pushEvent = (ev) => {
152
161
  if (pendingResolve) {
153
162
  const resolve = pendingResolve;
@@ -157,8 +166,25 @@ export async function* parseCsvRows(input, options = {}) {
157
166
  }
158
167
  queue.push(ev);
159
168
  };
169
+ // A `data` listener puts the parser in flowing mode, so without this the parser would read
170
+ // the whole input into `queue` no matter how slowly the consumer of this generator pulls.
171
+ // Pausing hands that backpressure through to the writer loop below, which already waits for
172
+ // `drain`.
173
+ const applyBackpressure = () => {
174
+ if (!paused && queue.length >= ROW_QUEUE_HIGH_WATER_MARK) {
175
+ paused = true;
176
+ parser.pause();
177
+ }
178
+ };
179
+ const releaseBackpressure = () => {
180
+ if (paused && queue.length <= ROW_QUEUE_LOW_WATER_MARK) {
181
+ paused = false;
182
+ parser.resume();
183
+ }
184
+ };
160
185
  const onData = (value) => {
161
186
  pushEvent({ type: "data", value });
187
+ applyBackpressure();
162
188
  };
163
189
  const onEnd = () => {
164
190
  ended = true;
@@ -202,6 +228,7 @@ export async function* parseCsvRows(input, options = {}) {
202
228
  while (true) {
203
229
  if (queue.length > 0) {
204
230
  const ev = queue.shift();
231
+ releaseBackpressure();
205
232
  if (ev.type === "data") {
206
233
  yield ev.value;
207
234
  continue;
@@ -234,6 +261,11 @@ export async function* parseCsvRows(input, options = {}) {
234
261
  }
235
262
  finally {
236
263
  aborted = true;
264
+ if (paused) {
265
+ // Let the writer loop finish rather than leaving it blocked behind a paused parser.
266
+ paused = false;
267
+ parser.resume();
268
+ }
237
269
  // Ensure stream stops as soon as possible.
238
270
  parser.destroy();
239
271
  // Release the writer if it is waiting for drain (destroy does not emit drain).
@@ -5,9 +5,9 @@
5
5
  * normalized parsing configuration from user options.
6
6
  */
7
7
  import { DEFAULT_LINEBREAK_REGEX } from "../constants.js";
8
- import { CsvError } from "../errors.js";
8
+ import { detectDelimiterFor } from "./delimiter-detector.js";
9
9
  import { createOnSkipHandler } from "./helpers.js";
10
- import { normalizeQuoteOption, normalizeEscapeOption, detectDelimiter, detectLinebreak, stripBom } from "../utils/detect.js";
10
+ import { applyFirstChunkPreprocessing, normalizeQuoteOption, normalizeEscapeOption, detectLinebreak } from "../utils/detect.js";
11
11
  // =============================================================================
12
12
  // Configuration Factory
13
13
  // =============================================================================
@@ -38,25 +38,19 @@ export function createParseConfig(opts) {
38
38
  let processedInput;
39
39
  if (input !== undefined) {
40
40
  processedInput = input;
41
- // Apply beforeFirstChunk if provided
42
- if (beforeFirstChunk) {
43
- const result = beforeFirstChunk(processedInput);
44
- if (typeof result === "string") {
45
- processedInput = result;
46
- }
47
- else if (result !== undefined && result !== null) {
48
- // Validate return type - must be string or void/undefined
49
- throw new CsvError(`beforeFirstChunk must return a string or undefined, got ${typeof result}`);
50
- }
51
- }
52
- // Strip BOM
53
- processedInput = stripBom(processedInput);
41
+ processedInput = applyFirstChunkPreprocessing(processedInput, beforeFirstChunk);
54
42
  }
55
43
  const shouldSkipEmpty = skipEmptyLines;
56
44
  // Normalize quote/escape
57
45
  const { enabled: quoteEnabled, char: quote } = normalizeQuoteOption(quoteOption);
58
46
  const escapeNormalized = normalizeEscapeOption(escapeOption, quote);
59
47
  const escape = escapeNormalized.enabled ? escapeNormalized.char || quote : "";
48
+ // Determine linebreak
49
+ const linebreak = lineEndingOption || (processedInput !== undefined ? detectLinebreak(processedInput) : "\n");
50
+ // Pre-compile linebreak regex for fast mode
51
+ const linebreakRegex = linebreak && linebreak !== "\n" && linebreak !== "\r\n" && linebreak !== "\r"
52
+ ? linebreak
53
+ : DEFAULT_LINEBREAK_REGEX;
60
54
  // Determine delimiter
61
55
  let delimiter;
62
56
  if (detectedDelimiter !== undefined) {
@@ -64,8 +58,19 @@ export function createParseConfig(opts) {
64
58
  delimiter = detectedDelimiter;
65
59
  }
66
60
  else if (delimiterOption === "" && processedInput !== undefined) {
67
- // Auto-detect from input (batch mode)
68
- delimiter = detectDelimiter(processedInput, quote || '"', delimitersToGuess, comment, shouldSkipEmpty);
61
+ // Auto-detect from input (batch mode). The same detector a stream uses, fed in one go, so
62
+ // the two cannot weigh the candidates differently.
63
+ delimiter = detectDelimiterFor(processedInput, {
64
+ // "" when quoting is disabled, so detection scores by the grammar the parse will use.
65
+ quote: quoteEnabled ? quote : "",
66
+ escape,
67
+ relaxQuotes,
68
+ comment,
69
+ delimitersToGuess,
70
+ // Only fastMode actually ends records at a configured separator; standard mode always
71
+ // ends them at CR/LF, so sampling there must not use it.
72
+ lineEnding: fastMode && linebreakRegex !== DEFAULT_LINEBREAK_REGEX ? linebreak : undefined
73
+ });
69
74
  }
70
75
  else if (delimiterOption === "") {
71
76
  // Streaming mode with auto-detect - use default, will be updated later
@@ -74,12 +79,6 @@ export function createParseConfig(opts) {
74
79
  else {
75
80
  delimiter = delimiterOption;
76
81
  }
77
- // Determine linebreak
78
- const linebreak = lineEndingOption || (processedInput !== undefined ? detectLinebreak(processedInput) : "\n");
79
- // Pre-compile linebreak regex for fast mode
80
- const linebreakRegex = linebreak && linebreak !== "\n" && linebreak !== "\r\n" && linebreak !== "\r"
81
- ? linebreak
82
- : DEFAULT_LINEBREAK_REGEX;
83
82
  const config = {
84
83
  delimiter,
85
84
  linebreak,
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Delimiter auto-detection.
3
+ *
4
+ * One incremental detector serves both `Csv.parse` and `CsvParserStream`. That is not tidiness:
5
+ * quote recognition depends on field boundaries, and field boundaries depend on the delimiter
6
+ * being detected, so a batch parse and a streamed parse of the same bytes can only agree if
7
+ * they weigh the candidates by identical rules. While these rules lived in two places — a
8
+ * sampler and a readiness check on the stream — they drifted repeatedly: over what an empty
9
+ * candidate list means, over whether a comment counts towards the sample, over which record
10
+ * separator applies, and over how much text is enough.
11
+ *
12
+ * The detector keeps *no copy of the input*. Each candidate owns a scanner, and every record
13
+ * that scanner completes is counted as it arrives. That is what makes the answer independent of
14
+ * chunking: a candidate is scored on its first `DELIMITER_DETECTION_SAMPLE_RECORDS` complete
15
+ * records, which is a fact about the bytes rather than about where a chunk boundary fell. It is
16
+ * also why deciding is O(candidates) rather than a re-walk of everything buffered, and why a
17
+ * long run of comments cannot grow the detector: those records are consumed like any other.
18
+ */
19
+ import { createScanner, scanRow } from "./scanner/index.js";
20
+ /** Delimiters weighed when none are given. */
21
+ const AUTO_DETECT_DELIMITERS = [",", ";", "\t", "|"];
22
+ /**
23
+ * Records each candidate is scored on.
24
+ *
25
+ * A stream must hold this many before committing, or its choice — and therefore its rows —
26
+ * would depend on where the chunk boundaries fell.
27
+ */
28
+ const SAMPLE_RECORDS = 10;
29
+ /**
30
+ * Characters of *scorable* records a candidate may be scored on, and the amount of undigested
31
+ * text after which a candidate is judged unable to complete another record.
32
+ */
33
+ const SAMPLE_CHARS = 65536;
34
+ /** Fields a candidate must average before it is considered a delimiter at all. */
35
+ const MIN_AVERAGE_FIELDS = 1.99;
36
+ /**
37
+ * Candidates a detector will weigh.
38
+ *
39
+ * An empty list is read as "unset", which is what a caller passing one is asking for. It once
40
+ * meant "no candidates, fall back to comma" to a batch parse and "not configured, use the
41
+ * defaults" to a stream, so the same options produced different delimiters.
42
+ */
43
+ function delimiterCandidates(delimitersToGuess) {
44
+ return delimitersToGuess && delimitersToGuess.length > 0
45
+ ? delimitersToGuess
46
+ : AUTO_DETECT_DELIMITERS;
47
+ }
48
+ /**
49
+ * Whether a record can contribute to a candidate's score.
50
+ *
51
+ * A record that is empty or only whitespace cannot indicate a delimiter, and a comment is not
52
+ * data. This is deliberately the only copy of the rule: a stream waits for exactly the records
53
+ * the scoring reads.
54
+ */
55
+ function isScorableRecord(raw, comment) {
56
+ if (comment && raw.startsWith(comment)) {
57
+ return false;
58
+ }
59
+ return raw.trim() !== "";
60
+ }
61
+ export function createDelimiterDetector(options) {
62
+ const quote = options.quote ?? '"';
63
+ const comment = options.comment;
64
+ const separator = options.lineEnding;
65
+ /** Records split by a configured separator rather than by the CSV grammar. */
66
+ const useSeparator = separator !== undefined && separator !== "";
67
+ const candidateDelimiters = delimiterCandidates(options.delimitersToGuess);
68
+ const configFor = (delimiter) => ({
69
+ delimiter,
70
+ quote,
71
+ escape: options.escape ?? quote,
72
+ quoteEnabled: quote !== "",
73
+ relaxQuotes: options.relaxQuotes ?? false
74
+ });
75
+ let candidates = [];
76
+ /** Total characters fed, so that undigested text can be measured without keeping it. */
77
+ let fedChars = 0;
78
+ /** The record being accumulated on the configured-separator path, which may span chunks. */
79
+ let pendingRecord = "";
80
+ let released = false;
81
+ /** The answer once given, so that a call after `release` cannot report a different one. */
82
+ let settled;
83
+ function build() {
84
+ candidates = candidateDelimiters.map(delimiter => ({
85
+ scanner: useSeparator ? undefined : createScanner(configFor(delimiter)),
86
+ config: configFor(delimiter),
87
+ fieldCounts: [],
88
+ scoredChars: 0,
89
+ consumedChars: 0
90
+ }));
91
+ fedChars = 0;
92
+ pendingRecord = "";
93
+ }
94
+ build();
95
+ /** Whether this candidate has all the sample it will be scored on. */
96
+ const hasFullSample = (candidate) => candidate.fieldCounts.length >= SAMPLE_RECORDS || candidate.scoredChars >= SAMPLE_CHARS;
97
+ /**
98
+ * Whether this candidate has stopped being able to complete records.
99
+ *
100
+ * Checked only after its scanner has been drained, so undigested text really is text the
101
+ * candidate cannot turn into a record — a quoted field its grammar never closes. Checking it
102
+ * before draining made any first chunk of this size look stuck, which decided the delimiter
103
+ * on however much text happened to arrive first.
104
+ */
105
+ const isStuck = (candidate) => fedChars - candidate.consumedChars >= SAMPLE_CHARS;
106
+ function countRecord(candidate, raw, consumed) {
107
+ candidate.consumedChars += consumed;
108
+ if (!isScorableRecord(raw, comment)) {
109
+ return;
110
+ }
111
+ candidate.fieldCounts.push(scanRow(raw, 0, candidate.config, true).fields.length);
112
+ candidate.scoredChars += raw.length;
113
+ }
114
+ function feedSeparatorPath(data) {
115
+ pendingRecord += data;
116
+ let at = pendingRecord.indexOf(separator);
117
+ while (at !== -1) {
118
+ const raw = pendingRecord.slice(0, at);
119
+ const consumed = at + separator.length;
120
+ for (const candidate of candidates) {
121
+ if (!hasFullSample(candidate)) {
122
+ countRecord(candidate, raw, consumed);
123
+ }
124
+ else {
125
+ candidate.consumedChars += consumed;
126
+ }
127
+ }
128
+ pendingRecord = pendingRecord.slice(consumed);
129
+ at = pendingRecord.indexOf(separator);
130
+ }
131
+ }
132
+ function feed(data) {
133
+ if (released || data === "") {
134
+ return;
135
+ }
136
+ fedChars += data.length;
137
+ if (useSeparator) {
138
+ feedSeparatorPath(data);
139
+ return;
140
+ }
141
+ for (const candidate of candidates) {
142
+ const scanner = candidate.scanner;
143
+ if (!scanner) {
144
+ continue;
145
+ }
146
+ scanner.feed(data);
147
+ // Drained fully even once the sample is full, so that `consumedChars` keeps pace with the
148
+ // text and a candidate that is merely finished is never mistaken for one that is stuck.
149
+ let row = scanner.nextRow();
150
+ while (row !== null) {
151
+ const raw = row.raw ?? "";
152
+ const consumed = raw.length + (row.newline?.length ?? 0);
153
+ if (hasFullSample(candidate)) {
154
+ candidate.consumedChars += consumed;
155
+ }
156
+ else {
157
+ countRecord(candidate, raw, consumed);
158
+ }
159
+ row = scanner.nextRow();
160
+ }
161
+ }
162
+ }
163
+ /** Take the trailing record, which has no terminator and so is only a record at end of input. */
164
+ function drainFinalRecords() {
165
+ if (useSeparator) {
166
+ if (pendingRecord !== "") {
167
+ const raw = pendingRecord;
168
+ for (const candidate of candidates) {
169
+ if (!hasFullSample(candidate)) {
170
+ countRecord(candidate, raw, raw.length);
171
+ }
172
+ }
173
+ pendingRecord = "";
174
+ }
175
+ return;
176
+ }
177
+ for (const candidate of candidates) {
178
+ let row = candidate.scanner?.flush() ?? null;
179
+ while (row !== null) {
180
+ const raw = row.raw ?? "";
181
+ if (!hasFullSample(candidate)) {
182
+ countRecord(candidate, raw, raw.length + (row.newline?.length ?? 0));
183
+ }
184
+ row = candidate.scanner?.flush() ?? null;
185
+ }
186
+ }
187
+ }
188
+ /** Pick the candidate whose field counts are most consistent, then most numerous. */
189
+ function score() {
190
+ let best;
191
+ let bestDelta;
192
+ let bestAverage;
193
+ for (let index = 0; index < candidates.length; index++) {
194
+ const counts = candidates[index].fieldCounts;
195
+ if (counts.length === 0) {
196
+ continue;
197
+ }
198
+ let delta = 0;
199
+ let total = 0;
200
+ let previous;
201
+ for (const count of counts) {
202
+ total += count;
203
+ if (previous !== undefined) {
204
+ delta += Math.abs(count - previous);
205
+ }
206
+ previous = count;
207
+ }
208
+ const average = total / counts.length;
209
+ if (average <= MIN_AVERAGE_FIELDS) {
210
+ continue;
211
+ }
212
+ if (bestDelta === undefined ||
213
+ delta < bestDelta ||
214
+ (delta === bestDelta && (bestAverage === undefined || average > bestAverage))) {
215
+ bestDelta = delta;
216
+ bestAverage = average;
217
+ best = candidateDelimiters[index];
218
+ }
219
+ }
220
+ return best;
221
+ }
222
+ /**
223
+ * Whether waiting could still change the answer.
224
+ *
225
+ * Every candidate must have its full sample or have shown it cannot complete another record,
226
+ * and at least one scorable record must exist — committing on a partial first line is what
227
+ * once made a stream pick comma for a semicolon-delimited file.
228
+ */
229
+ function isReady() {
230
+ let anyScorable = false;
231
+ for (const candidate of candidates) {
232
+ if (!hasFullSample(candidate) && !isStuck(candidate)) {
233
+ return false;
234
+ }
235
+ if (candidate.fieldCounts.length > 0) {
236
+ anyScorable = true;
237
+ }
238
+ }
239
+ return anyScorable;
240
+ }
241
+ return {
242
+ feed,
243
+ reset(text) {
244
+ released = false;
245
+ build();
246
+ feed(text);
247
+ },
248
+ decide() {
249
+ if (released || !isReady()) {
250
+ return undefined;
251
+ }
252
+ settled = score();
253
+ return settled;
254
+ },
255
+ decideAtEof() {
256
+ if (released) {
257
+ return settled ?? candidateDelimiters[0];
258
+ }
259
+ drainFinalRecords();
260
+ settled = score() ?? candidateDelimiters[0];
261
+ return settled;
262
+ },
263
+ release() {
264
+ released = true;
265
+ candidates = [];
266
+ fedChars = 0;
267
+ pendingRecord = "";
268
+ }
269
+ };
270
+ }
271
+ /** Detect from text already in hand, which is the batch case. */
272
+ export function detectDelimiterFor(input, options) {
273
+ const detector = createDelimiterDetector(options);
274
+ detector.feed(input);
275
+ return detector.decideAtEof();
276
+ }