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.
- package/README.md +1 -1
- package/README_zh.md +1 -1
- package/dist/esm/modules/archive/unzip/parser-core.js +10 -18
- package/dist/esm/modules/archive/zip-spec/binary.js +24 -67
- package/dist/esm/modules/csv/parse/async.js +32 -0
- package/dist/esm/modules/csv/parse/config.js +22 -23
- package/dist/esm/modules/csv/parse/delimiter-detector.js +276 -0
- package/dist/esm/modules/csv/parse/scanner/scanner.js +357 -76
- package/dist/esm/modules/csv/parse/scanner/types.js +2 -2
- package/dist/esm/modules/csv/stream/parser.js +285 -86
- package/dist/esm/modules/csv/utils/detect.js +30 -152
- package/dist/esm/modules/csv/worker/worker-script.generated.js +1 -1
- package/dist/esm/modules/excel/core/opaque-part.js +16 -0
- package/dist/esm/modules/excel/core/workbook.browser.js +10 -1
- package/dist/esm/modules/excel/core/worksheet.js +2 -0
- package/dist/esm/modules/excel/utils/ooxml-paths.js +4 -0
- package/dist/esm/modules/excel/xlsx/opaque-parts.js +427 -0
- package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +53 -8
- package/dist/esm/modules/excel/xlsx/xform/sheet/page-setup-xform.js +4 -2
- package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +37 -0
- package/dist/esm/modules/excel/xlsx/xlsx.browser.js +222 -30
- package/dist/esm/utils/binary.js +295 -0
- package/dist/iife/documonster.archive.iife.min.js +4 -4
- package/dist/iife/documonster.csv.iife.min.js +25 -28
- package/dist/iife/documonster.draw.iife.min.js +1 -1
- package/dist/iife/documonster.excel.iife.min.js +53 -53
- package/dist/iife/documonster.formula.iife.min.js +1 -1
- package/dist/iife/documonster.markdown.iife.min.js +1 -1
- package/dist/iife/documonster.mermaid.iife.min.js +1 -1
- package/dist/iife/documonster.pdf.iife.min.js +2 -2
- package/dist/iife/documonster.stream.iife.min.js +1 -1
- package/dist/iife/documonster.word.iife.min.js +61 -61
- package/dist/iife/documonster.xml.iife.min.js +1 -1
- package/dist/types/modules/archive/zip-spec/binary.d.ts +18 -20
- package/dist/types/modules/csv/parse/delimiter-detector.d.ts +53 -0
- package/dist/types/modules/csv/parse/scanner/scanner.d.ts +17 -5
- package/dist/types/modules/csv/parse/scanner/types.d.ts +5 -5
- package/dist/types/modules/csv/stream/parser.d.ts +80 -0
- package/dist/types/modules/csv/utils/detect.d.ts +9 -0
- package/dist/types/modules/csv/worker/worker-script.generated.d.ts +1 -1
- package/dist/types/modules/excel/core/opaque-part.d.ts +70 -0
- package/dist/types/modules/excel/core/workbook-core.d.ts +6 -0
- package/dist/types/modules/excel/core/workbook.browser.d.ts +28 -0
- package/dist/types/modules/excel/core/worksheet-core.d.ts +10 -0
- package/dist/types/modules/excel/core/worksheet.d.ts +6 -0
- package/dist/types/modules/excel/index.base.d.ts +1 -0
- package/dist/types/modules/excel/utils/ooxml-paths.d.ts +2 -0
- package/dist/types/modules/excel/xlsx/opaque-parts.d.ts +195 -0
- package/dist/types/modules/excel/xlsx/xform/core/content-types-xform.d.ts +13 -0
- package/dist/types/modules/excel/xlsx/xform/sheet/page-setup-xform.d.ts +11 -0
- package/dist/types/modules/excel/xlsx/xlsx.browser.d.ts +51 -20
- package/dist/types/utils/binary.d.ts +108 -0
- 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.
|
|
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.
|
|
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:
|
|
171
|
-
pubKeyLength:
|
|
172
|
-
signatureLength:
|
|
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:
|
|
183
|
-
compressedSize:
|
|
184
|
-
uncompressedSize:
|
|
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:
|
|
192
|
-
crc32:
|
|
193
|
-
compressedSize:
|
|
194
|
-
uncompressedSize:
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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] =
|
|
55
|
+
result[key] = buffer[offset];
|
|
99
56
|
break;
|
|
100
57
|
case 2:
|
|
101
|
-
result[key] =
|
|
58
|
+
result[key] = readUint16LE(buffer, offset);
|
|
102
59
|
break;
|
|
103
60
|
case 4:
|
|
104
|
-
result[key] =
|
|
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 =
|
|
109
|
-
const high =
|
|
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 {
|
|
8
|
+
import { detectDelimiterFor } from "./delimiter-detector.js";
|
|
9
9
|
import { createOnSkipHandler } from "./helpers.js";
|
|
10
|
-
import { normalizeQuoteOption, normalizeEscapeOption,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|