-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add PDF OCR support with validation and page limits #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| from fastapi import APIRouter, UploadFile, File, Form, HTTPException | ||
| from typing import List, Optional | ||
| import time | ||
| import os | ||
|
|
||
| from app.domain import pdf_ocr, fileUpload | ||
| from app.model.TextSchema import TextExtractDocument | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| @router.post("/extract_pdf", response_model=List[TextExtractDocument]) | ||
| async def extract_pdf( | ||
| input_file: UploadFile = File(..., description="PDF file to process"), | ||
| lang: str = Form("eng+por", description="Language code (eng, por, eng+por). Use 'auto' to enable OSD."), | ||
| mode: str = Form("fast", description="OCR mode: 'fast' or 'accurate'."), | ||
| auto_detect: bool = Form(False, description="Enable Orientation and Script Detection (OSD)."), | ||
| force_processing: bool = Form(False, description="Force processing even if page count > 10.") | ||
| ): | ||
| """ | ||
| Extract text from uploaded PDF document. | ||
|
|
||
| - **input_file**: PDF file to process. | ||
| - **lang**: Language(s) to use for OCR. Defaults to 'eng+por'. | ||
| - **mode**: processing mode. 'fast' is quicker, 'accurate' performs preprocessing. | ||
| - **auto_detect**: Explicitly enable OSD (Orientation and Script Detection). | ||
| - **force_processing**: Set to True to bypass the 10-page limit safeguard. | ||
| """ | ||
|
|
||
| # Validate mode | ||
| if mode not in ["fast", "accurate"]: | ||
| raise HTTPException(status_code=400, detail="Invalid mode. Use 'fast' or 'accurate'.") | ||
|
|
||
| # Handle 'auto' lang | ||
| if lang == "auto": | ||
| auto_detect = True | ||
| lang = "eng+por" | ||
|
|
||
| start_time = time.time() | ||
| print(f"Processing PDF: {input_file.filename}") | ||
|
|
||
| # Validate file type | ||
| fileUpload.validate_pdf_file(input_file) | ||
|
|
||
| # Save file using tempfile | ||
| temp_file = fileUpload._save_file_to_server(input_file) | ||
|
|
||
| try: | ||
| # Process PDF | ||
| text = await pdf_ocr.process_pdf( | ||
| file_path=temp_file, | ||
| lang=lang, | ||
| mode=mode, | ||
| auto=auto_detect, | ||
| force_processing=force_processing | ||
| ) | ||
| finally: | ||
| if os.path.exists(temp_file): | ||
| os.remove(temp_file) | ||
|
|
||
| time_taken = str(round((time.time() - start_time), 2)) | ||
|
|
||
| # Return list with single document result (consistent with image endpoint structure) | ||
| return [TextExtractDocument( | ||
| file_name=input_file.filename or "unknown", | ||
| text=text, | ||
| time_taken=time_taken | ||
| )] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import asyncio | ||
| import tempfile | ||
| import os | ||
| from pdf2image import convert_from_path, pdfinfo_from_path | ||
| from app.domain import ocr | ||
| from fastapi import HTTPException | ||
|
|
||
| async def process_pdf( | ||
| file_path: str, | ||
| lang: str = 'eng+por', | ||
| mode: str = 'fast', | ||
| auto: bool = False, | ||
| force_processing: bool = False | ||
| ) -> str: | ||
| """ | ||
| Converts PDF to images and extracts text from each page. | ||
|
|
||
| Args: | ||
| file_path (str): Path to the PDF file. | ||
| lang (str): Language code. | ||
| mode (str): 'fast' or 'accurate'. | ||
| auto (bool): Enable OSD. | ||
| force_processing (bool): If True, ignores the 10-page limit. | ||
|
|
||
| Returns: | ||
| str: Concatenated text from all processed pages. | ||
| """ | ||
| try: | ||
| # Check page count | ||
| # pdfinfo_from_path runs 'pdfinfo' command which is part of poppler-utils | ||
| info = await asyncio.to_thread(pdfinfo_from_path, file_path) | ||
| page_count = info.get("Pages", 0) | ||
|
|
||
| if page_count > 10 and not force_processing: | ||
| raise HTTPException( | ||
| status_code=400, | ||
| detail=f"PDF has {page_count} pages. Limit is 10. Set 'force_processing' to True to override." | ||
| ) | ||
|
|
||
| # Determine DPI based on mode | ||
| dpi = 300 if mode == 'accurate' else 200 | ||
|
|
||
| # Convert PDF to images | ||
| # We run this in a thread because it's CPU bound | ||
| def convert(): | ||
| return convert_from_path(file_path, dpi=dpi) | ||
|
|
||
| images = await asyncio.to_thread(convert) | ||
|
Comment on lines
+43
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All PDF pages are loaded into memory at once — high memory risk.
Sketch: page-by-page conversion- def convert():
- return convert_from_path(file_path, dpi=dpi)
-
- images = await asyncio.to_thread(convert)
-
extracted_text = []
- for i, image in enumerate(images):
+ for i in range(page_count):
+ page_num = i + 1
+ def convert_page(p=page_num):
+ return convert_from_path(file_path, dpi=dpi, first_page=p, last_page=p)
+
+ pages = await asyncio.to_thread(convert_page)
+ image = pages[0]
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_img:
image.save(temp_img.name)
temp_img_path = temp_img.name
try:
page_text = await ocr.read_image(temp_img_path, lang=lang, mode=mode, auto=auto)
- extracted_text.append(f"--- Page {i+1} ---\n{page_text}")
+ extracted_text.append(f"--- Page {page_num} ---\n{page_text}")
finally:
+ image.close()
if os.path.exists(temp_img_path):
os.remove(temp_img_path)🤖 Prompt for AI Agents |
||
|
|
||
| extracted_text = [] | ||
|
|
||
| for i, image in enumerate(images): | ||
| # Save image to temp file to reuse existing OCR logic | ||
| # Use delete=False so we can close it before OCR reads it (Windows compat, though running on Linux) | ||
| with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_img: | ||
| image.save(temp_img.name) | ||
| temp_img_path = temp_img.name | ||
|
|
||
| try: | ||
| # Reuse the existing OCR logic which handles preprocessing based on mode | ||
| page_text = await ocr.read_image(temp_img_path, lang=lang, mode=mode, auto=auto) | ||
| extracted_text.append(f"--- Page {i+1} ---\n{page_text}") | ||
| finally: | ||
| if os.path.exists(temp_img_path): | ||
| os.remove(temp_img_path) | ||
|
|
||
| return "\n\n".join(extracted_text) | ||
|
|
||
| except HTTPException as he: | ||
| raise he | ||
| except Exception as e: | ||
| # Log the error here if logging was set up | ||
| raise HTTPException(status_code=500, detail=f"Error processing PDF: {str(e)}") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Large PDFs with
force_processing=Truewill load all page images into memory at once.Looking at
app/domain/pdf_ocr.py(lines 48-51 in the relevant snippet),convert_from_pathmaterializes every page as a PIL Image simultaneously. For a 100-page PDF at 300 DPI, this can easily consume multiple GB of RAM and risk an OOM kill.Consider converting pages in batches (pdf2image supports
first_page/last_pageparameters) or streaming one page at a time to bound memory usage.🤖 Prompt for AI Agents