| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- /**
- * 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 <pdf-path> [--output <output.json>]");
- 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<string> {
- 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<DetectionResult> {
- 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<string>();
- 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();
|