Phase 3 implementation. Coordinate system implemented. Initial text box tool implemented.
Some checks failed
Automated Container Build / build-and-push (push) Failing after 4s
CI / Backend (Python) (push) Failing after 17s
CI / Frontend (TypeScript) (push) Failing after 4m48s

This commit is contained in:
Elijah 2026-06-12 16:30:07 -07:00
parent c159ad4f37
commit 4cb038ec78
34 changed files with 6676 additions and 130 deletions

View file

@ -14,5 +14,14 @@ router.include_router(auth_router)
# Documents
router.include_router(documents_router)
# Annotations
from app.api.v1.annotations import router as annotations_router
router.include_router(annotations_router)
# Health (unauthenticated)
router.include_router(health_router)
from app.config import settings
if settings.DEBUG:
from app.api.v1.debug import router as debug_router
router.include_router(debug_router)

View file

@ -0,0 +1,91 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from datetime import datetime, timezone
import json
from app.db import get_db
from app.models.document import Document
from app.models.annotation_state import AnnotationState
from app.auth.dependencies import get_current_user, verify_csrf
from app.schemas.annotations import AnnotationStateResponse, AnnotationStateUpdateRequest, AnnotationStateUpdateResponse
router = APIRouter(prefix="/documents", tags=["Annotations"])
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse)
async def get_annotations(
document_id: str,
db: Session = Depends(get_db),
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
if not state:
return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc))
try:
data = json.loads(state.data)
except json.JSONDecodeError:
data = []
return AnnotationStateResponse(
data=data,
updatedAt=datetime.fromisoformat(state.updated_at)
)
@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)])
async def update_annotations(
document_id: str,
request: AnnotationStateUpdateRequest,
db: Session = Depends(get_db),
user_id: Any = Depends(get_current_user)
):
doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)))
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id))
# Check for stale tab
if state and request.baseUpdatedAt:
current_updated_at = datetime.fromisoformat(state.updated_at).replace(tzinfo=timezone.utc)
request_updated_at = request.baseUpdatedAt.replace(tzinfo=timezone.utc)
# Allow a small grace period for timezone/parsing discrepancies
if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0:
raise HTTPException(
status_code=409,
detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}"
)
now = _now_iso()
import json
# serialize request.data list of models to json string
data_json = json.dumps([a.model_dump(mode="json", by_alias=True) for a in request.data])
if not state:
state = AnnotationState(
document_id=document_id,
data=data_json,
updated_at=now
)
db.add(state)
else:
state.data = data_json
state.updated_at = now
# Bump document updated_at
doc.updated_at = now
db.commit()
return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now))

View file

@ -0,0 +1,56 @@
import pymupdf
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from app.config import settings
router = APIRouter(prefix="/debug", tags=["Debug"])
class VerifyCoordsRequest(BaseModel):
document_id: str
page: int
x: float
y: float
width: float
height: float
@router.post("/verify-coords")
async def verify_coords(req: VerifyCoordsRequest):
"""
Test endpoint for cross-engine coordinate verification.
Takes a canonical rect, draws it on the PDF using PyMuPDF,
and returns the flattened PDF.
"""
filepath = settings.PDF_STORAGE_PATH / f"{req.document_id}.pdf"
if not filepath.exists():
raise HTTPException(status_code=404, detail="Document not found")
try:
doc = pymupdf.open(filepath)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if req.page < 0 or req.page >= len(doc):
doc.close()
raise HTTPException(status_code=400, detail="Invalid page number")
page = doc[req.page]
# Canonical space is defined as relative to the unrotated CropBox.
# PyMuPDF naturally draws relative to the current rotation.
# By temporarily setting rotation to 0, we can draw directly using canonical coordinates.
original_rotation = page.rotation
if original_rotation != 0:
page.set_rotation(0)
rect = pymupdf.Rect(req.x, req.y, req.x + req.width, req.y + req.height)
page.draw_rect(rect, color=(1, 0, 0), width=2, fill=(1, 0, 0), fill_opacity=0.3)
if original_rotation != 0:
page.set_rotation(original_rotation)
pdf_bytes = doc.write()
doc.close()
return Response(content=pdf_bytes, media_type="application/pdf")

View file

@ -0,0 +1,107 @@
from datetime import datetime
from typing import Literal, Union, List, Tuple, Optional
from pydantic import BaseModel, Field
from uuid import UUID
class Rect(BaseModel):
x: float
y: float
width: float
height: float
class AnnotationBase(BaseModel):
id: UUID
page: int = Field(ge=0)
type: str
rect: Rect
rotation: float = 0.0
z: int = 0
createdAt: datetime
updatedAt: datetime
class TextProps(BaseModel):
text: str
fontFamily: str = "Liberation Sans"
fontSize: float = 14
color: str = "#000000"
align: Literal["left", "center", "right"] = "left"
bold: bool = False
italic: bool = False
lineHeight: float = 1.2
highlightColor: Optional[str] = None
class TextAnnotation(AnnotationBase):
type: Literal["text"]
props: TextProps
class DrawProps(BaseModel):
paths: List[Tuple[float, float]]
strokeColor: str = "#000000"
strokeWidth: float = 2
opacity: float = 1.0
class DrawAnnotation(AnnotationBase):
type: Literal["draw"]
props: DrawProps
class SignatureDrawProps(BaseModel):
mode: Literal["draw"]
ref: str
strokeColor: str = "#000000"
class SignatureTypeProps(BaseModel):
mode: Literal["type"]
text: str
fontFamily: str
color: str = "#000000"
class SignatureAnnotation(AnnotationBase):
type: Literal["signature"]
props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode")
class ImageProps(BaseModel):
ref: str
naturalWidth: float
naturalHeight: float
class ImageAnnotation(AnnotationBase):
type: Literal["image"]
props: ImageProps
class HighlightProps(BaseModel):
color: str = "#FFEB3B"
opacity: float = 0.3
class HighlightAnnotation(AnnotationBase):
type: Literal["highlight"]
props: HighlightProps
class ShapeProps(BaseModel):
kind: Literal["rect", "ellipse", "line", "arrow"]
strokeColor: str = "#000000"
fillColor: str = "transparent"
strokeWidth: float = 2
class ShapeAnnotation(AnnotationBase):
type: Literal["shape"]
props: ShapeProps
Annotation = Union[
TextAnnotation,
DrawAnnotation,
SignatureAnnotation,
ImageAnnotation,
HighlightAnnotation,
ShapeAnnotation
]
class AnnotationStateResponse(BaseModel):
data: List[Annotation]
updatedAt: datetime
class AnnotationStateUpdateRequest(BaseModel):
data: List[Annotation]
baseUpdatedAt: datetime | None = None
class AnnotationStateUpdateResponse(BaseModel):
updatedAt: datetime