from fastapi import APIRouter, File, HTTPException, UploadFile, status from pydantic import BaseModel from app.services import engine from app.services.store import document_store router = APIRouter(prefix="/documents", tags=["documents"]) class PageInfoResponse(BaseModel): index: int width: float height: float class DocumentInfoResponse(BaseModel): id: str filename: str sizeBytes: int totalPages: int pageWidth: float pageHeight: float uploadedAt: str status: str pages: list[PageInfoResponse] = [] def make_document_response(d: dict) -> DocumentInfoResponse: pages_list = [] if "doc_instance" in d: doc = d["doc_instance"] for i in range(doc.page_count): try: page = doc.get_page(i) pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height)) except Exception: pass return DocumentInfoResponse( id=d["id"], filename=d["filename"], sizeBytes=d["sizeBytes"], totalPages=d["totalPages"], pageWidth=d.get("pageWidth", 612.0), pageHeight=d.get("pageHeight", 792.0), uploadedAt=d["uploadedAt"], status=d["status"], pages=pages_list, ) @router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED) async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) bytes_data = await file.read() try: pdfengine = engine.require() doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password) info = document_store.add_document(file.filename, bytes_data, doc) return make_document_response(info) except ValueError as e: detail = str(e) if "Password required" in detail: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required" ) elif "Invalid password" in detail: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password") else: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) except Exception as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}" ) @router.get("", response_model=list[DocumentInfoResponse]) def list_documents() -> list[DocumentInfoResponse]: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) docs = document_store.list_documents() return [make_document_response(d) for d in docs] @router.get("/{document_id}", response_model=DocumentInfoResponse) def get_document(document_id: str) -> DocumentInfoResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) d = document_store.get_document(document_id) if not d: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") return make_document_response(d) @router.delete("/{document_id}") def delete_document(document_id: str): if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) deleted = document_store.delete_document(document_id) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") return {"success": True} class DocumentMetadataResponse(BaseModel): title: str author: str creator: str producer: str creation_date: str modification_date: str @router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse) def get_document_metadata(document_id: str) -> DocumentMetadataResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) d = document_store.get_document(document_id) if not d: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") try: doc = d["doc_instance"] meta = doc.metadata return DocumentMetadataResponse( title=meta.title, author=meta.author, creator=meta.creator, producer=meta.producer, creation_date=meta.creation_date, modification_date=meta.modification_date, ) except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) class FontInfoResponse(BaseModel): fontName: str type: str isEmbedded: bool isSubset: bool isVertical: bool encoding: str hasToUnicode: bool cmapName: str cidSystemInfo: str subsetTag: str sourceType: str substitutedFrom: str substitutedTo: str normalizedFamily: str internalFontId: str flags: int ascent: float descent: float capHeight: float @router.get("/{document_id}/fonts", response_model=list[FontInfoResponse]) def get_document_fonts( document_id: str, start_page: int = 0, end_page: int = -1 ) -> list[FontInfoResponse]: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) doc_info = document_store.get_document(document_id) if not doc_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") try: doc = doc_info["doc_instance"] fonts = doc.get_fonts(start_page, end_page) return [ FontInfoResponse( fontName=f.font_name, type=f.type, isEmbedded=f.is_embedded, isSubset=f.is_subset, isVertical=f.is_vertical, encoding=f.encoding, hasToUnicode=f.has_to_unicode, cmapName=f.cmap_name, cidSystemInfo=f.cid_system_info, subsetTag=f.subset_tag, sourceType=f.source_type, substitutedFrom=f.substituted_from, substitutedTo=f.substituted_to, normalizedFamily=f.normalized_family, internalFontId=f.internal_font_id, flags=f.flags, ascent=f.ascent, descent=f.descent, capHeight=f.cap_height, ) for f in fonts ] except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except IndexError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) class SearchRect(BaseModel): x: float y: float w: float h: float class SearchMatch(BaseModel): pageIndex: int rects: list[SearchRect] text: str @router.get("/{document_id}/search", response_model=list[SearchMatch]) def search_document(document_id: str, q: str) -> list[SearchMatch]: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available." ) if not q: return [] doc_info = document_store.get_document(document_id) if not doc_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") try: doc = doc_info["doc_instance"] matches = [] lower_query = q.lower() query_len = len(lower_query) for page_idx in range(doc.page_count): page = doc.get_page(page_idx) glyphs = page.extract_text_with_bounds() if not glyphs: continue text_str = "" char_to_glyph = [] for i, g in enumerate(glyphs): s = g.get("text", "") start_len = len(text_str) text_str += s for _ in range(len(text_str) - start_len): char_to_glyph.append(i) lower_text = text_str.lower() idx = 0 while True: idx = lower_text.find(lower_query, idx) if idx == -1: break start_glyph_idx = char_to_glyph[idx] end_glyph_idx = char_to_glyph[idx + query_len - 1] rects = [] current_rect = None for g_idx in range(start_glyph_idx, end_glyph_idx + 1): g = glyphs[g_idx] dom_y = page.height - (g["y"] + g["h"]) if current_rect is None: current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]} else: if abs(dom_y - current_rect["y"]) < g.get("fontSize", 12) * 0.5: max_x = max(current_rect["x"] + current_rect["w"], g["x"] + g["w"]) current_rect["w"] = max_x - current_rect["x"] current_rect["y"] = min(current_rect["y"], dom_y) current_rect["h"] = max(current_rect["h"], g["h"]) else: rects.append(SearchRect(**current_rect)) current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]} if current_rect: rects.append(SearchRect(**current_rect)) matches.append(SearchMatch( pageIndex=page_idx, rects=rects, text=text_str[idx:idx + query_len] )) idx += 1 return matches except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) class GlyphModel(BaseModel): text: str unicode: int font_name: str flags: int font_size: float origin_x: float origin_y: float bbox_x: float bbox_y: float bbox_w: float bbox_h: float angle: float class TextRunModel(BaseModel): text: str font_name: str flags: int font_size: float internal_font_id: str is_embedded: bool type: str glyphs: list[GlyphModel] x: float y: float w: float h: float class TextLineModel(BaseModel): runs: list[TextRunModel] baseline_y: float x: float y: float w: float h: float class ParagraphModel(BaseModel): lines: list[TextLineModel] x: float y: float w: float h: float class PageModelResponse(BaseModel): paragraphs: list[ParagraphModel] width: float height: float page_index: int @router.get("/{document_id}/pages/{page_index}/model", response_model=PageModelResponse) def get_page_model(document_id: str, page_index: int) -> PageModelResponse: if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine bridge (bindings/python) not yet available.", ) doc_info = document_store.get_document(document_id) if not doc_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") try: doc = doc_info["doc_instance"] page = doc.get_page(page_index) model = page.extract_document_model() paragraphs = [] for p in model.paragraphs: lines = [] for l in p.lines: runs = [] for r in l.runs: glyphs = [] for g in r.glyphs: glyphs.append(GlyphModel( text=g.text, unicode=g.unicode, font_name=g.font_name, flags=g.flags, font_size=g.font_size, origin_x=g.origin_x, origin_y=g.origin_y, bbox_x=g.bbox_x, bbox_y=g.bbox_y, bbox_w=g.bbox_w, bbox_h=g.bbox_h, angle=g.angle )) runs.append(TextRunModel( text=r.text, font_name=r.font_name, flags=r.flags, font_size=r.font_size, internal_font_id=r.internal_font_id, is_embedded=r.is_embedded, type=r.type, glyphs=glyphs, x=r.x, y=r.y, w=r.w, h=r.h )) lines.append(TextLineModel( runs=runs, baseline_y=l.baseline_y, x=l.x, y=l.y, w=l.w, h=l.h )) paragraphs.append(ParagraphModel( lines=lines, x=p.x, y=p.y, w=p.w, h=p.h )) return PageModelResponse( paragraphs=paragraphs, width=model.width, height=model.height, page_index=model.page_index ) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except IndexError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))