439 lines
12 KiB
Python
439 lines
12 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
|
|
annotationId: str | None = None
|
|
|
|
|
|
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
|
|
|
|
|
|
class ReplaceTextData(BaseModel):
|
|
objectIndices: list[int]
|
|
text: str
|
|
internalFontId: str
|
|
fontSize: float
|
|
|
|
|
|
class ReplaceTextOperation(BaseModel):
|
|
id: str
|
|
type: Literal["replace_text"]
|
|
pageIndex: int = Field(..., ge=0)
|
|
data: ReplaceTextData
|
|
|
|
|
|
class ReflowRun(BaseModel):
|
|
text: str
|
|
internalFontId: str
|
|
fontSize: float
|
|
color: str = "#000000"
|
|
|
|
|
|
class ReflowParagraphData(BaseModel):
|
|
objectIndices: list[int]
|
|
runs: list[ReflowRun]
|
|
columnLeft: float
|
|
columnRight: float
|
|
firstBaselineY: float
|
|
leading: float
|
|
oldLineCount: int = 1
|
|
align: Literal["left", "justify"] = "left"
|
|
# Push-down column left edge (defaults to columnLeft). For a bullet item the text reflows from
|
|
# a hanging indent (columnLeft) but the push-down spans the full width from pushColumnLeft so
|
|
# bullet markers and items below move together.
|
|
pushColumnLeft: float | None = None
|
|
# Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of
|
|
# styled fragments per line). When present the engine emits these breaks verbatim.
|
|
lines: list[list[ReflowRun]] | None = None
|
|
# Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of
|
|
# styled fragments per line). When present the engine emits these breaks verbatim.
|
|
lines: list[list[ReflowRun]] | None = None
|
|
|
|
|
|
class ReflowParagraphOperation(BaseModel):
|
|
id: str
|
|
type: Literal["reflow_paragraph"]
|
|
pageIndex: int = Field(..., ge=0)
|
|
data: ReflowParagraphData
|
|
|
|
|
|
class DecorationData(BaseModel):
|
|
# Text-markup annotation (underline/strikeout/squiggly) over one or more text
|
|
# lines — quadpoints in PDF top-down space, mirroring HighlightData.
|
|
quadPoints: list[HighlightQuadPoint]
|
|
color: str = "#000000"
|
|
author: str = "User"
|
|
content: str | None = None
|
|
|
|
class UnderlineOperation(BaseModel):
|
|
id: str
|
|
type: Literal["underline"]
|
|
pageIndex: int = Field(..., ge=0)
|
|
data: DecorationData
|
|
|
|
class StrikeoutOperation(BaseModel):
|
|
id: str
|
|
type: Literal["strikeout"]
|
|
pageIndex: int = Field(..., ge=0)
|
|
data: DecorationData
|
|
|
|
class SquigglyOperation(BaseModel):
|
|
id: str
|
|
type: Literal["squiggly"]
|
|
pageIndex: int = Field(..., ge=0)
|
|
data: DecorationData
|
|
|
|
EditOperation = Annotated[
|
|
TextOverlayOperation
|
|
| RedactionOperation
|
|
| ImageOverlayOperation
|
|
| HighlightOperation
|
|
| FreeTextOperation
|
|
| CommentOperation
|
|
| FreehandOperation
|
|
| PageRotationOperation
|
|
| PageDeletionOperation
|
|
| PageReorderOperation
|
|
| UpdateFieldOperation
|
|
| DeleteAnnotationOperation
|
|
| UpdateAnnotationOperation
|
|
| ReplaceTextOperation
|
|
| ReflowParagraphOperation
|
|
| UnderlineOperation
|
|
| StrikeoutOperation
|
|
| SquigglyOperation,
|
|
Field(discriminator="type"),
|
|
]
|
|
|
|
|
|
class EditsRequest(BaseModel):
|
|
version: Literal["1.0"]
|
|
operations: list[EditOperation]
|
|
|
|
|
|
# Which PDF permission each edit operation requires. Unencrypted / owner-unlocked
|
|
# docs report every flag True (in the engine), so this never blocks them.
|
|
_OP_PERMISSION = {
|
|
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
|
|
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
|
|
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
|
|
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
|
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
|
|
"update_field": "canFillForms",
|
|
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
|
|
}
|
|
|
|
|
|
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")
|
|
|
|
# Enforce document permissions (defense-in-depth; the UI also gates these).
|
|
# Done before the try so the 403 isn't rewritten to 400 by the broad handler.
|
|
perms = doc_info.get("permissions") or {}
|
|
for op in request.operations:
|
|
required = _OP_PERMISSION.get(op.type)
|
|
if required and perms.get(required, True) is False:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Operation '{op.type}' is not permitted by this document's restrictions ({required}).",
|
|
)
|
|
|
|
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)
|
|
|
|
# Redaction and in-place text rewrites mutate existing objects, which do
|
|
# not round-trip cleanly through an incremental save — force a full save.
|
|
full_save_types = {"redaction", "replace_text", "reflow_paragraph"}
|
|
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
|
|
new_bytes = doc.save_full() if needs_full else doc.save_incremental()
|
|
|
|
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
|
|
|
# Carry the original permissions forward — the saved bytes are decrypted, so
|
|
# a fresh load would report full access and defeat enforcement.
|
|
new_info = document_store.add_document(
|
|
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
|
permissions=doc_info.get("permissions"),
|
|
)
|
|
|
|
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)
|