Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ dist/
*.log
.env
.env.local

# Tesseract.js downloads language data into cwd by default
*.traineddata
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,66 @@ All notable changes to `@thinkfleet/agentmark` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.0] — 2026-05-10

OCR + render-backend support. Pages with no extractable text (scanner
output, "Microsoft Print To PDF" exports, image-only PDFs) can now be
rasterized + OCR'd transparently. Two render backends and two OCR
backends ship; the interfaces let callers plug in any provider.

### Added

- **`OcrBackend` / `RenderBackend` interfaces.** Minimal, plug-and-play.
Bring AWS Textract, Google Document AI, Apple Vision, etc. by
implementing one method each.
- **`PopplerRenderBackend`** — shells out to `pdftoppm`. Lightest install.
- **`PdfjsRenderBackend`** — pure-Node via pdfjs-dist + node-canvas.
- **`TesseractOcrBackend`** — in-process WASM OCR. Free, offline.
- **`MistralOcrBackend`** — Mistral OCR cloud API. Best quality.
- **`convertPdf({ ocr: { render, ocr, mode } })`** — opt-in OCR pipeline
with three modes: `auto` (OCR only pages with no extractable text;
default), `always` (OCR every page), `never` (disable).
- **`document.ocr_used` flag** — set to `true` in the snapshot's
document metadata when OCR was actually applied.
- **`agentmark` capability `ocr: true`** is set on snapshots that used OCR.
- **Diagnostic CLI `--ocr` flag** — `npx tsx examples/diagnose-pdf.ts
./corpus --ocr` to validate OCR on a corpus.
- **`examples/ocr-pdf.ts`** — end-to-end demo wiring Poppler + Tesseract.

### Changed

- `tesseract.js` and `canvas` added as optional peer dependencies. Both
are required only by the matching backend; web-only callers install
neither.
- `convertPdf` defensively wraps cleanup `close()` calls so backends
may return `void | Promise<void>`.

### Real-world validation

Insurance corpus (12 docs) results, before vs after v0.5:

| Mode | 🟢 ≥70 | 🟡 30-69 | 🔴 <30 |
|---|---|---|---|
| Without OCR | 6 (50%) | 6 (50%) | 0 |
| With OCR (Poppler + Tesseract) | **12 (100%)** | 0 | 0 |

Failing categories before v0.5 — all now resolved by OCR:
- "Microsoft Print To PDF" vector-glyph PDFs (4 docs)
- Scanner output (2 docs)

### Tests

- 8 new OCR pipeline unit tests (mocked backends, deterministic).
- Total: 176 unit + 10 real-Chromium integration = 186 (was 176).

### Not in this release (deferred)

- AWS Textract / Google Document AI / Apple Vision reference adapters
(interface ships; community impls welcome)
- Form-structure inference (label/value pair detection on non-AcroForm
PDFs) — paired with M3 / v0.6
- AcroForm support — M3 / v0.6

## [0.4.0] — 2026-05-10

PDF support. The same wire format now applies to documents — `convertPdf()`
Expand Down Expand Up @@ -141,6 +201,7 @@ Initial release of `@thinkfleet/agentmark`.
- In-memory action binding
- 90 tests, npm provenance auto-publish

[0.5.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.5.0
[0.4.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.4.0
[0.3.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.3.0
[0.2.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.2.0
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,48 @@ PDF support is opt-in via the optional peer dependency:
npm install pdfjs-dist@^4
```

If `pdfjs-dist` is missing, `convertPdf()` throws a `SnapshotError` with install instructions. Heading detection uses font-size heuristics (configurable via `headingThreshold`); bullet and ordered lists auto-detect. Tables and OCR for scanned PDFs ship in v0.5.
If `pdfjs-dist` is missing, `convertPdf()` throws a `SnapshotError` with install instructions. Heading detection uses font-size + bold-font-name heuristics (configurable via `headingThreshold`); bullet and ordered lists auto-detect.

### OCR for scanned and "Print To PDF" documents (v0.5+)

Many real-world PDFs have no extractable text — scanner output, "Microsoft Print To PDF" exports, etc. AgentMark ships pluggable OCR + render backends to handle these. Two of each are bundled; bring your own (AWS Textract, Google Document AI, Apple Vision Framework) by implementing the `OcrBackend` / `RenderBackend` interfaces.

```ts
import {
convertPdf,
PopplerRenderBackend,
TesseractOcrBackend,
} from '@thinkfleet/agentmark'

const { agentmark } = await convertPdf({
data,
sourceUrl: 'file:///tmp/scanned.pdf',
ocr: {
render: new PopplerRenderBackend(), // pdftoppm-based rasterization
ocr: new TesseractOcrBackend(), // in-process WASM OCR
mode: 'auto', // OCR only pages with no extractable text (default)
},
})
```

**Bundled render backends:**

| Backend | Install | When to use |
|---|---|---|
| `PopplerRenderBackend` | `brew install poppler` (macOS) / `apt-get install poppler-utils` | Lightest. No native node modules. |
| `PdfjsRenderBackend` | `npm install canvas` | Pure-Node, no system deps. Heavier install. |

**Bundled OCR backends:**

| Backend | Install | Cost | Quality |
|---|---|---|---|
| `TesseractOcrBackend` | `npm install tesseract.js@^5` | Free | Decent on clean text |
| `MistralOcrBackend` | (none — uses `fetch`) | ~$1/1k pages | Excellent, layout-aware |

OCR modes:
- `'auto'` (default) — OCR only pages with no extractable text. Mixed text+image PDFs handled correctly.
- `'always'` — OCR every page (overrides any extracted text).
- `'never'` — disable OCR. Same as omitting `ocr` from `convertPdf`.

## Lower-level APIs

Expand Down
73 changes: 51 additions & 22 deletions examples/diagnose-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import { convertPdf } from '../src/pdf/pdf-converter'
import { parseSnapshot } from '../src/serializers/yaml-frontmatter'
import { validateSnapshot } from '../src/validators/schema-validator'
import { loadPdfjs } from '../src/pdf/pdfjs-loader'
import { PopplerRenderBackend, TesseractOcrBackend } from '../src/pdf/ocr'
import type { PdfDocument } from '../src/pdf/types'
import type { OcrPipelineOptions } from '../src/pdf/ocr'

/**
* What kind of PDF did this start life as? Drives the suggestion text and
Expand Down Expand Up @@ -76,7 +78,7 @@ interface DocReport {
suggestions: string[]
}

async function diagnose(filePath: string): Promise<DocReport> {
async function diagnose(filePath: string, ocr?: OcrPipelineOptions): Promise<DocReport> {
const flags: string[] = []
const suggestions: string[] = []

Expand Down Expand Up @@ -181,17 +183,20 @@ async function diagnose(filePath: string): Promise<DocReport> {
suggestions.push('Investigate body-builder line/paragraph clustering')
}

// Full conversion
// Full conversion (with optional OCR)
let bytes = 0
let valid = false
let validationErrors: string[] = []
try {
const { agentmark } = await convertPdf({ data, sourceUrl })
const { agentmark } = await convertPdf({ data, sourceUrl, ocr })
bytes = agentmark.length
const snap = parseSnapshot(agentmark)
const result = validateSnapshot(snap)
valid = result.valid
validationErrors = result.errors.map((e) => `${e.path}: ${e.message}`)
if (ocr && snap.document?.ocr_used) {
flags.push('✅ OCR backend filled in the missing text')
}
} catch (err) {
flags.push(`Full conversion failed: ${(err as Error).message}`)
}
Expand All @@ -203,10 +208,13 @@ async function diagnose(filePath: string): Promise<DocReport> {

// Quality score (rough)
let score = 100
if (scannedPages > 0) score -= Math.min(50, (scannedPages / extracted.pages.length) * 60)
// If OCR wasn't applied, penalize for scanned/print-to-pdf pages.
// If OCR WAS applied successfully, those penalties are nullified.
const ocrApplied = ocr && (sourceMode === 'scan' || sourceMode === 'print_to_pdf_vector' || sourceMode === 'mixed')
if (scannedPages > 0 && !ocrApplied) score -= Math.min(50, (scannedPages / extracted.pages.length) * 60)
if (multiColumnPages > 0) score -= Math.min(20, (multiColumnPages / extracted.pages.length) * 30)
if (headings === 0 && extracted.pages.length > 1) score -= 10
if (paragraphs === 0 && totalItems > 0) score -= 30
if (paragraphs === 0 && totalItems > 0 && !ocrApplied) score -= 30
if (!valid) score -= 20
score = Math.max(0, Math.round(score))

Expand Down Expand Up @@ -478,14 +486,18 @@ async function gatherFiles(input: string): Promise<string[]> {
async function main() {
const args = process.argv.slice(2)
if (args.length === 0) {
console.error('Usage: npx tsx examples/diagnose-pdf.ts <pdf-or-dir> [--out report.md]')
console.error(
'Usage: npx tsx examples/diagnose-pdf.ts <pdf-or-dir> [--out report.md] [--ocr]',
)
console.error(' --ocr Enable Tesseract+Poppler OCR for pages with no extractable text')
process.exit(1)
}

const outIdx = args.indexOf('--out')
const outPath = outIdx >= 0 ? args[outIdx + 1] : undefined
const enableOcr = args.includes('--ocr')
const inputs = args.filter((a, i) => {
if (a === '--out') return false
if (a === '--out' || a === '--ocr') return false
if (outIdx >= 0 && i === outIdx + 1) return false
return true
})
Expand All @@ -498,24 +510,41 @@ async function main() {
process.exit(1)
}

let ocr: OcrPipelineOptions | undefined
let ocrBackend: TesseractOcrBackend | undefined
if (enableOcr) {
console.error('OCR enabled (Poppler + Tesseract). First page may take ~10s as the worker spins up.')
ocrBackend = new TesseractOcrBackend({ language: 'eng' })
ocr = {
render: new PopplerRenderBackend(),
ocr: ocrBackend,
mode: 'auto',
dpi: 200,
}
}

console.error(`Diagnosing ${allFiles.length} file(s)...`)
const reports: DocReport[] = []
for (const file of allFiles) {
process.stderr.write(` ${path.basename(file)}... `)
try {
const r = await diagnose(file)
reports.push(r)
const tag = r.parseError
? '❌'
: (r.qualityScore ?? 0) >= 70
? '🟢'
: (r.qualityScore ?? 0) >= 30
? '🟡'
: '🔴'
console.error(`${tag} (score ${r.qualityScore ?? 'n/a'})`)
} catch (err) {
console.error(`💥 ${(err as Error).message}`)
try {
for (const file of allFiles) {
process.stderr.write(` ${path.basename(file)}... `)
try {
const r = await diagnose(file, ocr)
reports.push(r)
const tag = r.parseError
? '❌'
: (r.qualityScore ?? 0) >= 70
? '🟢'
: (r.qualityScore ?? 0) >= 30
? '🟡'
: '🔴'
console.error(`${tag} (score ${r.qualityScore ?? 'n/a'})`)
} catch (err) {
console.error(`💥 ${(err as Error).message}`)
}
}
} finally {
await ocrBackend?.close().catch(() => {})
}

const report = renderReport(reports)
Expand Down
57 changes: 57 additions & 0 deletions examples/ocr-pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* End-to-end OCR demo. Tries to convert a PDF that lacks extractable text
* (scan or "Microsoft Print To PDF" output) using:
*
* - Render backend: Poppler (`pdftoppm`) — must be on PATH
* - OCR backend: Tesseract.js (in-process, free)
*
* npx tsx examples/ocr-pdf.ts <path-to.pdf>
*/

import { readFile } from 'node:fs/promises'
import * as path from 'node:path'
import { pathToFileURL } from 'node:url'
import {
convertPdf,
PopplerRenderBackend,
TesseractOcrBackend,
consoleLogger,
} from '../src'

async function main() {
const filePath = process.argv[2]
if (!filePath) {
console.error('Usage: npx tsx examples/ocr-pdf.ts <pdf-path>')
process.exit(1)
}

const data = await readFile(filePath)
const sourceUrl = pathToFileURL(path.resolve(filePath)).toString()

const render = new PopplerRenderBackend()
const ocr = new TesseractOcrBackend({ language: 'eng' })

try {
const { agentmark } = await convertPdf({
data,
sourceUrl,
logger: consoleLogger,
ocr: {
render,
ocr,
mode: 'auto', // OCR only pages with no extractable text
dpi: 200,
},
})

console.log('\n────── AgentMark snapshot ──────\n')
console.log(agentmark)
} finally {
await ocr.close().catch(() => {})
}
}

main().catch((err) => {
console.error('FAILED:', err)
process.exit(1)
})
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@thinkfleet/agentmark",
"version": "0.4.0",
"description": "AI browser + document library — convert any web page or PDF into a compact AgentMark snapshot, then drive it via clean primitives any AI can call.",
"version": "0.5.0",
"description": "AI browser + document library — convert any web page or PDF (text, scanned, or printed) into a compact AgentMark snapshot, then drive it via clean primitives any AI can call.",
"type": "commonjs",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
Expand Down Expand Up @@ -41,14 +41,18 @@
},
"peerDependencies": {
"pdfjs-dist": "^4.10.38",
"playwright-core": ">=1.40.0"
"playwright-core": ">=1.40.0",
"tesseract.js": "^5.1.1"
},
"peerDependenciesMeta": {
"playwright-core": {
"optional": false
},
"pdfjs-dist": {
"optional": true
},
"tesseract.js": {
"optional": true
}
},
"devDependencies": {
Expand All @@ -57,6 +61,7 @@
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"playwright-core": "^1.40.0",
"tesseract.js": "^5.1.1",
"tsx": "^4.21.0",
"typescript": "^5.4.0",
"vitest": "3.0.8"
Expand Down
21 changes: 21 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,24 @@ export type {
PdfTextItem,
PdfBlock,
} from './pdf'

// ── v0.5: OCR + render backends (Tesseract / Mistral / Poppler / pdfjs) ──

export {
PopplerRenderBackend,
PdfjsRenderBackend,
TesseractOcrBackend,
MistralOcrBackend,
} from './pdf'
export type {
PopplerRenderOptions,
TesseractBackendOptions,
MistralOcrOptions,
RenderBackend,
RenderPageOptions,
RenderedPage,
OcrBackend,
OcrPageOptions,
OcrPageResult,
OcrPipelineOptions,
} from './pdf'
Loading