From 8e5e0b966cdec6aba92dca1d0631193fed7dd7f8 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Sun, 10 May 2026 09:09:28 -0400 Subject: [PATCH 1/3] feat(pdf): convertPdf() + spec v0.2 (kind: document) (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF support. The same wire format now applies to documents — convertPdf() produces a kind: 'document' AgentMark snapshot from PDF bytes. Spec v0.2 extension - Adds kind: 'webpage' | 'document' | 'form' discriminator (v0.2+) - Adds optional document metadata block: pages, author, created_at, modified_at, format, format_version, ocr_used - Adds [PAGE:p_n] body tag for page-boundary markers in documents - Fully backwards-compatible: v0.1 snapshots without kind still validate - New schema/agentmark-v0.2.json; validator picks v0.1 or v0.2 based on declared agentmark version - AGENTMARK_VERSION constant bumped from '0.1' to '0.2' PDF converter - convertPdf({ data, sourceUrl, ... }): main entry. Returns the same ConversionResult shape as convertPage() so downstream LLM pipelines are uniform regardless of source surface. - extractPdf(): lower-level extraction returning structured PdfDocument (positioned text items + metadata) for callers wanting custom structural inference. - buildBodyFromPdf(): body-segment builder consumed by convertPdf, exposed for callers wanting a different envelope. - Heading detection via font-size outliers (configurable threshold). - Bullet + ordered list detection via leading-glyph patterns. - Paragraph reflow with vertical-gap-based break detection. - PDF metadata parser handles non-ISO PDF date format (D:YYYYMMDDHHMMSS+HH'mm' → ISO 8601). Dependencies - pdfjs-dist@^4 added as optional peer dependency (web-only callers pay no install cost). Lazy-imported via dynamic import; throws clean SnapshotError with install instructions if missing. - pdf-lib added as devDependency for test-fixture generation (PDFs constructed in-process, not committed as binaries). Tests (166 unit + 10 real-Chromium = 176 total, all passing) - 13 new spec-v0.2 tests covering kind discriminator, document metadata, PAGE markers, version negotiation, backwards compat with v0.1. - 12 new PDF converter tests covering metadata extraction, page counts, heading promotion, bullet detection, PAGE markers, title fallback, vendor extensions, logger event flow, error handling on garbage input. Not yet shipped (deferred to v0.5) - OCR for scanned PDFs (interface designed via document.ocr_used flag) - Table detection - AcroForm support (M3) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 48 ++++- README.md | 42 ++++ examples/pdf.ts | 38 ++++ package.json | 22 ++- schema/agentmark-v0.2.json | 138 +++++++++++++ src/index.ts | 22 ++- src/pdf/body-builder.ts | 272 +++++++++++++++++++++++++ src/pdf/index.ts | 20 ++ src/pdf/pdf-converter.ts | 158 +++++++++++++++ src/pdf/pdf-extractor.ts | 141 +++++++++++++ src/pdf/pdfjs-loader.ts | 30 +++ src/pdf/types.ts | 59 ++++++ src/types.ts | 46 ++++- src/validators/schema-validator.ts | 54 ++++- test/build-snapshot.test.ts | 2 +- test/pdf/pdf-converter.test.ts | 306 +++++++++++++++++++++++++++++ test/spec-v0.2.test.ts | 173 ++++++++++++++++ 17 files changed, 1547 insertions(+), 24 deletions(-) create mode 100644 examples/pdf.ts create mode 100644 schema/agentmark-v0.2.json create mode 100644 src/pdf/body-builder.ts create mode 100644 src/pdf/index.ts create mode 100644 src/pdf/pdf-converter.ts create mode 100644 src/pdf/pdf-extractor.ts create mode 100644 src/pdf/pdfjs-loader.ts create mode 100644 src/pdf/types.ts create mode 100644 test/pdf/pdf-converter.test.ts create mode 100644 test/spec-v0.2.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0e658..fc3b72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,54 @@ 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.4.0] — 2026-05-10 + +PDF support. The same wire format now applies to documents — `convertPdf()` +produces a `kind: 'document'` snapshot from PDF bytes. Spec extension to v0.2. + +### Added + +- **Spec v0.2** — adds `kind: webpage | document | form` discriminator, + optional `document` metadata block (pages, author, created_at, format, + format_version, ocr_used), and the `[PAGE:p_n]` body tag for page-boundary + markers in documents. Fully backwards-compatible: v0.1 snapshots without + `kind` still validate (treated as webpages). +- **`convertPdf({ data, sourceUrl, ... })`** — main entry point. Parses + PDF metadata (title, author, dates, format version), extracts text + font + sizes per page, builds an AgentMark body with PAGE markers and inferred + structure (headings via font-size outliers, bullet + ordered list + detection, paragraph reflow). Returns the same `ConversionResult` as + `convertPage()` for uniform downstream handling. +- **`extractPdf()`** — lower-level extraction returning a structured + `PdfDocument` (pages with positioned text items + metadata). For callers + who want to do their own structural inference. +- **`buildBodyFromPdf()`** — body-segment builder consumed by `convertPdf`, + exposed for callers who want a different envelope. +- **`schema/agentmark-v0.2.json`** — JSON schema for the v0.2 envelope; + validator now picks v0.1 or v0.2 schema based on the declared `agentmark` + version. +- **`pdfjs-dist`** as an optional peer dependency. Throws clean + `SnapshotError` with install instructions if missing — web-only callers + pay no install cost. +- 13 new spec-v0.2 tests + 12 new PDF converter tests, all passing. + Total: 166 unit + 10 real-Chromium integration = 176 (was 141). + +### Changed + +- `AGENTMARK_VERSION` constant bumped from `'0.1'` to `'0.2'`. Existing + callers serializing snapshots get v0.2 by default. Validator accepts both. +- README and `examples/pdf.ts` show the new PDF flow. + +### Not yet shipped + +- OCR for scanned PDFs — interface designed (`document.ocr_used` flag in + metadata), implementation deferred to v0.5.0. +- Table detection — heuristics for column-aligned text deferred to v0.5.0. +- AcroForm support — coming in M3 / v0.5.0. + ## [0.3.0] — 2026-05-10 -This is the **first production-ready release**. Adds the high-level SDK +The **first production-ready release**. Adds the high-level SDK surface, structured error hierarchy, observability hooks, and session persistence on top of the v0.2 wire-format conversion. @@ -96,6 +141,7 @@ Initial release of `@thinkfleet/agentmark`. - In-memory action binding - 90 tests, npm provenance auto-publish +[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 [0.1.0]: https://github.com/ThinkfleetAI/agentmark/releases/tag/v0.1.0 diff --git a/README.md b/README.md index e5e182c..69ec29d 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,48 @@ flow, etc.) brings the loop. AgentMark just exposes great browser primitives. Cloudflare, reCAPTCHA, hCaptcha auto-resolved before snapshot. - **Library, not a framework.** Bring your own model, prompts, and loop. +## PDFs (v0.4+) + +The same wire format works for PDFs. `convertPdf()` produces a `kind: 'document'` snapshot with `[PAGE:p_n]` markers between pages. + +```ts +import { readFile } from 'node:fs/promises' +import { convertPdf } from '@thinkfleet/agentmark' + +const data = await readFile('./report.pdf') +const { agentmark } = await convertPdf({ + data, + sourceUrl: 'file:///abs/path/report.pdf', +}) + +console.log(agentmark) +// --- +// agentmark: "0.2" +// kind: document +// url: "file:///abs/path/report.pdf" +// title: "Annual Report 2025" +// document: +// pages: 47 +// author: "Acme Inc." +// format: pdf +// format_version: "1.7" +// ocr_used: false +// --- +// +// [PAGE:p_1] +// +// # Annual Report 2025 +// ... +``` + +PDF support is opt-in via the optional peer dependency: + +```bash +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. + ## Lower-level APIs For callers who want direct control over conversion or want to feed AgentMark diff --git a/examples/pdf.ts b/examples/pdf.ts new file mode 100644 index 0000000..ce39988 --- /dev/null +++ b/examples/pdf.ts @@ -0,0 +1,38 @@ +/** + * Convert a PDF to AgentMark and print the snapshot. + * + * npx tsx examples/pdf.ts /path/to/document.pdf + * + * Requires the optional peer dep: + * npm install pdfjs-dist@^4 + */ + +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' +import { pathToFileURL } from 'node:url' +import { convertPdf, consoleLogger } from '../src' + +async function main() { + const filePath = process.argv[2] + if (!filePath) { + console.error('Usage: npx tsx examples/pdf.ts ') + process.exit(1) + } + + const data = await readFile(filePath) + const sourceUrl = pathToFileURL(path.resolve(filePath)).toString() + + const { agentmark } = await convertPdf({ + data, + sourceUrl, + logger: consoleLogger, + }) + + console.log('\n────── AgentMark snapshot ──────\n') + console.log(agentmark) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/package.json b/package.json index 080a391..31e78e9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@thinkfleet/agentmark", - "version": "0.3.0", - "description": "AI browser library — convert any web page into a compact AgentMark snapshot, then drive it via clean primitives any AI can call. Spec: docs/specs/agentmark-v0.1.md", + "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.", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", @@ -34,22 +34,30 @@ "LICENSE" ], "dependencies": { - "js-yaml": "^4.1.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "js-yaml": "^4.1.0", "tslib": "2.6.2" }, "peerDependencies": { + "pdfjs-dist": "^4.10.38", "playwright-core": ">=1.40.0" }, "peerDependenciesMeta": { - "playwright-core": { "optional": false } + "playwright-core": { + "optional": false + }, + "pdfjs-dist": { + "optional": true + } }, "devDependencies": { - "vitest": "3.0.8", - "@types/node": "20.19.9", "@types/js-yaml": "4.0.9", + "@types/node": "20.19.9", + "pdf-lib": "^1.17.1", + "pdfjs-dist": "^4.10.38", "playwright-core": "^1.40.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "3.0.8" } } diff --git a/schema/agentmark-v0.2.json b/schema/agentmark-v0.2.json new file mode 100644 index 0000000..c552137 --- /dev/null +++ b/schema/agentmark-v0.2.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentmark.dev/schema/v0.2.json", + "title": "agentmark v0.2 frontmatter", + "description": "v0.2 extends v0.1 with a `kind` discriminator (webpage|document|form) and document metadata. Web-page snapshots without a `kind` field continue to validate.", + "type": "object", + "required": ["agentmark", "url", "title"], + "properties": { + "agentmark": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+(\\.[0-9]+)?$" }, + "kind": { "enum": ["webpage", "document", "form"] }, + "url": { "type": "string", "format": "uri" }, + "title": { "type": "string", "maxLength": 512 }, + "captured_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": "string", "format": "date-time" }, + "source": { "enum": ["rendered", "declared", "hybrid"] }, + "language": { "type": "string" }, + "direction": { "enum": ["ltr", "rtl"] }, + "state": { + "type": "object", + "additionalProperties": false, + "properties": { + "loading": { "type": "boolean" }, + "auth": { "enum": ["logged_in", "logged_out", "unknown"] }, + "error": { "type": ["string", "null"] }, + "empty": { "type": "boolean" }, + "ssl": { "enum": ["valid", "invalid", "mixed", "none"] }, + "modal_open": { "type": "boolean" }, + "active_tab": { "type": ["string", "null"] }, + "active_step": { "type": ["string", "null"] } + } + }, + "actions": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9_]{0,63}$": { "$ref": "#/$defs/action" } + }, + "additionalProperties": false + }, + "media": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9_]{0,63}$": { "$ref": "#/$defs/media" } + }, + "additionalProperties": false + }, + "document": { "$ref": "#/$defs/document" }, + "memory": { "type": "object" }, + "capabilities": { "type": "object" }, + "cookies": { "type": "object" }, + "permissions": { "type": "object" } + }, + "patternProperties": { + "^x-": {} + }, + "$defs": { + "action": { + "type": "object", + "required": ["type", "label"], + "properties": { + "type": { + "enum": [ + "click", "type", "check", "select", "multi_select", + "nav", "submit", "upload", + "date", "time", "datetime", + "range", "color", "key", + "hover", "scroll_to", "drag" + ] + }, + "label": { "type": "string", "maxLength": 256 }, + "description": { "type": "string", "maxLength": 1024 }, + "disabled": { "type": "boolean" }, + "disabled_reason": { "type": "string" }, + "required": { "type": "boolean" }, + "read_only": { "type": "boolean" }, + "validation": { "type": "string" }, + "value": {}, + "placeholder": { "type": "string" }, + "options": { "type": "array" }, + "min": { "type": "number" }, + "max": { "type": "number" }, + "step": { "type": "number" }, + "target": { "type": "string" }, + "target_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, + "cost": { "enum": ["free", "destructive", "financial"] }, + "confirms": { "type": "boolean" }, + "auth_required": { "type": "string" }, + "precondition_ids": { + "type": "array", + "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" } + }, + "aria": { + "type": "object", + "additionalProperties": false, + "properties": { + "expanded": { "type": "boolean" }, + "pressed": { "type": "boolean" }, + "checked": { "oneOf": [{ "type": "boolean" }, { "const": "mixed" }] }, + "selected": { "type": "boolean" }, + "disabled": { "type": "boolean" } + } + }, + "region_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, + "idempotent": { "type": "boolean" }, + "honeypot": { "type": "boolean" } + } + }, + "media": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["image", "video", "audio"] }, + "alt": { "type": "string" }, + "caption": { "type": ["string", "null"] }, + "preview_url": { "type": "string", "format": "uri" }, + "preview_token": { "type": ["string", "null"] }, + "text_extract": { "type": ["string", "null"] }, + "ocr_available": { "type": "boolean" }, + "transcript_available": { "type": "boolean" }, + "width": { "type": "integer" }, + "height": { "type": "integer" }, + "bytes": { "type": ["string", "null"] } + } + }, + "document": { + "type": "object", + "additionalProperties": false, + "properties": { + "pages": { "type": "integer", "minimum": 1 }, + "author": { "type": "string", "maxLength": 512 }, + "created_at": { "type": "string", "format": "date-time" }, + "modified_at": { "type": "string", "format": "date-time" }, + "format": { "enum": ["pdf", "docx", "rtf", "txt", "html"] }, + "format_version": { "type": "string", "maxLength": 32 }, + "ocr_used": { "type": "boolean" } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 261b77d..cde46ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,12 @@ // MIT License | https://github.com/ThinkfleetAI/agentmark // Spec: docs/specs/agentmark-v0.1.md -export { AGENTMARK_VERSION } from './types' +export { AGENTMARK_VERSION, SUPPORTED_SPEC_VERSIONS } from './types' export type { Snapshot, SnapshotSource, + SnapshotKind, + DocumentMeta, PageState, ActionType, ActionCost, @@ -95,3 +97,21 @@ export { SESSION_FORMAT_VERSION, } from './runtime/session' export type { SessionFile, StorageState } from './runtime/session' + +// ── M2: PDF / document support (kind: 'document') ───────────────────────── + +export { + convertPdf, + extractPdf, + buildBodyFromPdf, +} from './pdf' +export type { + ConvertPdfOptions, + ExtractPdfOptions, + BuildPdfBodyOptions, + PdfDocument, + PdfDocumentMeta, + PdfPage, + PdfTextItem, + PdfBlock, +} from './pdf' diff --git a/src/pdf/body-builder.ts b/src/pdf/body-builder.ts new file mode 100644 index 0000000..711d227 --- /dev/null +++ b/src/pdf/body-builder.ts @@ -0,0 +1,272 @@ +/** + * Convert a structured PdfDocument into AgentMark `BodySegment[]` ready for + * the existing serializer pipeline. + * + * The hard problem here is that PDFs have no semantic structure — only + * positioned glyphs. This builder uses lightweight heuristics: + * + * - Lines are reconstructed by Y-coordinate clustering within a page. + * - Headings are inferred from outlier (larger) font sizes. + * - Lists are inferred from leading bullet glyphs or "1.", "2." patterns. + * - Page boundaries become explicit `[PAGE:p_n]` markers. + * + * Heuristics are intentionally conservative — false positives (mistakenly + * promoted headings, missed lists) hurt agent comprehension less than + * overreach. Tables are deliberately skipped in v0.2; better-than-nothing + * text fallback ships, structural detection deferred to a later release. + */ + +import type { BodySegment } from '../extractors/dom-extractor' +import type { PdfDocument, PdfPage, PdfTextItem } from './types' + +export interface BuildPdfBodyOptions { + /** Multiplier on median font size above which text is promoted to a heading. + * Default: 1.3 — fairly conservative. */ + headingThreshold?: number +} + +/** + * Top-level: convert a parsed PdfDocument 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[] { + const headingThreshold = opts.headingThreshold ?? 1.3 + const allSizes = collectAllFontSizes(doc) + const sortedDescending = [...allSizes].sort((a, b) => b - a) + // Top 3 distinct sizes map to h1/h2/h3 if the document uses multiple sizes. + // Otherwise we fall back to median-based heading detection per page. + const topSizes = uniqueDescending(sortedDescending, 4) + const median = computeMedian(allSizes) + + const segments: BodySegment[] = [] + + for (const page of doc.pages) { + if (page.items.length === 0) { + // Empty page (e.g. pure-image page that needs OCR — coming in M2 Pass 3). + segments.push({ kind: 'tag', tag: 'PAGE', ref: pageRef(page.number) }) + continue + } + + segments.push({ kind: 'tag', tag: 'PAGE', ref: pageRef(page.number) }) + + const lines = groupItemsIntoLines(page) + const blocks = linesToBlocks(lines, median, topSizes, headingThreshold, page.height) + segments.push(...blocks) + } + + return segments +} + +// ──────────────────────────────────────────────────────────────────────── +// Line reconstruction +// ──────────────────────────────────────────────────────────────────────── + +interface PdfLine { + /** Y baseline of the line. */ + y: number + /** Maximum font size on this line — used as the line's "size class". */ + maxFontSize: number + /** Concatenated text. */ + text: string + /** X position of the first item — used for indentation hints. */ + leftX: number +} + +/** + * Cluster items by Y-coordinate. Items whose baselines are within + * `Y_TOLERANCE * fontSize` belong to the same visual line. + */ +function groupItemsIntoLines(page: PdfPage): PdfLine[] { + const Y_TOLERANCE = 0.5 + // Sort by Y descending (PDF origin is bottom-left, so larger Y = higher + // on the page = comes first in reading order). + const items = [...page.items].sort((a, b) => b.y - a.y || a.x - b.x) + + const lines: PdfLine[] = [] + let current: PdfTextItem[] = [] + let currentY: number | null = null + + for (const item of items) { + if (!item.text) continue + const tolerance = Math.max(item.fontSize * Y_TOLERANCE, 1) + if (currentY === null || Math.abs(item.y - currentY) <= tolerance) { + if (currentY === null) currentY = item.y + current.push(item) + } else { + if (current.length > 0) lines.push(buildLine(current)) + current = [item] + currentY = item.y + } + } + if (current.length > 0) lines.push(buildLine(current)) + + return lines +} + +function buildLine(items: PdfTextItem[]): PdfLine { + // Sort by X ascending so reading order is preserved. + const sorted = [...items].sort((a, b) => a.x - b.x) + const text = sorted + .map((it) => it.text) + .join(' ') + .replace(/\s+/g, ' ') + .trim() + const maxFontSize = sorted.reduce((m, it) => Math.max(m, it.fontSize), 0) + return { + y: sorted[0]?.y ?? 0, + maxFontSize, + text, + leftX: sorted[0]?.x ?? 0, + } +} + +// ──────────────────────────────────────────────────────────────────────── +// Block detection (lines → segments) +// ──────────────────────────────────────────────────────────────────────── + +function linesToBlocks( + lines: PdfLine[], + median: number, + topSizes: number[], + headingThreshold: number, + pageHeight: number, +): BodySegment[] { + const segments: BodySegment[] = [] + const PARAGRAPH_GAP_FACTOR = 1.6 + + let paragraphBuffer: string[] = [] + let prevY: number | null = null + let prevSize: number | null = null + let listBuffer: { ordered: boolean; items: string[] } | null = null + + const flushParagraph = () => { + if (paragraphBuffer.length > 0) { + const text = paragraphBuffer.join(' ').trim() + if (text) segments.push({ kind: 'paragraph', text }) + paragraphBuffer = [] + } + } + const flushList = () => { + if (listBuffer && listBuffer.items.length > 0) { + segments.push({ kind: 'list', ordered: listBuffer.ordered, items: listBuffer.items }) + } + listBuffer = null + } + const flushAll = () => { + flushParagraph() + flushList() + } + + for (const line of lines) { + if (!line.text) continue + + // ── Page-break-equivalent: treat large vertical gap as paragraph break ── + if (prevY !== null && prevSize !== null) { + const gap = prevY - line.y // PDF origin bottom-left, so prevY > line.y normally + if (gap > prevSize * PARAGRAPH_GAP_FACTOR) { + flushAll() + } + } + + // ── Heading detection ───────────────────────────────────────────── + const headingLevel = inferHeadingLevel(line.maxFontSize, median, topSizes, headingThreshold) + if (headingLevel !== null) { + flushAll() + segments.push({ kind: 'heading', level: headingLevel, text: line.text }) + prevY = line.y + prevSize = line.maxFontSize + continue + } + + // ── List item detection ─────────────────────────────────────────── + const listInfo = detectListItem(line.text) + if (listInfo) { + flushParagraph() + if (!listBuffer || listBuffer.ordered !== listInfo.ordered) { + flushList() + listBuffer = { ordered: listInfo.ordered, items: [] } + } + listBuffer.items.push(listInfo.text) + prevY = line.y + prevSize = line.maxFontSize + continue + } + + // ── Default: append to current paragraph ────────────────────────── + flushList() + paragraphBuffer.push(line.text) + prevY = line.y + prevSize = line.maxFontSize + } + + flushAll() + void pageHeight // reserved for future use + return segments +} + +function inferHeadingLevel( + fontSize: number, + median: number, + topSizes: number[], + threshold: number, +): 1 | 2 | 3 | null { + if (fontSize < median * threshold) return null + // If the document has multiple distinct large sizes, map them to h1/h2/h3. + if (topSizes.length >= 1 && approxEqual(fontSize, topSizes[0], 0.5)) return 1 + if (topSizes.length >= 2 && approxEqual(fontSize, topSizes[1], 0.5)) return 2 + if (topSizes.length >= 3 && approxEqual(fontSize, topSizes[2], 0.5)) return 3 + // Otherwise just call it h2. + return 2 +} + +function detectListItem(text: string): { ordered: boolean; text: string } | null { + // Bulleted: starts with •, ◦, ●, ○, ▪, ▫, *, –, —, - + const bulletMatch = text.match(/^[•◦●○▪▫\*–—\-]\s+(.+)$/) + if (bulletMatch) return { ordered: false, text: bulletMatch[1].trim() } + + // Ordered: starts with "1.", "2)", "(1)", etc. + const orderedMatch = text.match(/^(?:\d+|[a-zA-Z])[.)]\s+(.+)$/) + ?? text.match(/^\(\d+\)\s+(.+)$/) + if (orderedMatch) return { ordered: true, text: orderedMatch[1].trim() } + + return null +} + +// ──────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────── + +function collectAllFontSizes(doc: PdfDocument): number[] { + const sizes: number[] = [] + for (const page of doc.pages) { + for (const item of page.items) { + if (item.text.trim().length > 0) sizes.push(item.fontSize) + } + } + return sizes +} + +function computeMedian(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +function uniqueDescending(values: number[], limit: number): number[] { + const tolerance = 0.5 + const out: number[] = [] + for (const v of values) { + if (out.every((u) => Math.abs(u - v) > tolerance)) out.push(v) + if (out.length >= limit) break + } + return out +} + +function approxEqual(a: number, b: number, tolerance: number): boolean { + return Math.abs(a - b) <= tolerance +} + +function pageRef(pageNumber: number): string { + return `p_${pageNumber}` +} diff --git a/src/pdf/index.ts b/src/pdf/index.ts new file mode 100644 index 0000000..fa07156 --- /dev/null +++ b/src/pdf/index.ts @@ -0,0 +1,20 @@ +/** + * PDF support module. + * + * Public entry point: `convertPdf()` produces a `kind: 'document'` AgentMark + * snapshot from PDF bytes. Mirrors the shape of `convertPage()` for web pages. + */ + +export { convertPdf } from './pdf-converter' +export type { ConvertPdfOptions } from './pdf-converter' +export { extractPdf } from './pdf-extractor' +export type { ExtractPdfOptions } from './pdf-extractor' +export { buildBodyFromPdf } from './body-builder' +export type { BuildPdfBodyOptions } from './body-builder' +export type { + PdfDocument, + PdfDocumentMeta, + PdfPage, + PdfTextItem, + PdfBlock, +} from './types' diff --git a/src/pdf/pdf-converter.ts b/src/pdf/pdf-converter.ts new file mode 100644 index 0000000..192a6fc --- /dev/null +++ b/src/pdf/pdf-converter.ts @@ -0,0 +1,158 @@ +/** + * `convertPdf()` — convert a PDF buffer into an AgentMark snapshot with + * `kind: 'document'`. Mirrors the shape of `convertPage()` for web pages. + * + * Returns the same `ConversionResult` (serialized text + binding) so the + * downstream LLM pipeline is identical regardless of the source surface. + * + * @example + * import { readFile } from 'node:fs/promises' + * import { convertPdf } from '@thinkfleet/agentmark' + * + * const data = await readFile('./report.pdf') + * const { agentmark } = await convertPdf({ data, sourceUrl: 'file:///report.pdf' }) + * console.log(agentmark) + */ + +import { + AGENTMARK_VERSION, + type ConversionResult, + type DocumentMeta, + type Snapshot, +} from '../types' +import { buildBody } from '../extractors/body-builder' +import { serializeSnapshot } from '../serializers/yaml-frontmatter' +import { extractPdf } from './pdf-extractor' +import { buildBodyFromPdf, type BuildPdfBodyOptions } from './body-builder' +import { InMemoryActionBinding } from '../binding/action-binding' +import { noopLogger, type Logger } from '../observability/logger' +import { SnapshotError } from '../errors' + +export interface ConvertPdfOptions { + /** Raw PDF bytes (from `readFile`, `fetch`, etc.). */ + data: Uint8Array | ArrayBuffer + /** URL or `file://` URI identifying the document source. Used as `snapshot.url`. */ + sourceUrl: string + /** Override the document title. Default: PDF metadata title, or sourceUrl basename. */ + title?: string + /** Password for encrypted PDFs. */ + password?: string + /** Heading detection threshold passed to the body builder. Default: 1.3. */ + headingThreshold?: number + /** TTL for `expires_at` (ms). Default: 1 hour. PDFs change less than web pages. */ + ttlMs?: number + /** BCP-47 language tag, if known. */ + language?: string + /** Logger for structured events. Default: noopLogger. */ + logger?: Logger + /** Vendor extensions (`x-` prefix). */ + vendorExtensions?: Record + /** Extra body-builder options. */ + body?: BuildPdfBodyOptions +} + +/** + * Convert PDF bytes into an AgentMark snapshot. + * + * Throws `SnapshotError` on parse / extraction failure (wrapped from pdfjs-dist). + * The optional peer dep `pdfjs-dist` must be installed; surface a clear error + * if missing (see `pdfjs-loader.ts`). + */ +export async function convertPdf(options: ConvertPdfOptions): Promise { + const logger = options.logger ?? noopLogger + const ttlMs = options.ttlMs ?? 60 * 60_000 // 1 hour default — PDFs change rarely + + logger.debug('snapshot.capture.start', { source: options.sourceUrl, kind: 'document' }) + + let extracted: Awaited> + try { + extracted = await extractPdf({ data: options.data, password: options.password }) + } catch (err) { + logger.error('snapshot.failed', { error: (err as Error).message }) + // extractPdf wraps in SnapshotError already; pass through. + if (err instanceof SnapshotError) throw err + throw new SnapshotError(`PDF extraction failed: ${(err as Error).message}`, err as Error) + } + + const segments = buildBodyFromPdf(extracted, options.body ?? {}) + const body = buildBody(segments) + + const captured_at = new Date().toISOString() + const expires_at = new Date(Date.now() + ttlMs).toISOString() + + const documentMeta: DocumentMeta = { + pages: extracted.metadata.pages, + author: extracted.metadata.author, + created_at: extracted.metadata.created_at, + modified_at: extracted.metadata.modified_at, + format: 'pdf', + format_version: extracted.metadata.pdf_version, + ocr_used: false, + } + + const title = + options.title + ?? extracted.metadata.title + ?? deriveTitleFromUrl(options.sourceUrl) + + const snapshot: Snapshot = { + agentmark: AGENTMARK_VERSION, + kind: 'document', + url: options.sourceUrl, + title, + captured_at, + expires_at, + source: 'declared', + language: options.language, + document: stripUndefined(documentMeta), + capabilities: { + preview_media: false, + expand_disclosures: false, + paginate: true, + scroll: true, + keyboard: false, + drag: false, + ocr: documentMeta.ocr_used ?? false, + vision: false, + }, + body, + } + + if (options.vendorExtensions) { + for (const [k, v] of Object.entries(options.vendorExtensions)) { + if (k.startsWith('x-')) (snapshot as unknown as Record)[k] = v + } + } + + const text = serializeSnapshot(snapshot) + + logger.info('snapshot.captured', { + source: options.sourceUrl, + kind: 'document', + pages: documentMeta.pages, + segments: segments.length, + bytes: text.length, + }) + + // PDFs have no interactive actions in this release (M3 will add AcroForm + // support and populate the binding). Return an empty binding for now. + return { agentmark: text, binding: new InMemoryActionBinding() } +} + +function deriveTitleFromUrl(url: string): string { + try { + const u = new URL(url) + const last = u.pathname.split('/').filter(Boolean).pop() ?? '(untitled)' + return decodeURIComponent(last).replace(/\.[a-z0-9]+$/i, '') || '(untitled)' + } catch { + return '(untitled)' + } +} + +function stripUndefined(obj: T): T { + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v + } + return out as T +} diff --git a/src/pdf/pdf-extractor.ts b/src/pdf/pdf-extractor.ts new file mode 100644 index 0000000..ba88c04 --- /dev/null +++ b/src/pdf/pdf-extractor.ts @@ -0,0 +1,141 @@ +/** + * Extract a structured PdfDocument from a PDF buffer using pdfjs-dist. + * + * Outputs raw items per page (text + position + font size). Higher-level + * structural inference (headings, paragraphs, lists) lives in body-builder. + */ + +import { loadPdfjs } from './pdfjs-loader' +import { SnapshotError } from '../errors' +import type { PdfDocument, PdfPage, PdfTextItem } from './types' + +export interface ExtractPdfOptions { + /** Raw PDF bytes (from `readFile`, `fetch`, etc.). */ + data: Uint8Array | ArrayBuffer + /** Optional password, if the PDF is encrypted. */ + password?: string +} + +export async function extractPdf(opts: ExtractPdfOptions): Promise { + const pdfjs = await loadPdfjs() + + let doc: Awaited['promise']> + try { + const data = opts.data instanceof Uint8Array + ? opts.data + : new Uint8Array(opts.data) + doc = await pdfjs.getDocument({ + data, + password: opts.password, + // Suppress pdfjs-dist's verbose console logging. + verbosity: 0, + }).promise + } catch (err) { + throw new SnapshotError( + `Failed to open PDF: ${(err as Error).message}`, + err as Error, + ) + } + + const metadata = await readMetadata(doc) + const pages: PdfPage[] = [] + + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + const page = await doc.getPage(pageNum) + const viewport = page.getViewport({ scale: 1 }) + const text = await page.getTextContent({ + // Don't normalize whitespace — we do our own joining. + includeMarkedContent: false, + }) + + const items: PdfTextItem[] = [] + for (const raw of text.items) { + // Skip non-text items (marked-content is filtered above; this + // catches anything else pdfjs may return). + if (!('str' in raw)) continue + // pdfjs's transform: [a, b, c, d, e, f] + // a = x scale (font size), e = x position, f = y position (top-left origin in viewport) + const t = raw.transform + if (!t || t.length < 6) continue + items.push({ + text: raw.str, + fontSize: Math.abs(t[3]) || Math.abs(t[0]), + fontName: raw.fontName ?? 'unknown', + x: t[4], + y: t[5], + width: raw.width ?? 0, + hasEol: raw.hasEOL ?? false, + }) + } + + pages.push({ + number: pageNum, + width: viewport.width, + height: viewport.height, + items, + }) + + // Free the page resources. pdfjs holds references in a cache otherwise. + page.cleanup() + } + + await doc.destroy() + + return { + pages, + metadata: { ...metadata, pages: doc.numPages }, + } +} + +interface PdfInfoFields { + Title?: string + Author?: string + CreationDate?: string + ModDate?: string + PDFFormatVersion?: string +} + +async function readMetadata( + doc: Awaited>['getDocument']>['promise']>, +): Promise> { + try { + const m = await doc.getMetadata() + const info = (m.info ?? {}) as PdfInfoFields + return { + title: typeof info.Title === 'string' ? info.Title : undefined, + author: typeof info.Author === 'string' ? info.Author : undefined, + created_at: parsePdfDate(info.CreationDate), + modified_at: parsePdfDate(info.ModDate), + pdf_version: typeof info.PDFFormatVersion === 'string' ? info.PDFFormatVersion : undefined, + } + } catch { + // Some PDFs lack the info dict entirely — fall through with empty metadata. + return {} + } +} + +/** + * PDF dates are typically in the form `D:YYYYMMDDHHmmSS+HH'mm'`. Translate + * to ISO 8601, returning undefined on parse failure. + */ +function parsePdfDate(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const match = raw.match( + /^D?:?(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(?:([+\-Z])(\d{2})'?(\d{2})?'?)?$/, + ) + if (!match) return undefined + const [, y, mo, d, h, mi, s, tz, tzh, tzm] = match + const year = y + const month = mo ?? '01' + const day = d ?? '01' + const hour = h ?? '00' + const minute = mi ?? '00' + const second = s ?? '00' + let offset = 'Z' + if (tz === '+' || tz === '-') { + offset = `${tz}${tzh ?? '00'}:${tzm ?? '00'}` + } + const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}${offset}` + const parsed = Date.parse(iso) + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString() +} diff --git a/src/pdf/pdfjs-loader.ts b/src/pdf/pdfjs-loader.ts new file mode 100644 index 0000000..c9eae55 --- /dev/null +++ b/src/pdf/pdfjs-loader.ts @@ -0,0 +1,30 @@ +/** + * Lazy loader for `pdfjs-dist`. The dependency is an *optional* peer dep + * because most AgentMark callers only use the web-page path. Surface a + * clear error if the user calls `convertPdf()` without it installed. + */ + +import { SnapshotError } from '../errors' + +// pdfjs-dist's Node ESM bundle. We use the legacy build because the modern +// build expects a fetch-style worker setup; legacy runs cleanly in Node. +type PdfjsLib = typeof import('pdfjs-dist/legacy/build/pdf.mjs') + +let cached: PdfjsLib | null = null + +export async function loadPdfjs(): Promise { + if (cached) return cached + try { + // Dynamic import keeps pdfjs-dist out of the require graph for + // callers who never touch PDFs. The string-literal path is required + // for Node's ESM resolution to find the legacy build. + cached = await import('pdfjs-dist/legacy/build/pdf.mjs') + return cached + } catch (err) { + throw new SnapshotError( + 'PDF support requires the optional peer dependency pdfjs-dist. ' + + 'Install with: npm install pdfjs-dist@^4', + err as Error, + ) + } +} diff --git a/src/pdf/types.ts b/src/pdf/types.ts new file mode 100644 index 0000000..b7c8ec4 --- /dev/null +++ b/src/pdf/types.ts @@ -0,0 +1,59 @@ +/** + * Internal PDF extraction types — the structured intermediate between + * pdfjs-dist's text-content output and AgentMark's body grammar. + */ + +export interface PdfTextItem { + /** Plain text run. */ + text: string + /** Font height in PDF user-space units. */ + fontSize: number + /** Font name from the PDF's font dictionary. */ + fontName: string + /** X position (left edge), PDF user space. */ + x: number + /** Y position (baseline), PDF user space (origin bottom-left). */ + y: number + /** Width of the run, PDF user space. */ + width: number + /** Whether this item ends with whitespace requiring a join space. */ + hasEol: boolean +} + +export interface PdfPage { + /** 1-indexed page number. */ + number: number + /** Page width in PDF user space. */ + width: number + /** Page height in PDF user space. */ + height: number + /** Items in document order (top-to-bottom, left-to-right within line). */ + items: PdfTextItem[] +} + +export interface PdfDocument { + pages: PdfPage[] + metadata: PdfDocumentMeta +} + +export interface PdfDocumentMeta { + title?: string + author?: string + /** ISO 8601 if parseable. */ + created_at?: string + modified_at?: string + /** Format version reported by the PDF (e.g. "1.7"). */ + pdf_version?: string + /** Total page count. */ + pages: number +} + +/** + * A higher-level structural block — what we actually emit to AgentMark. + * One PDF page typically becomes many blocks. + */ +export type PdfBlock = + | { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; text: string; page: number } + | { kind: 'paragraph'; text: string; page: number } + | { kind: 'list'; ordered: boolean; items: string[]; page: number } + | { kind: 'page_break'; page: number } diff --git a/src/types.ts b/src/types.ts index c6bc050..7da17aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,18 +5,29 @@ * Producers build an `Snapshot`; serializers turn it into the wire format. */ -export const AGENTMARK_VERSION = '0.1' as const +export const AGENTMARK_VERSION = '0.2' as const + +/** Spec versions this implementation can validate against. */ +export const SUPPORTED_SPEC_VERSIONS = ['0.1', '0.2'] as const // ────────────────────────────────────────────────────────────────────────── // Frontmatter envelope // ────────────────────────────────────────────────────────────────────────── +/** + * Discriminator added in v0.2. Identifies what kind of surface the snapshot + * was extracted from. Defaults to 'webpage' when omitted (v0.1 compatibility). + */ +export type SnapshotKind = 'webpage' | 'document' | 'form' + export interface Snapshot { - /** Spec version, e.g. "0.1" */ + /** Spec version, e.g. "0.1" or "0.2" */ agentmark: string - /** Absolute URL of the page at capture time */ + /** Surface kind (v0.2+). Default: 'webpage'. */ + kind?: SnapshotKind + /** Absolute URL of the page (or document `file://` URI) at capture time */ url: string - /** Page title */ + /** Page or document title */ title: string /** ISO 8601 capture timestamp */ @@ -38,6 +49,9 @@ export interface Snapshot { cookies?: CookieState permissions?: PermissionState + /** Document-specific metadata (v0.2+, populated when kind === 'document'). */ + document?: DocumentMeta + /** The Markdown body */ body: string @@ -47,6 +61,27 @@ export interface Snapshot { export type SnapshotSource = 'rendered' | 'declared' | 'hybrid' +/** + * Document metadata extracted from PDF (or other document) backends. All + * fields optional — backends populate what they can. + */ +export interface DocumentMeta { + /** Total page count. */ + pages?: number + /** Document author, when present in metadata. */ + author?: string + /** ISO 8601 creation timestamp from the source document. */ + created_at?: string + /** ISO 8601 last-modified timestamp from the source document. */ + modified_at?: string + /** Source format identifier — 'pdf', 'docx', etc. */ + format?: 'pdf' | 'docx' | 'rtf' | 'txt' | 'html' + /** Format-specific version (e.g. PDF spec version "1.7"). */ + format_version?: string + /** Whether the source was OCR'd (i.e. originally a scan). */ + ocr_used?: boolean +} + // ────────────────────────────────────────────────────────────────────────── // Page state // ────────────────────────────────────────────────────────────────────────── @@ -191,6 +226,9 @@ export type BodyTagKind = | 'CHALLENGE' | 'ERROR' | 'TOAST' + /** v0.2+: page boundary marker for `kind: 'document'`. Payload is a + * page identifier like `p_1` whose number maps to the source PDF page. */ + | 'PAGE' export interface BodyTagReference { kind: BodyTagKind diff --git a/src/validators/schema-validator.ts b/src/validators/schema-validator.ts index ba28b00..3f9753f 100644 --- a/src/validators/schema-validator.ts +++ b/src/validators/schema-validator.ts @@ -6,18 +6,35 @@ import * as path from 'path' import type { Snapshot } from '../types' import { extractTagReferences } from '../serializers/body-text' -let cachedValidator: ValidateFunction | null = null +const validatorCache = new Map() + +/** + * Resolve the schema major.minor for a declared agentmark version. We accept + * any patch version of a known major.minor (e.g. "0.1.3" matches v0.1). + * Unknown versions fall back to the highest known schema and emit a warning + * elsewhere — see `validateSnapshot` cross-field check 2e. + */ +function resolveSchemaVersion(declared: string): '0.1' | '0.2' { + const [major, minor] = declared.split('.') + const minorMajor = `${major}.${minor}` + if (minorMajor === '0.1') return '0.1' + return '0.2' +} + +function loadValidator(version: '0.1' | '0.2'): ValidateFunction { + const cached = validatorCache.get(version) + if (cached) return cached -function loadValidator(): ValidateFunction { - if (cachedValidator) return cachedValidator const ajv = new Ajv2020({ allErrors: true, strict: false }) // ajv-formats ships its own nested ajv version; the cast bridges the type mismatch. // The runtime is identical (same JSON Schema spec). addFormats(ajv as unknown as Parameters[0]) - const schemaPath = path.join(__dirname, '..', '..', 'schema', 'agentmark-v0.1.json') + + const schemaPath = path.join(__dirname, '..', '..', 'schema', `agentmark-v${version}.json`) const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8')) - cachedValidator = ajv.compile(schema) - return cachedValidator + const validator = ajv.compile(schema) + validatorCache.set(version, validator) + return validator } export interface ValidationIssue { @@ -44,8 +61,9 @@ export function validateSnapshot(snapshot: Snapshot): ValidationResult { const errors: ValidationIssue[] = [] const warnings: ValidationIssue[] = [] - // 1. Schema validation (frontmatter only) - const validator = loadValidator() + // 1. Schema validation (frontmatter only) — pick schema by declared version + const schemaVersion = resolveSchemaVersion(snapshot.agentmark) + const validator = loadValidator(schemaVersion) const { body, ...envelope } = snapshot const valid = validator(envelope) if (!valid && validator.errors) { @@ -71,11 +89,15 @@ export function validateSnapshot(snapshot: Snapshot): ValidationResult { const ACTION_RESOLVING = new Set(['ACTION', 'INPUT', 'NAV', 'TOAST']) const MEDIA_RESOLVING = new Set(['MEDIA']) const PAYLOAD_TAGS = new Set(['ERROR', 'CHALLENGE']) + // PAGE is a v0.2 structural marker for `kind: 'document'` — payload is a + // page identifier (e.g. p_1) that does not resolve to any envelope entry. + const STRUCTURAL_TAGS = new Set(['PAGE']) const bodyRefs = extractTagReferences(body) for (const ref of bodyRefs) { if (!ref.payload) continue // AUTH_WALL etc. if (PAYLOAD_TAGS.has(ref.kind)) continue + if (STRUCTURAL_TAGS.has(ref.kind)) continue if (ACTION_RESOLVING.has(ref.kind) && !actionIds.has(ref.payload)) { errors.push({ severity: 'error', @@ -152,14 +174,26 @@ export function validateSnapshot(snapshot: Snapshot): ValidationResult { // 2e. version compatibility const major = parseInt(snapshot.agentmark.split('.')[0], 10) - if (major > 0) { + const minor = parseInt(snapshot.agentmark.split('.')[1] ?? '0', 10) + if (major > 0 || minor > 2) { warnings.push({ severity: 'warning', path: '/agentmark', - message: `This validator implements v0.x; document declares v${snapshot.agentmark}`, + message: `This validator implements v0.1 + v0.2; snapshot declares v${snapshot.agentmark}. Validated against v0.2 schema.`, }) } + // 2f. document-kind sanity (v0.2) + if (snapshot.kind === 'document') { + if (snapshot.state?.auth || snapshot.state?.modal_open) { + warnings.push({ + severity: 'warning', + path: '/state', + message: `Web-page state fields (auth, modal_open) are unusual for kind: 'document'`, + }) + } + } + return { valid: errors.length === 0, errors, warnings } } diff --git a/test/build-snapshot.test.ts b/test/build-snapshot.test.ts index 5a03bd8..45ae96a 100644 --- a/test/build-snapshot.test.ts +++ b/test/build-snapshot.test.ts @@ -45,7 +45,7 @@ describe('buildSnapshot', () => { it('sets agentmark version, source, and timestamps', () => { const snap = buildSnapshot(fakeExtraction()) - expect(snap.agentmark).toBe('0.1') + expect(snap.agentmark).toBe('0.2') expect(snap.source).toBe('rendered') expect(snap.captured_at).toBeDefined() expect(snap.expires_at).toBeDefined() diff --git a/test/pdf/pdf-converter.test.ts b/test/pdf/pdf-converter.test.ts new file mode 100644 index 0000000..7c4de2d --- /dev/null +++ b/test/pdf/pdf-converter.test.ts @@ -0,0 +1,306 @@ +/** + * Unit tests for the PDF → AgentMark converter pipeline. + * + * Generates fresh PDFs with `pdf-lib` so fixtures live in code (easy to + * inspect and modify) rather than as committed binary files. Tests both + * the extraction layer (text + metadata) and the higher-level converter + * (heading inference, page markers, AgentMark serialization). + */ + +import { describe, it, expect } from 'vitest' +import { + PDFDocument, + StandardFonts, + rgb, +} from 'pdf-lib' +import { convertPdf } from '../../src/pdf/pdf-converter' +import { extractPdf } from '../../src/pdf/pdf-extractor' +import { parseSnapshot } from '../../src/serializers/yaml-frontmatter' +import { validateSnapshot } from '../../src/validators/schema-validator' + +interface BuildPdfOpts { + title?: string + author?: string + pages: Array<{ + title?: { text: string; size?: number } + sections?: Array<{ heading?: { text: string; size?: number }; paragraphs?: string[] }> + bullets?: string[] + body?: string + }> +} + +async function buildPdf(opts: BuildPdfOpts): Promise { + const doc = await PDFDocument.create() + if (opts.title) doc.setTitle(opts.title) + if (opts.author) doc.setAuthor(opts.author) + + const helvetica = await doc.embedFont(StandardFonts.Helvetica) + const helveticaBold = await doc.embedFont(StandardFonts.HelveticaBold) + + for (const pageSpec of opts.pages) { + const page = doc.addPage([595, 842]) // A4 + let y = 800 + + if (pageSpec.title) { + const size = pageSpec.title.size ?? 24 + page.drawText(pageSpec.title.text, { + x: 50, + y, + size, + font: helveticaBold, + color: rgb(0, 0, 0), + }) + y -= size + 16 + } + + for (const section of pageSpec.sections ?? []) { + if (section.heading) { + const size = section.heading.size ?? 16 + page.drawText(section.heading.text, { + x: 50, + y, + size, + font: helveticaBold, + }) + y -= size + 8 + } + for (const para of section.paragraphs ?? []) { + page.drawText(para, { x: 50, y, size: 11, font: helvetica }) + y -= 18 + } + y -= 10 + } + + for (const bullet of pageSpec.bullets ?? []) { + page.drawText(`• ${bullet}`, { x: 60, y, size: 11, font: helvetica }) + y -= 16 + } + + if (pageSpec.body) { + page.drawText(pageSpec.body, { x: 50, y, size: 11, font: helvetica }) + } + } + + return await doc.save() +} + +describe('extractPdf', () => { + it('reports correct page count and metadata', async () => { + const pdf = await buildPdf({ + title: 'Test Doc', + author: 'AgentMark', + pages: [ + { body: 'Page one.' }, + { body: 'Page two.' }, + { body: 'Page three.' }, + ], + }) + const result = await extractPdf({ data: pdf }) + expect(result.metadata.pages).toBe(3) + expect(result.metadata.title).toBe('Test Doc') + expect(result.metadata.author).toBe('AgentMark') + expect(result.pages.length).toBe(3) + }) + + it('extracts text items with positions and font sizes', async () => { + const pdf = await buildPdf({ + pages: [{ body: 'Hello world' }], + }) + const result = await extractPdf({ data: pdf }) + const items = result.pages[0].items + expect(items.length).toBeGreaterThan(0) + // Should find "Hello world" content + const allText = items.map((i) => i.text).join(' ') + expect(allText).toMatch(/Hello/) + expect(allText).toMatch(/world/) + // Font size should be ~11 + expect(items[0].fontSize).toBeGreaterThan(8) + expect(items[0].fontSize).toBeLessThan(15) + }) + + it('throws SnapshotError on invalid PDF bytes', async () => { + const garbage = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + await expect(extractPdf({ data: garbage })).rejects.toMatchObject({ + code: 'snapshot_failed', + }) + }) +}) + +describe('convertPdf', () => { + it('produces a valid v0.2 document snapshot', async () => { + const pdf = await buildPdf({ + title: 'Annual Report', + author: 'Acme Inc.', + pages: [ + { + title: { text: 'Annual Report 2025', size: 28 }, + sections: [ + { + heading: { text: 'Introduction', size: 16 }, + paragraphs: [ + 'This is the introduction paragraph for the report.', + 'It contains some company background information.', + ], + }, + ], + }, + { + sections: [ + { + heading: { text: 'Financial Highlights', size: 16 }, + paragraphs: ['Revenue grew 25 percent year-over-year.'], + }, + ], + bullets: ['Q1 strong', 'Q2 record', 'Q3 steady', 'Q4 best ever'], + }, + ], + }) + + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/annual-report.pdf', + }) + + // Snapshot is parseable + validates against v0.2 schema + const snap = parseSnapshot(agentmark) + expect(snap.kind).toBe('document') + expect(snap.agentmark).toBe('0.2') + expect(snap.url).toBe('file:///tmp/annual-report.pdf') + expect(snap.title).toBe('Annual Report') + + // Document metadata populated + expect(snap.document?.pages).toBe(2) + expect(snap.document?.author).toBe('Acme Inc.') + expect(snap.document?.format).toBe('pdf') + expect(snap.document?.ocr_used).toBe(false) + + const result = validateSnapshot(snap) + expect(result.errors).toEqual([]) + expect(result.valid).toBe(true) + }) + + it('emits PAGE markers between pages', async () => { + const pdf = await buildPdf({ + pages: [ + { body: 'First page content here.' }, + { body: 'Second page content here.' }, + { body: 'Third page content here.' }, + ], + }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/multi.pdf', + }) + expect(agentmark).toContain('[PAGE:p_1]') + expect(agentmark).toContain('[PAGE:p_2]') + expect(agentmark).toContain('[PAGE:p_3]') + }) + + it('promotes large-font text to headings', async () => { + const pdf = await buildPdf({ + pages: [ + { + title: { text: 'Big Heading', size: 28 }, + sections: [ + { + paragraphs: ['Body text in a normal size font goes here.'], + }, + ], + }, + ], + }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/heading.pdf', + }) + // The 28pt title should become a heading; the 11pt body stays paragraph + expect(agentmark).toMatch(/^# +Big Heading/m) + }) + + it('detects bullet lists', async () => { + const pdf = await buildPdf({ + pages: [ + { + bullets: ['First item', 'Second item', 'Third item'], + }, + ], + }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/bullets.pdf', + }) + // The body builder emits these as a Markdown list + expect(agentmark).toMatch(/[-*] +First item/) + expect(agentmark).toMatch(/[-*] +Second item/) + expect(agentmark).toMatch(/[-*] +Third item/) + }) + + it('falls back to URL basename when title metadata is missing', async () => { + const pdf = await buildPdf({ + // no title + pages: [{ body: 'Content.' }], + }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/my-document.pdf', + }) + const snap = parseSnapshot(agentmark) + expect(snap.title).toBe('my-document') + }) + + it('passes logger events end-to-end', async () => { + const events: string[] = [] + const pdf = await buildPdf({ pages: [{ body: 'Hi.' }] }) + await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/x.pdf', + logger: { + debug: (e) => events.push(e), + info: (e) => events.push(e), + warn: (e) => events.push(e), + error: (e) => events.push(e), + }, + }) + expect(events).toContain('snapshot.capture.start') + expect(events).toContain('snapshot.captured') + }) + + it('respects custom title override', async () => { + const pdf = await buildPdf({ + title: 'PDF Internal Title', + pages: [{ body: 'Hi.' }], + }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/x.pdf', + title: 'Override Title', + }) + const snap = parseSnapshot(agentmark) + expect(snap.title).toBe('Override Title') + }) + + it('handles documents with no extracted text gracefully', async () => { + // Empty page (no text) + const pdf = await buildPdf({ pages: [{}, {}] }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/empty.pdf', + }) + const snap = parseSnapshot(agentmark) + expect(snap.kind).toBe('document') + expect(snap.document?.pages).toBe(2) + expect(agentmark).toContain('[PAGE:p_1]') + expect(agentmark).toContain('[PAGE:p_2]') + }) + + it('vendor extensions pass through to the snapshot', async () => { + const pdf = await buildPdf({ pages: [{ body: 'Content.' }] }) + const { agentmark } = await convertPdf({ + data: pdf, + sourceUrl: 'file:///tmp/x.pdf', + vendorExtensions: { 'x-custom-id': 'doc-42' }, + }) + expect(agentmark).toContain('x-custom-id') + expect(agentmark).toContain('doc-42') + }) +}) diff --git a/test/spec-v0.2.test.ts b/test/spec-v0.2.test.ts new file mode 100644 index 0000000..b230af4 --- /dev/null +++ b/test/spec-v0.2.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest' +import { validateSnapshot } from '../src/validators/schema-validator' +import { serializeSnapshot, parseSnapshot } from '../src/serializers/yaml-frontmatter' +import type { Snapshot } from '../src/types' +import { SUPPORTED_SPEC_VERSIONS, AGENTMARK_VERSION } from '../src/types' + +describe('Spec v0.2 — kind discriminator', () => { + it('default version is 0.2 in this implementation', () => { + expect(AGENTMARK_VERSION).toBe('0.2') + }) + + it('reports both v0.1 and v0.2 as supported', () => { + expect(SUPPORTED_SPEC_VERSIONS).toEqual(['0.1', '0.2']) + }) + + it('v0.1 snapshots without kind still validate (backwards compat)', () => { + const snap: Snapshot = { + agentmark: '0.1', + url: 'https://example.com/', + title: 'Example', + body: '# Hello', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + }) + + it('v0.2 snapshots may declare kind: webpage', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'webpage', + url: 'https://example.com/', + title: 'Example', + body: '# Hello', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + }) + + it('v0.2 snapshots may declare kind: document with metadata', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/report.pdf', + title: 'Annual Report 2025', + document: { + pages: 47, + author: 'Acme Inc.', + created_at: '2025-03-15T00:00:00.000Z', + format: 'pdf', + format_version: '1.7', + ocr_used: false, + }, + body: '[PAGE:p_1]\n\n# Cover\n\n[PAGE:p_2]\n\n## Introduction', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + expect(result.warnings).toEqual([]) + }) + + it('v0.2 snapshots may declare kind: form', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'form', + url: 'file:///tmp/application.pdf', + title: 'Vendor Application', + actions: { + act_company_name: { type: 'type', label: 'Company Name', required: true }, + act_submit: { type: 'submit', label: 'Submit Application' }, + }, + body: '# Vendor Application\n\n[INPUT:act_company_name]\n\n[ACTION:act_submit]', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + }) + + it('rejects kind: unknown_value', () => { + const snap = { + agentmark: '0.2', + kind: 'spreadsheet', + url: 'https://example.com/', + title: 'Test', + body: '# Hi', + } as unknown as Snapshot + const result = validateSnapshot(snap) + expect(result.valid).toBe(false) + }) + + it('rejects document with negative or zero pages', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/x.pdf', + title: 'Test', + document: { pages: 0 }, + body: '', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(false) + }) + + it('rejects document with unknown format', () => { + const snap = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/x.epub', + title: 'Test', + document: { format: 'epub' }, + body: '', + } as unknown as Snapshot + const result = validateSnapshot(snap) + expect(result.valid).toBe(false) + }) +}) + +describe('Spec v0.2 — PAGE body tag', () => { + it('PAGE markers do not require resolution to actions or media', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/doc.pdf', + title: 'Doc', + body: '[PAGE:p_1]\n\n# First page\n\nContent.\n\n[PAGE:p_2]\n\n# Second page', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + }) + + it('PAGE markers serialize and parse round-trip', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/doc.pdf', + title: 'Doc', + document: { pages: 2, format: 'pdf' }, + body: '[PAGE:p_1]\n\n# First page\n\n[PAGE:p_2]\n\n# Second page', + } + const serialized = serializeSnapshot(snap) + const parsed = parseSnapshot(serialized) + expect(parsed.kind).toBe('document') + expect(parsed.body).toContain('[PAGE:p_1]') + expect(parsed.body).toContain('[PAGE:p_2]') + expect(parsed.document?.pages).toBe(2) + expect(parsed.document?.format).toBe('pdf') + }) +}) + +describe('Spec v0.2 — version negotiation', () => { + it('warns when document declares unknown version above 0.2', () => { + const snap: Snapshot = { + agentmark: '0.5', + url: 'https://example.com/', + title: 'Future spec', + body: '# Hi', + } + const result = validateSnapshot(snap) + const versionWarning = result.warnings.find((w) => w.path === '/agentmark') + expect(versionWarning).toBeDefined() + }) + + it('warns when document with kind: document has webpage state fields', () => { + const snap: Snapshot = { + agentmark: '0.2', + kind: 'document', + url: 'file:///tmp/doc.pdf', + title: 'Doc', + state: { auth: 'logged_in' }, + body: '# Content', + } + const result = validateSnapshot(snap) + expect(result.valid).toBe(true) + expect(result.warnings.some((w) => w.path === '/state')).toBe(true) + }) +}) From 004a0a46b8ff481b0ff1106d5c1cabf0a60505f7 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Sun, 10 May 2026 09:18:24 -0400 Subject: [PATCH 2/3] feat(pdf): diagnostic CLI + Buffer/ArrayBuffer fixes (real-world hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds a diagnostic CLI for evaluating PDF→AgentMark quality on real-world documents (county forms, etc.) before extending PDF features. Also fixes two real bugs surfaced by running the diagnostic on the first sample. Bug fixes - pdf-extractor: defensively copy input bytes before passing to pdfjs-dist. pdfjs-dist (a) does a strict prototype check that rejects Node's Buffer even though it extends Uint8Array, and (b) transfers ownership of the underlying ArrayBuffer during parse, so calling extractPdf twice on the same data fails with "Cannot perform Construct on a detached ArrayBuffer". Both regressions now have unit tests. Diagnostic tool — examples/diagnose-pdf.ts - Per-page diagnostics: text-item count, font size distribution, median + outlier detection, suspected-scan flag (zero text items), suspected-multi-column flag (X-coordinate clustering) - Body-builder analysis: heading/paragraph/list counts, page-marker count - AgentMark size + estimated token cost - Quality score (0-100, heuristic) - Aggregated flag counts across a corpus - Suggestions tied to specific failure modes (OCR, multi-column, etc.) - Outputs Markdown report; --out flag writes to file - Accepts a single PDF or a directory of PDFs Use: npx tsx examples/diagnose-pdf.ts [--out report.md] Tests - 168 total now (was 166), 14 PDF tests including 2 new regression tests for the Buffer + ArrayBuffer-detachment fixes devDeps - tsx@^4 added so examples can be run with npx tsx without external installation Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/diagnose-pdf.ts | 387 +++++++++++++++++++++++++++++++++ package.json | 1 + src/pdf/pdf-extractor.ts | 14 +- test/pdf/pdf-converter.test.ts | 19 ++ 4 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 examples/diagnose-pdf.ts diff --git a/examples/diagnose-pdf.ts b/examples/diagnose-pdf.ts new file mode 100644 index 0000000..6ebd1b6 --- /dev/null +++ b/examples/diagnose-pdf.ts @@ -0,0 +1,387 @@ +/** + * Diagnostic CLI for the PDF→AgentMark converter. + * + * npx tsx examples/diagnose-pdf.ts [--out report.md] + * npx tsx examples/diagnose-pdf.ts [--out report.md] + * + * Produces a structured report scoring how well AgentMark parses the input: + * + * - Per-page diagnostics: text-item count, font size distribution, + * median + outlier detection, suspected-scan flag (zero text items), + * suspected-multi-column flag (X-coordinate clustering) + * - Body-builder output: heading count, paragraph count, list count, + * percentage of items captured, percentage dropped + * - AgentMark size + estimated token cost + * - Quality score (heuristic, 0-100) + * - Suggestions for v0.5 work based on what failed + * + * Use this on a corpus of real-world county PDFs to find blind spots. + */ + +import { readFile, readdir, writeFile, stat } from 'node:fs/promises' +import * as path from 'node:path' +import { pathToFileURL } from 'node:url' +import { extractPdf } from '../src/pdf/pdf-extractor' +import { buildBodyFromPdf } from '../src/pdf/body-builder' +import { convertPdf } from '../src/pdf/pdf-converter' +import { parseSnapshot } from '../src/serializers/yaml-frontmatter' +import { validateSnapshot } from '../src/validators/schema-validator' +import type { PdfDocument } from '../src/pdf/types' + +interface PageDiagnostic { + page: number + itemCount: number + medianFontSize: number + distinctFontSizes: number + suspectedScan: boolean + suspectedMultiColumn: boolean + minX: number + maxX: number + columnGapDetected: boolean +} + +interface DocReport { + file: string + sizeBytes: number + parseError?: string + pages?: number + metadata?: { title?: string; author?: string; pdf_version?: string } + perPage?: PageDiagnostic[] + body?: { + segments: number + headings: number + paragraphs: number + lists: number + page_markers: number + } + agentmark?: { bytes: number; tokens: number } + valid?: boolean + validationErrors?: string[] + qualityScore?: number + flags: string[] + suggestions: string[] +} + +async function diagnose(filePath: string): Promise { + const flags: string[] = [] + const suggestions: string[] = [] + + const stats = await stat(filePath).catch(() => null) + if (!stats) { + return { + file: filePath, + sizeBytes: 0, + parseError: 'file not found', + flags, + suggestions, + } + } + + const data = await readFile(filePath) + const sourceUrl = pathToFileURL(path.resolve(filePath)).toString() + + let extracted: PdfDocument + try { + extracted = await extractPdf({ data }) + } catch (err) { + return { + file: filePath, + sizeBytes: stats.size, + parseError: (err as Error).message, + flags: ['extract_failed'], + suggestions: ['Investigate parse failure — possibly encrypted, corrupt, or unsupported PDF version'], + } + } + + // Per-page analysis + const perPage: PageDiagnostic[] = [] + let scannedPages = 0 + let multiColumnPages = 0 + let totalItems = 0 + for (const page of extracted.pages) { + const sizes = page.items.map((i) => i.fontSize).filter((s) => s > 0) + const median = sizes.length === 0 ? 0 : medianOf(sizes) + const distinctSizes = new Set(sizes.map((s) => Math.round(s * 2) / 2)).size + const xs = page.items.map((i) => i.x) + const minX = xs.length ? Math.min(...xs) : 0 + const maxX = xs.length ? Math.max(...xs) : 0 + const columnGap = detectColumnGap(xs, page.width) + const suspectedScan = page.items.length === 0 || page.items.every((i) => !i.text.trim()) + const suspectedMultiColumn = !suspectedScan && columnGap + + if (suspectedScan) scannedPages++ + if (suspectedMultiColumn) multiColumnPages++ + totalItems += page.items.length + + perPage.push({ + page: page.number, + itemCount: page.items.length, + medianFontSize: round(median, 2), + distinctFontSizes: distinctSizes, + suspectedScan, + suspectedMultiColumn, + minX: round(minX, 1), + maxX: round(maxX, 1), + columnGapDetected: columnGap, + }) + } + + if (scannedPages > 0) { + flags.push(`${scannedPages}/${extracted.pages.length} pages have no extractable text — likely scanned`) + suggestions.push('OCR backend (v0.5) needed to handle this document') + } + if (multiColumnPages > 0) { + flags.push(`${multiColumnPages}/${extracted.pages.length} pages appear multi-column`) + suggestions.push('Multi-column reading-order inference (v0.5+) would improve this document') + } + + // Body-builder analysis + const segments = buildBodyFromPdf(extracted) + const headings = segments.filter((s) => s.kind === 'heading').length + const paragraphs = segments.filter((s) => s.kind === 'paragraph').length + const lists = segments.filter((s) => s.kind === 'list').length + const pageMarkers = segments.filter((s) => s.kind === 'tag' && s.tag === 'PAGE').length + + if (headings === 0 && extracted.pages.length > 1) { + flags.push('No headings detected — heading inference may have failed') + suggestions.push('Tune headingThreshold; document may use uniform font sizes') + } + if (paragraphs === 0 && totalItems > 0) { + flags.push('Text items present but no paragraphs emitted — body builder regression') + suggestions.push('Investigate body-builder line/paragraph clustering') + } + + // Full conversion + let bytes = 0 + let valid = false + let validationErrors: string[] = [] + try { + const { agentmark } = await convertPdf({ data, sourceUrl }) + bytes = agentmark.length + const snap = parseSnapshot(agentmark) + const result = validateSnapshot(snap) + valid = result.valid + validationErrors = result.errors.map((e) => `${e.path}: ${e.message}`) + } catch (err) { + flags.push(`Full conversion failed: ${(err as Error).message}`) + } + + if (!valid && validationErrors.length > 0) { + flags.push(`Schema validation: ${validationErrors.length} error(s)`) + suggestions.push('Investigate schema validation failures — see validationErrors') + } + + // Quality score (rough) + let score = 100 + if (scannedPages > 0) 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 (!valid) score -= 20 + score = Math.max(0, Math.round(score)) + + return { + file: filePath, + sizeBytes: stats.size, + pages: extracted.pages.length, + metadata: { + title: extracted.metadata.title, + author: extracted.metadata.author, + pdf_version: extracted.metadata.pdf_version, + }, + perPage, + body: { + segments: segments.length, + headings, + paragraphs, + lists, + page_markers: pageMarkers, + }, + agentmark: { bytes, tokens: Math.ceil(bytes / 4) }, + valid, + validationErrors: validationErrors.length > 0 ? validationErrors : undefined, + qualityScore: score, + flags, + suggestions, + } +} + +function detectColumnGap(xs: number[], pageWidth: number): boolean { + if (xs.length < 20) return false + const sorted = [...xs].sort((a, b) => a - b) + // Find largest gap between consecutive X positions in the middle 60% of the page. + const minRange = pageWidth * 0.2 + const maxRange = pageWidth * 0.8 + let largestGap = 0 + for (let i = 1; i < sorted.length; i++) { + if (sorted[i - 1] < minRange) continue + if (sorted[i] > maxRange) break + const gap = sorted[i] - sorted[i - 1] + if (gap > largestGap) largestGap = gap + } + // A gap > 10% of page width in the middle of the page suggests a column. + return largestGap > pageWidth * 0.1 +} + +function medianOf(values: number[]): number { + if (values.length === 0) return 0 + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +function round(n: number, decimals: number): number { + const factor = 10 ** decimals + return Math.round(n * factor) / factor +} + +function renderReport(reports: DocReport[]): string { + const lines: string[] = [] + lines.push('# AgentMark PDF Diagnostic Report') + lines.push('') + lines.push(`Generated: ${new Date().toISOString()}`) + lines.push(`Documents: ${reports.length}`) + lines.push('') + + // Summary + const ok = reports.filter((r) => !r.parseError && (r.qualityScore ?? 0) >= 70) + const partial = reports.filter((r) => !r.parseError && (r.qualityScore ?? 0) >= 30 && (r.qualityScore ?? 0) < 70) + const failed = reports.filter((r) => r.parseError || (r.qualityScore ?? 0) < 30) + + lines.push(`## Summary`) + lines.push('') + lines.push(`| Bucket | Count | Median quality |`) + lines.push(`|---|---|---|`) + lines.push(`| 🟢 Good (≥70) | ${ok.length} | ${medianOf(ok.map((r) => r.qualityScore ?? 0))} |`) + lines.push(`| 🟡 Partial (30-69) | ${partial.length} | ${medianOf(partial.map((r) => r.qualityScore ?? 0))} |`) + lines.push(`| 🔴 Failed (<30) | ${failed.length} | ${medianOf(failed.map((r) => r.qualityScore ?? 0))} |`) + lines.push('') + + // Aggregate flag counts + const flagCounts = new Map() + for (const r of reports) { + for (const f of r.flags) { + const key = f.replace(/\d+\/\d+/, 'N/M') + flagCounts.set(key, (flagCounts.get(key) ?? 0) + 1) + } + } + if (flagCounts.size > 0) { + lines.push(`## Top issues across corpus`) + lines.push('') + const sorted = [...flagCounts.entries()].sort((a, b) => b[1] - a[1]) + for (const [flag, count] of sorted) { + lines.push(`- **(${count}×)** ${flag}`) + } + lines.push('') + } + + lines.push(`## Per-document detail`) + lines.push('') + for (const r of reports) { + lines.push(`### ${path.basename(r.file)}`) + lines.push('') + lines.push(`- Path: \`${r.file}\``) + lines.push(`- Size: ${(r.sizeBytes / 1024).toFixed(1)} KB`) + if (r.parseError) { + lines.push(`- ❌ Parse error: ${r.parseError}`) + lines.push('') + continue + } + lines.push(`- Pages: ${r.pages}`) + lines.push(`- Quality score: **${r.qualityScore}/100**`) + if (r.metadata?.title) lines.push(`- Title: ${r.metadata.title}`) + if (r.metadata?.pdf_version) lines.push(`- PDF version: ${r.metadata.pdf_version}`) + if (r.body) { + lines.push( + `- Body: ${r.body.segments} segments (${r.body.headings} headings, ${r.body.paragraphs} paragraphs, ${r.body.lists} lists, ${r.body.page_markers} page markers)`, + ) + } + if (r.agentmark) { + lines.push(`- AgentMark size: ${(r.agentmark.bytes / 1024).toFixed(1)} KB (~${r.agentmark.tokens} tokens)`) + } + if (r.flags.length > 0) { + lines.push(`- Flags:`) + for (const f of r.flags) lines.push(` - ${f}`) + } + if (r.suggestions.length > 0) { + lines.push(`- Suggestions:`) + for (const s of r.suggestions) lines.push(` - ${s}`) + } + if (r.validationErrors && r.validationErrors.length > 0) { + lines.push(`- Validation errors:`) + for (const e of r.validationErrors) lines.push(` - ${e}`) + } + lines.push('') + } + + return lines.join('\n') +} + +async function gatherFiles(input: string): Promise { + const stats = await stat(input) + if (stats.isFile() && input.toLowerCase().endsWith('.pdf')) return [input] + if (stats.isDirectory()) { + const entries = await readdir(input) + return entries + .filter((e) => e.toLowerCase().endsWith('.pdf')) + .map((e) => path.join(input, e)) + } + throw new Error(`Not a PDF file or directory: ${input}`) +} + +async function main() { + const args = process.argv.slice(2) + if (args.length === 0) { + console.error('Usage: npx tsx examples/diagnose-pdf.ts [--out report.md]') + process.exit(1) + } + + const outIdx = args.indexOf('--out') + const outPath = outIdx >= 0 ? args[outIdx + 1] : undefined + const inputs = args.filter((a, i) => { + if (a === '--out') return false + if (outIdx >= 0 && i === outIdx + 1) return false + return true + }) + + const allFiles: string[] = [] + for (const inp of inputs) allFiles.push(...(await gatherFiles(inp))) + + if (allFiles.length === 0) { + console.error('No PDF files found.') + process.exit(1) + } + + 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}`) + } + } + + const report = renderReport(reports) + if (outPath) { + await writeFile(outPath, report, 'utf8') + console.error(`\nReport written to: ${outPath}`) + } else { + console.log(report) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/package.json b/package.json index 31e78e9..1eed1dc 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", "playwright-core": "^1.40.0", + "tsx": "^4.21.0", "typescript": "^5.4.0", "vitest": "3.0.8" } diff --git a/src/pdf/pdf-extractor.ts b/src/pdf/pdf-extractor.ts index ba88c04..9fd0cb5 100644 --- a/src/pdf/pdf-extractor.ts +++ b/src/pdf/pdf-extractor.ts @@ -21,9 +21,17 @@ export async function extractPdf(opts: ExtractPdfOptions): Promise let doc: Awaited['promise']> try { - const data = opts.data instanceof Uint8Array - ? opts.data - : new Uint8Array(opts.data) + // Materialize a *plain* Uint8Array view of the bytes. Two reasons: + // 1. Node's Buffer is technically a Uint8Array subclass, but + // pdfjs-dist does a stricter prototype check that rejects it. + // 2. pdfjs-dist may detach the underlying ArrayBuffer during parse + // (transferring ownership). We make a defensive copy so callers + // can reuse the same input bytes across multiple calls. + const src = opts.data + const view = src instanceof ArrayBuffer + ? new Uint8Array(src) + : new Uint8Array(src.buffer, src.byteOffset, src.byteLength) + const data = new Uint8Array(view) // explicit copy doc = await pdfjs.getDocument({ data, password: opts.password, diff --git a/test/pdf/pdf-converter.test.ts b/test/pdf/pdf-converter.test.ts index 7c4de2d..92537b0 100644 --- a/test/pdf/pdf-converter.test.ts +++ b/test/pdf/pdf-converter.test.ts @@ -124,6 +124,25 @@ describe('extractPdf', () => { code: 'snapshot_failed', }) }) + + it('accepts the same buffer twice without ArrayBuffer detachment errors', async () => { + // Regression: pdfjs-dist transfers ownership of the underlying + // ArrayBuffer during parse. extractPdf must defensively copy so + // callers can pass the same Uint8Array to multiple calls. + const pdf = await buildPdf({ pages: [{ body: 'Reusable bytes.' }] }) + const first = await extractPdf({ data: pdf }) + const second = await extractPdf({ data: pdf }) + expect(first.metadata.pages).toBe(1) + expect(second.metadata.pages).toBe(1) + }) + + it('accepts a Node Buffer (Uint8Array subclass) without prototype mismatch', async () => { + // Regression: pdfjs-dist's strict prototype check rejects Buffer. + const pdf = await buildPdf({ pages: [{ body: 'Buffer compat.' }] }) + const buffer = Buffer.from(pdf) + const result = await extractPdf({ data: buffer }) + expect(result.metadata.pages).toBe(1) + }) }) describe('convertPdf', () => { From b4bb269705b3ee2993a90736256a925a9b2d43ea Mon Sep 17 00:00:00 2001 From: rrader26 Date: Sun, 10 May 2026 09:28:51 -0400 Subject: [PATCH 3/3] feat(pdf): bold-font heading detection + source-mode classifier (insurance corpus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validation findings from a 12-doc real-world insurance corpus drive two hardening improvements. Improvements - Bold-font heading detection in body-builder. Detects headings encoded via font *weight* (e.g. "Helvetica-Bold") at body-sized point sizes, not just outlier sizes. Working on PDFs with proper bold encoding (e.g. Eventbrite tickets gained 6 headings; previously 0). Conservative guards: max 80 chars, ≤20% size delta from median, all items must use bold font names. - Diagnostic CLI now classifies source mode into: - real_text: text streams present — extraction works - print_to_pdf_vector: glyphs rendered as filled paths (Microsoft Print To PDF / similar — needs OCR or original source) - scan: image-only pages (scanner output — needs OCR) - mixed: some text + some image pages - empty / unknown Classification uses producer metadata + operator histograms (showText vs paintImageXObject vs constructPath/fill). - Diagnostic prints source-mode breakdown table + per-mode suggestions so v0.5 priorities are obvious from the report alone. New investigation tools - examples/probe-pdf.ts: dump operator histogram, metadata, font count for a single problem PDF. - examples/dump-fonts.ts: enumerate distinct fonts + sample text per font to debug heading-detection failures. Insurance corpus results (12 docs) - real_text: 6 (50%) — extraction works (FB renewals, tickets, CORP Articles, PRINTHEAD AGREEMENT) - print_to_pdf_vector: 4 (33%) — Erie auto/home quotes printed via "Microsoft: Print To PDF" - scan: 2 (17%) — Flood Map screenshot, NC reseller cert (Epson ScanSmart) - 0 outright failures - All 6 failing docs need OCR — that's the v0.5 priority Limitations surfaced (deferred to v0.5) - Form-style PDFs (insurance renewals) use anonymized embedded fonts and have field labels at SMALLER font sizes than body text, so neither size-based nor weight-based heading inference applies. Form-structure detection (label/value pairs) is a v0.5 feature paired with M3 AcroForm support. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/diagnose-pdf.ts | 156 +++++++++++++++++++++++++++++++++++++-- examples/dump-fonts.ts | 60 +++++++++++++++ examples/probe-pdf.ts | 86 +++++++++++++++++++++ src/pdf/body-builder.ts | 47 +++++++++++- 4 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 examples/dump-fonts.ts create mode 100644 examples/probe-pdf.ts diff --git a/examples/diagnose-pdf.ts b/examples/diagnose-pdf.ts index 6ebd1b6..2e9e85b 100644 --- a/examples/diagnose-pdf.ts +++ b/examples/diagnose-pdf.ts @@ -26,8 +26,21 @@ import { buildBodyFromPdf } from '../src/pdf/body-builder' 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 type { PdfDocument } from '../src/pdf/types' +/** + * What kind of PDF did this start life as? Drives the suggestion text and + * the diagnostic flags. + */ +type SourceMode = + | 'real_text' // Text streams with showText ops — extraction works + | 'scan' // Single-image-per-page (Epson, scanner output) — needs OCR + | 'print_to_pdf_vector' // Microsoft Print To PDF / similar — glyphs as vector paths, needs OCR + | 'mixed' // Some text + some images — partial extraction + | 'empty' // No content at all + | 'unknown' + interface PageDiagnostic { page: number itemCount: number @@ -45,7 +58,8 @@ interface DocReport { sizeBytes: number parseError?: string pages?: number - metadata?: { title?: string; author?: string; pdf_version?: string } + metadata?: { title?: string; author?: string; pdf_version?: string; producer?: string } + sourceMode?: SourceMode perPage?: PageDiagnostic[] body?: { segments: number @@ -93,6 +107,11 @@ async function diagnose(filePath: string): Promise { } } + // Source-mode classification — distinguishes the three failure modes + // discovered in the insurance corpus: real text, scanner output, and + // "Print To PDF" vector-rendered glyphs. + const { sourceMode, producer } = await classifySourceMode(data, extracted) + // Per-page analysis const perPage: PageDiagnostic[] = [] let scannedPages = 0 @@ -126,15 +145,26 @@ async function diagnose(filePath: string): Promise { }) } - if (scannedPages > 0) { - flags.push(`${scannedPages}/${extracted.pages.length} pages have no extractable text — likely scanned`) - suggestions.push('OCR backend (v0.5) needed to handle this document') - } if (multiColumnPages > 0) { flags.push(`${multiColumnPages}/${extracted.pages.length} pages appear multi-column`) suggestions.push('Multi-column reading-order inference (v0.5+) would improve this document') } + // Source-mode-specific flags + suggestions + if (sourceMode === 'scan') { + flags.push(`Source mode: scanner output${producer ? ` (Producer: "${producer}")` : ''} — pages are images, no extractable text`) + suggestions.push('OCR backend (v0.5) needed — images-only PDFs cannot be text-extracted without OCR') + } else if (sourceMode === 'print_to_pdf_vector') { + flags.push(`Source mode: "Print To PDF" vector-rendered glyphs (Producer: "${producer ?? 'unknown'}") — text rendered as filled paths, not text streams`) + suggestions.push('OCR backend (v0.5) is the practical fix; alternatively request the original source PDF from the issuer to skip OCR entirely') + } else if (sourceMode === 'mixed') { + flags.push(`${scannedPages}/${extracted.pages.length} pages have no extractable text (mixed-content document)`) + suggestions.push('OCR backend (v0.5) needed for the image pages; text pages already extract') + } else if (sourceMode === 'empty') { + flags.push('Document contains no extractable content (no text, no images)') + suggestions.push('Investigate — file may be corrupt or use an unsupported encoding') + } + // Body-builder analysis const segments = buildBodyFromPdf(extracted) const headings = segments.filter((s) => s.kind === 'heading').length @@ -188,7 +218,9 @@ async function diagnose(filePath: string): Promise { title: extracted.metadata.title, author: extracted.metadata.author, pdf_version: extracted.metadata.pdf_version, + producer, }, + sourceMode, perPage, body: { segments: segments.length, @@ -206,6 +238,94 @@ async function diagnose(filePath: string): Promise { } } +/** + * Determine the source mode by inspecting metadata + operator distribution + * on a sample of pages. Distinguishes: + * - 'real_text' → has text streams (showText ops) + * - 'scan' → images-only (paintImageXObject + scanner producer) + * - 'print_to_pdf_vector' → glyphs as filled paths (constructPath/fill, no text ops, Print-to-PDF producer) + * - 'mixed' → some text + some image pages + * - 'empty' → no text, no images + */ +async function classifySourceMode( + data: Uint8Array, + extracted: PdfDocument, +): Promise<{ sourceMode: SourceMode; producer?: string }> { + const pdfjs = await loadPdfjs() + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + const doc = await pdfjs + .getDocument({ data: new Uint8Array(view), verbosity: 0 }) + .promise + + const meta = await doc.getMetadata().catch(() => ({ info: {}, metadata: null })) + const info = (meta.info ?? {}) as { Producer?: string } + const producer = typeof info.Producer === 'string' ? info.Producer : undefined + + const ops = pdfjs.OPS as Record + const SHOW_TEXT = ops.showText + const PAINT_IMAGE = ops.paintImageXObject + const PAINT_INLINE_IMAGE = ops.paintInlineImageXObject + const CONSTRUCT_PATH = ops.constructPath + const FILL = ops.fill + + // Sample the first up-to-3 pages for op-level analysis (full doc would + // be too slow on large PDFs; first few pages are highly representative). + const sampleCount = Math.min(doc.numPages, 3) + let pagesWithText = 0 + let pagesWithImageOnly = 0 + let pagesWithVectorGlyphs = 0 + let pagesEmpty = 0 + + for (let i = 1; i <= sampleCount; i++) { + const page = await doc.getPage(i) + const opList = await page.getOperatorList() + const fns = opList.fnArray + let textOps = 0 + let imageOps = 0 + let pathOps = 0 + let fillOps = 0 + for (const fn of fns) { + if (fn === SHOW_TEXT) textOps++ + else if (fn === PAINT_IMAGE || fn === PAINT_INLINE_IMAGE) imageOps++ + else if (fn === CONSTRUCT_PATH) pathOps++ + else if (fn === FILL) fillOps++ + } + + const itemsOnThisPage = extracted.pages[i - 1]?.items.length ?? 0 + + if (textOps > 0 && itemsOnThisPage > 0) { + pagesWithText++ + } else if (imageOps > 0 && textOps === 0) { + pagesWithImageOnly++ + } else if (pathOps > 50 && fillOps > 50 && textOps === 0) { + // Heavy vector drawing with no text ops → glyphs as filled paths + pagesWithVectorGlyphs++ + } else if (fns.length === 0) { + pagesEmpty++ + } else { + // Some other shape — count as image-only fallback + pagesWithImageOnly++ + } + page.cleanup() + } + await doc.destroy() + + let sourceMode: SourceMode = 'unknown' + if (pagesWithText > 0 && pagesWithImageOnly + pagesWithVectorGlyphs === 0) { + sourceMode = 'real_text' + } else if (pagesWithImageOnly > 0 && pagesWithText === 0 && pagesWithVectorGlyphs === 0) { + sourceMode = 'scan' + } else if (pagesWithVectorGlyphs > 0 && pagesWithText === 0) { + sourceMode = 'print_to_pdf_vector' + } else if (pagesEmpty === sampleCount) { + sourceMode = 'empty' + } else if (pagesWithText > 0) { + sourceMode = 'mixed' + } + + return { sourceMode, producer } +} + function detectColumnGap(xs: number[], pageWidth: number): boolean { if (xs.length < 20) return false const sorted = [...xs].sort((a, b) => a - b) @@ -275,6 +395,30 @@ function renderReport(reports: DocReport[]): string { lines.push('') } + // Source mode breakdown + const modes = new Map() + for (const r of reports) { + if (r.sourceMode) modes.set(r.sourceMode, (modes.get(r.sourceMode) ?? 0) + 1) + } + if (modes.size > 0) { + lines.push(`## Source-mode breakdown`) + lines.push('') + lines.push(`| Mode | Count | Meaning |`) + lines.push(`|---|---|---|`) + const explain: Record = { + real_text: 'Text streams present — extraction works', + scan: 'Scanner output (image-per-page) — needs OCR', + print_to_pdf_vector: '"Print To PDF" vector glyphs — needs OCR or original source', + mixed: 'Some text pages + some image pages — needs OCR for image pages', + empty: 'No content', + unknown: 'Could not classify', + } + for (const [mode, count] of [...modes.entries()].sort((a, b) => b[1] - a[1])) { + lines.push(`| \`${mode}\` | ${count} | ${explain[mode] ?? '?'} |`) + } + lines.push('') + } + lines.push(`## Per-document detail`) lines.push('') for (const r of reports) { @@ -289,6 +433,8 @@ function renderReport(reports: DocReport[]): string { } lines.push(`- Pages: ${r.pages}`) lines.push(`- Quality score: **${r.qualityScore}/100**`) + if (r.sourceMode) lines.push(`- Source mode: \`${r.sourceMode}\``) + if (r.metadata?.producer) lines.push(`- Producer: ${r.metadata.producer}`) if (r.metadata?.title) lines.push(`- Title: ${r.metadata.title}`) if (r.metadata?.pdf_version) lines.push(`- PDF version: ${r.metadata.pdf_version}`) if (r.body) { diff --git a/examples/dump-fonts.ts b/examples/dump-fonts.ts new file mode 100644 index 0000000..e4b565c --- /dev/null +++ b/examples/dump-fonts.ts @@ -0,0 +1,60 @@ +/** + * Dump unique font names + sample text per font to understand a PDF's + * heading-vs-body distinction. Useful when bold-name detection alone + * misses headings. + * + * npx tsx examples/dump-fonts.ts + */ + +import { readFile } from 'node:fs/promises' +import { extractPdf } from '../src/pdf/pdf-extractor' + +async function main() { + const file = process.argv[2] + if (!file) { + console.error('Usage: npx tsx examples/dump-fonts.ts ') + process.exit(1) + } + const data = await readFile(file) + const doc = await extractPdf({ data }) + + interface Stat { + font: string + sizes: Set + samples: Set + count: number + } + const stats = new Map() + for (const page of doc.pages) { + for (const item of page.items) { + if (!item.text.trim()) continue + const key = item.fontName + let s = stats.get(key) + if (!s) { + s = { font: key, sizes: new Set(), samples: new Set(), count: 0 } + stats.set(key, s) + } + s.count++ + s.sizes.add(Math.round(item.fontSize * 2) / 2) + if (s.samples.size < 3) s.samples.add(item.text.slice(0, 50)) + } + } + + console.log(`File: ${file}`) + console.log(`Pages: ${doc.pages.length}`) + console.log(`Distinct fonts: ${stats.size}`) + console.log() + const sorted = [...stats.values()].sort((a, b) => b.count - a.count) + for (const s of sorted) { + const sizes = [...s.sizes].sort((a, b) => a - b).join(', ') + console.log(` ${s.count.toString().padStart(5)} × ${s.font}`) + console.log(` sizes: ${sizes}`) + for (const ex of s.samples) console.log(` e.g.: "${ex}"`) + console.log() + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/probe-pdf.ts b/examples/probe-pdf.ts new file mode 100644 index 0000000..7e3e60c --- /dev/null +++ b/examples/probe-pdf.ts @@ -0,0 +1,86 @@ +/** + * Deep probe of a problematic PDF — answers: "why doesn't text extract?" + * + * npx tsx examples/probe-pdf.ts + * + * Per page, reports: + * - Text content items (the extractor's normal channel) + * - Operator list (low-level draw ops — text-rendering, image-drawing, paths) + * - Font dictionary (font types, encodings) + * - Image XObjects (count + sizes — if many large images, the doc is rasterized) + * - Op-name histogram so we can spot e.g. "all draw ops are paintImageXObject" + */ + +import { readFile } from 'node:fs/promises' +import * as path from 'node:path' +import { loadPdfjs } from '../src/pdf/pdfjs-loader' + +async function main() { + const filePath = process.argv[2] + if (!filePath) { + console.error('Usage: npx tsx examples/probe-pdf.ts ') + process.exit(1) + } + + const pdfjs = await loadPdfjs() + const data = await readFile(filePath) + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + const doc = await pdfjs.getDocument({ + data: new Uint8Array(view), + verbosity: 0, + }).promise + + console.log(`File: ${path.basename(filePath)}`) + console.log(`Pages: ${doc.numPages}`) + const meta = await doc.getMetadata().catch(() => ({ info: {}, metadata: null })) + console.log(`Metadata: ${JSON.stringify(meta.info, null, 2)}`) + console.log() + + for (let n = 1; n <= Math.min(doc.numPages, 2); n++) { + const page = await doc.getPage(n) + console.log(`──── Page ${n} ────`) + + const text = await page.getTextContent() + console.log(` textContent items: ${text.items.length}`) + if (text.items.length > 0 && 'str' in text.items[0]) { + const sample = text.items.slice(0, 3).map((i) => 'str' in i ? `"${i.str}"` : '(non-text)').join(', ') + console.log(` first items: ${sample}`) + } + + const opList = await page.getOperatorList() + console.log(` operatorList ops: ${opList.fnArray.length}`) + + // Reverse-look-up op codes from the OPS map + const ops = pdfjs.OPS as Record + const opName = new Map() + for (const [name, code] of Object.entries(ops)) opName.set(code as number, name) + + const histogram = new Map() + for (const code of opList.fnArray) { + const name = opName.get(code) ?? `op_${code}` + histogram.set(name, (histogram.get(name) ?? 0) + 1) + } + const sorted = [...histogram.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10) + console.log(` top 10 ops:`) + for (const [name, count] of sorted) console.log(` ${count.toString().padStart(5)} ${name}`) + + // Fonts + try { + const objs = (page as unknown as { commonObjs: { _objs: Map } }).commonObjs + const fontKeys = objs?._objs ? [...objs._objs.keys()].filter((k) => k.startsWith('g_')) : [] + console.log(` font / common objects: ${fontKeys.length}`) + } catch { + // ignore + } + + page.cleanup() + console.log() + } + + await doc.destroy() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/src/pdf/body-builder.ts b/src/pdf/body-builder.ts index 711d227..8e98aed 100644 --- a/src/pdf/body-builder.ts +++ b/src/pdf/body-builder.ts @@ -70,6 +70,8 @@ interface PdfLine { text: string /** X position of the first item — used for indentation hints. */ leftX: number + /** Whether every text item on this line uses a bold font. */ + isBold: boolean } /** @@ -103,20 +105,33 @@ function groupItemsIntoLines(page: PdfPage): PdfLine[] { return lines } +/** + * Heuristic: a font is "bold" if its name contains common bold markers. + * pdfjs-dist exposes the original font name from the PDF font dictionary, + * which by convention encodes weight (e.g. "Helvetica-Bold", "ArialMT-Black", + * "TimesNewRoman,Bold"). False positives are unlikely. + */ +const BOLD_FONT_RE = /-?(Bold|Black|Heavy|Semibold|Demi|Extrabold)\b/i + function buildLine(items: PdfTextItem[]): PdfLine { // Sort by X ascending so reading order is preserved. const sorted = [...items].sort((a, b) => a.x - b.x) + const textOnly = sorted.filter((it) => it.text.trim().length > 0) const text = sorted .map((it) => it.text) .join(' ') .replace(/\s+/g, ' ') .trim() const maxFontSize = sorted.reduce((m, it) => Math.max(m, it.fontSize), 0) + const isBold = + textOnly.length > 0 + && textOnly.every((it) => BOLD_FONT_RE.test(it.fontName)) return { y: sorted[0]?.y ?? 0, maxFontSize, text, leftX: sorted[0]?.x ?? 0, + isBold, } } @@ -169,7 +184,14 @@ function linesToBlocks( } // ── Heading detection ───────────────────────────────────────────── - const headingLevel = inferHeadingLevel(line.maxFontSize, median, topSizes, headingThreshold) + // Two paths: + // 1. Outlier font size (≥ threshold × median) → heading by size + // 2. Bold-only line, short, alone in its vertical slot, body-sized + // → heading by weight (very common in form-style PDFs that + // render every line at the same point size) + const headingLevel = + inferHeadingLevel(line.maxFontSize, median, topSizes, headingThreshold) + ?? inferHeadingFromWeight(line, median) if (headingLevel !== null) { flushAll() segments.push({ kind: 'heading', level: headingLevel, text: line.text }) @@ -219,6 +241,29 @@ function inferHeadingLevel( return 2 } +/** + * Detect a heading by font *weight* rather than size. PDFs (especially + * forms) often render headings as bold text at the same point size as + * the body. Conditions: + * - Every text item on the line uses a bold font name + * - The line is short enough to be a heading (≤ 80 chars, heuristic) + * - Font size is roughly body-sized (within 20% of median) + * + * Returns h2 by default — we don't have enough signal to distinguish + * h1/h2/h3 from weight alone. False positives are bounded by the length + * cap. + */ +function inferHeadingFromWeight(line: PdfLine, median: number): 2 | null { + if (!line.isBold) return null + if (line.text.length === 0 || line.text.length > 80) return null + if (median > 0 && Math.abs(line.maxFontSize - median) / median > 0.2) { + // Significantly different size — already handled (or skipped) by + // size-based heuristic. Bold-by-weight is for body-sized lines. + return null + } + return 2 +} + function detectListItem(text: string): { ordered: boolean; text: string } | null { // Bulleted: starts with •, ◦, ●, ○, ▪, ▫, *, –, —, - const bulletMatch = text.match(/^[•◦●○▪▫\*–—\-]\s+(.+)$/)