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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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