From 280c27c8abeecd1a44af3c5b4053a93f15e167c9 Mon Sep 17 00:00:00 2001 From: Eduardo Russo Date: Wed, 14 Oct 2026 12:12:51 +0100 Subject: [PATCH] feat(core): read PDF values by the label beside or above them with an anchor extract An anchor extract finds a cell by its text and reads the cell(s) to its right or the line(s) below it, so one input recipe reads every document of the same kind even when the values sit at other coordinates. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_014yC6LN4a8qnR7gu3QmRnbg --- docs/recipes/authoring.md | 10 +- .../core/schemas/input-recipe.schema.json | 3 +- .../extract-from-document.use-case.ts | 22 +++- packages/core/src/index.ts | 2 +- packages/core/src/pdf-document/index.ts | 2 + .../pdf-document/pdf-anchor.algorithm.test.ts | 79 ++++++++++++ .../src/pdf-document/pdf-anchor.algorithm.ts | 114 ++++++++++++++++++ .../src/recipe-schema/recipe-kind.enum.ts | 2 +- .../web-steps/extract-from-page.use-case.ts | 2 +- 9 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/pdf-document/pdf-anchor.algorithm.test.ts create mode 100644 packages/core/src/pdf-document/pdf-anchor.algorithm.ts diff --git a/docs/recipes/authoring.md b/docs/recipes/authoring.md index fd8fc35d..158227b2 100644 --- a/docs/recipes/authoring.md +++ b/docs/recipes/authoring.md @@ -630,15 +630,23 @@ repeating element (`table.credit_group tr`, not `tr`) and check the first record `request` with `as: "pdf"` (or a response served as `application/pdf`, or a local `file:…pdf`) reads the PDF's text layer with pdf.js into pages of **rows**: text that sits side by side becomes a cell, cells whose vertical extents overlap become a row. A scanned page has no text layer and gives no rows; a PDF where no page -has one fails the step (no OCR). Four extract kinds read it: +has one fails the step (no OCR). Five extract kinds read it: | `kind` | Reads | Use for | |---|---|---| | `table` | tables, found by their header row | price lists, discount sheets, spec tables | | `region` | the text inside a box on a page: `page=1 x=72..252 y=640..664` (points from the page's bottom-left corner; `page=*` for every page) | a title, a date, a total — anything that sits at the same place on every issue of a document; what the studio's PDF canvas writes when you click a line | +| `anchor` | the cell(s) right of, or the line(s) below, a label found by its text: `right of ^Valid until`, `below count=2 of ^Bill to` | a value that sits next to a label, in documents of one kind whose layout shifts: **prefer it to `region`** | | `regex` | the text: one line per row, cells separated by a tab, pages by a blank line | a value next to a label: `Valid until\t(\S+)` | | `jsonpath` | the structure: `{ kind: "pdf", pages: [{ number, width, height, rows: [{ text, top, bottom, cells: [{ x, y, width, height, text }] }] }] }` | positions, a given page | +An **anchor** finds the first cell whose text matches its pattern (case-insensitive, top to bottom, left to +right) on each page and reads from there, so the same recipe reads another document of the same kind even when +the values sit at other points. The selector is `[page=] [count=] of `: `right` takes +the next `count` cells on the label's line, joined by a space; `below` takes the next `count` lines in the label's +column, a line's cells joined by a space and lines by a newline. A page without the label, or with nothing there, +is no match (the step fails without `many`; with `page=*` and `many: true` you get one text per page). + A **region** reads every cell at least half inside its box, row by row: a row's cells joined by a space, rows by a newline — so a box around a wrapped paragraph gives its lines. A page with nothing in the box is no match (the step fails without `many`); with `page=*` and `many: true` you get one text per page that has something there, a diff --git a/packages/core/schemas/input-recipe.schema.json b/packages/core/schemas/input-recipe.schema.json index a9185c4a..a2aca5a4 100644 --- a/packages/core/schemas/input-recipe.schema.json +++ b/packages/core/schemas/input-recipe.schema.json @@ -1705,7 +1705,8 @@ "jsonpath", "regex", "table", - "region" + "region", + "anchor" ] }, "take": { diff --git a/packages/core/src/api-steps/extract-from-document.use-case.ts b/packages/core/src/api-steps/extract-from-document.use-case.ts index 9f97b2c3..e63c0f5b 100644 --- a/packages/core/src/api-steps/extract-from-document.use-case.ts +++ b/packages/core/src/api-steps/extract-from-document.use-case.ts @@ -1,6 +1,6 @@ import { deckRegionText, deckText, findDeckTables, isDeckDocument } from '../deck-document' import type { ExtractionScope, ScopeDocument } from '../extraction-scope' -import { findTables, isPdfDocument, parseRegion, pdfText, regionText } from '../pdf-document' +import { anchorText, findTables, isPdfDocument, parseAnchor, parseRegion, pdfText, regionText } from '../pdf-document' import type { TableQuery } from '../pdf-document' import { fillDown, findGridTables, htmlTableSheets, isWorkbookDocument, workbookText } from '../workbook-document' import type { ExtractStep } from '../recipe-schema' @@ -72,6 +72,11 @@ export function extractFromDocument (step: ExtractStep, scope: ExtractionScope): break } + case 'anchor': { + values = readAnchor(document, selector) + + break + } default: { throw new Error(`unknown extract kind ${String(step.kind)}`) } @@ -167,9 +172,21 @@ function readRegion (document: ScopeDocument, selector: string): string[] { throw new Error(`region reads a PDF or a deck (a presentation); the current document is ${document.kind}${document.kind === 'html' ? '' : ' (request it with "as": "pdf" or "pptx")'}`) } +/** + * What an `anchor` extract reads: the text beside or under a label found by + * its own text on a PDF's pages, so the same recipe reads another document of + * the same kind whose values sit elsewhere (see + * `pdf-document/pdf-anchor.algorithm.ts`). + */ +function readAnchor (document: ScopeDocument, selector: string): string[] { + if (document.kind !== 'pdf') throw new Error(`anchor reads a PDF; the current document is ${document.kind}${document.kind === 'html' ? '' : ' (request it with "as": "pdf")'}`) + + return anchorText(document, parseAnchor(selector)).map(match => match.text) +} + /** What a `css` extract should have used instead, for a document that is not markup. */ function readHint (kind: ScopeDocument['kind']): string { - if (kind === 'pdf' || kind === 'deck') return ' (read it with kind "table", "region", "regex" or "jsonpath")' + if (kind === 'pdf' || kind === 'deck') return ' (read it with kind "table", "region", "anchor", "regex" or "jsonpath")' if (kind === 'workbook') return ' (read it with kind "table", "regex" or "jsonpath")' return '' @@ -249,6 +266,7 @@ function documentFor (step: ExtractStep, scope: ExtractionScope): ScopeDocument if (source === undefined) throw new Error(`"${step.from}" is not bound`) if (isPdfDocument(source) || isWorkbookDocument(source) || isDeckDocument(source) || isXmlDocument(source)) return source if (step.kind === 'region') throw new Error(`"${step.from}" is not a read PDF or deck; region reads those (request it with "as": "pdf" or "pptx")`) + if (step.kind === 'anchor') throw new Error(`"${step.from}" is not a read PDF; anchor reads those (request it with "as": "pdf")`) if (step.kind === 'xpath') { if (typeof source !== 'string') throw new Error(`"${step.from}" is not markup; xpath reads XML or HTML text`) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b510dd5a..79f57156 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,7 +27,7 @@ export { HostAllowlist, HostNotAllowedError } from './host-allowlist' export type { HttpClientOptions, HttpRequest, HttpResponse, HttpBody } from './http-session' export { tryParseJson, selectHtml, countMatches, matchesOf, NODE_ID_ATTRIBUTE } from './selection' export type { HtmlMatch } from './selection' -export { readPdf, PdfReadError, analyzeTables, findTables, pdfText, isPdfDocument, rowsOfCells, parseRegion, regionSelector, regionText, regionCells, isInsideRegion } from './pdf-document' +export { readPdf, PdfReadError, analyzeTables, findTables, pdfText, isPdfDocument, rowsOfCells, parseRegion, regionSelector, regionText, regionCells, isInsideRegion, parseAnchor, anchorText } from './pdf-document' export type { DocumentRegion, PdfRegionMatch, PositionedBox } from './pdf-document' export type { PdfDocument, PdfPage, PdfRow, PdfCell, PositionedText, PdfTable, TableQuery, TableAlign, TableDiagnostics, TableBandDiagnostics } from './pdf-document' export { parseCsv, detectDelimiter, csvWorkbook, readXlsxWorkbook, findGridTables, fillDown, workbookText, isWorkbookDocument, htmlTableSheets } from './workbook-document' diff --git a/packages/core/src/pdf-document/index.ts b/packages/core/src/pdf-document/index.ts index 8025d54f..dd6a8ace 100644 --- a/packages/core/src/pdf-document/index.ts +++ b/packages/core/src/pdf-document/index.ts @@ -3,6 +3,8 @@ export { pdfText, isPdfDocument } from './pdf-document.model' export type { PdfDocument, PdfPage, PdfRow, PdfCell, PositionedText } from './pdf-document.model' export { assembleRows, rowsOfCells } from './row-assembly.algorithm' export { analyzeTables, findTables } from './pdf-table.algorithm' +export { parseAnchor, anchorText } from './pdf-anchor.algorithm' +export type { PdfAnchor, PdfAnchorMatch } from './pdf-anchor.algorithm' export { parseRegion, regionSelector, regionText, regionCells, isInsideRegion } from './pdf-region.algorithm' export type { DocumentRegion, PdfRegionMatch, PositionedBox } from './pdf-region.algorithm' export type { PdfTable, TableAlign, TableBandDiagnostics, TableDiagnostics, TableQuery } from './pdf-table.algorithm' diff --git a/packages/core/src/pdf-document/pdf-anchor.algorithm.test.ts b/packages/core/src/pdf-document/pdf-anchor.algorithm.test.ts new file mode 100644 index 00000000..8f29e6b5 --- /dev/null +++ b/packages/core/src/pdf-document/pdf-anchor.algorithm.test.ts @@ -0,0 +1,79 @@ +import type { PdfCell, PdfDocument, PdfRow } from './pdf-document.model' +import { anchorText, parseAnchor } from './pdf-anchor.algorithm' + +function cell (x: number, y: number, text: string, width = text.length * 5, height = 10): PdfCell { + return { x, y, width, height, text } +} + +function row (...cells: PdfCell[]): PdfRow { + return { top: Math.max(...cells.map(c => c.y + c.height)), bottom: Math.min(...cells.map(c => c.y)), text: cells.map(c => c.text).join('\t'), cells } +} + +/** The same kind of document twice: the values sit at other points, the labels keep their text. */ +function invoice (shift: number, number: string, total: string): PdfDocument { + return { + kind: 'pdf', + pages: [{ + number: 1, + width: 595, + height: 842, + rows: [ + row(cell(72, 800 - shift, 'Invoice no'), cell(200, 800 - shift, number)), + row(cell(72, 760 - shift, 'Bill to')), + row(cell(72, 745 - shift, 'ACME Srl')), + row(cell(72, 730 - shift, 'Via Roma 1')), + row(cell(72, 700 - shift, 'Totale'), cell(300, 700 - shift, total)), + ], + }], + } +} + +describe('parseAnchor', () => { + it('reads direction, count, page and a pattern that may hold spaces', () => { + expect(parseAnchor('right of ^Valid until')).toMatchObject({ page: '*', direction: 'right', count: 1 }) + expect(parseAnchor('right of ^Valid until').pattern.source).toBe('^Valid until') + expect(parseAnchor('page=2 below count=3 of ^Bill to')).toMatchObject({ page: 2, direction: 'below', count: 3 }) + }) + + it('refuses a selector of another shape, a zero count and a bad pattern', () => { + expect(() => parseAnchor('^Total')).toThrow(/is not "\[page=/) + expect(() => parseAnchor('left of Total')).toThrow(/is not "\[page=/) + expect(() => parseAnchor('below count=0 of Total')).toThrow(/count must be at least 1/) + expect(() => parseAnchor('right of (')).toThrow(/not a regular expression/) + }) +}) + +describe('anchorText', () => { + it('reads the cell to the right of a label, wherever it sits', () => { + const anchor = parseAnchor('right of ^Invoice no') + expect(anchorText(invoice(0, 'A-1', '10,00'), anchor).map(match => match.text)).toEqual(['A-1']) + expect(anchorText(invoice(120, 'B-22', '99,00'), anchor).map(match => match.text)).toEqual(['B-22']) + }) + + it('reads the lines under a label, in its column only, nearest first', () => { + const document = invoice(0, 'A-1', '10,00') + expect(anchorText(document, parseAnchor('below of ^Bill to')).map(match => match.text)).toEqual(['ACME Srl']) + expect(anchorText(document, parseAnchor('below count=2 of ^Bill to')).map(match => match.text)).toEqual(['ACME Srl\nVia Roma 1']) + }) + + it('matches the label case-insensitively and reports the cells it read', () => { + const [match] = anchorText(invoice(0, 'A-1', '10,00'), parseAnchor('right of ^totale')) + expect(match.text).toBe('10,00') + expect(match.label.text).toBe('Totale') + expect(match.cells.map(c => c.text)).toEqual(['10,00']) + }) + + it('gives no match when the label is missing or has nothing beside it', () => { + const document = invoice(0, 'A-1', '10,00') + expect(anchorText(document, parseAnchor('right of ^Nowhere'))).toEqual([]) + expect(anchorText(document, parseAnchor('right of ^Bill to'))).toEqual([]) + expect(anchorText(document, parseAnchor('below of ^Totale'))).toEqual([]) + }) + + it('reads one text per page that has the label with page=*, or just the page named', () => { + const second = invoice(0, 'A-2', '20,00').pages[0] + const document: PdfDocument = { kind: 'pdf', pages: [invoice(0, 'A-1', '10,00').pages[0], { ...second, number: 2 }] } + expect(anchorText(document, parseAnchor('right of ^Totale')).map(match => [match.page, match.text])).toEqual([[1, '10,00'], [2, '20,00']]) + expect(anchorText(document, parseAnchor('page=2 right of ^Totale')).map(match => [match.page, match.text])).toEqual([[2, '20,00']]) + }) +}) diff --git a/packages/core/src/pdf-document/pdf-anchor.algorithm.ts b/packages/core/src/pdf-document/pdf-anchor.algorithm.ts new file mode 100644 index 00000000..1a9b0c03 --- /dev/null +++ b/packages/core/src/pdf-document/pdf-anchor.algorithm.ts @@ -0,0 +1,114 @@ +import type { PdfCell, PdfDocument, PdfPage } from './pdf-document.model' + +/** + * Where an `anchor` extract reads, relative to a label: the cell(s) to its + * `right` on the same line, or the line(s) `below` it in its column. The + * selector is `[page=] [count=] of `, e.g. + * `right of ^Valid until` or `page=1 below count=2 of ^Bill to`. The label + * is found by its text, so the same recipe reads another document of the + * same kind whose values sit at other coordinates. + */ +export interface PdfAnchor { + /** A 1-based page number, or `'*'` for every page. */ + page: number | '*' + direction: 'right' | 'below' + /** How many cells (to the right) or lines (below) to read. */ + count: number + /** The label: the first cell, in reading order, whose text matches, on each page. */ + pattern: RegExp +} + +/** What an anchor reads off one PDF page: the text, the label cell it was found from, and the cells read (what a studio canvas highlights). */ +export interface PdfAnchorMatch { + page: number + text: string + label: PdfCell + cells: PdfCell[] +} + +/** Points of slack when deciding a cell lies past a label's edge or under its baseline: text boxes are rounded and drawn a hair off. */ +const TOLERANCE = 1 + +const ANCHOR_PATTERN = /^\s*(?:page=(\*|\d+)\s+)?(right|below)(?:\s+count=(\d+))?\s+of\s+(\S.*)$/ + +/** + * Parses an `anchor` selector: `right of ^Valid until`, `below of …`, + * `page=2 right count=2 of ^Total`. The pattern is case-insensitive, like + * every pattern the engine reads PDFs with. + * + * @param selector - The selector. + * @returns The anchor. + * @throws Error when the selector does not have that shape, the count is not at least 1, or the pattern is not a regular expression. + */ +export function parseAnchor (selector: string): PdfAnchor { + const match = ANCHOR_PATTERN.exec(selector) + if (match === null) throw new Error(`anchor: "${selector}" is not "[page=] [count=] of " (for example "right of ^Valid until")`) + const [, page, direction, countText, rawSource] = match + const source = rawSource.trim() + const count = countText === undefined ? 1 : Number(countText) + if (count < 1) throw new Error(`anchor: count must be at least 1, got ${String(count)}`) + let pattern: RegExp + try { + pattern = new RegExp(source, 'i') + } catch (error) { + throw new Error(`anchor: the pattern "${source}" is not a regular expression: ${(error as Error).message}`, { cause: error }) + } + + return { page: page === undefined || page === '*' ? '*' : Number(page), direction: direction as 'right' | 'below', count, pattern } +} + +/** + * Reads what an anchor points at on a PDF: on each page in scope, from the + * first cell whose text matches the label (reading order: top to bottom, left + * to right). `right` takes the next `count` cells on the label's line, + * joined by a space. `below` takes the next `count` lines in the label's + * column (cells whose horizontal extent overlaps the label's), a line's cells + * joined by a space, lines by a newline. A page with no label, or with + * nothing there, gives no match. + * + * @param document - The read PDF. + * @param anchor - What to read. + * @returns One match per page where the anchor found something, in page order. + */ +export function anchorText (document: PdfDocument, anchor: PdfAnchor): PdfAnchorMatch[] { + const pages = anchor.page === '*' ? document.pages : document.pages.filter(page => page.number === anchor.page) + + return pages.flatMap((page) => { + const found = findLabel(page, anchor.pattern) + if (found === undefined) return [] + const lines = anchor.direction === 'right' ? [rightOf(found.row, found.cell, anchor.count)] : below(page, found.cell, anchor.count) + const cells = lines.flat() + if (cells.length === 0) return [] + + return [{ page: page.number, text: lines.filter(line => line.length > 0).map(line => line.map(cell => cell.text).join(' ')).join('\n'), label: found.cell, cells }] + }) +} + +function findLabel (page: PdfPage, pattern: RegExp): { row: PdfCell[], cell: PdfCell } | undefined { + for (const row of page.rows) { + const cell = row.cells.find(candidate => pattern.test(candidate.text)) + if (cell !== undefined) return { row: row.cells, cell } + } + + return undefined +} + +/** The cells after the label on its line, nearest first: one line of up to `count` cells. */ +function rightOf (row: readonly PdfCell[], label: PdfCell, count: number): PdfCell[] { + return row.filter(cell => cell !== label && cell.x >= label.x + label.width - TOLERANCE).sort((a, b) => a.x - b.x).slice(0, count) +} + +/** Up to `count` lines under the label, nearest first, each the cells of that line in the label's column. */ +function below (page: PdfPage, label: PdfCell, count: number): PdfCell[][] { + const lines: PdfCell[][] = [] + for (const row of page.rows) { + const cells = row.cells.filter(cell => cell !== label && cell.y + cell.height <= label.y + TOLERANCE && overlapsHorizontally(cell, label)) + if (cells.length > 0) lines.push(cells) + } + + return lines.sort((a, b) => Math.max(...b.map(cell => cell.y)) - Math.max(...a.map(cell => cell.y))).slice(0, count) +} + +function overlapsHorizontally (a: PdfCell, b: PdfCell): boolean { + return Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > TOLERANCE +} diff --git a/packages/core/src/recipe-schema/recipe-kind.enum.ts b/packages/core/src/recipe-schema/recipe-kind.enum.ts index 91c66817..770e4245 100644 --- a/packages/core/src/recipe-schema/recipe-kind.enum.ts +++ b/packages/core/src/recipe-schema/recipe-kind.enum.ts @@ -2,7 +2,7 @@ export const RECIPE_KINDS = ['input', 'output'] as const export const CRAWL_MODES = ['web', 'api'] as const -export const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table', 'region'] as const +export const SELECTOR_KINDS = ['css', 'xpath', 'jsonpath', 'regex', 'table', 'region', 'anchor'] as const /** `take` also accepts `attr:`, which is validated by pattern rather than listed. */ export const TAKE_KINDS = ['text', 'html', 'value', 'json'] as const export const BODY_KINDS = ['json', 'jsonl', 'html', 'text', 'pdf', 'csv', 'xlsx', 'pptx', 'yaml', 'markdown', 'xml', 'docx'] as const diff --git a/packages/core/src/web-steps/extract-from-page.use-case.ts b/packages/core/src/web-steps/extract-from-page.use-case.ts index 44881001..9b382737 100644 --- a/packages/core/src/web-steps/extract-from-page.use-case.ts +++ b/packages/core/src/web-steps/extract-from-page.use-case.ts @@ -13,7 +13,7 @@ import { NoMatchError } from '../step-flow' */ export async function extractFromPage (step: ExtractStep, page: Page, scope: ExtractionScope): Promise { // A bound fragment, or JSON/a PDF a `request` fetched: read as api mode reads a document. - if (step.from !== undefined || step.kind === 'jsonpath' || step.kind === 'region') { + if (step.from !== undefined || step.kind === 'jsonpath' || step.kind === 'region' || step.kind === 'anchor') { extractFromDocument(step, scope) return