Merge pull request 'furqan' (#55) from furqan into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/55
This commit is contained in:
furqan
2026-06-12 13:44:12 +00:00
9 changed files with 438 additions and 47 deletions
+11 -1
View File
@@ -153,7 +153,8 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("y", &pdfengine::TextRun::y)
.def_readonly("w", &pdfengine::TextRun::w)
.def_readonly("h", &pdfengine::TextRun::h)
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices);
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices)
.def_readonly("fill_color", &pdfengine::TextRun::fillColor);
py::class_<pdfengine::TextLine>(m, "TextLine")
.def_readonly("runs", &pdfengine::TextLine::runs)
@@ -303,6 +304,15 @@ PYBIND11_MODULE(pdfengine, m) {
.def("get_fonts", [](const pdfengine::PdfDocument& self, int start_page, int end_page) {
return get_or_throw(self.getFonts(start_page, end_page));
}, py::arg("start_page") = 0, py::arg("end_page") = -1)
.def("get_font_data", [](const pdfengine::PdfDocument& self, const std::string& internal_font_id) {
// Raw embedded font bytes for the given run font; empty bytes when the font
// is not embedded / not found (the gateway maps that to a 404 + CSS fallback).
auto res = self.getFontData(internal_font_id);
if (!res || res->empty()) {
return py::bytes();
}
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
}, py::arg("internal_font_id"))
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
get_or_throw(self.applyEdits(editsJson));
}, py::arg("edits_json"))
@@ -139,6 +139,7 @@ struct TextRun {
std::vector<Glyph> glyphs;
std::vector<int> objectIndices;
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
std::string fillColor = "#000000"; // hex of the run's fill (or stroke) color, for in-place editing
};
struct TextLine {
+27
View File
@@ -1205,10 +1205,37 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
p.h = maxY - minY;
};
// Fill color of a run, read from its lowest-index page object (the same object
// the replace_text commit path uses, so preview color == committed color). Falls
// back to stroke color for stroke-only text, else keeps the default "#000000".
auto hex2 = [](unsigned int c) -> std::string {
static const char* h = "0123456789abcdef";
c &= 0xFF;
return std::string{h[(c >> 4) & 0xF], h[c & 0xF]};
};
auto computeRunColor = [&](TextRun& r) {
if (r.objectIndices.empty()) return;
int minIdx = r.objectIndices[0];
for (int idx : r.objectIndices) {
if (idx < minIdx) minIdx = idx;
}
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page_, minIdx);
if (!obj) return;
unsigned int cr = 0, cg = 0, cb = 0, ca = 0;
bool got = (FPDFPageObj_GetFillColor(obj, &cr, &cg, &cb, &ca) && ca != 0) ||
(FPDFPageObj_GetStrokeColor(obj, &cr, &cg, &cb, &ca) && ca != 0);
if (got) {
r.fillColor = "#" + hex2(cr) + hex2(cg) + hex2(cb);
}
};
for (auto& p : paragraphs) {
for (auto& l : p.lines) {
// PDF text baseline of the line (glyphs in a line share ~one originY).
l.baselineY = l.glyphs.empty() ? 0.0 : l.glyphs[0].originY;
for (auto& r : l.runs) {
computeRunBBox(r);
computeRunColor(r);
}
computeLineBBox(l);
}
+72
View File
@@ -0,0 +1,72 @@
import { gatewayService } from './gatewayService';
// Loads a PDF run's real (embedded or PDFium-substitute) font into the browser via the
// FontFace API so the in-place text editor can render in the document's actual font.
// Falls back to null (→ caller uses a CSS font) when the font isn't browser-loadable.
//
// - One fetch + one document.fonts.add per (documentId, internalFontId), deduped.
// - releaseDocumentFonts(docId) unregisters a document's faces on document change
// (NOT on page change — the same fonts recur across pages).
// key -> promise resolving to the registered CSS family name, or null (use fallback).
const fontPromises = new Map<string, Promise<string | null>>();
// Registered FontFace objects, for cleanup on document change.
const fontFaces = new Map<string, FontFace>();
function keyOf(documentId: string, internalFontId: string): string {
return `${documentId}::${internalFontId}`;
}
// Small stable FNV-1a hash → CSS-safe family token (internalFontId has +/_/spaces).
function familyName(key: string): string {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return `pdf-${(h >>> 0).toString(16)}`;
}
// Resolves to the CSS family name to use, or null if no real font is available.
export function loadPdfFont(documentId: string, internalFontId: string): Promise<string | null> {
if (!internalFontId) return Promise.resolve(null);
const key = keyOf(documentId, internalFontId);
const existing = fontPromises.get(key);
if (existing) return existing;
const p = (async (): Promise<string | null> => {
const bytes = await gatewayService.getFontData(documentId, internalFontId);
if (!bytes || bytes.byteLength === 0) return null;
const family = familyName(key);
try {
const face = new FontFace(family, bytes);
await face.load();
document.fonts.add(face);
fontFaces.set(key, face);
return family;
} catch {
return null; // rejected load (e.g. unsupported format) — keep it out of the registry
}
})();
fontPromises.set(key, p);
return p;
}
// Unregister all FontFaces loaded for a document (call when the active document changes).
export function releaseDocumentFonts(documentId: string): void {
const prefix = `${documentId}::`;
for (const [key, face] of Array.from(fontFaces.entries())) {
if (key.startsWith(prefix)) {
try {
document.fonts.delete(face);
} catch {
/* ignore */
}
fontFaces.delete(key);
}
}
for (const key of Array.from(fontPromises.keys())) {
if (key.startsWith(prefix)) fontPromises.delete(key);
}
}
+14
View File
@@ -423,6 +423,20 @@ class GatewayService {
return response.json();
}
// Raw embedded/substitute font bytes for an in-place-editing preview. Returns null
// when the font isn't browser-loadable (Type1/non-sfnt → 404) so callers fall back
// to a CSS font.
async getFontData(documentId: string, internalFontId: string): Promise<ArrayBuffer | null> {
try {
const url = `${this.baseUrl}/documents/${documentId}/font?internal_font_id=${encodeURIComponent(internalFontId)}`;
const response = await fetch(url);
if (!response.ok) return null;
return await response.arrayBuffer();
} catch {
return null;
}
}
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/edits`, {
method: 'POST',
+18 -4
View File
@@ -91,9 +91,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const [containerHeight, setContainerHeight] = useState(800);
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
const verifiedPagesRef = useRef<Set<number>>(new Set());
// Reset cached page renders when switching documents
// Tracks which document version the cached page images belong to. Each edit creates a
// new documentId; we keep the OLD images visible and re-render in the background (below),
// swapping page-by-page when ready — so the viewer never blanks/flashes on an edit and
// the annotation overlay never unmounts.
const renderedDocIdRef = useRef<string>('');
useEffect(() => {
setRenderedPages([]);
// Text/model caches ARE document-specific and cheap to refetch — reset them.
// renderedPages is intentionally NOT cleared here (see renderedDocIdRef above).
setVerifiedPages({});
setPageTexts({});
verifiedPagesRef.current.clear();
@@ -204,8 +209,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
// Load the rendered SVG/Image URL for each visible page
useEffect(() => {
let active = true;
// On a document-version switch, re-render every visible page even though it's already
// cached (the cache is for the previous version). We keep the stale image on screen and
// overwrite each entry only when its fresh render arrives — no blank flash.
const docChanged = renderedDocIdRef.current !== documentId;
const fetchPageImages = async () => {
const missingPages = visiblePages.filter((page) => !renderedPages[page.index]);
const missingPages = docChanged
? visiblePages
: visiblePages.filter((page) => !renderedPages[page.index]);
if (missingPages.length === 0) return;
const renders = await Promise.all(
@@ -222,8 +233,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
if (!active) return;
renderedDocIdRef.current = documentId;
setRenderedPages((prev) => {
const next = [...prev];
// After a doc switch, drop the previous version's images (keep only the freshly
// rendered visible ones); otherwise merge into the existing cache.
const next = docChanged ? [] : [...prev];
renders.forEach(({ index, url }) => {
next[index] = url;
});
+117 -42
View File
@@ -1,18 +1,22 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
// A single editable run. Geometry (x/y/w/h) is PDF BOTTOM-LEFT (as getPageModel
// returns) and is used only to position the inline editor. The replace_text op
// itself targets the stable objectIndices, not coordinates.
// A single editable run. Geometry (x/y/w/h, baselineY) is PDF BOTTOM-LEFT (as
// getPageModel returns) and is used only to position the inline editor. The
// replace_text op itself targets the stable objectIndices, not coordinates.
export interface EditableRun {
text: string;
x: number;
y: number;
w: number;
h: number;
baselineY: number;
fontSize: number;
objectIndices: number[];
internalFontId: string;
fontName: string;
color: string;
}
interface TextEditLayerProps {
@@ -24,12 +28,43 @@ interface TextEditLayerProps {
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
}
// Nearest character index to an x-offset (px from the run's left edge), measured in the
// run's font — so a click drops the caret where you clicked, not at the end of the line.
let measureCanvas: HTMLCanvasElement | null = null;
function caretIndexFromX(text: string, cssFont: string, x: number): number {
if (x <= 0) return 0;
if (!measureCanvas) measureCanvas = document.createElement('canvas');
const ctx = measureCanvas.getContext('2d');
if (!ctx) return text.length;
ctx.font = cssFont;
let acc = 0;
for (let i = 0; i < text.length; i++) {
const w = ctx.measureText(text[i]).width;
if (acc + w / 2 >= x) return i; // click past the char's midpoint → caret after it
acc += w;
}
return text.length;
}
// Closest base-14 CSS family, used until (or instead of) the real PDF font loads.
function fallbackFamily(fontName: string): string {
const n = (fontName || '').toLowerCase();
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
return 'Georgia, "Times New Roman", Times, serif';
}
if (n.includes('courier') || n.includes('mono')) {
return '"Courier New", Courier, monospace';
}
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
}
// Flatten the dynamic page-model JSON into a flat list of editable runs.
function flattenRuns(model: any): EditableRun[] {
const runs: EditableRun[] = [];
const paragraphs = model?.paragraphs ?? [];
for (const p of paragraphs) {
for (const line of p.lines ?? []) {
const baselineY = typeof line.baseline_y === 'number' ? line.baseline_y : 0;
for (const r of line.runs ?? []) {
const objectIndices: number[] = Array.isArray(r.object_indices) ? r.object_indices : [];
// Only runs backed by real page objects are editable (replace_text targets them).
@@ -37,9 +72,12 @@ function flattenRuns(model: any): EditableRun[] {
runs.push({
text: r.text,
x: r.x, y: r.y, w: r.w, h: r.h,
baselineY,
fontSize: r.font_size ?? r.h,
objectIndices,
internalFontId: r.internal_font_id ?? '',
fontName: r.font_name ?? '',
color: typeof r.color === 'string' ? r.color : '#000000',
});
}
}
@@ -59,7 +97,11 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const [runs, setRuns] = useState<EditableRun[]>([]);
const [editing, setEditing] = useState<number | null>(null);
const [value, setValue] = useState('');
// Resolved CSS font-family chain for the editor (fallback first, real font swapped in).
const [fontFamily, setFontFamily] = useState('inherit');
const committedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const caretIdxRef = useRef<number | null>(null);
// Fetch the page model once per document/page.
useEffect(() => {
@@ -78,10 +120,14 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
};
}, [documentId, pageIndex]);
useEffect(() => {
return () => releaseDocumentFonts(documentId);
}, [documentId]);
// Page height in points (props are zoomed px) — needed for the bottom-left→top-left flip.
const heightPts = height / zoom;
// Display rect (top-left, zoomed px) for a run whose coords are bottom-left points.
// Display rect (top-left, zoomed px) for a run's glyph bbox.
const rectOf = (r: EditableRun) => ({
left: r.x * zoom,
top: (heightPts - (r.y + r.h)) * zoom,
@@ -89,10 +135,21 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
height: r.h * zoom,
});
const openEditor = (i: number) => {
const openEditor = (i: number, clickX: number) => {
committedRef.current = false;
const run = runs[i];
const fb = fallbackFamily(run.fontName);
// Caret lands at the clicked character (measured in the fallback font — close enough
// to pick the index; it stays correct after the real font swaps in).
caretIdxRef.current = caretIndexFromX(run.text, `${run.fontSize * zoom}px ${fb}`, clickX);
setEditing(i);
setValue(runs[i].text);
setValue(run.text);
setFontFamily(fb);
if (run.internalFontId) {
loadPdfFont(documentId, run.internalFontId).then((family) => {
if (family) setFontFamily(`'${family}', ${fb}`);
});
}
};
const commit = () => {
@@ -109,11 +166,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
setEditing(null);
};
const editingRect = useMemo(
() => (editing !== null && runs[editing] ? rectOf(runs[editing]) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[editing, runs, zoom, height],
);
const run = editing !== null ? runs[editing] : null;
return (
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
@@ -124,41 +177,63 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
return (
<div
key={i}
title="Click to edit this text"
onClick={() => openEditor(i)}
className="absolute cursor-text rounded-[2px] hover:bg-[rgba(37,99,235,0.12)] hover:outline hover:outline-1 hover:outline-[#2563eb]"
onClick={(e) => openEditor(i, e.nativeEvent.offsetX)}
className="absolute cursor-text hover:bg-[rgba(37,99,235,0.06)]"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
/>
);
})}
{/* Inline editor for the selected run. */}
{editing !== null && editingRect && (
<textarea
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
commit();
}
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
className="absolute z-[36] bg-white border-[1.5px] border-[#2563eb] rounded-[3px] shadow-[0_4px_12px_rgba(16,24,40,0.12)] outline-none resize-none overflow-hidden font-sans leading-[1.1] px-[3px] py-[1px] whitespace-nowrap"
style={{
left: editingRect.left,
top: editingRect.top,
minWidth: editingRect.width,
height: Math.max(editingRect.height, (runs[editing].fontSize + 4) * zoom),
fontSize: `${runs[editing].fontSize * zoom}px`,
}}
/>
)}
{/* Seamless in-place editor: a white cover hides the original glyphs, and a
chrome-less single-line input — in the document's real font, exact color,
size, and baseline — sits over them with a caret in the text. */}
{run && (() => {
const fpx = run.fontSize * zoom;
const baselineScreen = (heightPts - run.baselineY) * zoom;
const inputTop = baselineScreen - 0.8 * fpx; // place text baseline on the PDF baseline
const box = rectOf(run);
return (
<>
<div
className="absolute z-[36] bg-white"
style={{ left: box.left - 2, top: box.top - 2, width: box.width + 4, height: box.height + 4 }}
/>
<input
ref={inputRef}
autoFocus
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={(e) => {
// Drop the caret where the user clicked (set in openEditor), not at the end.
const idx = caretIdxRef.current;
if (idx != null) {
e.currentTarget.setSelectionRange(idx, idx);
caretIdxRef.current = null;
}
}}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}}
spellCheck={false}
className="absolute z-[37] m-0 border-0 bg-transparent p-0 outline-none whitespace-pre"
style={{
left: box.left,
top: inputTop,
width: Math.max(box.width + 120, 60),
height: fpx,
lineHeight: `${fpx}px`,
fontFamily,
fontSize: `${fpx}px`,
color: run.color,
caretColor: run.color,
}}
/>
</>
);
})()}
</div>
);
};
+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
)