open-brainy/src/augmentations/intelligentImport/handlers/pdfHandler.ts
David Snelling d5576ffb56 feat: comprehensive import progress tracking for all 7 formats
Add real-time progress reporting throughout the entire import pipeline
with a standardized API that works across all supported formats.

Workshop Team Feature Request:
- Eliminates "0% complete" hangs during AI extraction
- Shows continuous progress with entities/sec, throughput, ETA
- Reports contextual messages ("Processing page 5 of 23")
- Standardized progress API for CSV, PDF, Excel, JSON, Markdown, YAML, DOCX

Core Changes:
- Add FormatHandlerProgressHooks interface for extensible progress
- Wire up all 3 binary format handlers (CSV, PDF, Excel) with 7+ progress points
- Wire up all 4 text format importers (JSON, Markdown, YAML, DOCX)
- Add ImportProgress interface with stage, message, counts, throughput, ETA
- ImportCoordinator normalizes all format progress to standard interface

CLI Improvements:
- Import command now uses brain.import() directly with full progress
- Add --include-vfs flag to find command (v4.4.0 compatibility)
- Add --confidence and --weight options to add command

Documentation:
- docs/guides/standard-import-progress.md - Universal API guide
- docs/guides/import-progress-implementation.md - Developer guide
- docs/guides/import-progress-examples.md - Practical examples
- JSDoc on brain.import() with universal handler examples

Result: ONE progress handler works for ALL 7 formats with zero format-specific code!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 14:45:46 -07:00

333 lines
10 KiB
TypeScript

/**
* PDF Format Handler
* Handles PDF files with:
* - Text extraction with layout preservation
* - Table detection and extraction
* - Metadata extraction (author, dates, etc.)
* - Page-by-page processing
*/
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
import { BaseFormatHandler } from './base.js'
import { FormatHandlerOptions, ProcessedData } from '../types.js'
// Use built-in worker for Node.js environments
// In production, this can be customized via options
const initializeWorker = () => {
if (typeof pdfjsLib.GlobalWorkerOptions.workerSrc === 'undefined' ||
pdfjsLib.GlobalWorkerOptions.workerSrc === '') {
// Use a data URL to avoid file system dependencies
// This tells pdfjs to use the built-in fallback worker
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'data:,'
} catch {
// Ignore if already set or in incompatible environment
}
}
}
initializeWorker()
export class PDFHandler extends BaseFormatHandler {
readonly format = 'pdf'
canHandle(data: Buffer | string | { filename?: string, ext?: string }): boolean {
const ext = this.detectExtension(data)
if (ext === 'pdf') return true
// Check for PDF magic bytes
if (Buffer.isBuffer(data)) {
const header = data.slice(0, 5).toString('ascii')
return header === '%PDF-'
}
return false
}
async process(data: Buffer | string, options: FormatHandlerOptions): Promise<ProcessedData> {
const startTime = Date.now()
const progressHooks = options.progressHooks
// Convert to buffer
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'binary')
const totalBytes = buffer.length
// v4.5.0: Report start
if (progressHooks?.onBytesProcessed) {
progressHooks.onBytesProcessed(0)
}
if (progressHooks?.onCurrentItem) {
progressHooks.onCurrentItem('Loading PDF document...')
}
try {
// Load PDF document
const loadingTask = pdfjsLib.getDocument({
data: new Uint8Array(buffer),
useSystemFonts: true,
standardFontDataUrl: undefined
})
const pdfDoc = await loadingTask.promise
// Extract metadata
const metadata = await pdfDoc.getMetadata()
const numPages = pdfDoc.numPages
// v4.5.0: Report document loaded
if (progressHooks?.onCurrentItem) {
progressHooks.onCurrentItem(`Processing ${numPages} pages...`)
}
// Extract text and structure from all pages
const allData: Array<Record<string, any>> = []
let totalTextLength = 0
let detectedTables = 0
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
// v4.5.0: Report current page
if (progressHooks?.onCurrentItem) {
progressHooks.onCurrentItem(`Processing page ${pageNum} of ${numPages}`)
}
const page = await pdfDoc.getPage(pageNum)
const textContent = await page.getTextContent()
// Extract text items with positions
const textItems = textContent.items.map((item: any) => ({
text: item.str,
x: item.transform[4],
y: item.transform[5],
width: item.width,
height: item.height
}))
// Combine text items into lines (group by similar Y position)
const lines = this.groupIntoLines(textItems)
// Detect tables if requested
if (options.pdfExtractTables !== false) {
const tables = this.detectTables(lines)
if (tables.length > 0) {
detectedTables += tables.length
for (const table of tables) {
allData.push(...table.rows)
}
}
}
// Extract paragraphs from non-table lines
const paragraphs = this.extractParagraphs(lines)
for (let i = 0; i < paragraphs.length; i++) {
const text = paragraphs[i].trim()
if (text.length > 0) {
totalTextLength += text.length
allData.push({
_page: pageNum,
_type: 'paragraph',
_index: i,
text
})
}
}
// v4.5.0: Estimate bytes processed (pages are sequential)
const bytesProcessed = Math.floor((pageNum / numPages) * totalBytes)
if (progressHooks?.onBytesProcessed) {
progressHooks.onBytesProcessed(bytesProcessed)
}
// v4.5.0: Report extraction progress
if (progressHooks?.onDataExtracted) {
progressHooks.onDataExtracted(allData.length, undefined) // Total unknown until complete
}
}
// v4.5.0: Final progress - all bytes processed
if (progressHooks?.onBytesProcessed) {
progressHooks.onBytesProcessed(totalBytes)
}
if (progressHooks?.onDataExtracted) {
progressHooks.onDataExtracted(allData.length, allData.length)
}
const processingTime = Date.now() - startTime
// v4.5.0: Report completion
if (progressHooks?.onCurrentItem) {
progressHooks.onCurrentItem(
`PDF complete: ${numPages} pages, ${allData.length} items extracted`
)
}
// Get all unique fields (excluding metadata fields)
const fields = allData.length > 0
? Object.keys(allData[0]).filter(k => !k.startsWith('_'))
: []
return {
format: this.format,
data: allData,
metadata: this.createMetadata(
allData.length,
fields,
processingTime,
{
pageCount: numPages,
textLength: totalTextLength,
tableCount: detectedTables,
pdfMetadata: {
title: (metadata.info as any)?.Title || null,
author: (metadata.info as any)?.Author || null,
subject: (metadata.info as any)?.Subject || null,
creator: (metadata.info as any)?.Creator || null,
producer: (metadata.info as any)?.Producer || null,
creationDate: (metadata.info as any)?.CreationDate || null,
modificationDate: (metadata.info as any)?.ModDate || null
}
}
),
filename: options.filename
}
} catch (error) {
throw new Error(`PDF parsing failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Group text items into lines based on Y position
*/
private groupIntoLines(items: Array<{ text: string, x: number, y: number, width: number, height: number }>): Array<Array<{ text: string, x: number }>> {
if (items.length === 0) return []
// Sort by Y position (descending, since PDF coordinates go bottom-up)
const sorted = [...items].sort((a, b) => b.y - a.y)
const lines: Array<Array<{ text: string, x: number }>> = []
let currentLine: Array<{ text: string, x: number }> = []
let currentY = sorted[0].y
for (const item of sorted) {
// If Y position differs by more than half the height, it's a new line
if (Math.abs(item.y - currentY) > (item.height / 2)) {
if (currentLine.length > 0) {
// Sort line items by X position
currentLine.sort((a, b) => a.x - b.x)
lines.push(currentLine)
}
currentLine = []
currentY = item.y
}
if (item.text.trim()) {
currentLine.push({ text: item.text, x: item.x })
}
}
// Add last line
if (currentLine.length > 0) {
currentLine.sort((a, b) => a.x - b.x)
lines.push(currentLine)
}
return lines
}
/**
* Detect tables from lines
* Tables are detected when multiple consecutive lines have similar structure
*/
private detectTables(lines: Array<Array<{ text: string, x: number }>>): Array<{ rows: Array<Record<string, any>> }> {
const tables: Array<{ rows: Array<Record<string, any>> }> = []
let potentialTable: Array<Array<{ text: string, x: number }>> = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// A line with multiple items could be part of a table
if (line.length >= 2) {
potentialTable.push(line)
} else {
// End of potential table
if (potentialTable.length >= 3) { // Need at least header + 2 rows
const table = this.parseTable(potentialTable)
if (table) {
tables.push(table)
}
}
potentialTable = []
}
}
// Check last potential table
if (potentialTable.length >= 3) {
const table = this.parseTable(potentialTable)
if (table) {
tables.push(table)
}
}
return tables
}
/**
* Parse a potential table into structured rows
*/
private parseTable(lines: Array<Array<{ text: string, x: number }>>): { rows: Array<Record<string, any>> } | null {
if (lines.length < 2) return null
// First line is headers
const headerLine = lines[0]
const headers = headerLine.map(item => this.sanitizeFieldName(item.text))
// Remaining lines are data
const rows: Array<Record<string, any>> = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
const row: Record<string, any> = { _type: 'table_row' }
// Match each item to closest header by X position
for (let j = 0; j < line.length && j < headers.length; j++) {
const header = headers[j]
const value = line[j].text.trim()
row[header] = value || null
}
if (Object.keys(row).length > 1) { // More than just _type
rows.push(row)
}
}
return rows.length > 0 ? { rows } : null
}
/**
* Extract paragraphs from lines
*/
private extractParagraphs(lines: Array<Array<{ text: string, x: number }>>): string[] {
const paragraphs: string[] = []
let currentParagraph: string[] = []
for (const line of lines) {
const lineText = line.map(item => item.text).join(' ').trim()
if (lineText.length === 0) {
// Empty line - end paragraph
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph.join(' '))
currentParagraph = []
}
} else {
currentParagraph.push(lineText)
}
}
// Add last paragraph
if (currentParagraph.length > 0) {
paragraphs.push(currentParagraph.join(' '))
}
return paragraphs
}
}