detect-figures.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * detect-figures.ts
  3. * Automatically detect figures and tables from academic PDF papers.
  4. *
  5. * Usage:
  6. * npx -y bun detect-figures.ts --pdf paper.pdf --output figures.json
  7. *
  8. * Options:
  9. * --pdf Path to source PDF file (required)
  10. * --output Output JSON file path (optional, prints to stdout if omitted)
  11. *
  12. * Output JSON format:
  13. * {
  14. * "figures": [
  15. * { "type": "figure", "number": "1", "page": 2, "caption": "...", "label": "Figure 1" },
  16. * { "type": "table", "number": "I", "page": 5, "caption": "...", "label": "Table I" }
  17. * ],
  18. * "totalPages": 10
  19. * }
  20. */
  21. import { existsSync, writeFileSync } from "fs";
  22. import { resolve } from "path";
  23. // Use legacy build for Node.js compatibility
  24. import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs";
  25. interface FigureInfo {
  26. type: "figure" | "table";
  27. number: string;
  28. page: number;
  29. caption: string;
  30. label: string;
  31. }
  32. interface DetectionResult {
  33. figures: FigureInfo[];
  34. totalPages: number;
  35. pdfPath: string;
  36. }
  37. interface Args {
  38. pdf: string;
  39. output: string | null;
  40. }
  41. function parseArgs(): Args {
  42. const args = process.argv.slice(2);
  43. let pdf = "";
  44. let output: string | null = null;
  45. for (let i = 0; i < args.length; i++) {
  46. switch (args[i]) {
  47. case "--pdf":
  48. pdf = args[++i];
  49. break;
  50. case "--output":
  51. output = args[++i];
  52. break;
  53. }
  54. }
  55. if (!pdf) {
  56. console.error("Usage: bun detect-figures.ts --pdf <pdf-path> [--output <output.json>]");
  57. console.error("\nOptions:");
  58. console.error(" --pdf Path to source PDF file (required)");
  59. console.error(" --output Output JSON file path (optional)");
  60. process.exit(1);
  61. }
  62. return { pdf, output };
  63. }
  64. /**
  65. * Extract text content from a PDF page
  66. */
  67. async function getPageText(page: any): Promise<string> {
  68. const textContent = await page.getTextContent();
  69. const items = textContent.items as any[];
  70. // Sort by y position (top to bottom), then x position (left to right)
  71. items.sort((a, b) => {
  72. const yDiff = b.transform[5] - a.transform[5]; // y is inverted in PDF
  73. if (Math.abs(yDiff) > 5) return yDiff;
  74. return a.transform[4] - b.transform[4];
  75. });
  76. // Group items by line (similar y position)
  77. const lines: string[][] = [];
  78. let currentLine: string[] = [];
  79. let lastY = items[0]?.transform[5] ?? 0;
  80. for (const item of items) {
  81. const y = item.transform[5];
  82. if (Math.abs(y - lastY) > 8) {
  83. if (currentLine.length > 0) {
  84. lines.push(currentLine);
  85. }
  86. currentLine = [];
  87. lastY = y;
  88. }
  89. if (item.str.trim()) {
  90. currentLine.push(item.str);
  91. }
  92. }
  93. if (currentLine.length > 0) {
  94. lines.push(currentLine);
  95. }
  96. return lines.map(line => line.join(" ")).join("\n");
  97. }
  98. /**
  99. * Parse figure and table references from text
  100. */
  101. function parseFiguresFromText(text: string, pageNum: number): FigureInfo[] {
  102. const figures: FigureInfo[] = [];
  103. // Patterns for figure captions
  104. // Match: "Fig. 1.", "Figure 1:", "Fig. 1 ", "FIGURE 1."
  105. const figurePatterns = [
  106. /(?:^|\n)\s*(?:Fig(?:ure|\.)?)\s*(\d+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/gi,
  107. /(?:^|\n)\s*FIGURE\s+(\d+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/g,
  108. ];
  109. // Patterns for table captions
  110. // Match: "Table I", "TABLE 1", "Table 1:"
  111. const tablePatterns = [
  112. /(?:^|\n)\s*(?:Table|TABLE)\s+([IVX\d]+)[.:\s]+([^\n]+(?:\n(?![A-Z]{2,}|\d+\.|Fig|Table)[^\n]+)*)/gi,
  113. ];
  114. // Extract figures
  115. for (const pattern of figurePatterns) {
  116. let match;
  117. while ((match = pattern.exec(text)) !== null) {
  118. const number = match[1];
  119. const caption = match[2].trim().replace(/\s+/g, " ").substring(0, 300);
  120. const label = `Figure ${number}`;
  121. // Avoid duplicates
  122. if (!figures.some(f => f.label === label)) {
  123. figures.push({
  124. type: "figure",
  125. number,
  126. page: pageNum,
  127. caption,
  128. label,
  129. });
  130. }
  131. }
  132. }
  133. // Extract tables
  134. for (const pattern of tablePatterns) {
  135. let match;
  136. while ((match = pattern.exec(text)) !== null) {
  137. const number = match[1];
  138. const caption = match[2].trim().replace(/\s+/g, " ").substring(0, 300);
  139. const label = `Table ${number}`;
  140. // Avoid duplicates
  141. if (!figures.some(f => f.label === label)) {
  142. figures.push({
  143. type: "table",
  144. number,
  145. page: pageNum,
  146. caption,
  147. label,
  148. });
  149. }
  150. }
  151. }
  152. return figures;
  153. }
  154. /**
  155. * Detect all figures and tables in a PDF
  156. */
  157. async function detectFigures(pdfPath: string): Promise<DetectionResult> {
  158. const absolutePath = resolve(pdfPath);
  159. if (!existsSync(absolutePath)) {
  160. throw new Error(`PDF file not found: ${absolutePath}`);
  161. }
  162. console.error(`Loading PDF: ${absolutePath}`);
  163. const loadingTask = pdfjsLib.getDocument({
  164. url: absolutePath,
  165. useSystemFonts: true,
  166. standardFontDataUrl: "node_modules/pdfjs-dist/standard_fonts/",
  167. });
  168. const pdfDoc = await loadingTask.promise;
  169. const totalPages = pdfDoc.numPages;
  170. console.error(`PDF loaded: ${totalPages} pages`);
  171. console.error("Scanning for figures and tables...\n");
  172. const allFigures: FigureInfo[] = [];
  173. const seenLabels = new Set<string>();
  174. for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
  175. const page = await pdfDoc.getPage(pageNum);
  176. const text = await getPageText(page);
  177. const pageFigures = parseFiguresFromText(text, pageNum);
  178. for (const fig of pageFigures) {
  179. // Only add if not already seen (captions may span pages)
  180. if (!seenLabels.has(fig.label)) {
  181. seenLabels.add(fig.label);
  182. allFigures.push(fig);
  183. console.error(` Found: ${fig.label} on page ${fig.page}`);
  184. }
  185. }
  186. }
  187. // Sort by type then number
  188. allFigures.sort((a, b) => {
  189. if (a.type !== b.type) {
  190. return a.type === "figure" ? -1 : 1;
  191. }
  192. // Handle Roman numerals for tables
  193. const numA = romanToInt(a.number) || parseInt(a.number, 10) || 0;
  194. const numB = romanToInt(b.number) || parseInt(b.number, 10) || 0;
  195. return numA - numB;
  196. });
  197. console.error(`\nTotal: ${allFigures.length} figures/tables detected`);
  198. return {
  199. figures: allFigures,
  200. totalPages,
  201. pdfPath: absolutePath,
  202. };
  203. }
  204. /**
  205. * Convert Roman numeral to integer
  206. */
  207. function romanToInt(roman: string): number | null {
  208. const romanMap: { [key: string]: number } = {
  209. I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000,
  210. };
  211. const upper = roman.toUpperCase();
  212. if (!/^[IVXLCDM]+$/.test(upper)) {
  213. return null;
  214. }
  215. let result = 0;
  216. for (let i = 0; i < upper.length; i++) {
  217. const current = romanMap[upper[i]];
  218. const next = romanMap[upper[i + 1]] || 0;
  219. if (current < next) {
  220. result -= current;
  221. } else {
  222. result += current;
  223. }
  224. }
  225. return result;
  226. }
  227. async function main() {
  228. const { pdf, output } = parseArgs();
  229. try {
  230. const result = await detectFigures(pdf);
  231. const json = JSON.stringify(result, null, 2);
  232. if (output) {
  233. writeFileSync(output, json);
  234. console.error(`\nSaved to: ${output}`);
  235. } else {
  236. console.log(json);
  237. }
  238. } catch (error) {
  239. console.error("\nError:", error instanceof Error ? error.message : error);
  240. process.exit(1);
  241. }
  242. }
  243. main();