/** * detect-figures.ts * Automatically detect figures and tables from academic PDF papers. * * Usage: * npx -y bun detect-figures.ts --pdf paper.pdf --output figures.json * * Options: * --pdf Path to source PDF file (required) * --output Output JSON file path (optional, prints to stdout if omitted) * * Output JSON format: * { * "figures": [ * { "type": "figure", "number": "1", "page": 2, "caption": "...", "label": "Figure 1" }, * { "type": "table", "number": "I", "page": 5, "caption": "...", "label": "Table I" } * ], * "totalPages": 10 * } */ import { existsSync, writeFileSync } from "fs"; import { resolve } from "path"; // Use legacy build for Node.js compatibility import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs"; interface FigureInfo { type: "figure" | "table"; number: string; page: number; caption: string; label: string; } interface DetectionResult { figures: FigureInfo[]; totalPages: number; pdfPath: string; } interface Args { pdf: string; output: string | null; } function parseArgs(): Args { const args = process.argv.slice(2); let pdf = ""; let output: string | null = null; for (let i = 0; i < args.length; i++) { switch (args[i]) { case "--pdf": pdf = args[++i]; break; case "--output": output = args[++i]; break; } } if (!pdf) { console.error("Usage: bun detect-figures.ts --pdf [--output ]"); console.error("\nOptions:"); console.error(" --pdf Path to source PDF file (required)"); console.error(" --output Output JSON file path (optional)"); process.exit(1); } return { pdf, output }; } /** * Extract text content from a PDF page */ async function getPageText(page: any): Promise { const textContent = await page.getTextContent(); const items = textContent.items as any[]; // Sort by y position (top to bottom), then x position (left to right) items.sort((a, b) => { const yDiff = b.transform[5] - a.transform[5]; // y is inverted in PDF if (Math.abs(yDiff) > 5) return yDiff; return a.transform[4] - b.transform[4]; }); // Group items by line (similar y position) const lines: string[][] = []; let currentLine: string[] = []; let lastY = items[0]?.transform[5] ?? 0; for (const item of items) { const y = item.transform[5]; if (Math.abs(y - lastY) > 8) { if (currentLine.length > 0) { lines.push(currentLine); } currentLine = []; lastY = y; } if (item.str.trim()) { currentLine.push(item.str); } } if (currentLine.length > 0) { lines.push(currentLine); } return lines.map(line => line.join(" ")).join("\n"); } /** * Parse figure and table references from text */ function parseFiguresFromText(text: string, pageNum: number): FigureInfo[] { const figures: FigureInfo[] = []; // Patterns for figure captions // Match: "Fig. 1.", "Figure 1:", "Fig. 1 ", "FIGURE 1." const figurePatterns = [ /(?:^|\n)\s*(?:Fig(?:ure|\.)?)\s*(\d+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/gi, /(?:^|\n)\s*FIGURE\s+(\d+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/g, ]; // Patterns for table captions // Match: "Table I", "TABLE 1", "Table 1:" const tablePatterns = [ /(?:^|\n)\s*(?:Table|TABLE)\s+([IVX\d]+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/gi, ]; // Extract figures for (const pattern of figurePatterns) { let match; while ((match = pattern.exec(text)) !== null) { const number = match[1]; const caption = match[2].trim().replace(/\s+/g, " ").substring(0, 300); const label = `Figure ${number}`; // Avoid duplicates if (!figures.some(f => f.label === label)) { figures.push({ type: "figure", number, page: pageNum, caption, label, }); } } } // Extract tables for (const pattern of tablePatterns) { let match; while ((match = pattern.exec(text)) !== null) { const number = match[1]; const caption = match[2].trim().replace(/\s+/g, " ").substring(0, 300); const label = `Table ${number}`; // Avoid duplicates if (!figures.some(f => f.label === label)) { figures.push({ type: "table", number, page: pageNum, caption, label, }); } } } return figures; } /** * Detect all figures and tables in a PDF */ async function detectFigures(pdfPath: string): Promise { const absolutePath = resolve(pdfPath); if (!existsSync(absolutePath)) { throw new Error(`PDF file not found: ${absolutePath}`); } console.error(`Loading PDF: ${absolutePath}`); const loadingTask = pdfjsLib.getDocument({ url: absolutePath, useSystemFonts: true, standardFontDataUrl: "node_modules/pdfjs-dist/standard_fonts/", }); const pdfDoc = await loadingTask.promise; const totalPages = pdfDoc.numPages; console.error(`PDF loaded: ${totalPages} pages`); console.error("Scanning for figures and tables...\n"); const allFigures: FigureInfo[] = []; const seenLabels = new Set(); for (let pageNum = 1; pageNum <= totalPages; pageNum++) { const page = await pdfDoc.getPage(pageNum); const text = await getPageText(page); const pageFigures = parseFiguresFromText(text, pageNum); for (const fig of pageFigures) { // Only add if not already seen (captions may span pages) if (!seenLabels.has(fig.label)) { seenLabels.add(fig.label); allFigures.push(fig); console.error(` Found: ${fig.label} on page ${fig.page}`); } } } // Sort by type then number allFigures.sort((a, b) => { if (a.type !== b.type) { return a.type === "figure" ? -1 : 1; } // Handle Roman numerals for tables const numA = romanToInt(a.number) || parseInt(a.number, 10) || 0; const numB = romanToInt(b.number) || parseInt(b.number, 10) || 0; return numA - numB; }); console.error(`\nTotal: ${allFigures.length} figures/tables detected`); return { figures: allFigures, totalPages, pdfPath: absolutePath, }; } /** * Convert Roman numeral to integer */ function romanToInt(roman: string): number | null { const romanMap: { [key: string]: number } = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000, }; const upper = roman.toUpperCase(); if (!/^[IVXLCDM]+$/.test(upper)) { return null; } let result = 0; for (let i = 0; i < upper.length; i++) { const current = romanMap[upper[i]]; const next = romanMap[upper[i + 1]] || 0; if (current < next) { result -= current; } else { result += current; } } return result; } async function main() { const { pdf, output } = parseArgs(); try { const result = await detectFigures(pdf); const json = JSON.stringify(result, null, 2); if (output) { writeFileSync(output, json); console.error(`\nSaved to: ${output}`); } else { console.log(json); } } catch (error) { console.error("\nError:", error instanceof Error ? error.message : error); process.exit(1); } } main();