feat: implemented caret based selection at any click position
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user