feat: implemented caret based selection at any click position

This commit is contained in:
Furqan-14
2026-06-12 19:13:29 +05:30
parent d47895c990
commit 44f64a0d99
9 changed files with 438 additions and 47 deletions
+55
View File
@@ -1,3 +1,5 @@
import hashlib
from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status
from pydantic import BaseModel
@@ -245,6 +247,57 @@ def get_document_fonts(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# sfnt magic numbers — only these wrappers load as a browser FontFace.
_SFNT_TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"ttcf")
_SFNT_OTF_MAGIC = b"OTTO"
@router.get("/{document_id}/font")
def get_font_bytes(document_id: str, internal_font_id: str) -> Response:
"""Raw embedded font bytes for an in-place-editing preview.
Returns the font only when it's a browser-loadable sfnt (TrueType / OpenType-CFF).
Type1/PFB, non-embedded, and unknown fonts return 404 so the frontend falls back to
a base-14 CSS font. The lookup is sandboxed to fonts inside the loaded document.
"""
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
# Cap the id length; never echo it back into error bodies.
if not internal_font_id or len(internal_font_id) > 256:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not found")
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")
try:
data = bytes(doc_info["doc_instance"].get_font_data(internal_font_id))
except Exception:
data = b""
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not found")
magic = data[:4]
if magic in _SFNT_TTF_MAGIC:
media_type = "font/ttf"
elif magic == _SFNT_OTF_MAGIC:
media_type = "font/otf"
else:
# Type1 (\x80\x01 / "%!") or anything not sfnt-wrapped — not browser-loadable.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not loadable")
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
return Response(
content=data,
media_type=media_type,
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
)
class SearchRect(BaseModel):
x: float
y: float
@@ -396,6 +449,7 @@ class TextRunModel(BaseModel):
w: float
h: float
object_indices: list[int] = []
color: str = "#000000" # run fill (or stroke) color, for in-place editing
class TextLineModel(BaseModel):
@@ -479,6 +533,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
w=r.w,
h=r.h,
object_indices=r.object_indices,
color=getattr(r, "fill_color", "#000000") or "#000000",
)
)
lines.append(
+123
View File
@@ -0,0 +1,123 @@
"""Tests for the in-place-editing data path: per-run fill color + line baseline in the
page model, and the /font endpoint that serves browser-loadable font bytes.
These back the Adobe-style in-place text editor (real font + exact color + baseline).
"""
import contextlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.services import engine
from app.services.store import document_store
has_pdfium = False
if engine.is_available():
with contextlib.suppress(Exception):
has_pdfium = engine.require().engine_has_pdfium()
pytestmark = pytest.mark.skipif(
not engine.is_available() or not has_pdfium,
reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.",
)
def _build_colored_pdf() -> bytes:
"""Minimal 1-page PDF: red 'Red Text' + blue 'Blue Text', Helvetica."""
content = (
b"1 0 0 rg\nBT /F1 24 Tf 72 700 Td (Red Text) Tj ET\n"
b"0 0 1 rg\nBT /F1 24 Tf 72 650 Td (Blue Text) Tj ET\n"
)
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
pdf = b"%PDF-1.7\n"
offsets = []
for i, o in enumerate(objs, 1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n"
xref_pos = len(pdf)
pdf += b"xref\n0 %d\n" % (len(objs) + 1)
pdf += b"0000000000 65535 f \n"
for off in offsets:
pdf += b"%010d 00000 n \n" % off
pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
len(objs) + 1,
xref_pos,
)
return pdf
@pytest.fixture(autouse=True)
def clean_store():
with document_store._lock:
document_store._documents.clear()
yield
@pytest.fixture()
def colored_doc_id(client: TestClient) -> str:
resp = client.post(
"/documents", files={"file": ("colored.pdf", _build_colored_pdf(), "application/pdf")}
)
assert resp.status_code == 201
return resp.json()["id"]
def _runs(model: dict) -> list[dict]:
return [r for p in model["paragraphs"] for ln in p["lines"] for r in ln["runs"]]
def test_model_has_fill_color_and_baseline(client: TestClient, colored_doc_id: str):
model = client.get(f"/documents/{colored_doc_id}/pages/0/model").json()
runs = _runs(model)
colors = {r["text"].strip(): r["color"] for r in runs}
assert colors.get("Red Text") == "#ff0000"
assert colors.get("Blue Text") == "#0000ff"
# Each line carries a real (non-zero) PDF text baseline.
for p in model["paragraphs"]:
for line in p["lines"]:
if line["runs"]:
assert line["baseline_y"] > 0
def test_font_endpoint_serves_loadable_sfnt(client: TestClient, colored_doc_id: str):
model = client.get(f"/documents/{colored_doc_id}/pages/0/model").json()
fid = _runs(model)[0]["internal_font_id"]
resp = client.get(f"/documents/{colored_doc_id}/font", params={"internal_font_id": fid})
assert resp.status_code == 200
assert resp.headers["content-type"] in ("font/ttf", "font/otf")
# sfnt magic — must be browser-FontFace-loadable.
assert resp.content[:4] in (b"\x00\x01\x00\x00", b"true", b"ttcf", b"OTTO")
assert "immutable" in resp.headers.get("cache-control", "")
assert resp.headers.get("etag")
def test_font_endpoint_rejects_unknown(client: TestClient, colored_doc_id: str):
# Bogus font id → 404.
assert (
client.get(
f"/documents/{colored_doc_id}/font", params={"internal_font_id": "NoSuchFont_XYZ_0"}
).status_code
== 404
)
# Over-long id → 404 and the id is never echoed into the body.
long_resp = client.get(
f"/documents/{colored_doc_id}/font", params={"internal_font_id": "A" * 300}
)
assert long_resp.status_code == 404
assert "AAAA" not in long_resp.text
# Unknown document → 404.
assert (
client.get("/documents/does-not-exist/font", params={"internal_font_id": "x"}).status_code
== 404
)