618 lines
18 KiB
Python
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)
|