ofw-mcp 2.7.0 → 2.8.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.
@@ -0,0 +1,278 @@
1
+ // PDF text-layer extraction.
2
+ //
3
+ // A PDF has no "text" — it has content streams full of positioning operators
4
+ // that draw glyphs. This reads the text-showing operators (Tj/TJ/'/") out of
5
+ // each page's content stream and reassembles a readable transcript. It is not
6
+ // a renderer: exotic font encodings without a ToUnicode map will come back as
7
+ // mojibake, and column layout is approximated by line breaks.
8
+ //
9
+ // What it must never do is fail silently. A scanned PDF has no text layer at
10
+ // all, and the honest answer for one is `textLayer: false` plus a note saying
11
+ // it needs OCR — not an empty string that reads like an empty document.
12
+ import { inflateBounded, DecompressionLimitError, MAX_DECOMPRESSED_BYTES, } from './inflate.js';
13
+ const OBJ_HEADER = /(\d+)\s+\d+\s+obj\b/g;
14
+ function parseObjects(text) {
15
+ const objects = new Map();
16
+ for (let m = OBJ_HEADER.exec(text); m !== null; m = OBJ_HEADER.exec(text)) {
17
+ const start = m.index + m[0].length;
18
+ const end = text.indexOf('endobj', start);
19
+ objects.set(Number(m[1]), {
20
+ num: Number(m[1]),
21
+ body: text.slice(start, end === -1 ? undefined : end),
22
+ start,
23
+ });
24
+ }
25
+ OBJ_HEADER.lastIndex = 0;
26
+ return objects;
27
+ }
28
+ /** Object numbers referenced by `N 0 R` tokens in a fragment. */
29
+ function refsIn(fragment) {
30
+ return [...fragment.matchAll(/(\d+)\s+\d+\s+R\b/g)].map((m) => Number(m[1]));
31
+ }
32
+ /**
33
+ * Page objects in reading order: walk the catalog's page tree when it
34
+ * resolves, otherwise fall back to the order the page objects appear in the
35
+ * file (which matches reading order for any linearly-written PDF).
36
+ */
37
+ function orderedPages(objects) {
38
+ const isPage = (o) => /\/Type\s*\/Page[^s]/.test(o.body);
39
+ const inFileOrder = [...objects.values()].filter(isPage).sort((a, b) => a.start - b.start);
40
+ const catalog = [...objects.values()].find((o) => /\/Type\s*\/Catalog/.test(o.body));
41
+ const rootRef = catalog ? refsIn(/\/Pages\s+[^/>]*/.exec(catalog.body)?.[0] ?? '')[0] : undefined;
42
+ if (rootRef === undefined)
43
+ return inFileOrder;
44
+ const ordered = [];
45
+ const seen = new Set();
46
+ const walk = (num) => {
47
+ // A malformed (or hostile) page tree can contain a cycle; visiting each
48
+ // object at most once bounds the walk without rejecting the document.
49
+ if (seen.has(num))
50
+ return;
51
+ seen.add(num);
52
+ const obj = objects.get(num);
53
+ if (!obj)
54
+ return;
55
+ if (isPage(obj)) {
56
+ ordered.push(obj);
57
+ return;
58
+ }
59
+ const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
60
+ if (kids)
61
+ for (const kid of refsIn(kids))
62
+ walk(kid);
63
+ };
64
+ walk(rootRef);
65
+ return ordered.length > 0 ? ordered : inFileOrder;
66
+ }
67
+ /** Raw (still-encoded) bytes of a stream object, or null when it has none. */
68
+ function streamBytes(bytes, obj) {
69
+ const marker = /stream\r?\n/.exec(obj.body);
70
+ if (!marker)
71
+ return null;
72
+ const from = obj.start + marker.index + marker[0].length;
73
+ // /Length is frequently an indirect reference, so the end-of-stream marker is
74
+ // the reliable boundary; a direct numeric /Length is used when present.
75
+ const declared = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(obj.body);
76
+ if (declared)
77
+ return bytes.subarray(from, from + Number(declared[1]));
78
+ const end = bytes.indexOf('endstream', from, 'latin1');
79
+ return bytes.subarray(from, end === -1 ? undefined : end);
80
+ }
81
+ /** Decode a stream's bytes, honouring FlateDecode. Null for filters we can't read. */
82
+ async function decodeStream(bytes, obj) {
83
+ const raw = streamBytes(bytes, obj);
84
+ if (!raw)
85
+ return null;
86
+ const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? '';
87
+ if (filter === '')
88
+ return raw;
89
+ if (!filter.includes('FlateDecode'))
90
+ return null; // LZW/DCT/JPX: not text anyway
91
+ try {
92
+ // PDF stream dictionaries say nothing about inflated length, so the cap is
93
+ // the only thing standing between a crafted stream and the memory budget.
94
+ return await inflateBounded(raw, 'deflate', MAX_DECOMPRESSED_BYTES, 'PDF stream');
95
+ }
96
+ catch (err) {
97
+ // A truncated or mis-bounded stream must not take the whole document down;
98
+ // the page simply contributes no text. A cap breach is different in kind —
99
+ // that is a hostile or absurd file, and silently returning a document with
100
+ // pages quietly missing would be the worse answer.
101
+ if (err instanceof DecompressionLimitError)
102
+ throw err;
103
+ return null;
104
+ }
105
+ }
106
+ /** Read a PDF literal string starting at `text[i]` === '(' — returns its end index. */
107
+ function readLiteral(text, i) {
108
+ let value = '';
109
+ let depth = 1;
110
+ let p = i + 1;
111
+ for (; p < text.length; p++) {
112
+ const ch = text[p];
113
+ if (ch === '\\') {
114
+ const esc = text[++p];
115
+ const simple = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' };
116
+ if (simple[esc]) {
117
+ value += simple[esc];
118
+ continue;
119
+ }
120
+ const octal = /^[0-7]{1,3}/.exec(text.slice(p, p + 3))?.[0];
121
+ if (octal) {
122
+ value += String.fromCharCode(parseInt(octal, 8));
123
+ p += octal.length - 1;
124
+ continue;
125
+ }
126
+ if (esc === '\n')
127
+ continue; // line continuation
128
+ value += esc;
129
+ continue;
130
+ }
131
+ if (ch === '(') {
132
+ depth++;
133
+ value += ch;
134
+ continue;
135
+ }
136
+ if (ch === ')') {
137
+ depth--;
138
+ if (depth === 0)
139
+ break;
140
+ value += ch;
141
+ continue;
142
+ }
143
+ value += ch;
144
+ }
145
+ return { value, next: p };
146
+ }
147
+ /** Decode a `<...>` hex string, as UTF-16BE when it looks like one. */
148
+ function decodeHexString(hex) {
149
+ const clean = hex.replace(/[^0-9a-fA-F]/g, '');
150
+ const padded = clean.length % 2 === 1 ? `${clean}0` : clean;
151
+ const buf = Buffer.from(padded, 'hex');
152
+ if (buf.length >= 2 && buf.length % 2 === 0 && buf[0] === 0xfe && buf[1] === 0xff) {
153
+ return buf.subarray(2).swap16().toString('utf16le');
154
+ }
155
+ // Simple CID fonts emit 2-byte codes whose high byte is zero; reading those
156
+ // as latin1 would interleave NULs through every word.
157
+ if (buf.length % 2 === 0 && buf.length > 0 && buf.every((b, i) => i % 2 === 1 || b === 0)) {
158
+ return buf.swap16().toString('utf16le');
159
+ }
160
+ return buf.toString('latin1');
161
+ }
162
+ /**
163
+ * Pull the shown text out of a content stream. Text-positioning operators
164
+ * become line breaks, and the wide negative kerns inside a TJ array become
165
+ * spaces (that is how most producers encode an inter-word gap).
166
+ */
167
+ export function textFromContentStream(content) {
168
+ let out = '';
169
+ let pending = '';
170
+ let arrayDepth = 0;
171
+ for (let i = 0; i < content.length; i++) {
172
+ const ch = content[i];
173
+ if (ch === '(') {
174
+ const { value, next } = readLiteral(content, i);
175
+ pending += value;
176
+ i = next;
177
+ continue;
178
+ }
179
+ // `<<` opens a dictionary (marked-content properties, inline images).
180
+ // Skipping both angle brackets keeps the dictionary's own text from being
181
+ // read as a hex string.
182
+ if (ch === '<' && content[i + 1] === '<') {
183
+ i++;
184
+ continue;
185
+ }
186
+ if (ch === '<') {
187
+ const end = content.indexOf('>', i);
188
+ if (end === -1)
189
+ break;
190
+ pending += decodeHexString(content.slice(i + 1, end));
191
+ i = end;
192
+ continue;
193
+ }
194
+ if (ch === '[') {
195
+ arrayDepth++;
196
+ continue;
197
+ }
198
+ if (ch === ']') {
199
+ arrayDepth = 0;
200
+ continue;
201
+ }
202
+ if (arrayDepth > 0 && (ch === '-' || (ch >= '0' && ch <= '9'))) {
203
+ const num = /^-?\d+(\.\d+)?/.exec(content.slice(i));
204
+ /* v8 ignore next -- the leading char already guarantees a match */
205
+ if (!num)
206
+ continue;
207
+ if (Number(num[0]) <= -100)
208
+ pending += ' ';
209
+ i += num[0].length - 1;
210
+ continue;
211
+ }
212
+ if (/[A-Za-z'"*]/.test(ch)) {
213
+ /* v8 ignore next -- the character class above guarantees this regex matches */
214
+ const op = /^[A-Za-z*]+|^['"]/.exec(content.slice(i))?.[0] ?? ch;
215
+ i += op.length - 1;
216
+ if (op === 'Tj' || op === 'TJ') {
217
+ out += pending;
218
+ pending = '';
219
+ continue;
220
+ }
221
+ if (op === "'" || op === '"') {
222
+ out += `\n${pending}`;
223
+ pending = '';
224
+ continue;
225
+ }
226
+ if (op === 'Td' || op === 'TD' || op === 'T*' || op === 'ET') {
227
+ out += '\n';
228
+ continue;
229
+ }
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+ /** Collapse the runs of blank lines that positioning operators leave behind. */
235
+ function tidy(text) {
236
+ return text.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
237
+ }
238
+ /** Extract the text layer of a PDF, page by page. */
239
+ export async function extractPdf(bytes, opts = {}) {
240
+ const text = bytes.toString('latin1');
241
+ if (/\/Encrypt\b/.test(text)) {
242
+ throw new Error('the PDF is encrypted; its text cannot be extracted');
243
+ }
244
+ const objects = parseObjects(text);
245
+ const pageObjects = orderedPages(objects);
246
+ if (pageObjects.length === 0)
247
+ throw new Error('no pages found in the PDF');
248
+ const pages = [];
249
+ const omitted = [];
250
+ for (let i = 0; i < pageObjects.length; i++) {
251
+ const number = i + 1;
252
+ if (opts.select && !opts.select(i, `page ${number}`)) {
253
+ omitted.push(`page ${number}`);
254
+ continue;
255
+ }
256
+ const contentsFragment = /\/Contents\s*(\d+\s+\d+\s+R|\[[^\]]*\])/.exec(pageObjects[i].body)?.[1] ?? '';
257
+ let raw = '';
258
+ for (const ref of refsIn(contentsFragment)) {
259
+ const streamObj = objects.get(ref);
260
+ if (!streamObj)
261
+ continue;
262
+ const decoded = await decodeStream(bytes, streamObj);
263
+ if (decoded)
264
+ raw += `${decoded.toString('latin1')}\n`;
265
+ }
266
+ pages.push({ number, text: tidy(textFromContentStream(raw)) });
267
+ }
268
+ const textLayer = pages.some((p) => p.text !== '');
269
+ return {
270
+ kind: 'pdf',
271
+ pages,
272
+ textLayer,
273
+ ...(textLayer ? {} : {
274
+ note: 'This PDF has no extractable text layer — it is most likely a scan or an image-only export. Reading it requires OCR, which this server does not perform.',
275
+ }),
276
+ ...(omitted.length ? { omitted } : {}),
277
+ };
278
+ }
@@ -0,0 +1,54 @@
1
+ // PowerPoint (.pptx) text extraction: one entry per slide, plus the speaker
2
+ // notes attached to it.
3
+ //
4
+ // Notes parts are numbered independently of slides (notesSlide2.xml can belong
5
+ // to slide 5), so the notes link is followed through the slide's relationship
6
+ // part rather than guessed from the file name.
7
+ import { readZip } from './zip.js';
8
+ import { decodeXmlEntities, elements } from './xml.js';
9
+ import { readRels } from './ooxml.js';
10
+ const SLIDE_PATH = /^ppt\/slides\/slide(\d+)\.xml$/;
11
+ /** Text of every `<a:p>` paragraph in a slide/notes part, one per line. */
12
+ function slideText(xml) {
13
+ const lines = [];
14
+ for (const p of elements(xml, 'a:p')) {
15
+ let line = '';
16
+ for (const t of elements(p.inner, 'a:t'))
17
+ line += decodeXmlEntities(t.inner);
18
+ if (line !== '')
19
+ lines.push(line);
20
+ }
21
+ return lines.join('\n');
22
+ }
23
+ /** Extract text and speaker notes from every (selected) slide. */
24
+ export async function extractPptx(bytes, opts = {}) {
25
+ const zip = await readZip(bytes);
26
+ const paths = zip.names()
27
+ .map((name) => ({ name, n: Number(SLIDE_PATH.exec(name)?.[1]) }))
28
+ .filter((e) => Number.isFinite(e.n))
29
+ // slide10 sorts before slide2 lexically; presentation order is numeric.
30
+ .sort((a, b) => a.n - b.n);
31
+ if (paths.length === 0)
32
+ throw new Error('no slides found in the .pptx archive');
33
+ const slides = [];
34
+ const omitted = [];
35
+ for (let i = 0; i < paths.length; i++) {
36
+ const { name } = paths[i];
37
+ const number = i + 1;
38
+ if (opts.select && !opts.select(i, `slide ${number}`)) {
39
+ omitted.push(`slide ${number}`);
40
+ continue;
41
+ }
42
+ /* v8 ignore next -- `name` came from zip.names(), so readText cannot be null here */
43
+ const text = slideText((await zip.readText(name)) ?? '');
44
+ const notesRel = (await readRels(zip, name)).find((r) => r.type.endsWith('/notesSlide'));
45
+ const notesXml = notesRel ? await zip.readText(notesRel.target) : null;
46
+ const notes = notesXml === null ? '' : slideText(notesXml);
47
+ slides.push({ number, text, ...(notes ? { notes } : {}) });
48
+ }
49
+ return {
50
+ kind: 'presentation',
51
+ slides,
52
+ ...(omitted.length ? { omitted } : {}),
53
+ };
54
+ }
@@ -0,0 +1,258 @@
1
+ // Spreadsheet extraction: .xlsx/.xlsm (OOXML) and .csv/.tsv (delimited text).
2
+ //
3
+ // Both land on the same shape — a list of named sheets with a CSV rendering —
4
+ // so a caller reading a custody schedule does not care which one arrived.
5
+ //
6
+ // The values written out are Excel's own CACHED results: a formula cell stores
7
+ // both `<f>` (the formula) and `<v>` (what it last evaluated to), and `<v>` is
8
+ // what the co-parent saw on screen. Dates are the other translation that
9
+ // matters — Excel stores them as bare serial numbers, so a schedule extracted
10
+ // without style lookup reads as a column of five-digit integers.
11
+ import { readZip } from './zip.js';
12
+ import { elements, attr, textOf } from './xml.js';
13
+ import { readRels, findPart, dirOf, resolvePartPath } from './ooxml.js';
14
+ const DEFAULT_MAX_CELLS = 200_000;
15
+ // Built-in number formats that denote a date and/or time (ECMA-376 §18.8.30).
16
+ const BUILTIN_DATE_FORMATS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
17
+ /** Excel's serial-date epoch as a Unix-epoch offset, in days. */
18
+ const SERIAL_EPOCH_OFFSET = 25569;
19
+ /**
20
+ * Convert an Excel serial number to an ISO string: `YYYY-MM-DD` for a whole
21
+ * day, `…THH:MM:SS` when it carries a time, and `HH:MM:SS` for a time-only
22
+ * value (serial < 1). Returns null when the serial is out of representable
23
+ * range, so the caller can fall back to printing the raw number.
24
+ */
25
+ export function excelSerialToIso(serial, date1904) {
26
+ // The 1904 system counts from a different epoch, exactly 1462 days later.
27
+ const base = date1904 ? serial + 1462 : serial;
28
+ // Excel's 1900 system contains a phantom 1900-02-29 (serial 60) inherited
29
+ // from Lotus 1-2-3, so serials below it are one day ahead of real time.
30
+ const corrected = Math.floor(base) < 60 ? base + 1 : base;
31
+ const date = new Date(Math.round((corrected - SERIAL_EPOCH_OFFSET) * 86_400_000));
32
+ if (Number.isNaN(date.getTime()))
33
+ return null;
34
+ const iso = date.toISOString();
35
+ if (base < 1)
36
+ return iso.slice(11, 19);
37
+ return iso.slice(11, 19) === '00:00:00' ? iso.slice(0, 10) : iso.slice(0, 19);
38
+ }
39
+ /** True when a number format renders its value as a date or a time. */
40
+ function isDateFormat(numFmtId, formatCode) {
41
+ if (BUILTIN_DATE_FORMATS.has(numFmtId))
42
+ return true;
43
+ if (!formatCode)
44
+ return false;
45
+ // Strip literals and colour/condition brackets before looking for date
46
+ // tokens, so a currency format like [$-409]#,##0.00 is not read as a date.
47
+ const bare = formatCode.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, '').replace(/\\./g, '');
48
+ return /[ymdhs]/i.test(bare);
49
+ }
50
+ /** Column index (0-based) from a cell reference like `AA12`. */
51
+ function columnIndex(ref) {
52
+ const m = /^([A-Z]+)/.exec(ref);
53
+ if (!m)
54
+ return null;
55
+ let index = 0;
56
+ for (const ch of m[1])
57
+ index = index * 26 + (ch.charCodeAt(0) - 64);
58
+ return index - 1;
59
+ }
60
+ /** Quote a CSV field per RFC 4180 when it contains a delimiter, quote or newline. */
61
+ function csvField(value) {
62
+ return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
63
+ }
64
+ function toCsv(rows, cols) {
65
+ return rows
66
+ .map((row) => Array.from({ length: cols }, (_, i) => csvField(row[i] ?? '')).join(','))
67
+ .join('\n');
68
+ }
69
+ async function readStyles(zip, dir) {
70
+ const xml = await zip.readText(`${dir}styles.xml`);
71
+ if (!xml)
72
+ return { dateStyles: [] };
73
+ const custom = new Map();
74
+ for (const el of elements(xml, 'numFmt')) {
75
+ const id = Number(attr(el.attrs, 'numFmtId'));
76
+ const code = attr(el.attrs, 'formatCode');
77
+ if (Number.isFinite(id) && code !== null)
78
+ custom.set(id, code);
79
+ }
80
+ const dateStyles = [];
81
+ // Only <cellXfs> maps the `s` attribute; <cellStyleXfs> uses the same <xf>
82
+ // tag, so scan inside the cellXfs block rather than the whole part.
83
+ for (const block of elements(xml, 'cellXfs')) {
84
+ for (const xf of elements(block.inner, 'xf')) {
85
+ const id = Number(attr(xf.attrs, 'numFmtId') ?? '0');
86
+ dateStyles.push(isDateFormat(id, custom.get(id)));
87
+ }
88
+ }
89
+ return { dateStyles };
90
+ }
91
+ async function readSharedStrings(zip, dir) {
92
+ const xml = await zip.readText(`${dir}sharedStrings.xml`);
93
+ if (!xml)
94
+ return [];
95
+ // <si> may hold a single <t> or a sequence of <r> runs each with its own
96
+ // <t>; concatenating every <t> handles both.
97
+ return [...elements(xml, 'si')].map((si) => textOf(si.inner, 't'));
98
+ }
99
+ function cellValue(attrs, inner, ctx) {
100
+ const type = attr(attrs, 't') ?? 'n';
101
+ if (type === 'inlineStr')
102
+ return textOf(inner, 't');
103
+ const raw = textOf(inner, 'v');
104
+ switch (type) {
105
+ case 's': {
106
+ const index = Number(raw);
107
+ return ctx.shared[index] ?? '';
108
+ }
109
+ case 'b':
110
+ return raw === '1' ? 'TRUE' : 'FALSE';
111
+ case 'str':
112
+ case 'e':
113
+ return raw;
114
+ default: {
115
+ const styleIndex = Number(attr(attrs, 's') ?? '-1');
116
+ const numeric = Number(raw);
117
+ if (ctx.styles.dateStyles[styleIndex] && raw !== '' && Number.isFinite(numeric)) {
118
+ return excelSerialToIso(numeric, ctx.date1904) ?? raw;
119
+ }
120
+ return raw;
121
+ }
122
+ }
123
+ }
124
+ function parseSheet(xml, name, ctx, maxCells) {
125
+ const rows = [];
126
+ let cols = 0;
127
+ let cells = 0;
128
+ let truncated = false;
129
+ for (const row of elements(xml, 'row')) {
130
+ if (cells >= maxCells) {
131
+ truncated = true;
132
+ break;
133
+ }
134
+ const values = [];
135
+ for (const cell of elements(row.inner, 'c')) {
136
+ const ref = attr(cell.attrs, 'r');
137
+ const index = ref === null ? null : columnIndex(ref);
138
+ // A cell with no usable reference cannot be placed in a column; dropping
139
+ // it is better than shifting every later value in the row.
140
+ if (index === null)
141
+ continue;
142
+ values[index] = cellValue(cell.attrs, cell.inner, ctx);
143
+ cells++;
144
+ if (index + 1 > cols)
145
+ cols = index + 1;
146
+ }
147
+ rows.push(values);
148
+ }
149
+ return { name, rows: rows.length, cols, csv: toCsv(rows, cols), ...(truncated ? { truncated } : {}) };
150
+ }
151
+ /** Extract every (selected) sheet of an .xlsx/.xlsm workbook. */
152
+ export async function extractXlsx(bytes, opts = {}) {
153
+ const zip = await readZip(bytes);
154
+ const workbookPath = findPart(zip, 'xl/workbook.xml', 'workbook.xml');
155
+ if (!workbookPath)
156
+ throw new Error('no workbook part found in the .xlsx archive');
157
+ const dir = dirOf(workbookPath);
158
+ /* v8 ignore next -- findPart only returns a name the archive contains, so readText cannot be null here */
159
+ const workbookXml = (await zip.readText(workbookPath)) ?? '';
160
+ const rels = await readRels(zip, workbookPath);
161
+ const targetById = new Map(rels.map((r) => [r.id, r.target]));
162
+ const ctx = {
163
+ shared: await readSharedStrings(zip, dir),
164
+ styles: await readStyles(zip, dir),
165
+ date1904: /<workbookPr[^>]*date1904="(1|true)"/i.test(workbookXml),
166
+ };
167
+ const maxCells = opts.maxCells ?? DEFAULT_MAX_CELLS;
168
+ const sheets = [];
169
+ const omitted = [];
170
+ let index = 0;
171
+ for (const el of elements(workbookXml, 'sheet')) {
172
+ const position = index++;
173
+ const name = attr(el.attrs, 'name') ?? `Sheet${position + 1}`;
174
+ if (opts.select && !opts.select(position, name)) {
175
+ omitted.push(name);
176
+ continue;
177
+ }
178
+ const relId = attr(el.attrs, 'r:id') ?? attr(el.attrs, 'id');
179
+ // Fall back to the conventional positional path when the rels are absent
180
+ // or do not name this sheet — some producers omit them entirely.
181
+ const path = (relId && targetById.get(relId))
182
+ ?? resolvePartPath(dir, `worksheets/sheet${position + 1}.xml`);
183
+ const xml = await zip.readText(path);
184
+ if (xml === null) {
185
+ omitted.push(`${name} (sheet part not found in the workbook)`);
186
+ continue;
187
+ }
188
+ sheets.push(parseSheet(xml, name, ctx, maxCells));
189
+ }
190
+ const truncated = sheets.some((s) => s.truncated);
191
+ return {
192
+ kind: 'spreadsheet',
193
+ sheets,
194
+ ...(omitted.length ? { omitted } : {}),
195
+ ...(truncated ? { truncated } : {}),
196
+ };
197
+ }
198
+ /** Parse delimiter-separated text (RFC 4180 quoting) into rows of fields. */
199
+ function parseDelimitedRows(text, delimiter) {
200
+ const rows = [];
201
+ let row = [];
202
+ let field = '';
203
+ let quoted = false;
204
+ let dirty = false; // distinguishes a trailing newline from a trailing empty row
205
+ for (let i = 0; i < text.length; i++) {
206
+ const ch = text[i];
207
+ if (quoted) {
208
+ if (ch !== '"') {
209
+ field += ch;
210
+ continue;
211
+ }
212
+ if (text[i + 1] === '"') {
213
+ field += '"';
214
+ i++;
215
+ continue;
216
+ }
217
+ quoted = false;
218
+ continue;
219
+ }
220
+ if (ch === '"') {
221
+ quoted = true;
222
+ dirty = true;
223
+ continue;
224
+ }
225
+ if (ch === delimiter) {
226
+ row.push(field);
227
+ field = '';
228
+ dirty = true;
229
+ continue;
230
+ }
231
+ if (ch === '\r')
232
+ continue; // CRLF: the \n does the work
233
+ if (ch === '\n') {
234
+ row.push(field);
235
+ rows.push(row);
236
+ row = [];
237
+ field = '';
238
+ dirty = false;
239
+ continue;
240
+ }
241
+ field += ch;
242
+ dirty = true;
243
+ }
244
+ if (dirty || field !== '') {
245
+ row.push(field);
246
+ rows.push(row);
247
+ }
248
+ return rows;
249
+ }
250
+ /** Extract .csv/.tsv text as a single-sheet spreadsheet. */
251
+ export function extractDelimited(text, name, delimiter) {
252
+ const rows = parseDelimitedRows(text, delimiter);
253
+ const cols = rows.reduce((max, r) => Math.max(max, r.length), 0);
254
+ return {
255
+ kind: 'spreadsheet',
256
+ sheets: [{ name, rows: rows.length, cols, csv: toCsv(rows, cols) }],
257
+ };
258
+ }
@@ -0,0 +1,4 @@
1
+ // The shape every extractor returns. One discriminated union so the download
2
+ // tool can hand back "here is the content" without knowing which format it
3
+ // came from, and so a caller can branch on `kind` instead of on MIME strings.
4
+ export {};
@@ -0,0 +1,61 @@
1
+ // Just enough XML for OOXML part-reading.
2
+ //
3
+ // The office formats are machine-generated XML with a known, flat shape per
4
+ // part (rows of cells, runs of text), so a scanning reader beats pulling in a
5
+ // DOM parser that has to run in both Node and workerd. The one rule this file
6
+ // exists to enforce is that a tag match is anchored on the FULL tag name:
7
+ // naively scanning for `<w:p` also matches `<w:pPr>`, which silently turns
8
+ // paragraph properties into paragraphs.
9
+ //
10
+ // Known limitation: attribute values containing a literal `>` (legal but never
11
+ // emitted by Word/Excel/PowerPoint) would end an element match early.
12
+ const NAMED_ENTITIES = {
13
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
14
+ };
15
+ /** Decode the predefined XML entities plus numeric character references. */
16
+ export function decodeXmlEntities(text) {
17
+ if (!text.includes('&'))
18
+ return text;
19
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
20
+ if (body[0] === '#') {
21
+ const code = body[1] === 'x' || body[1] === 'X'
22
+ ? parseInt(body.slice(2), 16)
23
+ : parseInt(body.slice(1), 10);
24
+ return String.fromCodePoint(code);
25
+ }
26
+ // An unrecognized entity is left verbatim: mangling it would be worse than
27
+ // showing it, and OOXML only emits the five predefined ones.
28
+ return NAMED_ENTITIES[body] ?? whole;
29
+ });
30
+ }
31
+ /**
32
+ * Iterate every `<tag …>…</tag>` (and `<tag …/>`) in document order. Nested
33
+ * elements of the SAME name are not supported (the match is non-greedy); none
34
+ * of the parts read here nest — rows, cells, runs and paragraphs are all flat.
35
+ */
36
+ export function* elements(xml, tag) {
37
+ // The attribute run is LAZY and stops short of the closing `/`. A greedy
38
+ // `[^>]*` swallows the slash of a self-closing tag, after which the `>` arm
39
+ // matches and the scan runs on to the NEXT element's closing tag — merging
40
+ // two siblings into one. That silently shifted every `<xf/>` style index in a
41
+ // real workbook, so a General-formatted year printed as a 1905 date.
42
+ const re = new RegExp(`<${tag}((?:\\s[^>]*?)?)(?:\\s*/>|>([\\s\\S]*?)</${tag}>)`, 'g');
43
+ for (let m = re.exec(xml); m !== null; m = re.exec(xml)) {
44
+ yield { attrs: m[1], inner: m[2] ?? '' };
45
+ }
46
+ }
47
+ /** Read one attribute out of an element's raw attribute text. */
48
+ export function attr(attrs, name) {
49
+ // The leading (^|\s) keeps `s="1"` from matching inside `style="…"`.
50
+ const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`).exec(attrs);
51
+ if (!m)
52
+ return null;
53
+ return decodeXmlEntities(m[2] ?? m[3]);
54
+ }
55
+ /** Concatenated, entity-decoded text of every `<tag>` element in `xml`. */
56
+ export function textOf(xml, tag) {
57
+ let out = '';
58
+ for (const el of elements(xml, tag))
59
+ out += decodeXmlEntities(el.inner);
60
+ return out;
61
+ }