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/diagnose-pdf.ts b/examples/diagnose-pdf.ts new file mode 100644 index 0000000..2e9e85b --- /dev/null +++ b/examples/diagnose-pdf.ts @@ -0,0 +1,533 @@ +/** + * 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 { 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 + 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; producer?: string } + sourceMode?: SourceMode + 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'], + } + } + + // 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 + 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 (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 + 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, + producer, + }, + sourceMode, + 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, + } +} + +/** + * 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) + // 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('') + } + + // 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) { + 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.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) { + 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/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/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/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/package.json b/package.json index 080a391..1eed1dc 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,31 @@ "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" + "tsx": "^4.21.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..8e98aed --- /dev/null +++ b/src/pdf/body-builder.ts @@ -0,0 +1,317 @@ +/** + * 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 + /** Whether every text item on this line uses a bold font. */ + isBold: boolean +} + +/** + * 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 +} + +/** + * 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, + } +} + +// ──────────────────────────────────────────────────────────────────────── +// 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 ───────────────────────────────────────────── + // 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 }) + 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 +} + +/** + * 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+(.+)$/) + 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..9fd0cb5 --- /dev/null +++ b/src/pdf/pdf-extractor.ts @@ -0,0 +1,149 @@ +/** + * 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 { + // 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, + // 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..92537b0 --- /dev/null +++ b/test/pdf/pdf-converter.test.ts @@ -0,0 +1,325 @@ +/** + * 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', + }) + }) + + 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', () => { + 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) + }) +})