Merged with dev branch

This commit is contained in:
azeeee05
2026-06-08 11:28:43 +05:30
18 changed files with 1320 additions and 336 deletions
+166 -2
View File
@@ -13,6 +13,8 @@
#include <nlohmann/json.hpp>
#include <spdlog/spdlog.h>
#include <fstream>
#include <filesystem>
#include <csetjmp>
namespace pdfengine::parser {
@@ -560,6 +562,29 @@ void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, uns
}
}
std::vector<uint8_t> base64Decode(const std::string& encoded) {
std::vector<uint8_t> decoded;
int T[256];
std::fill(std::begin(T), std::end(T), -1);
for (int i = 0; i < 64; ++i) {
T[static_cast<unsigned char>("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i;
}
int val = 0;
int valb = -8;
for (char c : encoded) {
unsigned char uc = static_cast<unsigned char>(c);
if (T[uc] == -1) continue;
val = (val << 6) + T[uc];
valb += 6;
if (valb >= 0) {
decoded.push_back(static_cast<uint8_t>((val >> valb) & 0xFF));
valb -= 8;
}
}
return decoded;
}
}
namespace pdfengine {
@@ -1085,7 +1110,99 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
} else if (type == "redaction") {
spdlog::info("Parsed redaction edit operation (stub)");
} else if (type == "image_overlay") {
spdlog::info("Parsed image_overlay edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 100.0);
double height = data.value("height", 100.0);
int pixelWidth = data.value("pixelWidth", 0);
int pixelHeight = data.value("pixelHeight", 0);
std::string rawPixelData = data.value("rawPixelData", "");
std::string pixelDataPath = data.value("pixelDataPath", "");
std::vector<uint8_t> decodedBytes;
if (!pixelDataPath.empty()) {
std::ifstream infile(pixelDataPath, std::ios::binary);
if (!infile) {
spdlog::error("Failed to open pixel data path: {}", pixelDataPath);
return std::unexpected(EngineError::Unknown);
}
infile.seekg(0, std::ios::end);
std::streamsize size = infile.tellg();
infile.seekg(0, std::ios::beg);
decodedBytes.resize(static_cast<size_t>(size));
if (!infile.read(reinterpret_cast<char*>(decodedBytes.data()), size)) {
spdlog::error("Failed to read pixel data from path: {}", pixelDataPath);
return std::unexpected(EngineError::Unknown);
}
infile.close();
std::error_code ec;
std::filesystem::remove(pixelDataPath, ec);
} else if (!rawPixelData.empty()) {
decodedBytes = base64Decode(rawPixelData);
} else {
spdlog::warn("image_overlay operation contains invalid raw pixels or dimensions");
continue;
}
if (decodedBytes.size() != static_cast<size_t>(pixelWidth * pixelHeight * 4)) {
spdlog::error("Decoded image bytes size mismatch. Expected: {}, Got: {}",
pixelWidth * pixelHeight * 4, decodedBytes.size());
return std::unexpected(EngineError::InvalidFormat);
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for image insertion", pageIndex);
return std::unexpected(EngineError::Unknown);
}
FPDF_PAGEOBJECT imgObj = FPDFPageObj_NewImageObj(doc_);
if (!imgObj) {
spdlog::error("Failed to create new image object");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDF_BITMAP bitmap = FPDFBitmap_Create(pixelWidth, pixelHeight, 4); // 4 = FPDFBitmap_BGRA
if (!bitmap) {
spdlog::error("Failed to create FPDF_BITMAP");
FPDFPageObj_Destroy(imgObj);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
uint8_t* dest = static_cast<uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
std::memcpy(dest, decodedBytes.data(), decodedBytes.size());
if (!FPDFImageObj_SetBitmap(&page, 1, imgObj, bitmap)) {
spdlog::error("Failed to set bitmap on image object");
FPDFBitmap_Destroy(bitmap);
FPDFPageObj_Destroy(imgObj);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
// Apply positioning and scaling to the image object
FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y);
// Insert into page
FPDFPage_InsertObject(page, imgObj);
// Regenerate page contents
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after image insertion");
FPDFBitmap_Destroy(bitmap);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDFBitmap_Destroy(bitmap);
FPDF_ClosePage(page);
} else if (type == "highlight") {
spdlog::info("Parsed highlight edit operation (stub)");
} else if (type == "free_text") {
@@ -1095,7 +1212,54 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
} else if (type == "freehand") {
spdlog::info("Parsed freehand edit operation (stub)");
} else if (type == "page_rotation") {
spdlog::info("Parsed page_rotation edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_rotation operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
int rotation = data.value("rotation", 0);
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for page rotation", pageIndex);
return std::unexpected(EngineError::Unknown);
}
int currentCode = FPDFPage_GetRotation(page);
int currentDegrees = currentCode * 90;
int newDegrees = currentDegrees + rotation;
newDegrees = (newDegrees % 360 + 360) % 360;
int newCode = newDegrees / 90;
FPDFPage_SetRotation(page, newCode);
FPDF_ClosePage(page);
} else if (type == "page_deletion") {
if (pageCount() <= 1) {
spdlog::error("Cannot delete the only page in the document");
return std::unexpected(EngineError::Unknown);
}
FPDFPage_Delete(doc_, pageIndex);
} else if (type == "page_reorder") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_reorder operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
if (!data.contains("destPageIndex")) {
spdlog::error("page_reorder data missing 'destPageIndex'");
return std::unexpected(EngineError::InvalidFormat);
}
int destPageIndex = data["destPageIndex"];
if (destPageIndex < 0 || destPageIndex >= pageCount()) {
spdlog::error("Destination page index {} out of bounds (total pages: {})", destPageIndex, pageCount());
return std::unexpected(EngineError::PageOutOfBounds);
}
int fromIndex = pageIndex;
if (!FPDF_MovePages(doc_, &fromIndex, 1, destPageIndex)) {
spdlog::error("FPDF_MovePages failed from {} to {}", fromIndex, destPageIndex);
return std::unexpected(EngineError::Unknown);
}
} else {
spdlog::warn("Unsupported edit operation type: {}", type);
}
+231
View File
@@ -435,6 +435,237 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
}
TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
std::string editsJson = R"({
"version": "1.0",
"operations": [
{
"id": "op_test_img_1",
"type": "image_overlay",
"pageIndex": 0,
"data": {
"x": 100.0,
"y": 150.0,
"width": 200.0,
"height": 150.0,
"pixelWidth": 2,
"pixelHeight": 2,
"rawPixelData": "AAD//wAA//8AAP//AAD//w=="
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
EXPECT_EQ(newDoc->pageCount(), 1);
}
TEST(DocumentEditTest, ApplyPageRotationAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "about_blank.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "about_blank.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
double origW = (*pageRes)->width();
double origH = (*pageRes)->height();
EXPECT_GT(origW, 0.0);
EXPECT_GT(origH, origW);
// 1. Rotate by 90 degrees (90 total)
std::string editsJson1 = R"({
"version": "1.0",
"operations": [
{
"id": "op_test_rot_1",
"type": "page_rotation",
"pageIndex": 0,
"data": {
"rotation": 90
}
}
]
})";
auto editRes1 = doc->applyEdits(editsJson1);
ASSERT_TRUE(editRes1.has_value());
// 2. Rotate by another 90 degrees (180 total)
std::string editsJson2 = R"({
"version": "1.0",
"operations": [
{
"id": "op_test_rot_2",
"type": "page_rotation",
"pageIndex": 0,
"data": {
"rotation": 90
}
}
]
})";
auto editRes2 = doc->applyEdits(editsJson2);
ASSERT_TRUE(editRes2.has_value());
// Save and load back to verify 180 degree rotation (dimensions should be original again)
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
EXPECT_EQ(newDoc->pageCount(), 1);
auto newPageRes = newDoc->getPage(0);
ASSERT_TRUE(newPageRes.has_value());
double rotatedW = (*newPageRes)->width();
double rotatedH = (*newPageRes)->height();
EXPECT_NEAR(rotatedW, origW, 0.01);
EXPECT_NEAR(rotatedH, origH, 0.01);
// 3. Now rotate by -90 degrees (back to 90 total)
std::string editsJson3 = R"({
"version": "1.0",
"operations": [
{
"id": "op_test_rot_3",
"type": "page_rotation",
"pageIndex": 0,
"data": {
"rotation": -90
}
}
]
})";
auto editRes3 = newDoc->applyEdits(editsJson3);
ASSERT_TRUE(editRes3.has_value());
auto saveRes3 = newDoc->saveIncremental();
ASSERT_TRUE(saveRes3.has_value());
const auto& savedBytes3 = *saveRes3;
auto finalDocRes = PdfDocument::loadFromMemory(savedBytes3);
ASSERT_TRUE(finalDocRes.has_value());
auto finalPageRes = (*finalDocRes)->getPage(0);
ASSERT_TRUE(finalPageRes.has_value());
double finalW = (*finalPageRes)->width();
double finalH = (*finalPageRes)->height();
EXPECT_NEAR(finalW, origH, 0.01);
EXPECT_NEAR(finalH, origW, 0.01);
}
TEST(DocumentEditTest, ApplyPageDeletionAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world_2_pages.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
EXPECT_EQ(doc->pageCount(), 2);
std::string editsJson = R"({
"version": "1.0",
"operations": [
{
"id": "op_del_test_1",
"type": "page_deletion",
"pageIndex": 1,
"data": {}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
EXPECT_EQ(doc->pageCount(), 1);
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
EXPECT_EQ((*newDocRes)->pageCount(), 1);
}
TEST(DocumentEditTest, ApplyPageReorderAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world_2_pages.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world_2_pages.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
EXPECT_EQ(doc->pageCount(), 2);
// Swap the pages: move page 1 (index 1) to page 0 (index 0)
std::string editsJson = R"({
"version": "1.0",
"operations": [
{
"id": "op_reorder_test_1",
"type": "page_reorder",
"pageIndex": 1,
"data": {
"destPageIndex": 0
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
EXPECT_EQ(doc->pageCount(), 2);
auto saveRes = doc->saveIncremental();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
EXPECT_EQ((*newDocRes)->pageCount(), 2);
}
TEST(FontDiagnosticsTest, IntrospectionAccuracy) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
+116 -4
View File
@@ -17,7 +17,6 @@ function App() {
// Settings
const [zoom, setZoom] = useState<number>(1.0);
const [rotation, setRotation] = useState<number>(0);
const [activeTool, setActiveTool] = useState<string>('select');
const [currentPage, setCurrentPage] = useState<number>(0);
@@ -166,14 +165,125 @@ function App() {
setSidebarTab('annotations');
};
const handleRotateClick = async (newRotValue?: number) => {
if (!selectedDocId || !activeDoc) return;
const pageIndex = currentPage;
try {
setIsLoading(true);
const op = {
id: `rot_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_rotation' as const,
pageIndex: pageIndex,
data: {
rotation: 90 as const
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
}
} catch (err) {
console.error('Failed to rotate page:', err);
alert('Failed to rotate page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleDeletePage = async (pageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (activeDoc.totalPages <= 1) {
alert("Cannot delete the only page in the document.");
return;
}
if (!confirm(`Are you sure you want to delete Page ${pageIndex + 1}? This cannot be undone.`)) {
return;
}
try {
setIsLoading(true);
const op = {
id: `del_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_deletion' as const,
pageIndex: pageIndex,
data: {}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Adjust currentPage if it's out of bounds after deletion
if (currentPage >= activeDoc.totalPages - 1) {
setCurrentPage(Math.max(0, activeDoc.totalPages - 2));
}
}
} catch (err) {
console.error('Failed to delete page:', err);
alert('Failed to delete page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleReorderPage = async (pageIndex: number, destPageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (destPageIndex < 0 || destPageIndex >= activeDoc.totalPages) return;
try {
setIsLoading(true);
const op = {
id: `reorder_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_reorder' as const,
pageIndex: pageIndex,
data: {
destPageIndex: destPageIndex
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Update current page view to follow the moved page
setCurrentPage(destPageIndex);
}
} catch (err) {
console.error('Failed to reorder page:', err);
alert('Failed to reorder page. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
{/* Top Navigation / Toolbar */}
<Toolbar
zoom={zoom}
onZoomChange={setZoom}
rotation={rotation}
onRotationChange={setRotation}
rotation={0}
onRotationChange={handleRotateClick}
activeTool={activeTool}
onActiveToolChange={setActiveTool}
currentPage={currentPage}
@@ -200,6 +310,8 @@ function App() {
onNavigateToPage={(pageIndex) => {
viewerRef.current?.scrollToPage(pageIndex);
}}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
/>
{/* Main PDF Scroll Viewer Area */}
@@ -219,7 +331,7 @@ function App() {
pageWidth={activeDoc.pageWidth}
pageHeight={activeDoc.pageHeight}
zoom={zoom}
rotation={rotation}
pagesInfo={activeDoc.pages}
activeTool={activeTool}
annotations={annotations}
searchQuery={searchQuery}
+16
View File
@@ -12,6 +12,8 @@ interface SidebarProps {
activeTab: 'documents' | 'annotations' | 'outline';
setActiveTab: (tab: 'documents' | 'annotations' | 'outline') => void;
onNavigateToPage?: (pageIndex: number) => void;
onDeletePage?: (pageIndex: number) => void;
onReorderPage?: (pageIndex: number, destPageIndex: number) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
@@ -23,6 +25,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
activeTab,
setActiveTab,
onNavigateToPage,
onDeletePage,
onReorderPage,
}) => {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
@@ -112,6 +116,18 @@ export const Sidebar: React.FC<SidebarProps> = ({
documentId={selectedDocumentId}
pageIndex={idx}
onClick={() => onNavigateToPage?.(idx)}
onDelete={onDeletePage ? () => onDeletePage(idx) : undefined}
onMoveUp={
onReorderPage && idx > 0
? () => onReorderPage(idx, idx - 1)
: undefined
}
onMoveDown={
onReorderPage && idx < totalPages - 1
? () => onReorderPage(idx, idx + 1)
: undefined
}
totalPages={totalPages}
/>
))}
</div>
+69 -6
View File
@@ -5,12 +5,20 @@ interface ThumbnailProps {
documentId: string;
pageIndex: number;
onClick: () => void;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
totalPages?: number;
}
export const Thumbnail: React.FC<ThumbnailProps> = ({
documentId,
pageIndex,
onClick,
onDelete,
onMoveUp,
onMoveDown,
totalPages,
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -48,18 +56,73 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
return (
<div className="thumbnail-card" onClick={onClick}>
<div className="thumbnail-preview overflow-hidden bg-white hover:border-indigo-500 transition-colors">
<div className="thumbnail-preview relative group overflow-hidden bg-white hover:border-indigo-500 transition-colors">
{loading ? (
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-900">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mb-2" />
<span className="thumbnail-label">Loading...</span>
</div>
) : imageUrl ? (
<img
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
className="w-full h-full object-cover"
/>
<>
<img
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
className="w-full h-full object-cover"
/>
{/* Group Hover Overlay Controls */}
<div className="absolute inset-0 bg-slate-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-auto">
<div className="flex justify-end">
{onDelete && totalPages && totalPages > 1 && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 bg-rose-500 hover:bg-rose-600 text-white rounded shadow transition-colors cursor-pointer"
title="Delete Page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
<div className="flex justify-between w-full">
{onMoveUp ? (
<button
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
className="p-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Up"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</button>
) : (
<div />
)}
{onMoveDown ? (
<button
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
className="p-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Down"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
) : (
<div />
)}
</div>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full w-full">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="thumbnail-svg-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+19
View File
@@ -5,6 +5,12 @@
* Provides endpoints for document CRUD, rendering, metadata retrieval, and edits.
*/
export interface PageInfo {
index: number;
width: number;
height: number;
}
export interface DocumentInfo {
id: string;
filename: string;
@@ -14,6 +20,7 @@ export interface DocumentInfo {
pageHeight: number;
uploadedAt: string;
status: 'processing' | 'ready' | 'error';
pages?: PageInfo[];
}
export interface RenderParams {
@@ -111,6 +118,12 @@ export interface PageRotationData {
rotation: 0 | 90 | 180 | 270;
}
export interface PageDeletionData {}
export interface PageReorderData {
destPageIndex: number;
}
export type EditOperationDataMap = {
text_overlay: TextOverlayData;
redaction: RedactionData;
@@ -120,6 +133,8 @@ export type EditOperationDataMap = {
comment: StickyNoteData;
freehand: FreehandData;
page_rotation: PageRotationData;
page_deletion: PageDeletionData;
page_reorder: PageReorderData;
};
export type EditOperationType = keyof EditOperationDataMap;
@@ -190,6 +205,7 @@ class GatewayService {
pageHeight: 792,
uploadedAt: new Date().toISOString(),
status: 'ready',
pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })),
});
}, 1000);
});
@@ -291,6 +307,7 @@ class GatewayService {
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
status: 'ready',
pages: Array.from({ length: 12 }, (_, i) => ({ index: i, width: 612, height: 792 })),
},
{
id: 'sample-doc-2',
@@ -301,6 +318,7 @@ class GatewayService {
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
status: 'ready',
pages: Array.from({ length: 54 }, (_, i) => ({ index: i, width: 612, height: 792 })),
},
{
id: 'sample-doc-3',
@@ -311,6 +329,7 @@ class GatewayService {
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
status: 'ready',
pages: Array.from({ length: 4 }, (_, i) => ({ index: i, width: 612, height: 792 })),
}
];
}
+16 -11
View File
@@ -7,7 +7,7 @@ import { OverlayLayer } from './OverlayLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult } from '../lib/gatewayService';
import type { SearchResult, PageInfo } from '../lib/gatewayService';
interface PDFViewerProps {
documentId: string;
@@ -15,7 +15,7 @@ interface PDFViewerProps {
pageWidth: number;
pageHeight: number;
zoom: number;
rotation: number;
pagesInfo?: PageInfo[];
activeTool: string;
annotations: Annotation[];
searchQuery?: string;
@@ -44,7 +44,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
pageWidth,
pageHeight,
zoom,
rotation,
pagesInfo,
activeTool,
annotations,
searchQuery,
@@ -58,9 +58,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const [renderedPages, setRenderedPages] = useState<string[]>([]);
const [containerHeight, setContainerHeight] = useState(800);
// Reset cached page renders when switching documents
useEffect(() => {
setRenderedPages([]);
}, [documentId]);
// Use provided dimensions or fallback to Letter size
const basePageWidth = pageWidth || 612;
const basePageHeight = pageHeight || 792;
const pageGap = 24; // space between pages
// Calculate layout coordinates for all pages sequentially
@@ -69,10 +75,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
let currentTop = 0;
for (let i = 0; i < totalPages; i++) {
// Swapped width/height if rotated 90 or 270 degrees
const isSwapped = (rotation / 90) % 2 !== 0;
const w = isSwapped ? basePageHeight : basePageWidth;
const h = isSwapped ? basePageWidth : basePageHeight;
const pageInfo = pagesInfo?.[i];
const w = pageInfo ? pageInfo.width : basePageWidth;
const h = pageInfo ? pageInfo.height : basePageHeight;
layouts.push({
index: i,
@@ -84,7 +89,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
currentTop += (h * zoom) + pageGap;
}
return layouts;
}, [totalPages, zoom, rotation]);
}, [totalPages, zoom, pagesInfo]);
const totalContentHeight = useMemo(() => {
if (pageLayouts.length === 0) return 0;
@@ -173,7 +178,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
documentId,
pageIndex: page.index,
zoom,
rotation,
rotation: 0, // already rotated physically on backend
});
return { index: page.index, url };
})
@@ -194,7 +199,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
return () => {
active = false;
};
}, [visiblePages, documentId, zoom, rotation, renderedPages]);
}, [visiblePages, documentId, zoom, renderedPages]);
const handleTextSelection = (text: string, bbox: Rect) => {
if (activeTool === 'highlight') {
@@ -278,7 +283,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
pageIndex={page.index}
imageUrl={imageUrl}
zoom={zoom}
rotation={rotation}
rotation={0} // already rotated physically on backend
width={page.width}
height={page.height}
/>
+3 -3
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.routers import documents, edits, health, render, info
from app.routers import documents, edits, health, info, render
def create_app() -> FastAPI:
@@ -36,10 +36,10 @@ def create_app() -> FastAPI:
"name": "PDF Engine Gateway",
"version": __version__,
"health": "/health",
"docs": "/docs"
"docs": "/docs",
}
return app
app = create_app()
app = create_app()
+102 -89
View File
@@ -1,5 +1,4 @@
from typing import List
from fastapi import APIRouter, HTTPException, status, File, UploadFile
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel
from app.services import engine
@@ -7,6 +6,13 @@ from app.services.store import document_store
router = APIRouter(prefix="/documents", tags=["documents"])
class PageInfoResponse(BaseModel):
index: int
width: float
height: float
class DocumentInfoResponse(BaseModel):
id: str
filename: str
@@ -16,76 +22,18 @@ class DocumentInfoResponse(BaseModel):
pageHeight: float
uploadedAt: str
status: str
pages: list[PageInfoResponse] = []
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
)
bytes_data = await file.read()
try:
pdfengine = engine.require()
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
info = document_store.add_document(file.filename, bytes_data, doc)
return DocumentInfoResponse(
id=info["id"],
filename=info["filename"],
sizeBytes=info["sizeBytes"],
totalPages=info["totalPages"],
pageWidth=info["pageWidth"],
pageHeight=info["pageHeight"],
uploadedAt=info["uploadedAt"],
status=info["status"]
)
except ValueError as e:
detail = str(e)
if "Password required" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required")
elif "Invalid password" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {str(e)}")
@router.get("", response_model=List[DocumentInfoResponse])
def list_documents() -> List[DocumentInfoResponse]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
)
docs = document_store.list_documents()
return [
DocumentInfoResponse(
id=d["id"],
filename=d["filename"],
sizeBytes=d["sizeBytes"],
totalPages=d["totalPages"],
pageWidth=d.get("pageWidth", 612.0),
pageHeight=d.get("pageHeight", 792.0),
uploadedAt=d["uploadedAt"],
status=d["status"]
)
for d in docs
]
@router.get("/{document_id}", response_model=DocumentInfoResponse)
def get_document(document_id: str) -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
)
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
def make_document_response(d: dict) -> DocumentInfoResponse:
pages_list = []
if "doc_instance" in d:
doc = d["doc_instance"]
for i in range(doc.page_count):
try:
page = doc.get_page(i)
pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height))
except Exception:
pass
return DocumentInfoResponse(
id=d["id"],
filename=d["filename"],
@@ -94,23 +42,83 @@ def get_document(document_id: str) -> DocumentInfoResponse:
pageWidth=d.get("pageWidth", 612.0),
pageHeight=d.get("pageHeight", 792.0),
uploadedAt=d["uploadedAt"],
status=d["status"]
status=d["status"],
pages=pages_list,
)
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
bytes_data = await file.read()
try:
pdfengine = engine.require()
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
info = document_store.add_document(file.filename, bytes_data, doc)
return make_document_response(info)
except ValueError as e:
detail = str(e)
if "Password required" in detail:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required"
)
elif "Invalid password" in detail:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}"
)
@router.get("", response_model=list[DocumentInfoResponse])
def list_documents() -> list[DocumentInfoResponse]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
docs = document_store.list_documents()
return [make_document_response(d) for d in docs]
@router.get("/{document_id}", response_model=DocumentInfoResponse)
def get_document(document_id: str) -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
return make_document_response(d)
@router.delete("/{document_id}")
def delete_document(document_id: str):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
deleted = document_store.delete_document(document_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
return {"success": True}
class DocumentMetadataResponse(BaseModel):
title: str
author: str
@@ -119,18 +127,19 @@ class DocumentMetadataResponse(BaseModel):
creation_date: str
modification_date: str
@router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse)
def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = d["doc_instance"]
meta = doc.metadata
@@ -140,11 +149,12 @@ def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
creator=meta.creator,
producer=meta.producer,
creation_date=meta.creation_date,
modification_date=meta.modification_date
modification_date=meta.modification_date,
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
class FontInfoResponse(BaseModel):
fontName: str
type: str
@@ -166,18 +176,21 @@ class FontInfoResponse(BaseModel):
descent: float
capHeight: float
@router.get("/{document_id}/fonts", response_model=List[FontInfoResponse])
def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1) -> List[FontInfoResponse]:
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
def get_document_fonts(
document_id: str, start_page: int = 0, end_page: int = -1
) -> list[FontInfoResponse]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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:
doc = doc_info["doc_instance"]
fonts = doc.get_fonts(start_page, end_page)
@@ -201,7 +214,7 @@ def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1
flags=f.flags,
ascent=f.ascent,
descent=f.descent,
capHeight=f.cap_height
capHeight=f.cap_height,
)
for f in fonts
]
@@ -220,11 +233,11 @@ class SearchRect(BaseModel):
class SearchMatch(BaseModel):
pageIndex: int
rects: List[SearchRect]
rects: list[SearchRect]
text: str
@router.get("/{document_id}/search", response_model=List[SearchMatch])
def search_document(document_id: str, q: str) -> List[SearchMatch]:
@router.get("/{document_id}/search", response_model=list[SearchMatch])
def search_document(document_id: str, q: str) -> list[SearchMatch]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -302,4 +315,4 @@ def search_document(document_id: str, q: str) -> List[SearchMatch]:
return matches
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+128 -35
View File
@@ -1,17 +1,16 @@
import json
from typing import List, Any
from typing import Annotated, Literal
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.services import engine
from app.services.store import document_store
router = APIRouter(prefix="/documents/{document_id}/edits", tags=["edits"])
from typing import List, Literal, Union, Optional, Annotated
from pydantic import BaseModel, Field
compat_router = APIRouter(tags=["edits"])
class TextOverlayData(BaseModel):
text: str
x: float
@@ -22,6 +21,7 @@ class TextOverlayData(BaseModel):
fontFamily: str
color: str
class RedactionData(BaseModel):
x: float
y: float
@@ -29,6 +29,7 @@ class RedactionData(BaseModel):
height: float
fillColor: str = "#000000"
class ImageOverlayData(BaseModel):
x: float
y: float
@@ -36,6 +37,7 @@ class ImageOverlayData(BaseModel):
height: float
imageData: str
class HighlightQuadPoint(BaseModel):
x1: float
y1: float
@@ -46,12 +48,14 @@ class HighlightQuadPoint(BaseModel):
x4: float
y4: float
class HighlightData(BaseModel):
quadPoints: List[HighlightQuadPoint]
quadPoints: list[HighlightQuadPoint]
color: str
opacity: float = 1.0
author: str
content: Optional[str] = None
content: str | None = None
class FreeTextData(BaseModel):
x: float
@@ -62,127 +66,216 @@ class FreeTextData(BaseModel):
fontSize: float = 12.0
color: str = "#000000"
class StickyNoteData(BaseModel):
x: float
y: float
author: str
content: str
class FreehandPoint(BaseModel):
x: float
y: float
class FreehandData(BaseModel):
paths: List[List[FreehandPoint]]
paths: list[list[FreehandPoint]]
color: str
thickness: float
class PageRotationData(BaseModel):
rotation: Literal[0, 90, 180, 270]
class TextOverlayOperation(BaseModel):
id: str
type: Literal["text_overlay"]
pageIndex: int
data: TextOverlayData
class RedactionOperation(BaseModel):
id: str
type: Literal["redaction"]
pageIndex: int
data: RedactionData
class ImageOverlayOperation(BaseModel):
id: str
type: Literal["image_overlay"]
pageIndex: int
data: ImageOverlayData
class HighlightOperation(BaseModel):
id: str
type: Literal["highlight"]
pageIndex: int
data: HighlightData
class FreeTextOperation(BaseModel):
id: str
type: Literal["free_text"]
pageIndex: int
data: FreeTextData
class CommentOperation(BaseModel):
id: str
type: Literal["comment"]
pageIndex: int
data: StickyNoteData
class FreehandOperation(BaseModel):
id: str
type: Literal["freehand"]
pageIndex: int
data: FreehandData
class PageRotationOperation(BaseModel):
id: str
type: Literal["page_rotation"]
pageIndex: int
data: PageRotationData
class PageDeletionData(BaseModel):
pass
class PageDeletionOperation(BaseModel):
id: str
type: Literal["page_deletion"]
pageIndex: int
data: PageDeletionData
class PageReorderData(BaseModel):
destPageIndex: int
class PageReorderOperation(BaseModel):
id: str
type: Literal["page_reorder"]
pageIndex: int
data: PageReorderData
EditOperation = Annotated[
Union[
TextOverlayOperation,
RedactionOperation,
ImageOverlayOperation,
HighlightOperation,
FreeTextOperation,
CommentOperation,
FreehandOperation,
PageRotationOperation
],
Field(discriminator="type")
TextOverlayOperation
| RedactionOperation
| ImageOverlayOperation
| HighlightOperation
| FreeTextOperation
| CommentOperation
| FreehandOperation
| PageRotationOperation
| PageDeletionOperation
| PageReorderOperation,
Field(discriminator="type"),
]
class EditsRequest(BaseModel):
version: Literal["1.0"]
operations: List[EditOperation]
operations: list[EditOperation]
def apply_edits_impl(document_id: str, request: EditsRequest):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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")
created_temp_files = []
try:
pdfengine = engine.require()
doc = doc_info["doc_instance"]
edits_json = json.dumps(request.model_dump())
req_dict = request.model_dump()
for op in req_dict.get("operations", []):
if op.get("type") == "image_overlay":
img_data_str = op["data"].get("imageData", "")
if img_data_str:
import base64
import io
import os
import tempfile
from PIL import Image
# Remove data URI header if present
if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1]
raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
# Convert RGBA to BGRA
r, g, b, a = img_rgba.split()
img_bgra = Image.merge("RGBA", (b, g, r, a))
bgra_bytes = img_bgra.tobytes()
# Create a temporary binary file to hold raw pixel data
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
created_temp_files.append(temp_path)
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(bgra_bytes)
except Exception:
os.close(fd)
raise
op["data"]["pixelDataPath"] = temp_path
op["data"]["pixelWidth"] = img.width
op["data"]["pixelHeight"] = img.height
# Delete base64 strings to keep JSON payload tiny
if "imageData" in op["data"]:
del op["data"]["imageData"]
edits_json = json.dumps(req_dict)
doc.apply_edits(edits_json)
new_bytes = doc.save_incremental()
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
new_info = document_store.add_document(
filename=doc_info["filename"],
bytes_data=new_bytes,
doc_instance=new_doc
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc
)
return {"success": True, "newDocumentId": new_info["id"]}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
finally:
import contextlib
import os
for temp_path in created_temp_files:
if os.path.exists(temp_path):
with contextlib.suppress(Exception):
os.remove(temp_path)
@router.post("")
def apply_edits(document_id: str, request: EditsRequest):
return apply_edits_impl(document_id, request)
@compat_router.post("/edits/{document_id}")
def apply_edits_compat(document_id: str, request: EditsRequest):
return apply_edits_impl(document_id, request)
return apply_edits_impl(document_id, request)
+6 -4
View File
@@ -5,28 +5,30 @@ from app.services import engine
router = APIRouter(prefix="/engine", tags=["info"])
class EngineInfoResponse(BaseModel):
version: str
build_info: str
has_pdfium: bool
has_skia: bool
@router.get("/info", response_model=EngineInfoResponse)
def get_engine_info() -> EngineInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
pdfengine = engine.require()
try:
return EngineInfoResponse(
version=pdfengine.engine_version(),
build_info=pdfengine.engine_build_info(),
has_pdfium=pdfengine.engine_has_pdfium(),
has_skia=pdfengine.engine_has_skia()
has_skia=pdfengine.engine_has_skia(),
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+67 -33
View File
@@ -1,47 +1,50 @@
from typing import List
from fastapi import APIRouter, HTTPException, status, Response
from fastapi import APIRouter, HTTPException, Response, status
from app.routers.documents import FontInfoResponse
from app.services import engine
from app.services.store import document_store
from app.routers.documents import FontInfoResponse
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
compat_router = APIRouter(tags=["render"])
@router.get("/{page_index}/render")
def render_page(document_id: str, page_index: int, dpi: int = 96) -> Response:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
img = page.render(dpi)
return Response(content=img.data, media_type="image/png")
except IndexError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{page_index}/text")
def extract_page_text(document_id: str, page_index: int):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
@@ -52,87 +55,119 @@ def extract_page_text(document_id: str, page_index: int):
glyphs = page.extract_text_with_bounds()
return {"text": text, "glyphs": glyphs}
except IndexError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@compat_router.get("/render/{document_id}")
def render_page_compat(document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0) -> Response:
def render_page_compat(
document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
) -> Response:
dpi = int(96 * zoom)
return render_page(document_id, page, dpi)
@router.get("/{page_index}")
def get_page_info(document_id: str, page_index: int):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
return {"width": page.width, "height": page.height}
except IndexError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{page_index}/transform/page-to-device")
def transform_page_to_device(document_id: str, page_index: int, x: float, y: float, device_width: int, device_height: int, rotate: int = 0):
def transform_page_to_device(
document_id: str,
page_index: int,
x: float,
y: float,
device_width: int,
device_height: int,
rotate: int = 0,
):
if not engine.is_available():
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable"
)
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:
pdfengine = engine.require()
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
pt = pdfengine.Point2D(x=x, y=y)
res = page.page_to_device(pt, device_width, device_height, rotate)
return {"x": res.x, "y": res.y}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{page_index}/transform/device-to-page")
def transform_device_to_page(document_id: str, page_index: int, x: int, y: int, device_width: int, device_height: int, rotate: int = 0):
def transform_device_to_page(
document_id: str,
page_index: int,
x: int,
y: int,
device_width: int,
device_height: int,
rotate: int = 0,
):
if not engine.is_available():
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Engine unavailable"
)
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:
pdfengine = engine.require()
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
pt = pdfengine.DevicePoint(x=x, y=y)
res = page.device_to_page(pt, device_width, device_height, rotate)
return {"x": res.x, "y": res.y}
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{page_index}/fonts", response_model=List[FontInfoResponse])
def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
@router.get("/{page_index}/fonts", response_model=list[FontInfoResponse])
def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
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:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
@@ -157,7 +192,7 @@ def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
flags=f.flags,
ascent=f.ascent,
descent=f.descent,
capHeight=f.cap_height
capHeight=f.cap_height,
)
for f in fonts
]
@@ -165,4 +200,3 @@ def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+12 -10
View File
@@ -1,16 +1,17 @@
import threading
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from datetime import UTC, datetime,timezone
from typing import Any
class DocumentStore:
def __init__(self):
self._lock = threading.Lock()
self._documents: Dict[str, Dict[str, Any]] = {}
self._documents: dict[str, dict[str, Any]] = {}
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> Dict[str, Any]:
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> dict[str, Any]:
doc_id = str(uuid.uuid4())
uploaded_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
page_width = 612.0
page_height = 792.0
if doc_instance.page_count > 0:
@@ -31,19 +32,19 @@ class DocumentStore:
"uploadedAt": uploaded_at,
"status": "ready",
"doc_instance": doc_instance,
"bytes_data": bytes_data
"bytes_data": bytes_data,
}
with self._lock:
self._documents[doc_id] = info
return info
def get_document(self, doc_id: str) -> Optional[Dict[str, Any]]:
def get_document(self, doc_id: str) -> dict[str, Any] | None:
with self._lock:
return self._documents.get(doc_id)
def list_documents(self) -> List[Dict[str, Any]]:
def list_documents(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._documents.values())
@@ -54,4 +55,5 @@ class DocumentStore:
return True
return False
document_store = DocumentStore()
+3
View File
@@ -14,6 +14,7 @@ dependencies = [
"pydantic==2.10.4",
"pydantic-settings==2.7.1",
"python-multipart==0.0.19",
"pillow==10.4.0",
]
[project.optional-dependencies]
@@ -48,6 +49,8 @@ select = [
]
ignore = [
"E501", # line length handled by formatter
"B904", # within an except clause, raise exceptions with raise ... from err
"B008", # do not perform function calls in argument defaults (standard in FastAPI)
]
[tool.ruff.format]
+122 -77
View File
@@ -1,8 +1,14 @@
"""Live API validation script for the PDF engine gateway."""
import sys, json, urllib.request, urllib.parse
sys.path.insert(0, 'gateway')
BASE = 'http://localhost:8000'
import json
import sys
import urllib.parse
import urllib.request
sys.path.insert(0, "gateway")
BASE = "http://localhost:8000"
def http_get(url):
try:
@@ -11,21 +17,23 @@ def http_get(url):
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def multipart_upload(url, filepath, filename):
boundary = 'Xboundary1234X'
with open(filepath, 'rb') as f:
boundary = "Xboundary1234X"
with open(filepath, "rb") as f:
file_data = f.read()
header = (
'--' + boundary + '\r\n'
"--" + boundary + "\r\n"
'Content-Disposition: form-data; name="file"; filename="' + filename + '"\r\n'
'Content-Type: application/pdf\r\n\r\n'
).encode('utf-8')
footer = ('\r\n--' + boundary + '--\r\n').encode('utf-8')
"Content-Type: application/pdf\r\n\r\n"
).encode("utf-8")
footer = ("\r\n--" + boundary + "--\r\n").encode("utf-8")
body = header + file_data + footer
req = urllib.request.Request(
url, data=body,
headers={'Content-Type': 'multipart/form-data; boundary=' + boundary},
method='POST'
url,
data=body,
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
method="POST",
)
try:
resp = urllib.request.urlopen(req)
@@ -33,20 +41,23 @@ def multipart_upload(url, filepath, filename):
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
results = {}
# 1. Health check
health, status = http_get(f'{BASE}/health')
health, status = http_get(f"{BASE}/health")
print(f"[HEALTH] status={status} response={health}")
results['health'] = {'status': status, 'passed': status == 200}
results["health"] = {"status": status, "passed": status == 200}
# 2. Upload hello_world.pdf
print("\n=== UPLOADING hello_world.pdf ===")
doc, status = multipart_upload(f'{BASE}/documents', 'corpus/basic/hello_world.pdf', 'hello_world.pdf')
doc, status = multipart_upload(
f"{BASE}/documents", "corpus/basic/hello_world.pdf", "hello_world.pdf"
)
print(f"Upload status={status}")
print(f"Response: {doc}")
doc_id = doc.get('id')
results['upload'] = {'status': status, 'passed': status == 201, 'doc_id': doc_id}
doc_id = doc.get("id")
results["upload"] = {"status": status, "passed": status == 201, "doc_id": doc_id}
if not doc_id:
print("FATAL: No doc_id, cannot continue")
@@ -54,15 +65,30 @@ if not doc_id:
# 3. GET /documents/{id}/fonts
print(f"\n=== GET /documents/{doc_id}/fonts ===")
fonts, status = http_get(f'{BASE}/documents/{doc_id}/fonts')
fonts, status = http_get(f"{BASE}/documents/{doc_id}/fonts")
print(f"Status={status}, Font count={len(fonts)}")
results['doc_fonts'] = {'status': status, 'count': len(fonts), 'passed': status == 200}
results["doc_fonts"] = {"status": status, "count": len(fonts), "passed": status == 200}
ALL_FONT_FIELDS = [
'fontName','type','isEmbedded','isSubset','isVertical',
'encoding','hasToUnicode','cmapName','cidSystemInfo','subsetTag',
'sourceType','substitutedFrom','substitutedTo','normalizedFamily',
'internalFontId','flags','ascent','descent','capHeight'
"fontName",
"type",
"isEmbedded",
"isSubset",
"isVertical",
"encoding",
"hasToUnicode",
"cmapName",
"cidSystemInfo",
"subsetTag",
"sourceType",
"substitutedFrom",
"substitutedTo",
"normalizedFamily",
"internalFontId",
"flags",
"ascent",
"descent",
"capHeight",
]
schema_errors = []
@@ -71,137 +97,156 @@ for f in fonts:
if missing:
schema_errors.append(f"Missing fields: {missing} in font {f.get('fontName')}")
# Type checks
for boolField in ['isEmbedded','isSubset','isVertical','hasToUnicode']:
for boolField in ["isEmbedded", "isSubset", "isVertical", "hasToUnicode"]:
if not isinstance(f.get(boolField), bool):
schema_errors.append(f"Field {boolField} should be bool, got {type(f.get(boolField))}")
for floatField in ['ascent','descent','capHeight']:
if not isinstance(f.get(floatField), (int, float)):
for floatField in ["ascent", "descent", "capHeight"]:
if not isinstance(f.get(floatField), int | float):
schema_errors.append(f"Field {floatField} should be float")
if not isinstance(f.get('flags'), int):
if not isinstance(f.get("flags"), int):
schema_errors.append("Field flags should be int")
print(f" Font: {f.get('fontName'):30s} type={f.get('type'):15s} embedded={f.get('isEmbedded')} subset={f.get('isSubset')} vertical={f.get('isVertical')}")
print(f" encoding={f.get('encoding')} cmapName={f.get('cmapName')} cidSystemInfo={f.get('cidSystemInfo')}")
print(f" subsetTag={f.get('subsetTag')} sourceType={f.get('sourceType')} normalizedFamily={f.get('normalizedFamily')}")
print(
f" Font: {f.get('fontName'):30s} type={f.get('type'):15s} embedded={f.get('isEmbedded')} subset={f.get('isSubset')} vertical={f.get('isVertical')}"
)
print(
f" encoding={f.get('encoding')} cmapName={f.get('cmapName')} cidSystemInfo={f.get('cidSystemInfo')}"
)
print(
f" subsetTag={f.get('subsetTag')} sourceType={f.get('sourceType')} normalizedFamily={f.get('normalizedFamily')}"
)
print(f" internalFontId={f.get('internalFontId')} flags={f.get('flags')}")
print(f" ascent={f.get('ascent')} descent={f.get('descent')} capHeight={f.get('capHeight')}")
results['doc_fonts']['schema_errors'] = schema_errors
results["doc_fonts"]["schema_errors"] = schema_errors
print(f"Schema errors: {schema_errors or 'None'}")
# 4. GET /documents/{id}/pages/0/fonts
print(f"\n=== GET /documents/{doc_id}/pages/0/fonts ===")
pg_fonts, status = http_get(f'{BASE}/documents/{doc_id}/pages/0/fonts')
pg_fonts, status = http_get(f"{BASE}/documents/{doc_id}/pages/0/fonts")
print(f"Status={status}, Page font count={len(pg_fonts)}")
results['page_fonts'] = {'status': status, 'count': len(pg_fonts), 'passed': status == 200}
results["page_fonts"] = {"status": status, "count": len(pg_fonts), "passed": status == 200}
# Consistency check: doc-level vs page-level
if len(fonts) != len(pg_fonts):
print(f"WARNING: doc-level fonts ({len(fonts)}) != page-level fonts ({len(pg_fonts)})")
results['page_fonts']['consistency_warning'] = True
results["page_fonts"]["consistency_warning"] = True
else:
print("Consistency: doc-level and page-level font counts match OK")
# 5. GET /documents/{id}/pages/0/text
print(f"\n=== GET /documents/{doc_id}/pages/0/text ===")
text_data, status = http_get(f'{BASE}/documents/{doc_id}/pages/0/text')
text_data, status = http_get(f"{BASE}/documents/{doc_id}/pages/0/text")
print(f"Status={status}")
results['page_text'] = {'status': status, 'passed': status == 200}
results["page_text"] = {"status": status, "passed": status == 200}
text = text_data.get('text', '')
glyphs = text_data.get('glyphs', [])
print(f"Text: {repr(text[:80])}")
text = text_data.get("text", "")
glyphs = text_data.get("glyphs", [])
print(f"Text: {text[:80]!r}")
print(f"Glyph count: {len(glyphs)}")
glyph_errors = []
for i, g in enumerate(glyphs):
for field in ['text','x','y','w','h','fontSize']:
for field in ["text", "x", "y", "w", "h", "fontSize"]:
if field not in g:
glyph_errors.append(f"Glyph {i} missing field {field}")
if g.get('fontSize', 0) <= 0:
if g.get("fontSize", 0) <= 0:
glyph_errors.append(f"Glyph {i} '{g.get('text')}' has fontSize <= 0: {g.get('fontSize')}")
if g.get('text','').strip():
if g.get('w', 0) <= 0 or g.get('h', 0) <= 0:
glyph_errors.append(f"Glyph {i} '{g.get('text')}' has zero bounds w={g.get('w')} h={g.get('h')}")
if g.get('fontSize', 0) > 200:
if g.get("text", "").strip() and (g.get("w", 0) <= 0 or g.get("h", 0) <= 0):
glyph_errors.append(
f"Glyph {i} '{g.get('text')}' has zero bounds w={g.get('w')} h={g.get('h')}"
)
if g.get("fontSize", 0) > 200:
glyph_errors.append(f"Glyph {i} unrealistic fontSize={g.get('fontSize')}")
results['page_text']['glyph_count'] = len(glyphs)
results['page_text']['glyph_errors'] = glyph_errors
results["page_text"]["glyph_count"] = len(glyphs)
results["page_text"]["glyph_errors"] = glyph_errors
print(f"Glyph validation errors: {glyph_errors or 'None'}")
if glyphs:
print(f"Sample glyphs: {glyphs[:3]}")
# 6. Test 404 for non-existent document
print("\n=== TEST 404 ===")
not_found, status = http_get(f'{BASE}/documents/nonexistent-uuid/fonts')
not_found, status = http_get(f"{BASE}/documents/nonexistent-uuid/fonts")
print(f"404 test status={status} (expected 404)")
results['not_found'] = {'status': status, 'passed': status == 404}
results["not_found"] = {"status": status, "passed": status == 404}
# 7. Test page out of bounds
print("\n=== TEST PAGE OUT OF BOUNDS ===")
oob, status = http_get(f'{BASE}/documents/{doc_id}/pages/99/fonts')
oob, status = http_get(f"{BASE}/documents/{doc_id}/pages/99/fonts")
print(f"OOB test status={status} (expected 4xx)")
results['oob'] = {'status': status, 'passed': status in (400, 404)}
results["oob"] = {"status": status, "passed": status in (400, 404)}
# 8. Upload vertical_text.pdf and check vertical detection
print("\n=== UPLOADING vertical_text.pdf ===")
vert_doc, status = multipart_upload(f'{BASE}/documents', 'corpus/fonts/vertical_text.pdf', 'vertical_text.pdf')
vert_id = vert_doc.get('id')
vert_doc, status = multipart_upload(
f"{BASE}/documents", "corpus/fonts/vertical_text.pdf", "vertical_text.pdf"
)
vert_id = vert_doc.get("id")
print(f"Upload status={status} doc_id={vert_id}")
if vert_id:
vert_fonts, status = http_get(f'{BASE}/documents/{vert_id}/fonts')
vert_fonts, status = http_get(f"{BASE}/documents/{vert_id}/fonts")
print(f"Vertical PDF fonts ({len(vert_fonts)}):")
for vf in vert_fonts:
print(f" fontName={vf['fontName']} isVertical={vf['isVertical']} encoding={vf['encoding']}")
print(
f" fontName={vf['fontName']} isVertical={vf['isVertical']} encoding={vf['encoding']}"
)
# 9. Upload latin_extended.pdf and validate metrics
print("\n=== UPLOADING latin_extended.pdf ===")
lat_doc, status = multipart_upload(f'{BASE}/documents', 'corpus/fonts/latin_extended.pdf', 'latin_extended.pdf')
lat_id = lat_doc.get('id')
lat_doc, status = multipart_upload(
f"{BASE}/documents", "corpus/fonts/latin_extended.pdf", "latin_extended.pdf"
)
lat_id = lat_doc.get("id")
if lat_id:
lat_fonts, st = http_get(f'{BASE}/documents/{lat_id}/fonts')
lat_fonts, st = http_get(f"{BASE}/documents/{lat_id}/fonts")
print(f"Latin extended fonts ({len(lat_fonts)}):")
for lf in lat_fonts:
ascent_ok = lf['ascent'] > 0
descent_ok = lf['descent'] < 0
cap_ok = lf['capHeight'] > 0
print(f" {lf['fontName']} ascent={lf['ascent']}({'OK' if ascent_ok else 'FAIL'}) descent={lf['descent']}({'OK' if descent_ok else 'FAIL'}) capHeight={lf['capHeight']}({'OK' if cap_ok else 'FAIL'})")
ascent_ok = lf["ascent"] > 0
descent_ok = lf["descent"] < 0
cap_ok = lf["capHeight"] > 0
print(
f" {lf['fontName']} ascent={lf['ascent']}({'OK' if ascent_ok else 'FAIL'}) descent={lf['descent']}({'OK' if descent_ok else 'FAIL'}) capHeight={lf['capHeight']}({'OK' if cap_ok else 'FAIL'})"
)
# 10. Repeated requests (cache consistency)
print("\n=== CACHE CONSISTENCY (3 repeated font requests) ===")
responses = []
for _ in range(3):
fonts_rep, st = http_get(f'{BASE}/documents/{doc_id}/fonts')
fonts_rep, st = http_get(f"{BASE}/documents/{doc_id}/fonts")
responses.append(len(fonts_rep))
print(f"Font counts on repeated requests: {responses}")
results['cache_consistency'] = {'counts': responses, 'passed': len(set(responses)) == 1}
results["cache_consistency"] = {"counts": responses, "passed": len(set(responses)) == 1}
# 11. Invalid PDF upload
print("\n=== INVALID PDF UPLOAD ===")
boundary = 'Xboundary1234X'
garbage = b'NOT A PDF FILE AT ALL 12345'
header = ('--' + boundary + '\r\nContent-Disposition: form-data; name="file"; filename="bad.pdf"\r\nContent-Type: application/pdf\r\n\r\n').encode()
footer = ('\r\n--' + boundary + '--\r\n').encode()
boundary = "Xboundary1234X"
garbage = b"NOT A PDF FILE AT ALL 12345"
header = (
"--"
+ boundary
+ '\r\nContent-Disposition: form-data; name="file"; filename="bad.pdf"\r\nContent-Type: application/pdf\r\n\r\n'
).encode()
footer = ("\r\n--" + boundary + "--\r\n").encode()
req = urllib.request.Request(
f'{BASE}/documents',
f"{BASE}/documents",
data=header + garbage + footer,
headers={'Content-Type': 'multipart/form-data; boundary=' + boundary},
method='POST'
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
method="POST",
)
try:
resp = urllib.request.urlopen(req)
bad_result = json.loads(resp.read()), resp.status
print(f"UNEXPECTED SUCCESS: {bad_result}")
results['invalid_upload'] = {'passed': False}
results["invalid_upload"] = {"passed": False}
except urllib.error.HTTPError as e:
err_detail = json.loads(e.read())
print(f"Invalid PDF correctly rejected: status={e.code} detail={err_detail}")
results['invalid_upload'] = {'status': e.code, 'passed': e.code == 400}
results["invalid_upload"] = {"status": e.code, "passed": e.code == 400}
print("\n" + "="*60)
print("\n" + "=" * 60)
print("VALIDATION SUMMARY")
print("="*60)
print("=" * 60)
for test, res in results.items():
passed = res.get('passed', '?')
status = res.get('status', '-')
passed = res.get("passed", "?")
status = res.get("status", "-")
print(f" {'PASS' if passed else 'FAIL'}: {test:35s} status={status}")
+20 -16
View File
@@ -1,31 +1,29 @@
import pytest
from fastapi.testclient import TestClient
import sys
import contextlib
import os
import sys
from fastapi.testclient import TestClient
# Add both the root directory and the gateway directory to PYTHONPATH
sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
sys.path.insert(0, os.path.abspath("."))
sys.path.insert(0, os.path.abspath(".."))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
try:
with contextlib.suppress(ImportError):
from app.main import app
except ImportError:
pass
client = TestClient(app)
def get_doc_id(filename: str) -> str:
# First ensure the document is loaded
filepath = os.path.abspath(f"../corpus/fonts/{filename}")
with open(filepath, "rb") as f:
resp = client.post(
"/documents",
files={"file": (filename, f, "application/pdf")}
)
resp = client.post("/documents", files={"file": (filename, f, "application/pdf")})
assert resp.status_code == 201, f"Failed to load {filename}: {resp.json()}"
return resp.json()["id"]
def test_is_vertical_regression():
# 1. Verify Identity-V fonts are detected correctly
doc_id = get_doc_id("vertical_text.pdf")
@@ -36,7 +34,7 @@ def test_is_vertical_regression():
font = fonts[0]
assert font["isVertical"] is True
assert font["encoding"] == "Identity-V"
# 2. Verify horizontal fonts are not falsely detected as vertical
doc_id_h = get_doc_id("utf-8.pdf")
resp_h = client.get(f"/documents/{doc_id_h}/fonts")
@@ -45,6 +43,7 @@ def test_is_vertical_regression():
for f in fonts_h:
assert f["isVertical"] is False
def test_internal_font_id_regression():
# Verify subset fonts do not duplicate subset prefixes
doc_id = get_doc_id("text_font.pdf")
@@ -55,7 +54,10 @@ def test_internal_font_id_regression():
for font in fonts:
if font.get("isSubset"):
assert font["subsetTag"] in font["fontName"]
assert not font["internalFontId"].startswith(font["subsetTag"] + "_" + font["subsetTag"])
assert not font["internalFontId"].startswith(
font["subsetTag"] + "_" + font["subsetTag"]
)
def test_cid_collection_regression():
# Verify Adobe collections
@@ -67,6 +69,7 @@ def test_cid_collection_regression():
if font.get("cidSystemInfo") and font.get("cidSystemInfo") != "None":
assert "Adobe-" in font["cidSystemInfo"]
def test_utf8_corpus_regression():
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text")
@@ -75,9 +78,10 @@ def test_utf8_corpus_regression():
assert len(data["glyphs"]) > 0
assert len(data["text"]) > 0
def test_font_size_regression():
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text")
data = resp.json()
sizes = set(g["fontSize"] for g in data["glyphs"])
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
+1 -1
View File
@@ -28,4 +28,4 @@ def test_health_does_not_require_engine(client: TestClient) -> None:
assert "pdfengine" not in sys.modules
finally:
if had_pdfengine and pdfengine_module is not None:
sys.modules["pdfengine"] = pdfengine_module
sys.modules["pdfengine"] = pdfengine_module
+223 -45
View File
@@ -1,5 +1,6 @@
import os
import contextlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@@ -8,34 +9,33 @@ from app.services.store import document_store
has_pdfium = False
if engine.is_available():
try:
with contextlib.suppress(Exception):
has_pdfium = engine.require().engine_has_pdfium()
except Exception:
pass
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."
reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.",
)
CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus"
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
@pytest.fixture(autouse=True)
def clean_store():
with document_store._lock:
document_store._documents.clear()
yield
def test_upload_document_success(client: TestClient):
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
with open(HELLO_WORLD_PDF, "rb") as f:
response = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
assert response.status_code == 201
payload = response.json()
assert "id" in payload
@@ -44,20 +44,20 @@ def test_upload_document_success(client: TestClient):
assert payload["totalPages"] == 1
assert payload["status"] == "ready"
def test_upload_document_invalid(client: TestClient):
response = client.post(
"/documents",
files={"file": ("test.pdf", b"not-a-pdf-file-content", "application/pdf")}
"/documents", files={"file": ("test.pdf", b"not-a-pdf-file-content", "application/pdf")}
)
assert response.status_code == 400
assert "invalid pdf" in response.json()["detail"].lower()
def test_list_and_get_document(client: TestClient):
# Upload one
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -77,11 +77,11 @@ def test_list_and_get_document(client: TestClient):
fake_resp = client.get("/documents/non-existent-uuid")
assert fake_resp.status_code == 404
def test_delete_document(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -94,11 +94,11 @@ def test_delete_document(client: TestClient):
get_resp = client.get(f"/documents/{doc_id}")
assert get_resp.status_code == 404
def test_render_page_standard_and_compat(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -118,11 +118,11 @@ def test_render_page_standard_and_compat(client: TestClient):
fail_resp = client.get(f"/documents/{doc_id}/pages/5/render")
assert fail_resp.status_code == 404
def test_extract_page_text(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -138,11 +138,11 @@ def test_extract_page_text(client: TestClient):
for key in ["text", "x", "y", "w", "h", "fontSize"]:
assert key in first_glyph
def test_apply_edits_and_incremental_save(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -161,10 +161,10 @@ def test_apply_edits_and_incremental_save(client: TestClient):
"height": 20.0,
"fontSize": 14.0,
"fontFamily": "Helvetica",
"color": "#000000"
}
"color": "#000000",
},
}
]
],
}
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
@@ -182,11 +182,171 @@ def test_apply_edits_and_incremental_save(client: TestClient):
assert text_resp.status_code == 200
assert "Edited Text Annotation" in text_resp.json()["text"]
def test_apply_image_overlay_and_incremental_save(client: TestClient):
import base64
import io
from PIL import Image
# 1. Upload Hello World PDF
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
# 2. Create a tiny 2x2 solid red PNG image using Pillow
img = Image.new("RGBA", (2, 2), color="red")
buf = io.BytesIO()
img.save(buf, format="PNG")
png_bytes = buf.getvalue()
png_base64 = base64.b64encode(png_bytes).decode("utf-8")
# 3. Create edits payload
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_img_test_123",
"type": "image_overlay",
"pageIndex": 0,
"data": {
"x": 100.0,
"y": 150.0,
"width": 200.0,
"height": 150.0,
"imageData": f"data:image/png;base64,{png_base64}",
},
}
],
}
# 4. Apply edits via POST endpoint
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
assert new_doc_id != doc_id
# 5. Render new document's page to make sure it functions correctly
render_resp = client.get(f"/documents/{new_doc_id}/pages/0/render")
assert render_resp.status_code == 200
assert render_resp.headers["content-type"] == "image/png"
def test_apply_page_rotation_and_incremental_save(client: TestClient):
# 1. Upload Hello World PDF
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
# 2. Create edits payload to rotate page 0 by 90 degrees
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_rot_test_123",
"type": "page_rotation",
"pageIndex": 0,
"data": {"rotation": 90},
}
],
}
# 3. Apply edits via POST endpoint
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
assert new_doc_id != doc_id
# 4. Render new document's page to make sure it functions correctly
render_resp = client.get(f"/documents/{new_doc_id}/pages/0/render")
assert render_resp.status_code == 200
assert render_resp.headers["content-type"] == "image/png"
def test_apply_page_deletion_and_incremental_save(client: TestClient):
# We need a 2-page PDF to test deletion
two_pages_pdf = CORPUS_DIR / "basic" / "hello_world_2_pages.pdf"
assert two_pages_pdf.exists(), f"hello_world_2_pages.pdf not found at {two_pages_pdf}"
with open(two_pages_pdf, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (two_pages_pdf.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
assert upload_resp.json()["totalPages"] == 2
# Delete page index 1
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_del_test_123",
"type": "page_deletion",
"pageIndex": 1,
"data": {},
}
],
}
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
# Verify page count is 1
get_resp = client.get(f"/documents/{new_doc_id}")
assert get_resp.status_code == 200
assert get_resp.json()["totalPages"] == 1
def test_apply_page_reorder_and_incremental_save(client: TestClient):
two_pages_pdf = CORPUS_DIR / "basic" / "hello_world_2_pages.pdf"
assert two_pages_pdf.exists(), f"hello_world_2_pages.pdf not found at {two_pages_pdf}"
with open(two_pages_pdf, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (two_pages_pdf.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
# Reorder page 1 to dest page index 0
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_reorder_test_123",
"type": "page_reorder",
"pageIndex": 1,
"data": {"destPageIndex": 0},
}
],
}
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
# Verify totalPages is still 2
get_resp = client.get(f"/documents/{new_doc_id}")
assert get_resp.status_code == 200
assert get_resp.json()["totalPages"] == 2
def test_get_document_and_page_fonts(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
@@ -195,7 +355,7 @@ def test_get_document_and_page_fonts(client: TestClient):
assert fonts_resp.status_code == 200
fonts = fonts_resp.json()
assert isinstance(fonts, list)
# 2. Page level fonts
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
assert page_fonts_resp.status_code == 200
@@ -206,14 +366,30 @@ def test_get_document_and_page_fonts(client: TestClient):
if len(fonts) > 0:
f = fonts[0]
fields = [
"fontName", "type", "isEmbedded", "isSubset", "isVertical",
"encoding", "hasToUnicode", "cmapName", "cidSystemInfo", "subsetTag",
"sourceType", "substitutedFrom", "substitutedTo", "normalizedFamily",
"internalFontId", "flags", "ascent", "descent", "capHeight"
"fontName",
"type",
"isEmbedded",
"isSubset",
"isVertical",
"encoding",
"hasToUnicode",
"cmapName",
"cidSystemInfo",
"subsetTag",
"sourceType",
"substitutedFrom",
"substitutedTo",
"normalizedFamily",
"internalFontId",
"flags",
"ascent",
"descent",
"capHeight",
]
for field in fields:
assert field in f
def test_font_size_and_diagnostics_advanced(client: TestClient):
utf8_pdf = CORPUS_DIR / "fonts" / "utf-8.pdf"
vertical_pdf = CORPUS_DIR / "fonts" / "vertical_text.pdf"
@@ -223,8 +399,7 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
# 1. Upload utf-8.pdf containing standard CJK/fonts with subsetting
with open(utf8_pdf, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (utf8_pdf.name, f, "application/pdf")}
"/documents", files={"file": (utf8_pdf.name, f, "application/pdf")}
)
assert upload_resp.status_code == 201
doc_id = upload_resp.json()["id"]
@@ -256,9 +431,9 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
assert isinstance(font["normalizedFamily"], str)
assert isinstance(font["internalFontId"], str)
assert isinstance(font["flags"], int)
assert isinstance(font["ascent"], (int, float))
assert isinstance(font["descent"], (int, float))
assert isinstance(font["capHeight"], (int, float))
assert isinstance(font["ascent"], int | float)
assert isinstance(font["descent"], int | float)
assert isinstance(font["capHeight"], int | float)
# Check subset tagging format if font is a subset
if font["isSubset"]:
@@ -295,11 +470,11 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
for glyph in glyphs:
assert isinstance(glyph["text"], str)
assert len(glyph["text"]) > 0
assert isinstance(glyph["x"], (int, float))
assert isinstance(glyph["y"], (int, float))
assert isinstance(glyph["w"], (int, float))
assert isinstance(glyph["h"], (int, float))
assert isinstance(glyph["fontSize"], (int, float))
assert isinstance(glyph["x"], int | float)
assert isinstance(glyph["y"], int | float)
assert isinstance(glyph["w"], int | float)
assert isinstance(glyph["h"], int | float)
assert isinstance(glyph["fontSize"], int | float)
# Font sizes must be positive and realistic
assert glyph["fontSize"] > 0
@@ -314,8 +489,7 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
if vertical_pdf.exists():
with open(vertical_pdf, "rb") as f:
upload_resp = client.post(
"/documents",
files={"file": (vertical_pdf.name, f, "application/pdf")}
"/documents", files={"file": (vertical_pdf.name, f, "application/pdf")}
)
assert upload_resp.status_code == 201
vert_doc_id = upload_resp.json()["id"]
@@ -328,6 +502,10 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
for font in vert_fonts:
if font["isVertical"]:
has_vertical = True
assert "-V" in font["encoding"] or "-V" in font["cmapName"] or "Identity-V" in font["encoding"]
assert (
"-V" in font["encoding"]
or "-V" in font["cmapName"]
or "Identity-V" in font["encoding"]
)
assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"
assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"