@opencraw/core 0.1.0 → 0.1.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 CHANGED
@@ -20,6 +20,7 @@ npx playwright install chromium # web recipes and browser bootstraps only
20
20
  | `loadAccessConfig(path)`, `AccessBroker`, `ACCESS_PRESETS` | Access configs: load and validate one, lease a profile outside a crawl (the cli's `probe` does), list the provider presets. |
21
21
  | `crawler.run(set)` | Runs every input recipe in sequence; returns a `CrawlReport`. |
22
22
  | `crawler.close()` | Closes the browser, if one was launched. |
23
+ | `readPdf(bytes)`, `findTables(pdf, query)`, `pdfText(pdf)` | The PDF reader and table extractor `request as: "pdf"` and `extract kind: "table"` use, for tooling. |
23
24
  | `HttpClient`, `BrowserClient` | The same clients the engine's runners use, for tooling built on top of `@opencraw/core` (`@opencraw/cli`'s `probe` command uses both). |
24
25
  | `parseInputRecipe`, `parseOutputRecipe`, `inputRecipeJsonSchema`, `outputRecipeJsonSchema`, `accessConfigJsonSchema` | The contracts, for tooling. |
25
26
 
package/dist/index.esm.js CHANGED
@@ -7,6 +7,7 @@ import { createWriteStream } from 'node:fs';
7
7
  import { once } from 'node:events';
8
8
  import { JSONPath } from 'jsonpath-plus';
9
9
  import { load } from 'cheerio';
10
+ import { fileURLToPath } from 'node:url';
10
11
 
11
12
  /** Resource types a page may skip loading, to save bandwidth on per-GB proxies. */
12
13
  const BLOCKABLE_RESOURCES = ['image', 'media', 'font', 'stylesheet', 'script', 'texttrack', 'xhr', 'fetch', 'eventsource', 'websocket', 'manifest', 'other'];
@@ -1292,7 +1293,7 @@ function traceLine(event) {
1292
1293
  }
1293
1294
  case 'recipe:finish':
1294
1295
  {
1295
- return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
1296
+ return `■ ${event.recipeId}: ${event.emitted} emitted, ${event.rejected} rejected, ${event.duplicates} duplicates, ${event.skipped > 0 ? `${event.skipped} skipped, ` : ''}${(event.stepsSkipped ?? 0) > 0 ? `${event.stepsSkipped} steps skipped, ` : ''}${event.pages} pages, ${event.durationMs} ms${event.error === undefined ? '' : `\n ✖ stopped: ${event.error}`}`;
1296
1297
  }
1297
1298
  case 'access:lease':
1298
1299
  {
@@ -2106,10 +2107,444 @@ async function detectBlock(response, rule = DEFAULT_BLOCK_RULE) {
2106
2107
  return undefined;
2107
2108
  }
2108
2109
 
2110
+ /** Runs closer than this share of the font size join into one cell. */
2111
+ const JOIN_GAP = 0.35;
2112
+ /** Runs further apart than this share of the font size get a space between them when joined. */
2113
+ const SPACE_GAP = 0.1;
2114
+ /** Runs on baselines closer than this share of the font size are on one line. */
2115
+ const SAME_BASELINE = 0.2;
2116
+ /** A cell joins a row when this share of its height overlaps the row. */
2117
+ const ROW_OVERLAP = 0.4;
2118
+ /**
2119
+ * Turns a page's text runs into rows of cells, top to bottom.
2120
+ *
2121
+ * Runs on one baseline that nearly touch become one cell. Cells whose vertical
2122
+ * extents overlap become one row, even when their baselines differ: a table
2123
+ * that centres its cells vertically puts a one-line value a few points above
2124
+ * or below its two-line label, and a row built from equal baselines would pair
2125
+ * the value with the wrong label.
2126
+ *
2127
+ * @param runs - The page's text runs, in any order.
2128
+ * @returns The rows.
2129
+ */
2130
+ function assembleRows(runs) {
2131
+ const cells = joinCells(runs);
2132
+ const ordered = [...cells].sort((a, b) => middle(b) - middle(a) || a.x - b.x);
2133
+ const rows = [];
2134
+ let top = 0;
2135
+ let bottom = 0;
2136
+ for (const cell of ordered) {
2137
+ const current = rows.at(-1);
2138
+ const overlap = Math.min(cell.y + cell.height, top) - Math.max(cell.y, bottom);
2139
+ if (current !== undefined && overlap >= ROW_OVERLAP * cell.height) {
2140
+ current.push(cell);
2141
+ top = Math.max(top, cell.y + cell.height);
2142
+ bottom = Math.min(bottom, cell.y);
2143
+ } else {
2144
+ rows.push([cell]);
2145
+ top = cell.y + cell.height;
2146
+ bottom = cell.y;
2147
+ }
2148
+ }
2149
+ return rows.map(row => rowOf(row));
2150
+ }
2151
+ function joinCells(runs) {
2152
+ // Whitespace runs are gaps, not text: pdf.js emits the space between two
2153
+ // table columns as one wide " ", which would bridge the columns.
2154
+ // Left to right within a line, lines top to bottom: a run may sit a fraction of a point off its neighbours' baseline.
2155
+ const ordered = runs.filter(run => run.text.trim() !== '').sort((a, b) => Math.abs(a.y - b.y) <= SAME_BASELINE * Math.max(a.height, b.height, 1) ? a.x - b.x : b.y - a.y);
2156
+ const cells = [];
2157
+ for (const run of ordered) {
2158
+ const previous = lastOf(cells, cell => Math.abs(cell.y - run.y) <= SAME_BASELINE * Math.max(cell.height, run.height, 1));
2159
+ const size = Math.max(run.height, previous?.height ?? 0, 1);
2160
+ const gap = previous === undefined ? Infinity : run.x - (previous.x + previous.width);
2161
+ if (previous !== undefined && gap <= JOIN_GAP * size && gap > -size) {
2162
+ const space = gap > SPACE_GAP * size && !/\s$/.test(previous.text) && !/^\s/.test(run.text) ? ' ' : '';
2163
+ previous.text += space + run.text;
2164
+ previous.width = run.x + run.width - previous.x;
2165
+ previous.height = Math.max(previous.height, run.height);
2166
+ } else {
2167
+ cells.push({
2168
+ ...run
2169
+ });
2170
+ }
2171
+ }
2172
+ return cells.map(cell => ({
2173
+ ...cell,
2174
+ text: cell.text.trim()
2175
+ })).filter(cell => cell.text !== '');
2176
+ }
2177
+ function rowOf(cells) {
2178
+ const ordered = [...cells].sort((a, b) => a.x - b.x || b.y - a.y);
2179
+ return {
2180
+ top: Math.max(...ordered.map(cell => cell.y + cell.height)),
2181
+ bottom: Math.min(...ordered.map(cell => cell.y)),
2182
+ cells: ordered,
2183
+ text: ordered.map(cell => cell.text).join('\t')
2184
+ };
2185
+ }
2186
+ function middle(cell) {
2187
+ return cell.y + cell.height / 2;
2188
+ }
2189
+ /** `Array#findLast`, which the es2022 library does not declare. */
2190
+ function lastOf(items, test) {
2191
+ for (let index = items.length - 1; index >= 0; index -= 1) if (test(items[index])) return items[index];
2192
+ return undefined;
2193
+ }
2194
+
2195
+ /** A PDF that cannot be read: not a PDF, encrypted, or with no text to read. */
2196
+ class PdfReadError extends Error {
2197
+ name = 'PdfReadError';
2198
+ }
2199
+ /**
2200
+ * Reads a PDF's text layer into rows of positioned cells, with pdf.js. pdf.js
2201
+ * is imported on first use, so recipes that never read a PDF never load it.
2202
+ * Only text is read: no page is rendered, no script runs, no font is loaded.
2203
+ *
2204
+ * @param bytes - The file.
2205
+ * @param source - Where it came from, for messages.
2206
+ * @returns The document.
2207
+ * @throws PdfReadError when the bytes are not a readable PDF, or no page has a
2208
+ * text layer (a scan: OCR is not supported).
2209
+ */
2210
+ async function readPdf(bytes, source = 'PDF') {
2211
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
2212
+ // pdf.js takes ownership of the buffer it is given, and refuses a Node
2213
+ // Buffer: hand it a plain copy.
2214
+ const task = pdfjs.getDocument({
2215
+ data: new Uint8Array(bytes),
2216
+ verbosity: 0,
2217
+ disableFontFace: true,
2218
+ useSystemFonts: false,
2219
+ stopAtErrors: true
2220
+ });
2221
+ let loaded;
2222
+ try {
2223
+ loaded = await task.promise;
2224
+ } catch (error) {
2225
+ throw new PdfReadError(`${source}: not a readable PDF (${error.message})`, {
2226
+ cause: error
2227
+ });
2228
+ }
2229
+ try {
2230
+ const pages = [];
2231
+ for (let number = 1; number <= loaded.numPages; number += 1) {
2232
+ const page = await loaded.getPage(number);
2233
+ const {
2234
+ width,
2235
+ height
2236
+ } = page.getViewport({
2237
+ scale: 1
2238
+ });
2239
+ const content = await page.getTextContent();
2240
+ const runs = content.items.flatMap(item => 'str' in item ? [runOf(item)] : []);
2241
+ pages.push({
2242
+ number,
2243
+ width,
2244
+ height,
2245
+ rows: assembleRows(runs)
2246
+ });
2247
+ }
2248
+ if (pages.every(page => page.rows.length === 0)) throw new PdfReadError(`${source}: no page has a text layer (a scanned PDF? OCR is not supported)`);
2249
+ return {
2250
+ kind: 'pdf',
2251
+ pages
2252
+ };
2253
+ } finally {
2254
+ await task.destroy();
2255
+ }
2256
+ }
2257
+ function runOf(item) {
2258
+ const [a, b, c, d, x, y] = item.transform;
2259
+ return {
2260
+ x,
2261
+ y,
2262
+ width: item.width,
2263
+ height: item.height > 0 ? item.height : Math.hypot(c, d) || Math.hypot(a, b),
2264
+ text: item.str
2265
+ };
2266
+ }
2267
+
2268
+ /**
2269
+ * The text a `regex` extract reads: one line per row (cells separated by a
2270
+ * tab), pages separated by a blank line.
2271
+ *
2272
+ * @param document - The PDF.
2273
+ * @returns The text.
2274
+ */
2275
+ function pdfText(document) {
2276
+ return document.pages.map(page => page.rows.map(row => row.text).join('\n')).join('\n\n');
2277
+ }
2278
+ /**
2279
+ * Whether a value bound in scope is a read PDF (so `extract … from` can take it).
2280
+ *
2281
+ * @param value - Anything.
2282
+ * @returns Whether it is a {@link PdfDocument}.
2283
+ */
2284
+ function isPdfDocument(value) {
2285
+ return typeof value === 'object' && value !== null && value.kind === 'pdf' && Array.isArray(value.pages);
2286
+ }
2287
+
2288
+ /**
2289
+ * Finds every table whose header row matches, and reads its rows by column.
2290
+ *
2291
+ * Columns come from the body, not the header: a header is often centred over
2292
+ * a column whose cells are left-aligned, so the header's position says little
2293
+ * about where the column starts. The left edges of the body cells cluster into
2294
+ * bands; each band belongs to the header cell that overlaps it most, or the
2295
+ * nearest one when none does. A header spanning two columns reads both.
2296
+ *
2297
+ * A cell wrapped over several lines (a long name, a note, a list of versions)
2298
+ * spreads one row over several lines, its values often centred beside it:
2299
+ * the lines are regrouped into rows around the lines that carry values.
2300
+ *
2301
+ * @param document - The PDF.
2302
+ * @param query - Which tables, and how to name their columns.
2303
+ * @returns The tables, in page order.
2304
+ */
2305
+ function findTables(document, query) {
2306
+ const tables = [];
2307
+ for (const page of document.pages) {
2308
+ const starts = page.rows.flatMap((row, index) => query.header.test(plain(row)) ? [index] : []);
2309
+ for (const [position, start] of starts.entries()) {
2310
+ const body = bodyOf(page.rows.slice(start + 1, starts[position + 1] ?? page.rows.length), query.until);
2311
+ tables.push(readTable(page.number, page.rows[start], body, query));
2312
+ }
2313
+ }
2314
+ return tables;
2315
+ }
2316
+ /** The rows under a header, up to the first one `until` matches. */
2317
+ function bodyOf(rows, until) {
2318
+ const end = until === undefined ? -1 : rows.findIndex(row => until.test(plain(row)));
2319
+ return end === -1 ? [...rows] : rows.slice(0, end);
2320
+ }
2321
+ function readTable(page, headerRow, body, query) {
2322
+ const headers = headerCells(headerRow);
2323
+ const bands = bandsOf(body, headers);
2324
+ const lines = body.map(row => ({
2325
+ row,
2326
+ values: valuesOf(row, bands, headers.length)
2327
+ }));
2328
+ const groups = groupLines(lines, query.align ?? 'auto');
2329
+ return {
2330
+ page,
2331
+ title: headers[0]?.text ?? '',
2332
+ header: headers.map(header => header.text),
2333
+ rows: groups.map(group => named(joinLines(group, headers.length), headers, query.columns))
2334
+ };
2335
+ }
2336
+ /**
2337
+ * Groups the body's lines into table rows. A line anchors a row when it has a
2338
+ * name (the first column) and a value, or, without a name, a value in the
2339
+ * first value column: the middle line of a name wrapped over several lines,
2340
+ * the values centred beside it. Every other line (a wrapped name, a wrapped
2341
+ * note, a list of versions over two lines) joins an anchor:
2342
+ *
2343
+ * - `top`: the nearest anchor above it; `bottom`: the nearest below;
2344
+ * - `center`: a wrapped name splits evenly around its anchor (as many lines
2345
+ * below as above), and other lines join the nearest anchor;
2346
+ * - `auto`: `center` when some line carries values but no name (only a
2347
+ * centred table does that), else the nearest anchor, a tie going to the one
2348
+ * below (a wrapped cell's first line comes before its row).
2349
+ */
2350
+ function groupLines(lines, align) {
2351
+ const named = line => line.values[0] !== '';
2352
+ const valued = line => line.values.slice(1).some(value => value !== '');
2353
+ let anchors = lines.filter(line => named(line) && valued(line) || !named(line) && (line.values[1] ?? '') !== '');
2354
+ if (anchors.length === 0) anchors = lines.filter(line => named(line));
2355
+ if (anchors.length === 0) return [];
2356
+ const groups = new Map(anchors.map(anchor => [anchor, [anchor]]));
2357
+ const centred = align === 'center' || align === 'auto' && anchors.some(anchor => !named(anchor));
2358
+ if (centred) splitNamesEvenly(lines, anchors, named, groups);
2359
+ const placed = new Set();
2360
+ for (const members of groups.values()) for (const line of members) placed.add(line);
2361
+ for (const line of lines) if (!placed.has(line)) groups.get(ownerOf(line, anchors, align))?.push(line);
2362
+ // An anchor that ended up with no name at all is a value spilling out of the row next to it.
2363
+ const nameless = anchors.filter(anchor => (groups.get(anchor) ?? []).every(line => !named(line)));
2364
+ const kept = anchors.filter(anchor => !nameless.includes(anchor));
2365
+ if (kept.length === 0) return [];
2366
+ for (const anchor of nameless) groups.get(nearest(anchor, kept))?.push(...(groups.get(anchor) ?? []));
2367
+ return kept.map(anchor => groups.get(anchor) ?? []);
2368
+ }
2369
+ /**
2370
+ * Hands the wrapped name lines between two anchors out evenly: the upper
2371
+ * anchor takes as many lines below it as it took above it, the lower one the
2372
+ * rest. Lines are in page order, top to bottom.
2373
+ */
2374
+ function splitNamesEvenly(lines, anchors, named, groups) {
2375
+ const positions = anchors.map(anchor => lines.indexOf(anchor));
2376
+ const namesBetween = (from, to) => lines.slice(from, to).filter(line => named(line) && !groups.has(line));
2377
+ let above = namesBetween(0, positions[0]);
2378
+ groups.get(anchors[0])?.push(...above);
2379
+ for (const [index, anchor] of anchors.entries()) {
2380
+ const next = anchors[index + 1];
2381
+ const run = namesBetween(positions[index] + 1, next === undefined ? lines.length : positions[index + 1]);
2382
+ const taken = next === undefined ? run.length : Math.min(run.length, above.length);
2383
+ groups.get(anchor)?.push(...run.slice(0, taken));
2384
+ above = run.slice(taken);
2385
+ if (next !== undefined) groups.get(next)?.push(...above);
2386
+ }
2387
+ }
2388
+ function ownerOf(line, anchors, align) {
2389
+ if (align === 'top') return lastOf(anchors, anchor => anchor.row.bottom >= line.row.bottom) ?? anchors[0];
2390
+ if (align === 'bottom') return anchors.find(anchor => anchor.row.bottom <= line.row.bottom) ?? anchors.at(-1) ?? anchors[0];
2391
+ return nearest(line, anchors);
2392
+ }
2393
+ /** A row's text per column: its lines top to bottom, each column's pieces joined by spaces. */
2394
+ function joinLines(group, width) {
2395
+ const ordered = [...group].sort((a, b) => b.row.top - a.row.top);
2396
+ return Array.from({
2397
+ length: width
2398
+ }, (_, column) => ordered.map(line => line.values[column]).filter(value => value !== '').join(' '));
2399
+ }
2400
+ /** Header cells that overlap horizontally (a header on two lines) are one header. */
2401
+ function headerCells(row) {
2402
+ const merged = [];
2403
+ for (const cell of row.cells) {
2404
+ const previous = merged.at(-1);
2405
+ if (previous !== undefined && cell.x < previous.x + previous.width) {
2406
+ previous.text = `${previous.text} ${cell.text}`;
2407
+ previous.width = Math.max(previous.x + previous.width, cell.x + cell.width) - previous.x;
2408
+ } else {
2409
+ merged.push({
2410
+ ...cell
2411
+ });
2412
+ }
2413
+ }
2414
+ return merged;
2415
+ }
2416
+ function bandsOf(body, headers) {
2417
+ const cells = body.flatMap(row => row.cells);
2418
+ if (cells.length === 0 || headers.length === 0) return [];
2419
+ const tolerance = Math.max(3, median(cells.map(cell => cell.height)) * 0.6);
2420
+ const edges = cells.map(cell => cell.x).sort((a, b) => a - b);
2421
+ const starts = [];
2422
+ for (const edge of edges) if (starts.length === 0 || edge - (starts.at(-1) ?? 0) > tolerance) starts.push(edge);
2423
+ const spans = starts.map((start, index) => {
2424
+ const next = starts[index + 1] ?? Infinity;
2425
+ // A band spans what its cells cover, not the gap up to the next band.
2426
+ const right = Math.max(...cells.filter(cell => cell.x >= start - 0.5 && cell.x < next - 0.5).map(cell => cell.x + cell.width));
2427
+ return {
2428
+ start,
2429
+ end: Math.min(next, right)
2430
+ };
2431
+ });
2432
+ const columns = assignColumns(spans, headers);
2433
+ return spans.map((span, index) => ({
2434
+ start: span.start,
2435
+ column: columns[index]
2436
+ }));
2437
+ }
2438
+ /**
2439
+ * Maps bands to headers, left to right: columns never cross, so the mapping
2440
+ * is monotone. Among monotone mappings it first uses as many headers as it
2441
+ * can (a table with as many bands as headers maps one to one), then prefers
2442
+ * the one where bands overlap their header most, or sit nearest to it. The
2443
+ * leftover choice is which neighbouring bands share a header: one header
2444
+ * over two columns, or a column whose cells start at two edges.
2445
+ *
2446
+ * @param spans - The bands, left to right.
2447
+ * @param headers - The header cells, left to right.
2448
+ * @returns The header index of each band.
2449
+ */
2450
+ function assignColumns(spans, headers) {
2451
+ const affinity = (span, header) => {
2452
+ const overlap = overlapOf(header, span);
2453
+ return overlap > 0 ? overlap : -Math.max(0, header.x - span.end, span.start - (header.x + header.width));
2454
+ };
2455
+ const better = (a, b) => b === undefined || a.used > b.used || a.used === b.used && a.affinity > b.affinity;
2456
+ const table = [headers.map(header => ({
2457
+ used: 1,
2458
+ affinity: affinity(spans[0], header),
2459
+ previous: -1
2460
+ }))];
2461
+ for (const span of spans.slice(1)) {
2462
+ const last = table.at(-1) ?? [];
2463
+ table.push(headers.map((header, column) => {
2464
+ let best;
2465
+ for (let from = 0; from <= column; from += 1) {
2466
+ const candidate = {
2467
+ used: last[from].used + (from === column ? 0 : 1),
2468
+ affinity: last[from].affinity + affinity(span, header),
2469
+ previous: from
2470
+ };
2471
+ if (better(candidate, best)) best = candidate;
2472
+ }
2473
+ return best ?? {
2474
+ used: 0,
2475
+ affinity: -Infinity,
2476
+ previous: 0
2477
+ };
2478
+ }));
2479
+ }
2480
+ const last = table.at(-1) ?? [];
2481
+ let column = last.reduce((best, score, index) => better(score, last[best]) ? index : best, 0);
2482
+ const columns = [];
2483
+ for (let index = table.length - 1; index >= 0; index -= 1) {
2484
+ columns.unshift(column);
2485
+ column = table[index][column].previous;
2486
+ }
2487
+ return columns;
2488
+ }
2489
+ function valuesOf(row, bands, width) {
2490
+ const values = Array.from({
2491
+ length: width
2492
+ }, () => '');
2493
+ const ordered = [...row.cells].sort((a, b) => b.y - a.y || a.x - b.x);
2494
+ for (const cell of ordered) {
2495
+ const band = lastOf(bands, candidate => candidate.start <= cell.x + 0.5) ?? bands[0];
2496
+ if (band === undefined) continue;
2497
+ values[band.column] = joinText(values[band.column], cell.text);
2498
+ }
2499
+ return values;
2500
+ }
2501
+ function named(values, headers, columns) {
2502
+ if (columns === undefined) return Object.fromEntries(headers.map((header, index) => [header.text, values[index]]));
2503
+ const record = {};
2504
+ for (const [key, pattern] of Object.entries(columns)) {
2505
+ const index = headers.findIndex(header => pattern.test(header.text));
2506
+ if (index !== -1) record[key] = values[index];
2507
+ }
2508
+ return record;
2509
+ }
2510
+ /** Distances closer than this, in points, are a tie. */
2511
+ const TIE = 1;
2512
+ /**
2513
+ * The anchor a line belongs to: the nearest by vertical gap. On a tie (evenly
2514
+ * spaced lines) the anchor below wins: text reads top down, so a wrapped
2515
+ * cell's first line comes before the row it belongs to.
2516
+ */
2517
+ function nearest(line, candidates) {
2518
+ const row = line.row;
2519
+ const gap = candidate => Math.max(0, candidate.bottom - row.top, row.bottom - candidate.top);
2520
+ const [first, ...rest] = candidates;
2521
+ if (first === undefined) throw new Error('no row to attach a line to');
2522
+ let best = first;
2523
+ for (const candidate of rest) {
2524
+ const difference = gap(candidate.row) - gap(best.row);
2525
+ if (difference < -TIE || Math.abs(difference) <= TIE && candidate.row.bottom < best.row.bottom) best = candidate;
2526
+ }
2527
+ return best;
2528
+ }
2529
+ function overlapOf(header, band) {
2530
+ return Math.max(0, Math.min(header.x + header.width, band.end) - Math.max(header.x, band.start));
2531
+ }
2532
+ function plain(row) {
2533
+ return row.cells.map(cell => cell.text).join(' ');
2534
+ }
2535
+ function joinText(first, second) {
2536
+ return first === '' ? second : second === '' ? first : `${first} ${second}`;
2537
+ }
2538
+ function median(values) {
2539
+ const ordered = [...values].sort((a, b) => a - b);
2540
+ return ordered[Math.floor(ordered.length / 2)] ?? 0;
2541
+ }
2542
+
2109
2543
  /**
2110
2544
  * Runs an `extract` step against a static document: the value bound under
2111
2545
  * `from`, else the scope's current document. `css` reads HTML, `jsonpath`
2112
- * reads JSON; `xpath` needs a live page and is refused here.
2546
+ * reads JSON (or a read PDF's rows), `table` reads a PDF's tables, `regex`
2547
+ * reads any document as text; `xpath` needs a live page and is refused here.
2113
2548
  *
2114
2549
  * A `jsonpath` extract whose `from` is text parses that text as JSON, and a
2115
2550
  * list of texts (every `<script type="application/ld+json">` of a page) becomes
@@ -2128,8 +2563,14 @@ function extractFromDocument(step, scope) {
2128
2563
  switch (step.kind) {
2129
2564
  case 'jsonpath':
2130
2565
  {
2131
- if (document.kind !== 'json') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
2132
- values = selectJson(document.data, selector).map(node => takeFromJson(node, take));
2566
+ if (document.kind !== 'json' && document.kind !== 'pdf') throw new Error(`jsonpath needs a JSON document; the current document is ${document.kind}`);
2567
+ values = selectJson(document.kind === 'pdf' ? document : document.data, selector).map(node => takeFromJson(node, take));
2568
+ break;
2569
+ }
2570
+ case 'table':
2571
+ {
2572
+ if (document.kind !== 'pdf') throw new Error(`table reads a PDF; the current document is ${document.kind} (request it with "as": "pdf")`);
2573
+ values = findTables(document, tableQuery(step, selector));
2133
2574
  break;
2134
2575
  }
2135
2576
  case 'css':
@@ -2166,10 +2607,34 @@ function extractFromDocument(step, scope) {
2166
2607
  function renderSelector(selector, scope) {
2167
2608
  return hasPlaceholder(selector) ? renderText(selector, path => scope.lookup(path)) : selector;
2168
2609
  }
2169
- /** The text a regex extract reads: markup, text, or JSON re-serialised (a list of texts joined by newlines). */
2610
+ /**
2611
+ * A table extract's query: the selector matches the header row, the other
2612
+ * patterns come from the step; all case-insensitive, since PDFs capitalise
2613
+ * headings freely.
2614
+ */
2615
+ function tableQuery(step, selector) {
2616
+ const columns = step.columns === undefined ? undefined : Object.fromEntries(Object.entries(step.columns).map(([key, pattern]) => [key, patternOf(pattern, `columns.${key}`)]));
2617
+ return {
2618
+ header: patternOf(selector, 'selector'),
2619
+ until: step.until === undefined ? undefined : patternOf(step.until, 'until'),
2620
+ columns,
2621
+ align: step.align
2622
+ };
2623
+ }
2624
+ function patternOf(source, where) {
2625
+ try {
2626
+ return new RegExp(source, 'i');
2627
+ } catch (error) {
2628
+ throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
2629
+ cause: error
2630
+ });
2631
+ }
2632
+ }
2633
+ /** The text a regex extract reads: markup, text, a PDF's rows, or JSON re-serialised (a list of texts joined by newlines). */
2170
2634
  function textOf$1(document) {
2171
2635
  if (document.kind === 'html') return document.html;
2172
2636
  if (document.kind === 'text') return document.text;
2637
+ if (document.kind === 'pdf') return pdfText(document);
2173
2638
  if (Array.isArray(document.data) && document.data.every(entry => typeof entry === 'string')) return document.data.join('\n');
2174
2639
  return typeof document.data === 'string' ? document.data : JSON.stringify(document.data);
2175
2640
  }
@@ -2181,6 +2646,8 @@ function documentFor(step, scope) {
2181
2646
  }
2182
2647
  const source = scope.get(step.from);
2183
2648
  if (source === undefined) throw new Error(`"${step.from}" is not bound`);
2649
+ if (isPdfDocument(source)) return source;
2650
+ if (step.kind === 'table') throw new Error(`"${step.from}" is not a PDF; request it with "as": "pdf"`);
2184
2651
  if (step.kind === 'regex') {
2185
2652
  if (typeof source === 'string') return {
2186
2653
  kind: 'text',
@@ -2263,6 +2730,7 @@ class HttpClient {
2263
2730
  * @throws HttpError for a 4xx or 5xx status.
2264
2731
  */
2265
2732
  async send(httpRequest) {
2733
+ if (httpRequest.url.startsWith('file:')) return readLocalFile(httpRequest);
2266
2734
  const response = await this.context.fetch(httpRequest.url, {
2267
2735
  method: httpRequest.method ?? (httpRequest.body === undefined ? 'GET' : 'POST'),
2268
2736
  params: httpRequest.query,
@@ -2290,7 +2758,25 @@ class HttpClient {
2290
2758
  }
2291
2759
  async function readBody(response, as) {
2292
2760
  const kind = as ?? kindFromContentType(response.headers()['content-type'] ?? '');
2293
- const text = await response.text();
2761
+ return parseBody(kind, await response.body(), response.url());
2762
+ }
2763
+ /**
2764
+ * A `file:` URL, read from disk: a PDF or JSON a recipe gets from a folder
2765
+ * instead of a server. The kind is `as`, else the file extension.
2766
+ */
2767
+ async function readLocalFile(httpRequest) {
2768
+ const path = fileURLToPath(httpRequest.url);
2769
+ const bytes = await readFile(path);
2770
+ return {
2771
+ status: 200,
2772
+ url: httpRequest.url,
2773
+ headers: {},
2774
+ body: await parseBody(httpRequest.as ?? kindFromExtension(extname(path)), bytes, httpRequest.url)
2775
+ };
2776
+ }
2777
+ async function parseBody(kind, bytes, url) {
2778
+ if (kind === 'pdf') return readPdf(bytes, url);
2779
+ const text = new TextDecoder().decode(bytes);
2294
2780
  if (kind === 'json') {
2295
2781
  try {
2296
2782
  return {
@@ -2298,7 +2784,7 @@ async function readBody(response, as) {
2298
2784
  data: JSON.parse(text)
2299
2785
  };
2300
2786
  } catch (error) {
2301
- throw new Error(`${response.url()}: body is not JSON (${error.message})`, {
2787
+ throw new Error(`${url}: body is not JSON (${error.message})`, {
2302
2788
  cause: error
2303
2789
  });
2304
2790
  }
@@ -2314,9 +2800,20 @@ async function readBody(response, as) {
2314
2800
  function kindFromContentType(contentType) {
2315
2801
  const type = contentType.toLowerCase();
2316
2802
  if (type.includes('json')) return 'json';
2803
+ if (type.includes('pdf')) return 'pdf';
2317
2804
  if (type.includes('html') || type.includes('xml')) return 'html';
2318
2805
  return 'text';
2319
2806
  }
2807
+ function kindFromExtension(extension) {
2808
+ const kinds = {
2809
+ '.json': 'json',
2810
+ '.pdf': 'pdf',
2811
+ '.html': 'html',
2812
+ '.htm': 'html',
2813
+ '.xml': 'html'
2814
+ };
2815
+ return kinds[extension.toLowerCase()] ?? 'text';
2816
+ }
2320
2817
 
2321
2818
  /**
2322
2819
  * Sends a `request` step: renders its templates, waits for the gate's throttle,
@@ -2384,6 +2881,7 @@ async function sendRequest(step, scope, client, recipe, gate, events) {
2384
2881
  }
2385
2882
  function bodyText(body) {
2386
2883
  if (body.kind === 'json') return JSON.stringify(body.data);
2884
+ if (body.kind === 'pdf') return pdfText(body);
2387
2885
  return body.kind === 'html' ? body.html : body.text;
2388
2886
  }
2389
2887
  function renderMap(map, lookup) {
@@ -2404,9 +2902,10 @@ function resolveUrl(target, base) {
2404
2902
  throw new Error(`"${target}" is not a URL${base === undefined || base === '' ? ' and no page is known to resolve it against' : ` and cannot be resolved against ${base}`}`);
2405
2903
  }
2406
2904
  }
2407
- /** What a step id holds for a document: parsed JSON, or the markup / text. */
2905
+ /** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
2408
2906
  function documentValue(body) {
2409
2907
  if (body.kind === 'json') return body.data;
2908
+ if (body.kind === 'pdf') return body;
2410
2909
  return body.kind === 'html' ? body.html : body.text;
2411
2910
  }
2412
2911
 
@@ -3489,6 +3988,7 @@ async function extractFromPage(step, page, scope) {
3489
3988
  extractFromDocument(step, scope);
3490
3989
  return;
3491
3990
  }
3991
+ if (step.kind === 'table') throw new Error('table reads a PDF: fetch it with a request step in api mode, or extract "from" a PDF bound earlier');
3492
3992
  const rendered = renderSelector(step.selector, scope);
3493
3993
  const take = step.take ?? 'text';
3494
3994
  const raw = step.kind === 'regex' ? selectRegex(await page.content(), rendered) : await page.locator(step.kind === 'xpath' ? `xpath=${rendered}` : rendered).evaluateAll(readAll, take);
@@ -4051,6 +4551,7 @@ async function runInputRecipe(input, output, deps) {
4051
4551
  rejected: 0,
4052
4552
  duplicates: 0,
4053
4553
  skipped: 0,
4554
+ stepsSkipped: 0,
4054
4555
  pages: 0,
4055
4556
  durationMs: 0
4056
4557
  };
@@ -4061,6 +4562,7 @@ async function runInputRecipe(input, output, deps) {
4061
4562
  let chain = Promise.resolve();
4062
4563
  const unsubscribe = deps.events.subscribe(event => {
4063
4564
  if (event.type === 'page:visit' && event.recipeId === input.id) report.pages += 1;
4565
+ if (event.type === 'step:skip' && event.recipeId === input.id) report.stepsSkipped += 1;
4064
4566
  });
4065
4567
  deps.events.emit({
4066
4568
  type: 'recipe:start',
@@ -4118,6 +4620,7 @@ async function runInputRecipe(input, output, deps) {
4118
4620
  rejected: report.rejected,
4119
4621
  duplicates: report.duplicates,
4120
4622
  skipped: report.skipped,
4623
+ stepsSkipped: report.stepsSkipped,
4121
4624
  pages: report.pages,
4122
4625
  durationMs: report.durationMs,
4123
4626
  error: report.error
@@ -4371,10 +4874,12 @@ function createCrawler(options = {}) {
4371
4874
 
4372
4875
  /** The closed vocabularies a recipe file can use. Each is a `readonly` tuple so zod and TypeScript share it. */
4373
4876
  const CRAWL_MODES = ['web', 'api'];
4374
- const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex'];
4877
+ const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table'];
4375
4878
  /** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
4376
4879
  const TAKE_KINDS = ['text', 'html', 'value', 'json'];
4377
- const BODY_KINDS = ['json', 'html', 'text'];
4880
+ const BODY_KINDS = ['json', 'html', 'text', 'pdf'];
4881
+ /** How a PDF table aligns a row's values against a cell wrapped over several lines. */
4882
+ const TABLE_ALIGNS = ['auto', 'top', 'center', 'bottom'];
4378
4883
  const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'date', 'datetime', 'currency', 'url', 'enum', 'array', 'object', 'json'];
4379
4884
  const MISSING_POLICIES = ['fail', 'skip-record', 'null', 'default'];
4380
4885
  const RECIPE_MISSING_POLICIES = ['fail', 'skip-record', 'null'];
@@ -4551,6 +5056,7 @@ const requestStep = z.strictObject({
4551
5056
  body: z.unknown().optional(),
4552
5057
  as: z.enum(BODY_KINDS).optional()
4553
5058
  });
5059
+ const tableOnly = ['columns', 'until', 'align'];
4554
5060
  const extractStep = z.strictObject({
4555
5061
  ...base,
4556
5062
  type: z.literal('extract'),
@@ -4558,7 +5064,20 @@ const extractStep = z.strictObject({
4558
5064
  kind: z.enum(SELECTOR_KINDS),
4559
5065
  take: takeKindSchema.optional(),
4560
5066
  many: z.boolean().optional(),
4561
- from: stepId.optional()
5067
+ from: stepId.optional(),
5068
+ columns: stringMap.optional(),
5069
+ until: z.string().min(1).optional(),
5070
+ align: z.enum(TABLE_ALIGNS).optional()
5071
+ }).check(context => {
5072
+ if (context.value.kind === 'table') return;
5073
+ for (const key of tableOnly) {
5074
+ if (context.value[key] !== undefined) context.issues.push({
5075
+ code: 'custom',
5076
+ input: context.value,
5077
+ path: [key],
5078
+ message: `"${key}" belongs to kind "table"`
5079
+ });
5080
+ }
4562
5081
  });
4563
5082
  const assignStep = z.strictObject({
4564
5083
  ...base,
@@ -5136,6 +5655,7 @@ function walkStep(step, at, mode, known, report, state) {
5136
5655
  known.add(step.id);
5137
5656
  }
5138
5657
  if (step.type === 'extract' && step.from !== undefined && !known.has(step.from)) report(`${at}.from`, `"${step.from}" is not a known id`);
5658
+ if (mode === 'web' && state.bootstrap !== true && step.type === 'extract' && step.kind === 'table' && step.from === undefined) report(at, 'a "table" extract reads a PDF, which a web page is not: fetch the PDF with a request step in an api recipe, or extract "from" a PDF bound earlier');
5139
5659
  if (step.type === 'collect' && !known.has(step.into)) report(`${at}.into`, `"${step.into}" is not a known id: set it to [] before the loop that collects into it`);
5140
5660
  const nested = () => ({
5141
5661
  ...state,
@@ -5298,5 +5818,5 @@ function isSameOutput(document, output) {
5298
5818
  return recipeKindOf(document.content) === 'output' && document.content.id === output.id;
5299
5819
  }
5300
5820
 
5301
- export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, readRecipeSource, traceLine, tryParseJson, validateBinding };
5821
+ export { ACCESS_PRESETS, AccessBroker, AccessConfigError, BrowserClient, BrowserSession, HttpClient, HttpError, MappingFailedError, PdfReadError, RecipeBindingError, RecipeSet, RecipeValidationError, RecordRejectedError, StepFailure, TransformError, UnknownHookError, accessConfigJsonSchema, accessConfigSchema, bindRecipeSet, createCrawler, findTables, inputRecipeJsonSchema, inputRecipeSchema, jsonLinesSink, loadAccessConfig, loadRecipeSet, loadRecipes, memorySink, outputRecipeJsonSchema, outputRecipeSchema, parseInputRecipe, parseOutputRecipe, pdfText, readPdf, readRecipeSource, traceLine, tryParseJson, validateBinding };
5302
5822
  //# sourceMappingURL=index.esm.js.map
@@ -3,7 +3,8 @@ import type { ExtractStep } from '../recipe-schema/index.js';
3
3
  /**
4
4
  * Runs an `extract` step against a static document: the value bound under
5
5
  * `from`, else the scope's current document. `css` reads HTML, `jsonpath`
6
- * reads JSON; `xpath` needs a live page and is refused here.
6
+ * reads JSON (or a read PDF's rows), `table` reads a PDF's tables, `regex`
7
+ * reads any document as text; `xpath` needs a live page and is refused here.
7
8
  *
8
9
  * A `jsonpath` extract whose `from` is text parses that text as JSON, and a
9
10
  * list of texts (every `<script type="application/ld+json">` of a page) becomes
@@ -17,6 +17,6 @@ import type { RunGate } from '../step-flow/index.js';
17
17
  * @throws BlockedError when the response is a block; HttpError for any other 4xx/5xx.
18
18
  */
19
19
  export declare function sendRequest(step: RequestStep, scope: ExtractionScope, client: HttpSender, recipe: InputRecipe, gate: RunGate, events: EventBus): Promise<void>;
20
- /** What a step id holds for a document: parsed JSON, or the markup / text. */
20
+ /** What a step id holds for a document: parsed JSON, the read PDF, or the markup / text. */
21
21
  export declare function documentValue(body: HttpBody): unknown;
22
22
  //# sourceMappingURL=send-request.use-case.d.ts.map
@@ -16,6 +16,7 @@ export type CrawlEvent = (Base & {
16
16
  rejected: number;
17
17
  duplicates: number;
18
18
  skipped: number;
19
+ stepsSkipped?: number;
19
20
  pages: number;
20
21
  durationMs: number;
21
22
  error?: string;
@@ -8,6 +8,8 @@ export interface RecipeReport {
8
8
  duplicates: number;
9
9
  /** Records a resumed run found in the sink already. */
10
10
  skipped: number;
11
+ /** Steps whose `onError: skip` policy swallowed a failure: a check that found nothing, a value a page lacked. */
12
+ stepsSkipped: number;
11
13
  pages: number;
12
14
  durationMs: number;
13
15
  /** Set when the recipe stopped on a failure. */
@@ -7,8 +7,9 @@
7
7
  * Page state (the current URL, page number and the document `extract` reads by
8
8
  * default) is scope state too, bound in the innermost scope that navigated.
9
9
  */
10
+ import type { PdfDocument } from '../pdf-document/index.js';
10
11
  /** A fetched or rendered document a later `extract` can read. */
11
- export type ScopeDocument = {
12
+ export type ScopeDocument = PdfDocument | {
12
13
  kind: 'json';
13
14
  data: unknown;
14
15
  } | {
@@ -1,3 +1,4 @@
1
+ import type { PdfDocument } from '../pdf-document/index.js';
1
2
  import type { BodyKind, HttpMethod } from '../recipe-schema/index.js';
2
3
  /** One HTTP request as the api runner sends it, templates already rendered. */
3
4
  export interface HttpRequest {
@@ -11,7 +12,7 @@ export interface HttpRequest {
11
12
  timeoutMs?: number;
12
13
  }
13
14
  /** A parsed response body. Structurally the same as a scope document, on purpose. */
14
- export type HttpBody = {
15
+ export type HttpBody = PdfDocument | {
15
16
  kind: 'json';
16
17
  data: unknown;
17
18
  } | {
@@ -19,6 +19,8 @@ export { BrowserClient, BrowserSession } from './browser-session/index.js';
19
19
  export { HttpClient, HttpError } from './http-session/index.js';
20
20
  export type { HttpClientOptions, HttpRequest, HttpResponse, HttpBody } from './http-session/index.js';
21
21
  export { tryParseJson } from './selection/index.js';
22
+ export { readPdf, PdfReadError, findTables, pdfText } from './pdf-document/index.js';
23
+ export type { PdfDocument, PdfPage, PdfRow, PdfCell, PdfTable, TableQuery, TableAlign } from './pdf-document/index.js';
22
24
  export { StepFailure } from './step-flow/index.js';
23
25
  export { TransformError } from './transformation/index.js';
24
26
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,7 @@
1
+ export { readPdf, PdfReadError } from './read-pdf.client.js';
2
+ export { pdfText, isPdfDocument } from './pdf-document.model.js';
3
+ export type { PdfDocument, PdfPage, PdfRow, PdfCell, PositionedText } from './pdf-document.model.js';
4
+ export { assembleRows } from './row-assembly.algorithm.js';
5
+ export { findTables } from './pdf-table.algorithm.js';
6
+ export type { PdfTable, TableQuery, TableAlign } from './pdf-table.algorithm.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,51 @@
1
+ /** A run of text at a position on a page, in PDF points, y growing upwards from the bottom edge. */
2
+ export interface PositionedText {
3
+ x: number;
4
+ /** The baseline. */
5
+ y: number;
6
+ width: number;
7
+ /** The font size: the text spans roughly `y` to `y + height`. */
8
+ height: number;
9
+ text: string;
10
+ }
11
+ /** One or more text runs that sit side by side on one baseline: a table cell, a label, a value. */
12
+ export type PdfCell = PositionedText;
13
+ /** Cells whose vertical extents overlap: one visual line, which in a table is one row. */
14
+ export interface PdfRow {
15
+ /** The top of the highest cell. */
16
+ top: number;
17
+ /** The baseline of the lowest cell. */
18
+ bottom: number;
19
+ /** The cells, left to right. */
20
+ cells: PdfCell[];
21
+ /** The cells' texts joined by a tab, left to right. */
22
+ text: string;
23
+ }
24
+ export interface PdfPage {
25
+ number: number;
26
+ width: number;
27
+ height: number;
28
+ /** Top to bottom. Empty on a page with no text layer (a scan). */
29
+ rows: PdfRow[];
30
+ }
31
+ /** A PDF read into rows of positioned text: what `extract` works on. */
32
+ export interface PdfDocument {
33
+ kind: 'pdf';
34
+ pages: PdfPage[];
35
+ }
36
+ /**
37
+ * The text a `regex` extract reads: one line per row (cells separated by a
38
+ * tab), pages separated by a blank line.
39
+ *
40
+ * @param document - The PDF.
41
+ * @returns The text.
42
+ */
43
+ export declare function pdfText(document: PdfDocument): string;
44
+ /**
45
+ * Whether a value bound in scope is a read PDF (so `extract … from` can take it).
46
+ *
47
+ * @param value - Anything.
48
+ * @returns Whether it is a {@link PdfDocument}.
49
+ */
50
+ export declare function isPdfDocument(value: unknown): value is PdfDocument;
51
+ //# sourceMappingURL=pdf-document.model.d.ts.map
@@ -0,0 +1,43 @@
1
+ import type { PdfDocument } from './pdf-document.model.js';
2
+ /** What a table extract looks for. */
3
+ export interface TableQuery {
4
+ /** Matches a table's header row, its cells joined by spaces (`^MODELLI`). */
5
+ header: RegExp;
6
+ /** Matches the row that ends a table (`^NOTA BENE`); a table also ends at the next header or the page's end. */
7
+ until?: RegExp;
8
+ /** Output key -> a pattern for the header cell of that column; unmatched columns are dropped. Without it, the header texts are the keys. */
9
+ columns?: Record<string, RegExp>;
10
+ /** How the table aligns a row's values against a cell wrapped over several lines; `auto` (the default) infers it. */
11
+ align?: TableAlign;
12
+ }
13
+ /** Where a row's values sit against a cell wrapped over several lines. */
14
+ export type TableAlign = 'auto' | 'top' | 'center' | 'bottom';
15
+ /** One table found in a PDF. */
16
+ export interface PdfTable {
17
+ page: number;
18
+ /** The first header cell: the table's name when it has one (`MODELLI FIAT`). */
19
+ title: string;
20
+ /** The header cells, left to right. */
21
+ header: string[];
22
+ /** One object per row, keyed by column; a cell's lines are joined by spaces. */
23
+ rows: Record<string, string>[];
24
+ }
25
+ /**
26
+ * Finds every table whose header row matches, and reads its rows by column.
27
+ *
28
+ * Columns come from the body, not the header: a header is often centred over
29
+ * a column whose cells are left-aligned, so the header's position says little
30
+ * about where the column starts. The left edges of the body cells cluster into
31
+ * bands; each band belongs to the header cell that overlaps it most, or the
32
+ * nearest one when none does. A header spanning two columns reads both.
33
+ *
34
+ * A cell wrapped over several lines (a long name, a note, a list of versions)
35
+ * spreads one row over several lines, its values often centred beside it:
36
+ * the lines are regrouped into rows around the lines that carry values.
37
+ *
38
+ * @param document - The PDF.
39
+ * @param query - Which tables, and how to name their columns.
40
+ * @returns The tables, in page order.
41
+ */
42
+ export declare function findTables(document: PdfDocument, query: TableQuery): PdfTable[];
43
+ //# sourceMappingURL=pdf-table.algorithm.d.ts.map
@@ -0,0 +1,18 @@
1
+ import type { PdfDocument } from './pdf-document.model.js';
2
+ /** A PDF that cannot be read: not a PDF, encrypted, or with no text to read. */
3
+ export declare class PdfReadError extends Error {
4
+ readonly name = "PdfReadError";
5
+ }
6
+ /**
7
+ * Reads a PDF's text layer into rows of positioned cells, with pdf.js. pdf.js
8
+ * is imported on first use, so recipes that never read a PDF never load it.
9
+ * Only text is read: no page is rendered, no script runs, no font is loaded.
10
+ *
11
+ * @param bytes - The file.
12
+ * @param source - Where it came from, for messages.
13
+ * @returns The document.
14
+ * @throws PdfReadError when the bytes are not a readable PDF, or no page has a
15
+ * text layer (a scan: OCR is not supported).
16
+ */
17
+ export declare function readPdf(bytes: Uint8Array, source?: string): Promise<PdfDocument>;
18
+ //# sourceMappingURL=read-pdf.client.d.ts.map
@@ -0,0 +1,17 @@
1
+ import type { PdfRow, PositionedText } from './pdf-document.model.js';
2
+ /**
3
+ * Turns a page's text runs into rows of cells, top to bottom.
4
+ *
5
+ * Runs on one baseline that nearly touch become one cell. Cells whose vertical
6
+ * extents overlap become one row, even when their baselines differ: a table
7
+ * that centres its cells vertically puts a one-line value a few points above
8
+ * or below its two-line label, and a row built from equal baselines would pair
9
+ * the value with the wrong label.
10
+ *
11
+ * @param runs - The page's text runs, in any order.
12
+ * @returns The rows.
13
+ */
14
+ export declare function assembleRows(runs: readonly PositionedText[]): PdfRow[];
15
+ /** `Array#findLast`, which the es2022 library does not declare. */
16
+ export declare function lastOf<T>(items: readonly T[], test: (item: T) => boolean): T | undefined;
17
+ //# sourceMappingURL=row-assembly.algorithm.d.ts.map
@@ -1,10 +1,12 @@
1
1
  /** The closed vocabularies a recipe file can use. Each is a `readonly` tuple so zod and TypeScript share it. */
2
2
  export declare const RECIPE_KINDS: readonly ["input", "output"];
3
3
  export declare const CRAWL_MODES: readonly ["web", "api"];
4
- export declare const SELECTOR_KINDS: readonly ["css", "xpath", "jsonpath", "regex"];
4
+ export declare const SELECTOR_KINDS: readonly ["css", "xpath", "jsonpath", "regex", "table"];
5
5
  /** `take` also accepts `attr:<name>`, which is validated by pattern rather than listed. */
6
6
  export declare const TAKE_KINDS: readonly ["text", "html", "value", "json"];
7
- export declare const BODY_KINDS: readonly ["json", "html", "text"];
7
+ export declare const BODY_KINDS: readonly ["json", "html", "text", "pdf"];
8
+ /** How a PDF table aligns a row's values against a cell wrapped over several lines. */
9
+ export declare const TABLE_ALIGNS: readonly ["auto", "top", "center", "bottom"];
8
10
  export declare const FIELD_TYPES: readonly ["string", "number", "integer", "boolean", "date", "datetime", "currency", "url", "enum", "array", "object", "json"];
9
11
  export declare const MISSING_POLICIES: readonly ["fail", "skip-record", "null", "default"];
10
12
  export declare const RECIPE_MISSING_POLICIES: readonly ["fail", "skip-record", "null"];
@@ -21,6 +23,7 @@ export type RecipeKind = typeof RECIPE_KINDS[number];
21
23
  export type CrawlMode = typeof CRAWL_MODES[number];
22
24
  export type SelectorKind = typeof SELECTOR_KINDS[number];
23
25
  export type BodyKind = typeof BODY_KINDS[number];
26
+ export type TableAlign = typeof TABLE_ALIGNS[number];
24
27
  export type FieldType = typeof FIELD_TYPES[number];
25
28
  export type MissingPolicy = typeof MISSING_POLICIES[number];
26
29
  export type RecipeMissingPolicy = typeof RECIPE_MISSING_POLICIES[number];
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { TAKE_KINDS } from './recipe-kind.enum.js';
3
- import type { BodyKind, HttpMethod, SelectorKind, WaitUntil } from './recipe-kind.enum.js';
3
+ import type { BodyKind, HttpMethod, SelectorKind, TableAlign, WaitUntil } from './recipe-kind.enum.js';
4
4
  /** What to do when a step fails. Resolved step -> recipe -> `fail`. */
5
5
  export type ErrorPolicy = {
6
6
  policy: 'fail';
@@ -95,6 +95,12 @@ export interface ExtractStep extends StepBaseFields {
95
95
  many?: boolean;
96
96
  /** Id of a document or fragment to read instead of the current document. */
97
97
  from?: string;
98
+ /** `table` only: output key -> a pattern (case-insensitive) for that column's header cell. */
99
+ columns?: Record<string, string>;
100
+ /** `table` only: a pattern (case-insensitive) for the row that ends a table. */
101
+ until?: string;
102
+ /** `table` only: how a row's values sit against a cell wrapped over several lines; default `auto`. */
103
+ align?: TableAlign;
98
104
  }
99
105
  export interface SetStep extends StepBaseFields {
100
106
  type: 'set';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencraw/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.esm.js",
6
6
  "module": "./dist/index.esm.js",
@@ -23,6 +23,7 @@
23
23
  "cheerio": "^1.2.0",
24
24
  "domhandler": "^6.0.1",
25
25
  "jsonpath-plus": "^10.4.0",
26
+ "pdfjs-dist": "^6.3.289",
26
27
  "playwright": "^1.63.0",
27
28
  "zod": "^4.6.5"
28
29
  },