Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/recipes/authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n|*>] <right|below> [count=<n>] of <pattern>`: `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
Expand Down
3 changes: 2 additions & 1 deletion packages/core/schemas/input-recipe.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1705,7 +1705,8 @@
"jsonpath",
"regex",
"table",
"region"
"region",
"anchor"
]
},
"take": {
Expand Down
22 changes: 20 additions & 2 deletions packages/core/src/api-steps/extract-from-document.use-case.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)}`)
}
Expand Down Expand Up @@ -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 ''
Expand Down Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/pdf-document/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
79 changes: 79 additions & 0 deletions packages/core/src/pdf-document/pdf-anchor.algorithm.test.ts
Original file line number Diff line number Diff line change
@@ -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']])
})
})
114 changes: 114 additions & 0 deletions packages/core/src/pdf-document/pdf-anchor.algorithm.ts
Original file line number Diff line number Diff line change
@@ -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=<n|*>] <right|below> [count=<n>] of <pattern>`, 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=<number|*>] <right|below> [count=<n>] of <pattern>" (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
}
2 changes: 1 addition & 1 deletion packages/core/src/recipe-schema/recipe-kind.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`, 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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/web-steps/extract-from-page.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { NoMatchError } from '../step-flow'
*/
export async function extractFromPage (step: ExtractStep, page: Page, scope: ExtractionScope): Promise<void> {
// 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
Expand Down
Loading