pdf-providers.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. /**
  2. * PDF Parsing Provider Implementation
  3. *
  4. * Factory pattern for routing PDF parsing requests to appropriate provider implementations.
  5. * Follows the same architecture as lib/ai/providers.ts for consistency.
  6. *
  7. * Currently Supported Providers:
  8. * - unpdf: Built-in Node.js PDF parser with text and image extraction
  9. * - MinerU: Advanced commercial service with OCR, formula, and table extraction
  10. * (https://mineru.ai or self-hosted)
  11. *
  12. * HOW TO ADD A NEW PROVIDER:
  13. *
  14. * 1. Add provider ID to PDFProviderId in lib/pdf/types.ts
  15. * Example: | 'tesseract-ocr'
  16. *
  17. * 2. Add provider configuration to lib/pdf/constants.ts
  18. * Example:
  19. * 'tesseract-ocr': {
  20. * id: 'tesseract-ocr',
  21. * name: 'Tesseract OCR',
  22. * requiresApiKey: false,
  23. * icon: '/tesseract.svg',
  24. * features: ['text', 'images', 'ocr']
  25. * }
  26. *
  27. * 3. Implement provider function in this file
  28. * Pattern: async function parseWithXxx(config, pdfBuffer): Promise<ParsedPdfContent>
  29. * - Accept PDF as Buffer
  30. * - Extract text, images, tables, formulas as needed
  31. * - Return unified format:
  32. * {
  33. * text: string, // Markdown or plain text
  34. * images: string[], // Base64 data URLs
  35. * metadata: {
  36. * pageCount: number,
  37. * parser: string,
  38. * ... // Provider-specific metadata
  39. * }
  40. * }
  41. *
  42. * Example:
  43. * async function parseWithTesseractOCR(
  44. * config: PDFParserConfig,
  45. * pdfBuffer: Buffer
  46. * ): Promise<ParsedPdfContent> {
  47. * const { createWorker } = await import('tesseract.js');
  48. *
  49. * // Convert PDF pages to images
  50. * const pdf = await getDocumentProxy(new Uint8Array(pdfBuffer));
  51. * const numPages = pdf.numPages;
  52. *
  53. * const texts: string[] = [];
  54. * const images: string[] = [];
  55. *
  56. * for (let pageNum = 1; pageNum <= numPages; pageNum++) {
  57. * // Render page to canvas/image
  58. * const page = await pdf.getPage(pageNum);
  59. * const viewport = page.getViewport({ scale: 2.0 });
  60. * const canvas = createCanvas(viewport.width, viewport.height);
  61. * const context = canvas.getContext('2d');
  62. * await page.render({ canvasContext: context, viewport }).promise;
  63. *
  64. * // OCR the image
  65. * const worker = await createWorker('eng+chi_sim');
  66. * const { data: { text } } = await worker.recognize(canvas.toBuffer());
  67. * texts.push(text);
  68. * await worker.terminate();
  69. *
  70. * // Save image
  71. * images.push(canvas.toDataURL());
  72. * }
  73. *
  74. * return {
  75. * text: texts.join('\n\n'),
  76. * images,
  77. * metadata: {
  78. * pageCount: numPages,
  79. * parser: 'tesseract-ocr',
  80. * },
  81. * };
  82. * }
  83. *
  84. * 4. Add case to parsePDF() switch statement
  85. * case 'tesseract-ocr':
  86. * result = await parseWithTesseractOCR(config, pdfBuffer);
  87. * break;
  88. *
  89. * 5. Add i18n translations in lib/i18n.ts
  90. * providerTesseractOCR: { zh: 'Tesseract OCR', en: 'Tesseract OCR' }
  91. *
  92. * 6. Update features in constants.ts to reflect parser capabilities
  93. * features: ['text', 'images', 'ocr'] // OCR-capable
  94. *
  95. * Provider Implementation Patterns:
  96. *
  97. * Pattern 1: Local Node.js Parser (like unpdf)
  98. * - Import parsing library
  99. * - Process Buffer directly
  100. * - Extract text and images synchronously or asynchronously
  101. * - Convert images to base64 data URLs
  102. * - Return immediately
  103. *
  104. * Pattern 2: Remote API (like MinerU)
  105. * - Upload PDF or provide URL
  106. * - Create task and get task ID
  107. * - Poll for completion (with timeout)
  108. * - Download results (text, images, metadata)
  109. * - Parse and convert to unified format
  110. *
  111. * Pattern 3: OCR-based Parser (Tesseract, Google Vision)
  112. * - Render PDF pages to images
  113. * - Send images to OCR service
  114. * - Collect text from all pages
  115. * - Combine with layout analysis if available
  116. * - Return combined text and original images
  117. *
  118. * Image Extraction Best Practices:
  119. * - Always convert to base64 data URLs (data:image/png;base64,...)
  120. * - Use PNG for lossless quality
  121. * - Use sharp for efficient image processing
  122. * - Handle errors per image (don't fail entire parsing)
  123. * - Log extraction failures but continue processing
  124. *
  125. * Metadata Recommendations:
  126. * - pageCount: Number of pages in PDF
  127. * - parser: Provider ID for debugging
  128. * - processingTime: Time taken (auto-added)
  129. * - taskId/jobId: For async providers (useful for troubleshooting)
  130. * - Custom fields: imageMapping, pdfImages, tables, formulas, etc.
  131. *
  132. * Error Handling:
  133. * - Validate API key if requiresApiKey is true
  134. * - Throw descriptive errors for missing configuration
  135. * - For async providers, handle timeout and polling errors
  136. * - Log warnings for non-critical failures (e.g., single page errors)
  137. * - Always include provider name in error messages
  138. */
  139. import { extractText, getDocumentProxy, extractImages } from 'unpdf';
  140. import sharp from 'sharp';
  141. import type { PDFParserConfig } from './types';
  142. import type { ParsedPdfContent } from '@/lib/types/pdf';
  143. import { PDF_PROVIDERS } from './constants';
  144. import { createLogger } from '@/lib/logger';
  145. const log = createLogger('PDFProviders');
  146. /**
  147. * Parse PDF using specified provider
  148. */
  149. export async function parsePDF(
  150. config: PDFParserConfig,
  151. pdfBuffer: Buffer,
  152. ): Promise<ParsedPdfContent> {
  153. const provider = PDF_PROVIDERS[config.providerId];
  154. if (!provider) {
  155. throw new Error(`Unknown PDF provider: ${config.providerId}`);
  156. }
  157. // Validate API key if required
  158. if (provider.requiresApiKey && !config.apiKey) {
  159. throw new Error(`API key required for PDF provider: ${config.providerId}`);
  160. }
  161. const startTime = Date.now();
  162. let result: ParsedPdfContent;
  163. switch (config.providerId) {
  164. case 'unpdf':
  165. result = await parseWithUnpdf(pdfBuffer);
  166. break;
  167. case 'mineru':
  168. result = await parseWithMinerU(config, pdfBuffer);
  169. break;
  170. default:
  171. throw new Error(`Unsupported PDF provider: ${config.providerId}`);
  172. }
  173. // Add processing time to metadata
  174. if (result.metadata) {
  175. result.metadata.processingTime = Date.now() - startTime;
  176. }
  177. return result;
  178. }
  179. /**
  180. * Parse PDF using unpdf (existing implementation)
  181. */
  182. async function parseWithUnpdf(pdfBuffer: Buffer): Promise<ParsedPdfContent> {
  183. const uint8Array = new Uint8Array(pdfBuffer);
  184. const pdf = await getDocumentProxy(uint8Array);
  185. const numPages = pdf.numPages;
  186. // Extract text using the document proxy
  187. const { text: pdfText } = await extractText(pdf, {
  188. mergePages: true,
  189. });
  190. // Extract images using the same document proxy
  191. const images: string[] = [];
  192. const pdfImagesMeta: Array<{
  193. id: string;
  194. src: string;
  195. pageNumber: number;
  196. width: number;
  197. height: number;
  198. }> = [];
  199. let imageCounter = 0;
  200. for (let pageNum = 1; pageNum <= numPages; pageNum++) {
  201. try {
  202. const pageImages = await extractImages(pdf, pageNum);
  203. for (let i = 0; i < pageImages.length; i++) {
  204. const imgData = pageImages[i];
  205. try {
  206. // Use sharp to convert raw image data to PNG base64
  207. const pngBuffer = await sharp(Buffer.from(imgData.data), {
  208. raw: {
  209. width: imgData.width,
  210. height: imgData.height,
  211. channels: imgData.channels,
  212. },
  213. })
  214. .png()
  215. .toBuffer();
  216. // Convert to base64
  217. const base64 = `data:image/png;base64,${pngBuffer.toString('base64')}`;
  218. imageCounter++;
  219. const imgId = `img_${imageCounter}`;
  220. images.push(base64);
  221. pdfImagesMeta.push({
  222. id: imgId,
  223. src: base64,
  224. pageNumber: pageNum,
  225. width: imgData.width,
  226. height: imgData.height,
  227. });
  228. } catch (sharpError) {
  229. log.error(`Failed to convert image ${i + 1} from page ${pageNum}:`, sharpError);
  230. }
  231. }
  232. } catch (pageError) {
  233. log.error(`Failed to extract images from page ${pageNum}:`, pageError);
  234. }
  235. }
  236. return {
  237. text: pdfText,
  238. images,
  239. metadata: {
  240. pageCount: numPages,
  241. parser: 'unpdf',
  242. imageMapping: Object.fromEntries(pdfImagesMeta.map((m) => [m.id, m.src])),
  243. pdfImages: pdfImagesMeta,
  244. },
  245. };
  246. }
  247. /**
  248. * Parse PDF using self-hosted MinerU service (mineru-api)
  249. *
  250. * Official MinerU API endpoint:
  251. * POST /file_parse (multipart/form-data)
  252. *
  253. * Response format:
  254. * { results: { "document.pdf": { md_content, images, content_list, ... } } }
  255. *
  256. * @see https://github.com/opendatalab/MinerU
  257. */
  258. async function parseWithMinerU(
  259. config: PDFParserConfig,
  260. pdfBuffer: Buffer,
  261. ): Promise<ParsedPdfContent> {
  262. if (!config.baseUrl) {
  263. throw new Error(
  264. 'MinerU base URL is required. ' +
  265. 'Please deploy MinerU locally or specify the server URL. ' +
  266. 'See: https://github.com/opendatalab/MinerU',
  267. );
  268. }
  269. log.info('[MinerU] Parsing PDF with MinerU server:', config.baseUrl);
  270. const fileName = 'document.pdf';
  271. // Create FormData for file upload
  272. const formData = new FormData();
  273. // Convert Buffer to Blob
  274. const arrayBuffer = pdfBuffer.buffer.slice(
  275. pdfBuffer.byteOffset,
  276. pdfBuffer.byteOffset + pdfBuffer.byteLength,
  277. );
  278. const blob = new Blob([arrayBuffer as ArrayBuffer], {
  279. type: 'application/pdf',
  280. });
  281. formData.append('files', blob, fileName);
  282. // MinerU API form fields
  283. // Defaults already: return_md=true, formula_enable=true, table_enable=true
  284. formData.append('parse_method', 'auto');
  285. // hybrid-auto-engine: best accuracy, uses VLM for layout understanding (requires GPU)
  286. // pipeline: basic mode, no VLM, faster but lower quality image extraction
  287. formData.append('backend', 'hybrid-auto-engine');
  288. formData.append('return_content_list', 'true');
  289. formData.append('return_images', 'true');
  290. // API key (if required by deployment)
  291. const headers: Record<string, string> = {};
  292. if (config.apiKey) {
  293. headers['Authorization'] = `Bearer ${config.apiKey}`;
  294. }
  295. // POST /file_parse
  296. const response = await fetch(`${config.baseUrl}/file_parse`, {
  297. method: 'POST',
  298. headers,
  299. body: formData,
  300. });
  301. if (!response.ok) {
  302. const errorText = await response.text().catch(() => response.statusText);
  303. throw new Error(`MinerU API error (${response.status}): ${errorText}`);
  304. }
  305. const json = await response.json();
  306. // Response: { results: { "<fileName>": { md_content, images, content_list, ... } } }
  307. const fileResult = json.results?.[fileName];
  308. if (!fileResult) {
  309. const keys = json.results ? Object.keys(json.results) : [];
  310. // Try first available key in case filename doesn't match exactly
  311. const fallback = keys.length > 0 ? json.results[keys[0]] : null;
  312. if (!fallback) {
  313. throw new Error(`MinerU returned no results. Response keys: ${JSON.stringify(keys)}`);
  314. }
  315. log.warn(`[MinerU] Filename mismatch, using key "${keys[0]}" instead of "${fileName}"`);
  316. return extractMinerUResult(fallback);
  317. }
  318. return extractMinerUResult(fileResult);
  319. }
  320. /** Extract ParsedPdfContent from a single MinerU file result */
  321. function extractMinerUResult(fileResult: Record<string, unknown>): ParsedPdfContent {
  322. const markdown: string = (fileResult.md_content as string) || '';
  323. const imageData: Record<string, string> = {};
  324. let pageCount = 0;
  325. // Extract images from the images object (key → base64 string)
  326. if (fileResult.images && typeof fileResult.images === 'object') {
  327. Object.entries(fileResult.images as Record<string, string>).forEach(([key, value]) => {
  328. imageData[key] = value.startsWith('data:') ? value : `data:image/png;base64,${value}`;
  329. });
  330. }
  331. // Parse content_list to build image metadata lookup (img_path → metadata)
  332. const imageMetaLookup = new Map<string, { pageIdx: number; bbox: number[]; caption?: string }>();
  333. const contentList =
  334. typeof fileResult.content_list === 'string'
  335. ? JSON.parse(fileResult.content_list as string)
  336. : fileResult.content_list;
  337. if (Array.isArray(contentList)) {
  338. const pages = new Set(
  339. contentList
  340. .map((item: Record<string, unknown>) => item.page_idx)
  341. .filter((v: unknown) => v != null),
  342. );
  343. pageCount = pages.size;
  344. for (const item of contentList) {
  345. if (item.type === 'image' && item.img_path) {
  346. const metaEntry = {
  347. pageIdx: item.page_idx ?? 0,
  348. bbox: item.bbox || [0, 0, 1000, 1000],
  349. caption: Array.isArray(item.image_caption) ? item.image_caption[0] : undefined,
  350. };
  351. // Store under both the full path and basename so lookup works
  352. // regardless of whether images dict uses "abc.jpg" or "images/abc.jpg"
  353. imageMetaLookup.set(item.img_path, metaEntry);
  354. const basename = item.img_path.split('/').pop();
  355. if (basename && basename !== item.img_path) {
  356. imageMetaLookup.set(basename, metaEntry);
  357. }
  358. }
  359. }
  360. }
  361. // Build image mapping and pdfImages array
  362. const imageMapping: Record<string, string> = {};
  363. const pdfImages: Array<{
  364. id: string;
  365. src: string;
  366. pageNumber: number;
  367. description?: string;
  368. width?: number;
  369. height?: number;
  370. }> = [];
  371. Object.entries(imageData).forEach(([key, base64Url], index) => {
  372. const imageId = key.startsWith('img_') ? key : `img_${index + 1}`;
  373. imageMapping[imageId] = base64Url;
  374. // Try exact key first, then with 'images/' prefix (MinerU content_list uses prefixed paths)
  375. const meta = imageMetaLookup.get(key) || imageMetaLookup.get(`images/${key}`);
  376. pdfImages.push({
  377. id: imageId,
  378. src: base64Url,
  379. pageNumber: meta ? meta.pageIdx + 1 : 0,
  380. description: meta?.caption,
  381. width: meta ? meta.bbox[2] - meta.bbox[0] : undefined,
  382. height: meta ? meta.bbox[3] - meta.bbox[1] : undefined,
  383. });
  384. });
  385. const images = Object.values(imageMapping);
  386. log.info(
  387. `[MinerU] Parsed successfully: ${images.length} images, ` +
  388. `${markdown.length} chars of markdown`,
  389. );
  390. return {
  391. text: markdown,
  392. images,
  393. metadata: {
  394. pageCount,
  395. parser: 'mineru',
  396. imageMapping,
  397. pdfImages,
  398. },
  399. };
  400. }
  401. /**
  402. * Get current PDF parser configuration from settings store
  403. * Note: This function should only be called in browser context
  404. */
  405. export async function getCurrentPDFConfig(): Promise<PDFParserConfig> {
  406. if (typeof window === 'undefined') {
  407. throw new Error('getCurrentPDFConfig() can only be called in browser context');
  408. }
  409. // Dynamic import to avoid circular dependency
  410. const { useSettingsStore } = await import('@/lib/store/settings');
  411. const { pdfProviderId, pdfProvidersConfig } = useSettingsStore.getState();
  412. const providerConfig = pdfProvidersConfig?.[pdfProviderId];
  413. return {
  414. providerId: pdfProviderId,
  415. apiKey: providerConfig?.apiKey,
  416. baseUrl: providerConfig?.baseUrl,
  417. };
  418. }
  419. // Re-export from constants for convenience
  420. export { getAllPDFProviders, getPDFProvider } from './constants';