diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 57892ef..f3e14bd 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -13,6 +13,8 @@ #include #include +#include +#include #include namespace pdfengine::parser { @@ -560,6 +562,29 @@ void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, uns } } +std::vector base64Decode(const std::string& encoded) { + std::vector decoded; + int T[256]; + std::fill(std::begin(T), std::end(T), -1); + for (int i = 0; i < 64; ++i) { + T[static_cast("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i; + } + + int val = 0; + int valb = -8; + for (char c : encoded) { + unsigned char uc = static_cast(c); + if (T[uc] == -1) continue; + val = (val << 6) + T[uc]; + valb += 6; + if (valb >= 0) { + decoded.push_back(static_cast((val >> valb) & 0xFF)); + valb -= 8; + } + } + return decoded; +} + } namespace pdfengine { @@ -1085,7 +1110,99 @@ std::expected 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 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)); + if (!infile.read(reinterpret_cast(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(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(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 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); } diff --git a/engine/tests/document_test.cpp b/engine/tests/document_test.cpp index c8db0fd..bacdd26 100644 --- a/engine/tests/document_test.cpp +++ b/engine/tests/document_test.cpp @@ -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"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6282708..fccf633 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,6 @@ function App() { // Settings const [zoom, setZoom] = useState(1.0); - const [rotation, setRotation] = useState(0); const [activeTool, setActiveTool] = useState('select'); const [currentPage, setCurrentPage] = useState(0); @@ -148,14 +147,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 (
{/* Top Navigation / Toolbar */} { viewerRef.current?.scrollToPage(pageIndex); }} + onDeletePage={handleDeletePage} + onReorderPage={handleReorderPage} /> {/* Main PDF Scroll Viewer Area */} @@ -199,7 +311,7 @@ function App() { documentId={activeDoc.id} totalPages={activeDoc.totalPages} zoom={zoom} - rotation={rotation} + pagesInfo={activeDoc.pages} activeTool={activeTool} annotations={annotations} searchQuery={searchQuery} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0dd77a8..b681149 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 = ({ @@ -23,6 +25,8 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab, onNavigateToPage, + onDeletePage, + onReorderPage, }) => { const formatBytes = (bytes: number) => { if (bytes === 0) return '0 Bytes'; @@ -112,6 +116,18 @@ export const Sidebar: React.FC = ({ 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} /> ))}
diff --git a/frontend/src/components/Thumbnail.tsx b/frontend/src/components/Thumbnail.tsx index 2827fc5..759a12a 100644 --- a/frontend/src/components/Thumbnail.tsx +++ b/frontend/src/components/Thumbnail.tsx @@ -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 = ({ documentId, pageIndex, onClick, + onDelete, + onMoveUp, + onMoveDown, + totalPages, }) => { const [imageUrl, setImageUrl] = useState(null); const [loading, setLoading] = useState(true); @@ -48,18 +56,73 @@ export const Thumbnail: React.FC = ({ return (
-
+
{loading ? (
Loading...
) : imageUrl ? ( - {`Page + <> + {`Page + {/* Group Hover Overlay Controls */} +
+
+ {onDelete && totalPages && totalPages > 1 && ( + + )} +
+
+ {onMoveUp ? ( + + ) : ( +
+ )} + {onMoveDown ? ( + + ) : ( +
+ )} +
+
+ ) : (
diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 9496694..708b7f5 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -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; @@ -12,6 +18,7 @@ export interface DocumentInfo { totalPages: number; uploadedAt: string; status: 'processing' | 'ready' | 'error'; + pages?: PageInfo[]; } export interface RenderParams { @@ -96,6 +103,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; @@ -105,6 +118,8 @@ export type EditOperationDataMap = { comment: StickyNoteData; freehand: FreehandData; page_rotation: PageRotationData; + page_deletion: PageDeletionData; + page_reorder: PageReorderData; }; export type EditOperationType = keyof EditOperationDataMap; @@ -173,6 +188,7 @@ class GatewayService { totalPages: 5, // Mocked total pages uploadedAt: new Date().toISOString(), status: 'ready', + pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })), }); }, 1000); }); @@ -257,6 +273,7 @@ class GatewayService { totalPages: 12, 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', @@ -265,6 +282,7 @@ class GatewayService { totalPages: 54, 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', @@ -273,6 +291,7 @@ class GatewayService { totalPages: 4, uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(), status: 'ready', + pages: Array.from({ length: 4 }, (_, i) => ({ index: i, width: 612, height: 792 })), } ]; } diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index c9e9a28..d2bccaf 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -7,12 +7,13 @@ import { OverlayLayer } from './OverlayLayer'; import { SearchOverlayLayer } from './SearchOverlayLayer'; import type { Rect } from '../lib/coordinateMapping'; import { gatewayService } from '../lib/gatewayService'; +import type { PageInfo } from '../lib/gatewayService'; interface PDFViewerProps { documentId: string; totalPages: number; zoom: number; - rotation: number; + pagesInfo?: PageInfo[]; activeTool: string; annotations: Annotation[]; searchQuery?: string; @@ -37,7 +38,7 @@ export const PDFViewer = React.forwardRef(({ documentId, totalPages, zoom, - rotation, + pagesInfo, activeTool, annotations, searchQuery, @@ -49,6 +50,11 @@ export const PDFViewer = React.forwardRef(({ const [renderedPages, setRenderedPages] = useState([]); const [containerHeight, setContainerHeight] = useState(800); + // Reset cached page renders when switching documents + useEffect(() => { + setRenderedPages([]); + }, [documentId]); + // Standard Page Dimensions: Letter size is 612x792 pt const basePageWidth = 612; const basePageHeight = 792; @@ -60,10 +66,9 @@ export const PDFViewer = React.forwardRef(({ 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, @@ -75,7 +80,7 @@ export const PDFViewer = React.forwardRef(({ currentTop += (h * zoom) + pageGap; } return layouts; - }, [totalPages, zoom, rotation]); + }, [totalPages, zoom, pagesInfo]); const totalContentHeight = useMemo(() => { if (pageLayouts.length === 0) return 0; @@ -164,7 +169,7 @@ export const PDFViewer = React.forwardRef(({ documentId, pageIndex: page.index, zoom, - rotation, + rotation: 0, // already rotated physically on backend }); return { index: page.index, url }; }) @@ -185,7 +190,7 @@ export const PDFViewer = React.forwardRef(({ return () => { active = false; }; - }, [visiblePages, documentId, zoom, rotation, renderedPages]); + }, [visiblePages, documentId, zoom, renderedPages]); const handleTextSelection = (text: string, bbox: Rect) => { if (activeTool === 'highlight') { @@ -269,7 +274,7 @@ export const PDFViewer = React.forwardRef(({ pageIndex={page.index} imageUrl={imageUrl} zoom={zoom} - rotation={rotation} + rotation={0} // already rotated physically on backend width={page.width} height={page.height} /> diff --git a/gateway/app/main.py b/gateway/app/main.py index c0a8361..bfca32e 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -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() \ No newline at end of file +app = create_app() diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index 7019b9e..5fe78bb 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -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 @@ -14,95 +20,102 @@ class DocumentInfoResponse(BaseModel): totalPages: int 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"], - 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"], - 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"], sizeBytes=d["sizeBytes"], totalPages=d["totalPages"], 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 @@ -111,18 +124,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 @@ -132,11 +146,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 @@ -158,18 +173,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) @@ -193,7 +211,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 ] @@ -202,4 +220,4 @@ def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1 except IndexError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 5bfb916..7588d8c 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -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) \ No newline at end of file + return apply_edits_impl(document_id, request) diff --git a/gateway/app/routers/info.py b/gateway/app/routers/info.py index ace7fc8..c72813a 100644 --- a/gateway/app/routers/info.py +++ b/gateway/app/routers/info.py @@ -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)) diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index 8ffd228..061d137 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -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)) - diff --git a/gateway/app/services/store.py b/gateway/app/services/store.py index 2437029..b57ad3d 100644 --- a/gateway/app/services/store.py +++ b/gateway/app/services/store.py @@ -1,17 +1,18 @@ import threading import uuid -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional +from datetime import UTC, datetime +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") + info = { "id": doc_id, "filename": filename, @@ -20,19 +21,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()) @@ -43,4 +44,5 @@ class DocumentStore: return True return False + document_store = DocumentStore() diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml index ac8e4cf..298a0ee 100644 --- a/gateway/pyproject.toml +++ b/gateway/pyproject.toml @@ -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] diff --git a/gateway/tests/api_validate.py b/gateway/tests/api_validate.py index 71a7cc1..726495e 100644 --- a/gateway/tests/api_validate.py +++ b/gateway/tests/api_validate.py @@ -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}") diff --git a/gateway/tests/test_font_regression.py b/gateway/tests/test_font_regression.py index 708acf5..b73ed10 100644 --- a/gateway/tests/test_font_regression.py +++ b/gateway/tests/test_font_regression.py @@ -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 diff --git a/gateway/tests/test_health.py b/gateway/tests/test_health.py index 43cc0ae..a586410 100644 --- a/gateway/tests/test_health.py +++ b/gateway/tests/test_health.py @@ -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 \ No newline at end of file + sys.modules["pdfengine"] = pdfengine_module diff --git a/gateway/tests/test_routes.py b/gateway/tests/test_routes.py index 2849845..03fb8d9 100644 --- a/gateway/tests/test_routes.py +++ b/gateway/tests/test_routes.py @@ -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" \ No newline at end of file + assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"