Files
pdf/gateway/app/routers/edits.py
T
2026-06-11 19:23:34 +05:30

328 lines
7.9 KiB
Python

import json
from typing import Annotated, Literal
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from app.services import engine
from app.services.store import document_store
router = APIRouter(prefix="/documents/{document_id}/edits", tags=["edits"])
compat_router = APIRouter(tags=["edits"])
class TextOverlayData(BaseModel):
text: str
x: float
y: float
width: float
height: float
fontSize: float = Field(..., gt=0)
fontFamily: str
color: str
class RedactionData(BaseModel):
x: float
y: float
width: float
height: float
fillColor: str = "#000000"
class ImageOverlayData(BaseModel):
x: float
y: float
width: float
height: float
imageData: str
class HighlightQuadPoint(BaseModel):
x1: float
y1: float
x2: float
y2: float
x3: float
y3: float
x4: float
y4: float
class HighlightData(BaseModel):
quadPoints: list[HighlightQuadPoint]
color: str
opacity: float = 1.0
author: str
content: str | None = None
class FreeTextData(BaseModel):
x: float
y: float
width: float
height: float
text: str
fontSize: float = Field(12.0, gt=0)
color: str = "#000000"
class StickyNoteData(BaseModel):
x: float
y: float
author: str
content: str
timestamp: str | None = None
class FreehandPoint(BaseModel):
x: float
y: float
class FreehandData(BaseModel):
paths: list[list[FreehandPoint]]
color: str
thickness: float
class PageRotationData(BaseModel):
rotation: Literal[0, 90, 180, 270]
class TextOverlayOperation(BaseModel):
id: str
type: Literal["text_overlay"]
pageIndex: int = Field(..., ge=0)
data: TextOverlayData
class RedactionOperation(BaseModel):
id: str
type: Literal["redaction"]
pageIndex: int = Field(..., ge=0)
data: RedactionData
class ImageOverlayOperation(BaseModel):
id: str
type: Literal["image_overlay"]
pageIndex: int = Field(..., ge=0)
data: ImageOverlayData
class HighlightOperation(BaseModel):
id: str
type: Literal["highlight"]
pageIndex: int = Field(..., ge=0)
data: HighlightData
class FreeTextOperation(BaseModel):
id: str
type: Literal["free_text"]
pageIndex: int = Field(..., ge=0)
data: FreeTextData
class CommentOperation(BaseModel):
id: str
type: Literal["comment"]
pageIndex: int = Field(..., ge=0)
data: StickyNoteData
class FreehandOperation(BaseModel):
id: str
type: Literal["freehand"]
pageIndex: int = Field(..., ge=0)
data: FreehandData
class PageRotationOperation(BaseModel):
id: str
type: Literal["page_rotation"]
pageIndex: int = Field(..., ge=0)
data: PageRotationData
class PageDeletionData(BaseModel):
pass
class PageDeletionOperation(BaseModel):
id: str
type: Literal["page_deletion"]
pageIndex: int
data: PageDeletionData
class PageReorderData(BaseModel):
destPageIndex: int
class PageReorderOperation(BaseModel):
id: str
type: Literal["page_reorder"]
pageIndex: int
data: PageReorderData
class UpdateFieldData(BaseModel):
value: str | bool
class UpdateFieldOperation(BaseModel):
id: str
type: Literal["update_field"]
pageIndex: int
data: UpdateFieldData
class DeleteAnnotationData(BaseModel):
annotationId: str
class DeleteAnnotationOperation(BaseModel):
id: str
type: Literal["delete_annotation"]
pageIndex: int = Field(..., ge=0)
data: DeleteAnnotationData
class UpdateAnnotationData(BaseModel):
annotationId: str
x: float | None = None
y: float | None = None
width: float | None = None
height: float | None = None
color: str | None = None
thickness: float | None = None
text: str | None = None
class UpdateAnnotationOperation(BaseModel):
id: str
type: Literal["update_annotation"]
pageIndex: int = Field(..., ge=0)
data: UpdateAnnotationData
EditOperation = Annotated[
TextOverlayOperation
| RedactionOperation
| ImageOverlayOperation
| HighlightOperation
| FreeTextOperation
| CommentOperation
| FreehandOperation
| PageRotationOperation
| PageDeletionOperation
| PageReorderOperation
| UpdateFieldOperation
| DeleteAnnotationOperation
| UpdateAnnotationOperation,
Field(discriminator="type"),
]
class EditsRequest(BaseModel):
version: Literal["1.0"]
operations: list[EditOperation]
def apply_edits_impl(document_id: str, request: EditsRequest):
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")
created_temp_files = []
try:
pdfengine = engine.require()
doc = doc_info["doc_instance"]
req_dict = request.model_dump(exclude_none=True)
for op in req_dict.get("operations", []):
if op.get("type") == "image_overlay":
img_data_str = op["data"].get("imageData", "")
if img_data_str:
import base64
import io
import os
import tempfile
# pyrefly: ignore [missing-import]
from PIL import Image
# Remove data URI header if present
if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1]
raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
# Convert RGBA to BGRA
r, g, b, a = img_rgba.split()
img_bgra = Image.merge("RGBA", (b, g, r, a))
bgra_bytes = img_bgra.tobytes()
# Create a temporary binary file to hold raw pixel data
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
created_temp_files.append(temp_path)
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(bgra_bytes)
except Exception:
os.close(fd)
raise
op["data"]["pixelDataPath"] = temp_path
op["data"]["pixelWidth"] = img.width
op["data"]["pixelHeight"] = img.height
# Delete base64 strings to keep JSON payload tiny
if "imageData" in op["data"]:
del op["data"]["imageData"]
edits_json = json.dumps(req_dict)
doc.apply_edits(edits_json)
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
new_info = document_store.add_document(
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc
)
return {"success": True, "newDocumentId": new_info["id"]}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
finally:
import contextlib
import os
for temp_path in created_temp_files:
if os.path.exists(temp_path):
with contextlib.suppress(Exception):
os.remove(temp_path)
@router.post("")
def apply_edits(document_id: str, request: EditsRequest):
return apply_edits_impl(document_id, request)
@compat_router.post("/edits/{document_id}")
def apply_edits_compat(document_id: str, request: EditsRequest):
return apply_edits_impl(document_id, request)