Files
pdf/gateway/app/routers/edits.py
T

618 lines
18 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 StampData(BaseModel):
text: str
x: float
y: float
width: float
height: float
textColor: str
backgroundColor: str
borderColor: str
fontSize: float = Field(..., gt=0)
includeDate: bool = False
timestamp: str | None = None
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 StampOperation(BaseModel):
id: str
type: Literal["stamp"]
pageIndex: int = Field(..., ge=0)
data: StampData
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
disableJustify: bool = False
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"
advances: list[float] | None = None
advanceSeedText: str | None = None
class ReflowParagraphData(BaseModel):
objectIndices: list[int]
runs: list[ReflowRun]
columnLeft: float
columnRight: float
firstBaselineY: float
leading: float
oldLineCount: int = 1
align: Literal["left", "justify", "center", "right"] = "left"
pushColumnLeft: float | None = None
paraId: str | None = None
lines: list[list[ReflowRun]] | None = None
lineBaselineY: list[float] | None = None
lineX: list[float] | None = None
hangingIndent: float = 0.0
listMarker: str | None = None
listKind: Literal["bullet", "ordered"] | None = None
listLevel: int | None = None
class ReflowParagraphOperation(BaseModel):
id: str
type: Literal["reflow_paragraph"]
pageIndex: int = Field(..., ge=0)
data: ReflowParagraphData
class DecorationData(BaseModel):
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
class SignatureData(BaseModel):
x: float
y: float
width: float
height: float
image_data: str | None = None
author: str = "Signer"
class SignatureOperation(BaseModel):
id: str
type: Literal["signature"]
pageIndex: int = Field(..., ge=0)
data: SignatureData
EditOperation = Annotated[
TextOverlayOperation
| StampOperation
| RedactionOperation
| ImageOverlayOperation
| HighlightOperation
| FreeTextOperation
| CommentOperation
| FreehandOperation
| PageRotationOperation
| PageDeletionOperation
| PageReorderOperation
| UpdateFieldOperation
| DeleteAnnotationOperation
| UpdateAnnotationOperation
| ReplaceTextOperation
| ReflowParagraphOperation
| UnderlineOperation
| StrikeoutOperation
| SquigglyOperation
| SignatureOperation
,
Field(discriminator="type"),
]
class EditsRequest(BaseModel):
version: Literal["1.0"]
operations: list[EditOperation]
_OP_PERMISSION = {
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "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")
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
from PIL import Image
if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1]
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
r, g, b, a = img_rgba.split()
img_bgra = Image.merge("RGBA", (b, g, r, a))
bgra_bytes = img_bgra.tobytes()
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
temp_path = temp_path.replace("\\", "/")
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
if "imageData" in op["data"]:
del op["data"]["imageData"]
edits_json = json.dumps(req_dict)
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
invalidated_regions = doc_copy.apply_edits(edits_json)
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_copy.save_full() if needs_full else doc_copy.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,
permissions=doc_info.get("permissions"),
)
if "remote_context" in doc_info:
new_info["remote_context"] = doc_info["remote_context"]
from app.services.render_cache import tile_cache
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions}
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)
class PreviewEditsBody(EditsRequest):
"""Same ops as apply_edits, plus preview render parameters. Not persisted."""
pageIndex: int = Field(0, ge=0)
dpi: int = Field(144, ge=36, le=600)
yTopPt: float = 0.0
@router.post("/preview")
def preview_edits(document_id: str, request: PreviewEditsBody):
"""Apply edits on a throwaway copy and return a page-region PNG.
Live typing must preview through this path (same engine as save), not the
stale browser WASM — otherwise font/width jump on keystroke then snap back on save.
"""
import base64
import io
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")
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 ({required}).",
)
try:
pdfengine = engine.require()
req_dict = request.model_dump(exclude_none=True)
# Strip preview-only fields before apply_edits JSON
page_index = int(req_dict.pop("pageIndex", 0))
dpi = int(req_dict.pop("dpi", 144))
y_top = float(req_dict.pop("yTopPt", 0.0))
edits_json = json.dumps(req_dict)
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
doc_copy.apply_edits(edits_json)
page = doc_copy.get_page(page_index)
# Binding returns (width, height, rgba_bytes) — not an Image-like object.
w, h, raw = page.render_region_raw(dpi, y_top, 0.0)
raw = bytes(raw)
w, h = int(w), int(h)
from PIL import Image
png_buf = io.BytesIO()
Image.frombytes("RGBA", (w, h), raw).save(png_buf, format="PNG")
b64 = base64.b64encode(png_buf.getvalue()).decode("ascii")
layout_obj = None
layout_source = "extract"
try:
# Prefer engine's reflow layout (matches WASM STAGE_5) when binding exposes it.
if hasattr(doc_copy, "last_reflow_layout"):
raw_lay = doc_copy.last_reflow_layout()
if raw_lay:
layout_obj = json.loads(raw_lay)
layout_source = "reflow"
except Exception:
layout_obj = None
layout_source = "extract"
if not layout_obj:
# Fallback: extract page model and keep only lines in the edited paragraph band.
reflow_op = next((op for op in request.operations if op.type == "reflow_paragraph"), None)
col_l = float(reflow_op.data.columnLeft) if reflow_op else None
col_r = float(reflow_op.data.columnRight) if reflow_op else None
base0 = float(reflow_op.data.firstBaselineY) if reflow_op else None
leading = float(reflow_op.data.leading) if reflow_op else 14.0
old_n = int(reflow_op.data.oldLineCount) if reflow_op else 1
y_lo = (base0 - leading * 0.75) if base0 is not None else None
y_hi = (base0 + leading * max(old_n + 48, 64)) if base0 is not None else None
layout_lines = []
try:
model = page.extract_document_model()
for para in model.paragraphs:
for ln in para.lines:
text = "".join(r.text or "" for r in ln.runs)
if not text.strip():
continue
# Flatten glyphs across runs, then advances from origin deltas
# (per-run last-glyph bbox_w under-advances and lags the caret).
glyphs = []
for r in ln.runs:
glyphs.extend(list(r.glyphs))
if not glyphs:
continue
adv: list[float] = []
for i, g in enumerate(glyphs):
if i + 1 < len(glyphs):
adv.append(float(glyphs[i + 1].origin_x - g.origin_x))
else:
bw = float(g.bbox_w) if g.bbox_w > 0 else 0.0
adv.append(bw if bw > 0 else float(max((r.font_size or 0) for r in ln.runs) or 12) * 0.5)
x0 = float(glyphs[0].origin_x)
by = float(ln.baseline_y)
if col_l is not None and x0 < col_l - 24:
continue
if col_r is not None and x0 > col_r + 8:
continue
if y_lo is not None and by < y_lo:
continue
if y_hi is not None and by > y_hi:
continue
layout_lines.append({
"baselineY": by,
"x0": x0,
"fontSize": float(max((r.font_size or 0) for r in ln.runs) or 12),
"text": text,
"adv": adv,
"pageIndex": page_index,
})
except Exception:
layout_lines = []
layout_lines.sort(key=lambda L: -L["baselineY"])
layout_obj = {
"columnLeft": layout_lines[0]["x0"] if layout_lines else (col_l or 0),
"anchorPage": page_index,
"lines": layout_lines,
}
layout_source = "extract"
if isinstance(layout_obj, dict):
layout_obj["layoutSource"] = layout_source
return {
"width": w,
"height": h,
"yTopPt": y_top,
"pageIndex": page_index,
"pngBase64": b64,
"layout": layout_obj,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@compat_router.post("/edits/{document_id}")
def apply_edits_compat(document_id: str, request: EditsRequest):
return apply_edits_impl(document_id, request)