@firedrill-tools/unstructured 0.1.4

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 (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +275 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/conformance.suite.json +21 -0
  6. package/firedrill/overloaded.scenario.json +11 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/run-response-lost.scenario.json +11 -0
  9. package/firedrill/small-responses.scenario.json +21 -0
  10. package/firedrill/tight-limits.scenario.json +21 -0
  11. package/firedrill/tools/unstructured/behavior.mjs +69 -0
  12. package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
  13. package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
  14. package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
  15. package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
  16. package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
  17. package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
  18. package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
  19. package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
  20. package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
  21. package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
  22. package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
  23. package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
  24. package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
  25. package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
  26. package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
  27. package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
  28. package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
  29. package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
  30. package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
  31. package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
  32. package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
  33. package/firedrill/tools/unstructured/lib/store.mjs +33 -0
  34. package/firedrill/tools/unstructured/lib/util.mjs +149 -0
  35. package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
  36. package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
  37. package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
  38. package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
  39. package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
  40. package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
  41. package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
  42. package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
  43. package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
  44. package/firedrill/unstructured-archivist.drill.json +68 -0
  45. package/firedrill/unstructured-chunking.drill.json +67 -0
  46. package/firedrill/unstructured-connectors.drill.json +121 -0
  47. package/firedrill/unstructured-denied.drill.json +58 -0
  48. package/firedrill/unstructured-fresh-actor.drill.json +68 -0
  49. package/firedrill/unstructured-overloaded.drill.json +51 -0
  50. package/firedrill/unstructured-partition-errors.drill.json +66 -0
  51. package/firedrill/unstructured-partition.drill.json +95 -0
  52. package/firedrill/unstructured-rate-limited.drill.json +66 -0
  53. package/firedrill/unstructured-revoked-key.drill.json +773 -0
  54. package/firedrill/unstructured-run-lost.drill.json +51 -0
  55. package/firedrill/unstructured-small-responses.drill.json +173 -0
  56. package/firedrill/unstructured-tight-limits.drill.json +203 -0
  57. package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
  58. package/firedrill/world.json +1556 -0
  59. package/firedrill.json +5 -0
  60. package/package.json +52 -0
  61. package/starter.json +1114 -0
  62. package/test/conformance.mjs +37 -0
  63. package/test/flows/access.mjs +54 -0
  64. package/test/flows/chunking.mjs +95 -0
  65. package/test/flows/connectors.mjs +76 -0
  66. package/test/flows/errors.mjs +115 -0
  67. package/test/flows/faults.mjs +73 -0
  68. package/test/flows/partition.mjs +225 -0
  69. package/test/flows/workflows.mjs +123 -0
  70. package/test/hostile-gen.mjs +0 -0
  71. package/test/hostile.mjs +155 -0
  72. package/test/lib.mjs +113 -0
@@ -0,0 +1,243 @@
1
+ // Chunking strategies basic / by_title / by_page over a partitioned element list.
2
+ import { gzipBase64Json } from "../gzip.mjs";
3
+ import { utf8Length } from "../util.mjs";
4
+
5
+ /** Response budget assumed when the caller passes none (`meta/limits.response_bytes` default). */
6
+ const DEFAULT_BUDGET = 921600;
7
+
8
+ const MERGED_ARRAYS = ["link_texts", "link_urls", "emphasized_text_contents", "emphasized_text_tags"];
9
+
10
+ /**
11
+ * The metadata one chunk group shares: built once per group (its gzip included), measured once, and copied by reference into
12
+ * every piece — the merged link/emphasis arrays, e-mail recipient lists and `orig_elements` are never rebuilt per piece.
13
+ * @param includeOrig true to compute the gzip+base64 `orig_elements` of the group, false to omit it
14
+ */
15
+ function chunkMetadata(originals, includeOrig, budget) {
16
+ const first = originals[0].metadata;
17
+ const metadata = { filename: first.filename, filetype: first.filetype, languages: first.languages, page_number: first.page_number, last_modified: first.last_modified };
18
+ for (const key of ["parent_id", "sent_from", "sent_to", "cc_recipient", "bcc_recipient", "subject", "email_message_id"]) if (first[key] !== undefined) metadata[key] = first[key];
19
+ // Merged arrays are copied item by item (never spread as call arguments) and stop at the response budget: every item costs
20
+ // at least its characters plus a quote pair and a comma in each piece that carries it.
21
+ let bytes = 0;
22
+ for (const key of MERGED_ARRAYS) {
23
+ const merged = [];
24
+ for (const element of originals) {
25
+ const values = element.metadata[key];
26
+ if (!Array.isArray(values)) continue;
27
+ for (let i = 0; i < values.length; i += 1) {
28
+ const value = values[i];
29
+ bytes += (typeof value === "string" ? value.length : 1) + 3;
30
+ if (bytes > budget) return null;
31
+ merged.push(value);
32
+ }
33
+ }
34
+ if (merged.length > 0) metadata[key] = merged;
35
+ }
36
+ if (includeOrig) metadata.orig_elements = gzipBase64Json(originals);
37
+ return metadata;
38
+ }
39
+
40
+ /** UTF-8 bytes one copy of a chunk group's metadata costs in the response. */
41
+ const metadataBytes = (metadata) => utf8Length(JSON.stringify(metadata));
42
+
43
+ /** Splits `text` into pieces of at most `max` characters, each after the first prefixed with the previous tail. */
44
+ function splitOversized(text, max, overlap) {
45
+ const pieces = [];
46
+ let start = 0;
47
+ let prefix = "";
48
+ while (start < text.length || pieces.length === 0) {
49
+ const room = Math.max(1, max - prefix.length);
50
+ const piece = prefix + text.slice(start, start + room);
51
+ pieces.push(piece);
52
+ start += room;
53
+ prefix = overlap > 0 ? piece.slice(-overlap) : "";
54
+ }
55
+ return pieces;
56
+ }
57
+
58
+ /** Sections: element groups that never share a chunk. */
59
+ function sections(elements, options) {
60
+ const out = [];
61
+ let current = [];
62
+ let lastPage = null;
63
+ let lastTitleDepth = null;
64
+ for (const element of elements) {
65
+ if (element.type === "PageBreak") continue;
66
+ const page = element.metadata.page_number;
67
+ const pageChanged = lastPage !== null && page !== lastPage;
68
+ let boundary = false;
69
+ if (options.strategy === "by_title") boundary = element.type === "Title" || (pageChanged && !options.multipageSections);
70
+ else if (options.strategy === "by_page") boundary = pageChanged;
71
+ if (boundary && current.length > 0) {
72
+ out.push(current);
73
+ current = [];
74
+ }
75
+ current.push(element);
76
+ lastPage = page;
77
+ if (element.type === "Title") lastTitleDepth = element.metadata.category_depth ?? 0;
78
+ }
79
+ if (current.length > 0) out.push(current);
80
+ if (options.strategy === "by_title" && options.combineUnderNChars > 0) {
81
+ // Running length and table flag of the last combined section: O(total elements), never re-reduced.
82
+ const combined = [];
83
+ let prevLength = 0;
84
+ let prevHasTable = false;
85
+ for (const section of out) {
86
+ const previous = combined.length > 0 ? combined[combined.length - 1] : null;
87
+ let length = -2;
88
+ let hasTable = false;
89
+ for (const e of section) {
90
+ length += e.text.length + 2;
91
+ if (e.type === "Table") hasTable = true;
92
+ }
93
+ if (previous !== null && prevLength < options.combineUnderNChars && prevLength + 2 + length <= options.combineUnderNChars && !prevHasTable && !hasTable) {
94
+ for (const element of section) previous.push(element);
95
+ prevLength += 2 + length;
96
+ } else {
97
+ combined.push(section);
98
+ prevLength = length;
99
+ prevHasTable = hasTable;
100
+ }
101
+ }
102
+ return combined;
103
+ }
104
+ void lastTitleDepth;
105
+ return out;
106
+ }
107
+
108
+ /** @param metadata the table's shared chunk metadata (already accounted against the budget by the caller). */
109
+ function tableChunks(element, options, idOf, metadata) {
110
+ const out = [];
111
+ const max = options.maxCharacters;
112
+ if (element.text.length <= max) {
113
+ out.push({ type: "Table", element_id: idOf(element.text), text: element.text, metadata: { ...metadata, text_as_html: element.metadata.text_as_html } });
114
+ return out;
115
+ }
116
+ const rows = element.text.split("\n");
117
+ const htmlRows = typeof element.metadata.text_as_html === "string" ? element.metadata.text_as_html.replace(/^<table>|<\/table>$/g, "").split("</tr>").filter((r) => r.length > 0).map((r) => `${r}</tr>`) : [];
118
+ let text = "";
119
+ let html = "";
120
+ let first = true;
121
+ const emit = () => {
122
+ const piece = { ...metadata };
123
+ if (html.length > 0) piece.text_as_html = `<table>${html}</table>`;
124
+ if (!first) piece.is_continuation = true;
125
+ out.push({ type: "TableChunk", element_id: idOf(text), text, metadata: piece });
126
+ first = false;
127
+ text = "";
128
+ html = "";
129
+ };
130
+ for (let i = 0; i < rows.length; i += 1) {
131
+ const row = rows[i];
132
+ if (row.length > max) {
133
+ // A row longer than max_characters becomes its own run of pieces; its markup rides with the first piece.
134
+ if (text.length > 0) emit();
135
+ for (let start = 0; start < row.length; start += max) {
136
+ text = row.slice(start, start + max);
137
+ if (start === 0 && htmlRows[i] !== undefined) html = htmlRows[i];
138
+ emit();
139
+ }
140
+ continue;
141
+ }
142
+ if (text.length > 0 && text.length + 1 + row.length > max) emit();
143
+ text = text.length > 0 ? `${text}\n${row}` : row;
144
+ if (htmlRows[i] !== undefined) html += htmlRows[i];
145
+ }
146
+ if (text.length > 0) emit();
147
+ return out;
148
+ }
149
+
150
+ /** Lower bound of the JSON bytes one chunk costs besides its text and metadata: `{"type":…,"element_id":"<32 hex>","text":"","metadata":},`. */
151
+ const CHUNK_OVERHEAD = 90;
152
+
153
+ /**
154
+ * Size of the pieces `splitOversized(text, max, overlap)` would build, computed arithmetically before any piece exists:
155
+ * the first piece holds `max` characters, every later one `max - overlap` new characters after an `overlap` prefix.
156
+ */
157
+ export function splitSize(length, max, overlap) {
158
+ const count = length <= max ? 1 : Math.ceil((length - max) / (max - overlap)) + 1;
159
+ return { count, chars: length + (count - 1) * overlap };
160
+ }
161
+
162
+ /**
163
+ * @param elements partitioned elements (PageBreak elements are dropped)
164
+ * @param options { strategy, maxCharacters, newAfterNChars, overlap, overlapAll, combineUnderNChars, multipageSections, includeOrigElements, responseBudget, chunkSizing? }
165
+ * @param idOf (text) => element id for a chunk
166
+ * @returns { ok: true, elements } or { ok: false, code: "RESPONSE_TOO_LARGE", message } when the pieces of a chunk group (a
167
+ * lower bound of the response, settled before any piece of that group is built) would pass the response budget. Two things
168
+ * multiply: an overlap close to `max_characters` repeats the text up to `max_characters` times, and every piece carries a
169
+ * copy of the group's metadata (merged link and emphasis arrays, e-mail recipients, `orig_elements`). The text size is
170
+ * arithmetic; the metadata is built once per group — linear in that group's elements — measured once and multiplied by the
171
+ * piece count. Per group the work before the check is bounded by the group's input, never by the piece count.
172
+ */
173
+ export function chunkElements(elements, options, idOf) {
174
+ const out = [];
175
+ const includeOrig = options.includeOrigElements;
176
+ const budget = typeof options.responseBudget === "number" ? options.responseBudget : DEFAULT_BUDGET;
177
+ // The running chunk size is shared across the files of one request when the caller passes `chunkSizing`, so 32 files
178
+ // cannot each build a budget's worth of pieces before the response is measured.
179
+ const sizing = options.chunkSizing ?? { chars: 0, count: 0 };
180
+ let overflow = null;
181
+ const account = (count, chars, metaBytes) => {
182
+ sizing.count += count;
183
+ sizing.chars += chars + count * metaBytes;
184
+ const bytes = sizing.chars + sizing.count * CHUNK_OVERHEAD;
185
+ if (bytes > budget) overflow = { ok: false, code: "RESPONSE_TOO_LARGE", message: `Partition output of at least ${bytes} bytes exceeds the ${budget} byte response limit; upload fewer or smaller files, raise max_characters or lower overlap` };
186
+ return overflow === null;
187
+ };
188
+ const tooMany = () => {
189
+ overflow = { ok: false, code: "RESPONSE_TOO_LARGE", message: `Partition output with merged link and emphasis metadata exceeds the ${budget} byte response limit; upload fewer or smaller files or raise max_characters` };
190
+ };
191
+ for (const section of sections(elements, options)) {
192
+ if (overflow !== null) break;
193
+ let texts = [];
194
+ let originals = [];
195
+ let currentLength = 0; // texts.join("\n\n").length, maintained incrementally
196
+ let previousText = null;
197
+ const flush = () => {
198
+ if (originals.length === 0) return;
199
+ const joined = texts.join("\n\n");
200
+ const prefix = options.overlapAll && options.overlap > 0 && previousText !== null ? previousText.slice(-options.overlap) : "";
201
+ const size = splitSize(prefix.length + joined.length, options.maxCharacters, options.overlap);
202
+ const metadata = chunkMetadata(originals, includeOrig, budget);
203
+ if (metadata === null) return tooMany();
204
+ if (!account(size.count, size.chars, metadataBytes(metadata))) return;
205
+ const pieces = splitOversized(prefix + joined, options.maxCharacters, options.overlap);
206
+ for (let index = 0; index < pieces.length; index += 1) {
207
+ const piece = { ...metadata };
208
+ if (index > 0) piece.is_continuation = true;
209
+ out.push({ type: "CompositeElement", element_id: idOf(pieces[index]), text: pieces[index], metadata: piece });
210
+ }
211
+ previousText = pieces[pieces.length - 1];
212
+ texts = [];
213
+ originals = [];
214
+ currentLength = 0;
215
+ };
216
+ for (const element of section) {
217
+ if (element.type === "Table") {
218
+ flush();
219
+ if (overflow !== null) break;
220
+ // Table pieces never overlap (linear in the table text and its markup); at least one piece per max_characters window.
221
+ const tableSize = splitSize(element.text.length, options.maxCharacters, 0);
222
+ const metadata = chunkMetadata([element], includeOrig, budget);
223
+ if (metadata === null) {
224
+ tooMany();
225
+ break;
226
+ }
227
+ const html = typeof element.metadata.text_as_html === "string" ? element.metadata.text_as_html.length : 0;
228
+ if (!account(tableSize.count, tableSize.chars + html, metadataBytes(metadata))) break;
229
+ for (const piece of tableChunks(element, options, idOf, metadata)) out.push(piece);
230
+ previousText = null;
231
+ continue;
232
+ }
233
+ if (texts.length > 0 && (currentLength >= options.newAfterNChars || currentLength + 2 + element.text.length > options.maxCharacters)) flush();
234
+ if (overflow !== null) break;
235
+ currentLength += (texts.length === 0 ? 0 : 2) + element.text.length;
236
+ texts.push(element.text);
237
+ originals.push(element);
238
+ }
239
+ if (overflow === null) flush();
240
+ }
241
+ if (overflow !== null) return overflow;
242
+ return { ok: true, elements: out };
243
+ }
@@ -0,0 +1,85 @@
1
+ // RFC 4180 CSV/TSV parsing into a single Table element with `text_as_html`.
2
+ import { escapeHtml } from "./html-util.mjs";
3
+
4
+ const MAX_ROWS = 10000;
5
+ const MAX_COLUMNS = 200;
6
+
7
+ /** Returns { rows } or { error }. Quoted fields may contain delimiters, quotes ("") and newlines. */
8
+ export function parseDelimited(text, delimiter) {
9
+ const rows = [];
10
+ let row = [];
11
+ let field = "";
12
+ let quoted = false;
13
+ let i = 0;
14
+ const n = text.length;
15
+ while (i < n) {
16
+ const ch = text[i];
17
+ if (quoted) {
18
+ if (ch === '"') {
19
+ if (text[i + 1] === '"') {
20
+ field += '"';
21
+ i += 2;
22
+ continue;
23
+ }
24
+ quoted = false;
25
+ i += 1;
26
+ continue;
27
+ }
28
+ field += ch;
29
+ i += 1;
30
+ continue;
31
+ }
32
+ if (ch === '"' && field.length === 0) {
33
+ quoted = true;
34
+ i += 1;
35
+ continue;
36
+ }
37
+ if (ch === delimiter) {
38
+ row.push(field);
39
+ field = "";
40
+ if (row.length > MAX_COLUMNS) return { error: `File has more than ${MAX_COLUMNS} columns` };
41
+ i += 1;
42
+ continue;
43
+ }
44
+ if (ch === "\r" && text[i + 1] === "\n") i += 1;
45
+ if (ch === "\n" || ch === "\r") {
46
+ row.push(field);
47
+ field = "";
48
+ rows.push(row);
49
+ row = [];
50
+ if (rows.length > MAX_ROWS) return { error: `File has more than ${MAX_ROWS} rows` };
51
+ i += 1;
52
+ continue;
53
+ }
54
+ field += ch;
55
+ i += 1;
56
+ }
57
+ if (quoted) return { error: "File is not a valid csv" };
58
+ if (field.length > 0 || row.length > 0) {
59
+ row.push(field);
60
+ rows.push(row);
61
+ }
62
+ if (rows.length > MAX_ROWS) return { error: `File has more than ${MAX_ROWS} rows` };
63
+ return { rows: rows.filter((r) => !(r.length === 1 && r[0].length === 0)) };
64
+ }
65
+
66
+ export function tableHtml(rows) {
67
+ let html = "<table>";
68
+ for (const row of rows) {
69
+ html += "<tr>";
70
+ for (const cell of row) html += `<td>${escapeHtml(cell)}</td>`;
71
+ html += "</tr>";
72
+ }
73
+ return `${html}</table>`;
74
+ }
75
+
76
+ export function tableText(rows) {
77
+ return rows.map((row) => row.map((cell) => cell.replace(/[\r\n]+/g, " ")).join(" ")).join("\n");
78
+ }
79
+
80
+ export function partitionDelimited(builder, text, delimiter) {
81
+ const parsed = parseDelimited(text, delimiter);
82
+ if (parsed.error !== undefined) return { ok: false, message: parsed.error };
83
+ if (parsed.rows.length > 0) builder.add("Table", tableText(parsed.rows), { text_as_html: tableHtml(parsed.rows) });
84
+ return { ok: true };
85
+ }
@@ -0,0 +1,25 @@
1
+ // `output_format=text/csv`: a fixed column set, arrays as JSON strings, RFC 4180 quoting.
2
+
3
+ export const CSV_COLUMNS = [
4
+ "type", "element_id", "text", "filename", "filetype", "languages", "page_number", "parent_id", "category_depth", "text_as_html", "last_modified",
5
+ "link_urls", "link_texts", "emphasized_text_contents", "emphasized_text_tags", "is_continuation", "orig_elements",
6
+ ];
7
+
8
+ function cell(value) {
9
+ if (value === undefined || value === null) return "";
10
+ const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
11
+ return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
12
+ }
13
+
14
+ export function elementsToCsv(elements) {
15
+ const lines = [CSV_COLUMNS.join(",")];
16
+ for (const element of elements) {
17
+ const row = [];
18
+ for (const column of CSV_COLUMNS) {
19
+ if (column === "type" || column === "element_id" || column === "text") row.push(cell(element[column]));
20
+ else row.push(cell(element.metadata[column]));
21
+ }
22
+ lines.push(row.join(","));
23
+ }
24
+ return `${lines.join("\r\n")}\r\n`;
25
+ }
@@ -0,0 +1,171 @@
1
+ // Element construction shared by every document type: ids, metadata, page tracking and the Title parent chain.
2
+ import { sha256Id } from "../sha256.mjs";
3
+ import { uuid } from "../ids.mjs";
4
+
5
+ export const MAX_TEXT = 100000;
6
+ export const MAX_ELEMENTS = 100000;
7
+
8
+ export const ELEMENT_TYPES = new Set([
9
+ "Title", "NarrativeText", "ListItem", "UncategorizedText", "Table", "TableChunk", "CompositeElement", "Image", "Address", "EmailAddress",
10
+ "CodeSnippet", "Header", "Footer", "PageBreak", "FigureCaption", "Formula", "PageNumber", "Text",
11
+ ]);
12
+
13
+ export class Builder {
14
+ /**
15
+ * @param {object} doc { filename, filetype, languages, startingPage, lastModified, includePageBreaks }
16
+ * @param {object|null} context when `unique_element_ids` is set, ids are UUIDs drawn from context.random
17
+ */
18
+ constructor(doc, context) {
19
+ this.filename = doc.filename;
20
+ this.filetype = doc.filetype;
21
+ this.languages = doc.languages;
22
+ this.page = doc.startingPage;
23
+ this.lastModified = doc.lastModified;
24
+ this.includePageBreaks = doc.includePageBreaks;
25
+ this.context = context;
26
+ this.elements = [];
27
+ this.ordinal = 0;
28
+ this.titles = [];
29
+ this.extraMetadata = null;
30
+ this.extraBytes = 0;
31
+ this.overflow = false;
32
+ // Running lower bound of the JSON (and CSV) bytes of the elements built so far, shared across the files of one request
33
+ // when `doc.sizing` is given; passing `doc.byteBudget` stops the build (`tooLarge`) before megabytes of elements exist.
34
+ this.sizing = doc.sizing ?? { bytes: 0 };
35
+ this.byteBudget = typeof doc.byteBudget === "number" ? doc.byteBudget : Infinity;
36
+ this.tooLarge = false;
37
+ this.fixedBytes = 60 + String(doc.filename).length + String(doc.filetype).length + String(doc.lastModified).length + stringsBytes(doc.languages);
38
+ }
39
+
40
+ /** E-mail metadata copied onto every element; its size is measured once. */
41
+ setExtraMetadata(extra) {
42
+ this.extraMetadata = extra;
43
+ this.extraBytes = valuesBytes(extra);
44
+ }
45
+
46
+ /** Adds `bytes` to the running size; false (and `tooLarge`, `overflow`) once the byte budget is passed. */
47
+ account(bytes) {
48
+ this.sizing.bytes += bytes;
49
+ if (this.sizing.bytes <= this.byteBudget) return true;
50
+ this.tooLarge = true;
51
+ this.overflow = true;
52
+ return false;
53
+ }
54
+
55
+ id(text) {
56
+ if (this.context !== null) return uuid(this.context);
57
+ return sha256Id(this.filename, String(this.page), String(this.ordinal), text);
58
+ }
59
+
60
+ /** Appends one element; text longer than MAX_TEXT is split into continuation elements. */
61
+ add(type, text, meta = {}) {
62
+ if (this.overflow) return;
63
+ const pieces = [];
64
+ const metaBytes = valuesBytes(meta) + this.extraBytes + this.fixedBytes + type.length + 32;
65
+ if (text.length <= MAX_TEXT) pieces.push(text);
66
+ else for (let i = 0; i < text.length; i += MAX_TEXT) pieces.push(text.slice(i, i + MAX_TEXT));
67
+ for (let index = 0; index < pieces.length; index += 1) {
68
+ if (this.elements.length >= MAX_ELEMENTS) {
69
+ this.overflow = true;
70
+ return;
71
+ }
72
+ const piece = pieces[index];
73
+ if (!this.account(metaBytes + piece.length)) return;
74
+ // `languages` is shared by every element of the document (never mutated; serialised identically).
75
+ const metadata = { filename: this.filename, filetype: this.filetype, languages: this.languages, page_number: this.page, last_modified: this.lastModified };
76
+ if (type === "Title") {
77
+ const depth = Number.isInteger(meta.category_depth) ? meta.category_depth : 0;
78
+ while (this.titles.length > 0 && this.titles[this.titles.length - 1].depth >= depth) this.titles.pop();
79
+ if (this.titles.length > 0) metadata.parent_id = this.titles[this.titles.length - 1].id;
80
+ } else if (this.titles.length > 0 && type !== "PageBreak") metadata.parent_id = this.titles[this.titles.length - 1].id;
81
+ for (const key in meta) if (Object.hasOwn(meta, key) && meta[key] !== undefined && meta[key] !== null) metadata[key] = meta[key];
82
+ if (index > 0) metadata.is_continuation = true;
83
+ if (this.extraMetadata !== null) Object.assign(metadata, this.extraMetadata);
84
+ const element = { type, element_id: this.id(piece), text: piece, metadata };
85
+ this.ordinal += 1;
86
+ this.elements.push(element);
87
+ if (type === "Title") this.titles.push({ id: element.element_id, depth: metadata.category_depth ?? 0 });
88
+ }
89
+ }
90
+
91
+ /** A page change; emits a PageBreak element when the caller asked for them. */
92
+ pageBreak() {
93
+ if (this.includePageBreaks) this.add("PageBreak", "");
94
+ this.page += 1;
95
+ }
96
+ }
97
+
98
+ /** Lower bound of the encoded bytes of an array of strings: every character is at least one byte, plus quotes or separators. */
99
+ export function stringsBytes(values) {
100
+ let bytes = 2;
101
+ if (Array.isArray(values)) for (const value of values) bytes += (typeof value === "string" ? value.length : 1) + 1;
102
+ return bytes;
103
+ }
104
+
105
+ /** Lower bound of the encoded bytes of the values of a flat metadata object (strings, scalars and arrays of them). */
106
+ export function valuesBytes(object) {
107
+ let bytes = 0;
108
+ if (object === null || typeof object !== "object") return bytes;
109
+ for (const key of Object.keys(object)) {
110
+ const value = object[key];
111
+ if (value === undefined || value === null) continue;
112
+ bytes += key.length;
113
+ if (typeof value === "string") bytes += value.length;
114
+ else if (Array.isArray(value)) bytes += stringsBytes(value);
115
+ else bytes += 1;
116
+ }
117
+ return bytes;
118
+ }
119
+
120
+ const ADDRESS_RE = /^\d{1,6}[A-Za-z]?\s+[A-Za-z0-9.' -]+\s(Street|St\.?|Avenue|Ave\.?|Road|Rd\.?|Lane|Ln\.?|Way|Blvd\.?|Boulevard|Drive|Dr\.?)\s*$/;
121
+ const EMAIL_RE = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
122
+ const LIST_LINE_RE = /^\s{0,8}(?:[-*•–]|\d{1,4}[.)])\s+\S/;
123
+
124
+ export const isEmailBlock = (block) => block.length <= 260 && EMAIL_RE.test(block);
125
+ export const isListLine = (line) => LIST_LINE_RE.test(line);
126
+ export const stripListMarker = (line) => line.replace(/^\s{0,8}(?:[-*•–]|\d{1,4}[.)])\s+/, "");
127
+
128
+ export function isAddressBlock(lines) {
129
+ if (lines.length < 2 || lines.length > 5) return false;
130
+ if (!lines.every((line) => line.length > 0 && line.length <= 120)) return false;
131
+ const street = lines.findIndex((line) => ADDRESS_RE.test(line));
132
+ return street >= 0 && street <= 1 && street < lines.length - 1;
133
+ }
134
+
135
+ const WORD_RE = /[A-Za-zÀ-ɏЀ-ӿ]+/g;
136
+ const SMALL_WORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "in", "on", "for", "with", "at", "by", "from", "de", "von", "und"]);
137
+
138
+ /** A short single line without terminal punctuation in ALL CAPS or Title Case. */
139
+ export function isTitleLine(line) {
140
+ if (line.length === 0 || line.length > 100 || /[.!?;:,]$/.test(line)) return false;
141
+ const words = line.match(WORD_RE);
142
+ if (words === null || words.length === 0) return false;
143
+ if (line === line.toUpperCase() && line !== line.toLowerCase()) return true;
144
+ let capitalised = 0;
145
+ for (const word of words) {
146
+ const lower = word.toLowerCase();
147
+ if (word[0] !== lower[0]) capitalised += 1;
148
+ else if (!SMALL_WORDS.has(lower)) return false;
149
+ }
150
+ return capitalised >= 1 && words[0][0] !== words[0][0].toLowerCase();
151
+ }
152
+
153
+ export const wordCount = (text) => {
154
+ let count = 0;
155
+ for (const part of text.split(/\s+/)) if (part.length > 0) count += 1;
156
+ return count;
157
+ };
158
+
159
+ /** Plain-text classification of one block (already split from its neighbours by blank lines). */
160
+ export function classifyBlock(block) {
161
+ const lines = block.split("\n").map((line) => line.trimEnd());
162
+ if (lines.length === 1) {
163
+ const line = lines[0].trim();
164
+ if (isEmailBlock(line)) return "EmailAddress";
165
+ if (isTitleLine(line)) return "Title";
166
+ }
167
+ if (lines.length >= 2 && lines.length <= 5 && isAddressBlock(lines.map((l) => l.trim()))) return "Address";
168
+ if (lines.every(isListLine)) return "ListItem";
169
+ if (/[.!?:;]["')\]]?$/.test(block.trimEnd()) && wordCount(block) >= 5) return "NarrativeText";
170
+ return "UncategorizedText";
171
+ }