import hashlib from typing import Annotated from fastapi import APIRouter, HTTPException, Path, Query, Request, Response, status from app.schemas.font import FontInfoResponse from app.services import engine from app.services.font import font_info_to_response from app.services.render_cache import RENDERER_VERSION, RenderMode, TileCacheKey, tile_cache from app.services.store import document_store router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"]) compat_router = APIRouter(tags=["render"]) def _etag(data: bytes) -> str: return hashlib.sha256(data).hexdigest()[:16] @router.get("/{page_index}/render") def render_page( request: Request, document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96, zoom: float = 1.0, rotation: int = 0, render_mode: RenderMode = RenderMode.NORMAL ) -> Response: print(f"[RENDER] document_id={document_id} page_index={page_index} zoom={zoom} dpi={dpi}") 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) key = TileCacheKey( doc_hash=doc_info.get("doc_hash", ""), renderer_version=RENDERER_VERSION, page=page_index, dpi=dpi, zoom=zoom, rotation=rotation, render_mode=render_mode, tile_x=0.0, tile_y=0.0, tile_w=round(page.width, 2), tile_h=round(page.height, 2) ) cached = tile_cache.get(key) if cached: etag_val = f'"{_etag(cached)}"' if request.headers.get("if-none-match") == etag_val: return Response(status_code=304) return Response( content=cached, media_type="image/png", headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"} ) import time start = time.perf_counter_ns() img = page.render(dpi) elapsed = time.perf_counter_ns() - start tile_cache.put(key, img.data) tile_cache.record_render_time(elapsed) etag_val = f'"{_etag(img.data)}"' return Response( content=img.data, media_type="image/png", headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"} ) except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" ) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/render-tile") def render_page_tile( request: Request, document_id: str, page_index: Annotated[int, Path(ge=0)], x: float, y: float, width: float, height: float, dpi: int = 96, zoom: float = 1.0, rotation: int = 0, render_mode: RenderMode = RenderMode.NORMAL ) -> Response: 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) key = TileCacheKey( doc_hash=doc_info.get("doc_hash", ""), renderer_version=RENDERER_VERSION, page=page_index, dpi=dpi, zoom=zoom, rotation=rotation, render_mode=render_mode, tile_x=round(x, 2), tile_y=round(y, 2), tile_w=round(width, 2), tile_h=round(height, 2) ) cached = tile_cache.get(key) if cached: etag_val = f'"{_etag(cached)}"' if request.headers.get("if-none-match") == etag_val: return Response(status_code=304) return Response( content=cached, media_type="image/png", headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"} ) import time start = time.perf_counter_ns() img_width, img_height, img_data = page.render_tile(dpi, x, y, width, height) import io from PIL import Image img = Image.frombytes("RGBA", (img_width, img_height), img_data) out_buf = io.BytesIO() img.save(out_buf, format="PNG") png_bytes = out_buf.getvalue() elapsed = time.perf_counter_ns() - start tile_cache.put(key, png_bytes) tile_cache.record_render_time(elapsed) etag_val = f'"{_etag(png_bytes)}"' return Response( content=png_bytes, media_type="image/png", headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"} ) except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" ) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/text") def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]): 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) text = page.extract_text() annots = page.extract_annotations_text() if annots: text += "\n" + "\n".join(annots) page_height = page.height glyphs = page.extract_text_with_bounds() for g in glyphs: g["y"] = page_height - (g["y"] + g["h"]) return {"text": text, "glyphs": glyphs} except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" ) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @compat_router.get("/render/{document_id}") def render_page_compat( request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0, dpi: int | None = None ) -> Response: if dpi is None: dpi = int(96 * zoom) print(f"[RENDER_COMPAT] document_id={document_id} page={page} zoom={zoom} dpi={dpi}") return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL) @router.get("/{page_index}") def get_page_info(document_id: str, page_index: Annotated[int, Path(ge=0)]): 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) return {"width": page.width, "height": page.height} except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" ) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/transform/page-to-device") def transform_page_to_device( document_id: str, page_index: int, x: float, y: float, device_width: int, device_height: int, rotate: int = 0, ): if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable" ) 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: pdfengine = engine.require() doc = doc_info["doc_instance"] page = doc.get_page(page_index) pt = pdfengine.Point2D(x=x, y=y) res = page.page_to_device(pt, device_width, device_height, rotate) return {"x": res.x, "y": res.y} except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/transform/device-to-page") def transform_device_to_page( document_id: str, page_index: int, x: int, y: int, device_width: int, device_height: int, rotate: int = 0, ): if not engine.is_available(): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable" ) 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: pdfengine = engine.require() doc = doc_info["doc_instance"] page = doc.get_page(page_index) pt = pdfengine.DevicePoint(x=x, y=y) res = page.device_to_page(pt, device_width, device_height, rotate) return {"x": res.x, "y": res.y} except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/fonts", response_model=list[FontInfoResponse]) def get_page_fonts(document_id: str, page_index: int) -> 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"] page = doc.get_page(page_index) fonts = page.get_fonts() return [font_info_to_response(f) for f in fonts] except IndexError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range") except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{page_index}/fonts/glyph-width") def get_page_glyph_width( document_id: str, page_index: Annotated[int, Path(ge=0)], font_name: str, charcode: int, font_size: Annotated[float, Query(gt=0)] = 12.0, ): 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) width = page.get_glyph_width(font_name, charcode, font_size) return {"width": width} except IndexError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range") except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))