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
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,75 @@ 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.6.0] — 2026-05-10

PDF form support. AcroForm fields become AgentMark actions; the new
`PdfDocument` class lets agents fill, save, and flatten forms with the
same `execute()` shape as the web `Page` SDK.

### Added

- **AcroForm extraction.** `convertPdf()` automatically reads AcroForm
fields and sets `kind: 'form'` on snapshots that have any. Fields
become `ActionDefinition`s with the correct AgentMark action types
(text → `type`, checkbox → `check`, radio/combo → `select`,
multi-list → `multi_select`, signature → disabled `click`).
- **Field flag handling.** `Required` and `ReadOnly` flags are read from
page annotations (where pdfjs-dist surfaces them) since
`getFieldObjects()` doesn't expose them in v4+.
- **Sensitive-name redaction.** Field names matching common patterns
(password, ssn, credit_card, cvv, account_num, token, secret, etc.)
get `(redacted)` labels and `undefined` values, mirroring the
password-field handling in the web extractor.
- **Humanized labels.** `applicant.first_name` / `firstName` /
`first-name` all become `"First Name"` in the action's `label`.
- **`PdfDocument` SDK class** + `openPdfDocument()` factory — stateful
wrapper that pairs the snapshot with field-fill state:
- `snapshot()` — capture current form state
- `execute(actionId, value)` — queue a field value
- `save({ flatten? })` — write a new PDF with all queued values
applied; `flatten: true` bakes values into page content
- `reset()` — discard queued values
- `close()` — release resources
- `fields`, `pending`, `snapshotCache` — read-only accessors
- **Schema validation.** AgentMark IDs synthesized for AcroForm fields
match the spec regex `^[a-z][a-z0-9_]{0,63}$` regardless of how
irregular the source field names are.
- **`pdf-lib` as optional peer dependency.** Reading + extracting fields
uses `pdfjs-dist`; writing fields back requires `pdf-lib`. Surface a
clean `SnapshotError` with install instructions if `pdf-lib` is
missing.

### Changed

- Internal type `PdfDocument` (the extraction-result interface) renamed
to `ExtractedPdf` to free `PdfDocument` for the public class. The
type was internal; no consumer code references it through the public
API.
- `convertPdf()` now sets `kind: 'form'` (not `'document'`) when the
source PDF has AcroForm fields.
- Action IDs for AcroForm fields are synthesized as `act_field_N` to
guarantee schema compliance — original field names are preserved in
the binding map for fill operations.

### Tests

- 12 new AcroForm extractor tests + 11 new `PdfDocument` round-trip
tests, all passing.
- Total: 199 unit + 10 real-Chromium integration = 209 (was 188).
- Round-trip coverage: text / checkbox / dropdown / multi-select listbox
all verified through fill → save → re-extract.

### Known limitations

- `pdfjs-dist`'s `getFieldObjects()` only reports the first selected
value of a multi-select listbox. The PDF saved by AgentMark contains
ALL selected values correctly (verified via direct pdf-lib reading);
it's only the snapshot that under-reports. No fix planned — wait for
pdfjs-dist upstream support.
- Signature fields surface as disabled actions; AgentMark intentionally
refuses to fulfill them. Human review required.

## [0.5.0] — 2026-05-10

OCR + render-backend support. Pages with no extractable text (scanner
Expand Down Expand Up @@ -201,6 +270,7 @@ Initial release of `@thinkfleet/agentmark`.
- In-memory action binding
- 90 tests, npm provenance auto-publish

[0.6.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.6.0
[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
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,58 @@ npm install pdfjs-dist@^4

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.

### Fillable PDF forms (v0.6+)

When a PDF contains AcroForm fields (most fillable government and business forms), AgentMark sets `kind: 'form'` on the snapshot and exposes each field as an action. Use the stateful `PdfDocument` SDK class to fill and save:

```ts
import { openPdfDocument } from '@thinkfleet/agentmark'

const data = await readFile('./vendor-application.pdf')
const doc = await openPdfDocument({ data, sourceUrl: 'file:///vendor.pdf' })

const snap = await doc.snapshot()
console.log(snap.snapshot.kind) // 'form'
console.log(Object.keys(snap.snapshot.actions ?? {}))

// Fill fields. Same execute() shape as the web Page SDK.
await doc.execute('act_field_1', 'Acme Inc.')
await doc.execute('act_field_2', true) // checkbox
await doc.execute('act_field_3', 'NC') // dropdown
await doc.execute('act_field_4', ['English', 'Spanish']) // multi-select

// Save the filled PDF as new bytes.
const filled = await doc.save()
await writeFile('./vendor-application-filled.pdf', filled)

// Or flatten — bake values into the page content; no longer fillable.
const flattened = await doc.save({ flatten: true })

await doc.close()
```

Field handling:

| AcroForm type | AgentMark action | Notes |
|---|---|---|
| Text (single + multi-line) | `type: 'type'` | Sensitive names auto-redacted (password, ssn, credit_card, etc.) |
| Checkbox | `type: 'check'` | Boolean |
| Radio group | `type: 'select'` | Options from PDF |
| Dropdown | `type: 'select'` | Options from PDF |
| Listbox (single / multi) | `type: 'select'` / `'multi_select'` | |
| Signature | `type: 'click'` (disabled) | Refused — agents can't sign |
| Push button | `type: 'click'` | |

Required fields, read-only fields, and PDF field flags (read from page annotations) all surface in the resulting `ActionDefinition`.

PDF form filling is opt-in via the optional peer dependency:

```bash
npm install pdf-lib
```

If `pdf-lib` is missing, `doc.save()` throws a `SnapshotError` with install instructions — `doc.snapshot()` and `doc.execute()` still work without it (fields are read via pdfjs-dist).

### 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.
Expand Down
6 changes: 3 additions & 3 deletions examples/diagnose-pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ 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 { ExtractedPdf } from '../src/pdf/types'
import type { OcrPipelineOptions } from '../src/pdf/ocr'

/**
Expand Down Expand Up @@ -96,7 +96,7 @@ async function diagnose(filePath: string, ocr?: OcrPipelineOptions): Promise<Doc
const data = await readFile(filePath)
const sourceUrl = pathToFileURL(path.resolve(filePath)).toString()

let extracted: PdfDocument
let extracted: ExtractedPdf
try {
extracted = await extractPdf({ data })
} catch (err) {
Expand Down Expand Up @@ -257,7 +257,7 @@ async function diagnose(filePath: string, ocr?: OcrPipelineOptions): Promise<Doc
*/
async function classifySourceMode(
data: Uint8Array,
extracted: PdfDocument,
extracted: ExtractedPdf,
): Promise<{ sourceMode: SourceMode; producer?: string }> {
const pdfjs = await loadPdfjs()
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@thinkfleet/agentmark",
"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.",
"version": "0.6.0",
"description": "AI browser + document + form library — convert any web page or PDF (text, scanned, printed, or fillable AcroForm) 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 @@ -40,6 +40,7 @@
"tslib": "2.6.2"
},
"peerDependencies": {
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"playwright-core": ">=1.40.0",
"tesseract.js": "^5.1.1"
Expand All @@ -53,6 +54,9 @@
},
"tesseract.js": {
"optional": true
},
"pdf-lib": {
"optional": true
}
},
"devDependencies": {
Expand Down
15 changes: 14 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export type {
ConvertPdfOptions,
ExtractPdfOptions,
BuildPdfBodyOptions,
PdfDocument,
ExtractedPdf,
PdfDocumentMeta,
PdfPage,
PdfTextItem,
Expand All @@ -136,3 +136,16 @@ export type {
OcrPageResult,
OcrPipelineOptions,
} from './pdf'

// ── M3 / v0.6: AcroForm support (kind: 'form') ───────────────────────────

export { extractAcroForm, PdfDocument, openPdfDocument } from './pdf'
export type {
ExtractAcroFormOptions,
AcroFormExtraction,
AcroFormField,
AcroFormFieldKind,
OpenPdfDocumentOptions,
PdfDocumentSnapshot,
SaveOptions,
} from './pdf'
10 changes: 5 additions & 5 deletions src/pdf/body-builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Convert a structured PdfDocument into AgentMark `BodySegment[]` ready for
* Convert a structured ExtractedPdf into AgentMark `BodySegment[]` ready for
* the existing serializer pipeline.
*
* The hard problem here is that PDFs have no semantic structure — only
Expand All @@ -17,7 +17,7 @@
*/

import type { BodySegment } from '../extractors/dom-extractor'
import type { PdfDocument, PdfPage, PdfTextItem } from './types'
import type { ExtractedPdf, PdfPage, PdfTextItem } from './types'

export interface BuildPdfBodyOptions {
/** Multiplier on median font size above which text is promoted to a heading.
Expand All @@ -26,10 +26,10 @@ export interface BuildPdfBodyOptions {
}

/**
* Top-level: convert a parsed PdfDocument to AgentMark body segments.
* Top-level: convert a parsed ExtractedPdf to AgentMark body segments.
* Each page emits a `[PAGE:p_N]` tag followed by its text segments.
*/
export function buildBodyFromPdf(doc: PdfDocument, opts: BuildPdfBodyOptions = {}): BodySegment[] {
export function buildBodyFromPdf(doc: ExtractedPdf, opts: BuildPdfBodyOptions = {}): BodySegment[] {
const headingThreshold = opts.headingThreshold ?? 1.3
const allSizes = collectAllFontSizes(doc)
const sortedDescending = [...allSizes].sort((a, b) => b - a)
Expand Down Expand Up @@ -281,7 +281,7 @@ function detectListItem(text: string): { ordered: boolean; text: string } | null
// Helpers
// ────────────────────────────────────────────────────────────────────────

function collectAllFontSizes(doc: PdfDocument): number[] {
function collectAllFontSizes(doc: ExtractedPdf): number[] {
const sizes: number[] = []
for (const page of doc.pages) {
for (const item of page.items) {
Expand Down
Loading