merger
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <csetjmp>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
@@ -567,6 +569,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 {
|
||||
@@ -1384,7 +1409,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") {
|
||||
@@ -1394,7 +1511,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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
@@ -182,6 +292,8 @@ function App() {
|
||||
onNavigateToPage={(pageIndex) => {
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
{/* 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}>
|
||||
|
||||
@@ -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 })),
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<PDFViewerRef, PDFViewerProps>(({
|
||||
documentId,
|
||||
totalPages,
|
||||
zoom,
|
||||
rotation,
|
||||
pagesInfo,
|
||||
activeTool,
|
||||
annotations,
|
||||
searchQuery,
|
||||
@@ -49,6 +50,11 @@ 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]);
|
||||
|
||||
// Standard Page Dimensions: Letter size is 612x792 pt
|
||||
const basePageWidth = 612;
|
||||
const basePageHeight = 792;
|
||||
@@ -60,10 +66,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,
|
||||
@@ -75,7 +80,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;
|
||||
@@ -164,7 +169,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 };
|
||||
})
|
||||
@@ -185,7 +190,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') {
|
||||
@@ -269,7 +274,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}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -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,7 +36,7 @@ def create_app() -> FastAPI:
|
||||
"name": "PDF Engine Gateway",
|
||||
"version": __version__,
|
||||
"health": "/health",
|
||||
"docs": "/docs"
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<<<<<<< HEAD
|
||||
from typing import List, Annotated
|
||||
from fastapi import APIRouter, HTTPException, status, File, UploadFile, Query
|
||||
=======
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import engine
|
||||
@@ -7,6 +11,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,13 +25,36 @@ class DocumentInfoResponse(BaseModel):
|
||||
totalPages: int
|
||||
uploadedAt: str
|
||||
status: str
|
||||
pages: list[PageInfoResponse] = []
|
||||
|
||||
|
||||
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"],
|
||||
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."
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
bytes_data = await file.read()
|
||||
@@ -28,73 +62,56 @@ async def upload_document(file: UploadFile = File(...), password: str = "") -> D
|
||||
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"]
|
||||
)
|
||||
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")
|
||||
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)}")
|
||||
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]:
|
||||
|
||||
@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."
|
||||
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
|
||||
]
|
||||
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."
|
||||
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 DocumentInfoResponse(
|
||||
id=d["id"],
|
||||
filename=d["filename"],
|
||||
sizeBytes=d["sizeBytes"],
|
||||
totalPages=d["totalPages"],
|
||||
uploadedAt=d["uploadedAt"],
|
||||
status=d["status"]
|
||||
)
|
||||
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)
|
||||
@@ -103,6 +120,7 @@ def delete_document(document_id: str):
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
class DocumentMetadataResponse(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
@@ -111,12 +129,13 @@ 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)
|
||||
@@ -132,11 +151,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,12 +178,20 @@ class FontInfoResponse(BaseModel):
|
||||
descent: float
|
||||
capHeight: float
|
||||
|
||||
<<<<<<< HEAD
|
||||
@router.get("/{document_id}/fonts", response_model=List[FontInfoResponse])
|
||||
def get_document_fonts(document_id: str, start_page: Annotated[int, Query(ge=0)] = 0, end_page: Annotated[int, Query(ge=-1)] = -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]:
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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)
|
||||
@@ -193,7 +221,7 @@ def get_document_fonts(document_id: str, start_page: Annotated[int, Query(ge=0)]
|
||||
flags=f.flags,
|
||||
ascent=f.ascent,
|
||||
descent=f.descent,
|
||||
capHeight=f.cap_height
|
||||
capHeight=f.cap_height,
|
||||
)
|
||||
for f in fonts
|
||||
]
|
||||
|
||||
+119
-26
@@ -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,107 +66,188 @@ class FreeTextData(BaseModel):
|
||||
fontSize: float = Field(12.0, gt=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 = Field(..., ge=0)
|
||||
data: TextOverlayData
|
||||
|
||||
|
||||
class RedactionOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["redaction"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: RedactionData
|
||||
|
||||
|
||||
class ImageOverlayOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["image_overlay"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: ImageOverlayData
|
||||
|
||||
|
||||
class HighlightOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["highlight"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: HighlightData
|
||||
|
||||
|
||||
class FreeTextOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["free_text"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: FreeTextData
|
||||
|
||||
|
||||
class CommentOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["comment"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: StickyNoteData
|
||||
|
||||
|
||||
class FreehandOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["freehand"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: FreehandData
|
||||
|
||||
|
||||
class PageRotationOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["page_rotation"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
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()
|
||||
@@ -170,19 +255,27 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
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)
|
||||
@@ -5,18 +5,20 @@ 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()
|
||||
@@ -26,7 +28,7 @@ def get_engine_info() -> 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))
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
<<<<<<< HEAD
|
||||
from typing import List, Annotated
|
||||
from fastapi import APIRouter, HTTPException, status, Response, Path, Query
|
||||
=======
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
|
||||
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: Annotated[int, Path(ge=0)], 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)
|
||||
@@ -26,16 +31,19 @@ def render_page(document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: i
|
||||
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: Annotated[int, Path(ge=0)]):
|
||||
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)
|
||||
@@ -52,21 +60,31 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
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}")
|
||||
<<<<<<< HEAD
|
||||
def render_page_compat(document_id: str, page: Annotated[int, Query(ge=0)] = 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:
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
dpi = int(96 * zoom)
|
||||
return render_page(document_id, page, dpi)
|
||||
|
||||
|
||||
@router.get("/{page_index}")
|
||||
def get_page_info(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
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)
|
||||
@@ -78,14 +96,31 @@ def get_page_info(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
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")
|
||||
<<<<<<< HEAD
|
||||
def transform_page_to_device(document_id: str, page_index: Annotated[int, Path(ge=0)], 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,
|
||||
):
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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:
|
||||
@@ -102,10 +137,25 @@ def transform_page_to_device(document_id: str, page_index: Annotated[int, Path(g
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{page_index}/transform/device-to-page")
|
||||
<<<<<<< HEAD
|
||||
def transform_device_to_page(document_id: str, page_index: Annotated[int, Path(ge=0)], 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,
|
||||
):
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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:
|
||||
@@ -121,12 +171,19 @@ def transform_device_to_page(document_id: str, page_index: Annotated[int, Path(g
|
||||
return {"x": res.x, "y": res.y}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
<<<<<<< HEAD
|
||||
@router.get("/{page_index}/fonts", response_model=List[FontInfoResponse])
|
||||
def get_page_fonts(document_id: str, page_index: Annotated[int, Path(ge=0)]) -> List[FontInfoResponse]:
|
||||
=======
|
||||
|
||||
|
||||
@router.get("/{page_index}/fonts", response_model=list[FontInfoResponse])
|
||||
def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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)
|
||||
@@ -157,7 +214,7 @@ def get_page_fonts(document_id: str, page_index: Annotated[int, Path(ge=0)]) ->
|
||||
flags=f.flags,
|
||||
ascent=f.ascent,
|
||||
descent=f.descent,
|
||||
capHeight=f.cap_height
|
||||
capHeight=f.cap_height,
|
||||
)
|
||||
for f in fonts
|
||||
]
|
||||
@@ -165,6 +222,7 @@ def get_page_fonts(document_id: str, page_index: Annotated[int, Path(ge=0)]) ->
|
||||
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))
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
@router.get("/{page_index}/fonts/glyph-width")
|
||||
@@ -189,3 +247,5 @@ def get_page_glyph_width(document_id: str, page_index: Annotated[int, Path(ge=0)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
@@ -20,7 +21,7 @@ class DocumentStore:
|
||||
"uploadedAt": uploaded_at,
|
||||
"status": "ready",
|
||||
"doc_instance": doc_instance,
|
||||
"bytes_data": bytes_data
|
||||
"bytes_data": bytes_data,
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
@@ -28,11 +29,11 @@ class DocumentStore:
|
||||
|
||||
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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
+120
-75
@@ -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("VALIDATION SUMMARY")
|
||||
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}")
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
<<<<<<< HEAD
|
||||
import re
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
import sys
|
||||
=======
|
||||
import contextlib
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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)
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Regex: six uppercase ASCII letters followed by '+'
|
||||
SUBSET_PREFIX_RE = re.compile(r'^[A-Z]{6}\+')
|
||||
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
|
||||
def get_doc_id(filename: str) -> str:
|
||||
"""Upload a PDF from corpus/fonts and return its document ID."""
|
||||
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"]
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
# =========================================================================
|
||||
# 1. Vertical font regression
|
||||
# =========================================================================
|
||||
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
def test_is_vertical_regression():
|
||||
"""Identity-V fonts must be flagged isVertical; horizontal fonts must not."""
|
||||
doc_id = get_doc_id("vertical_text.pdf")
|
||||
@@ -47,6 +55,10 @@ def test_is_vertical_regression():
|
||||
assert font["isVertical"] is True
|
||||
assert font["encoding"] == "Identity-V"
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
# 2. Verify horizontal fonts are not falsely detected as vertical
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
doc_id_h = get_doc_id("utf-8.pdf")
|
||||
resp_h = client.get(f"/documents/{doc_id_h}/fonts")
|
||||
fonts_h = resp_h.json()
|
||||
@@ -55,6 +67,7 @@ def test_is_vertical_regression():
|
||||
assert f["isVertical"] is False
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
# =========================================================================
|
||||
# 2. Internal Font ID: no duplicate subset prefix (core regression)
|
||||
# =========================================================================
|
||||
@@ -92,6 +105,10 @@ def test_internal_font_id_subset_format():
|
||||
For any subset font the internalFontId must match the pattern
|
||||
ABCDEF+BaseName — exactly the fontName reported by PDFium.
|
||||
"""
|
||||
=======
|
||||
def test_internal_font_id_regression():
|
||||
# Verify subset fonts do not duplicate subset prefixes
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
doc_id = get_doc_id("text_font.pdf")
|
||||
resp = client.get(f"/documents/{doc_id}/fonts")
|
||||
assert resp.status_code == 200
|
||||
@@ -101,6 +118,7 @@ def test_internal_font_id_subset_format():
|
||||
for font in fonts:
|
||||
if font.get("isSubset"):
|
||||
assert font["subsetTag"] in font["fontName"]
|
||||
<<<<<<< HEAD
|
||||
# internalFontId == fontName (e.g. "ABCDEF+Arial")
|
||||
assert font["internalFontId"] == font["fontName"]
|
||||
# The ID must contain exactly one '+' from the subset tag
|
||||
@@ -168,6 +186,12 @@ def test_font_id_stable_across_apis():
|
||||
# =========================================================================
|
||||
# 5. CID collection regression
|
||||
# =========================================================================
|
||||
=======
|
||||
assert not font["internalFontId"].startswith(
|
||||
font["subsetTag"] + "_" + font["subsetTag"]
|
||||
)
|
||||
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
|
||||
def test_cid_collection_regression():
|
||||
"""Adobe CID collections must use the 'Adobe-' prefix."""
|
||||
@@ -180,6 +204,7 @@ def test_cid_collection_regression():
|
||||
assert "Adobe-" in font["cidSystemInfo"]
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
def test_cns1_regression():
|
||||
"""Verify Adobe-CNS1 (Traditional Chinese) CID fonts and text extraction."""
|
||||
doc_id = get_doc_id("cns1_test.pdf")
|
||||
@@ -202,6 +227,8 @@ def test_cns1_regression():
|
||||
# 6. UTF-8 corpus regression
|
||||
# =========================================================================
|
||||
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
def test_utf8_corpus_regression():
|
||||
doc_id = get_doc_id("utf-8.pdf")
|
||||
resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||
@@ -211,16 +238,20 @@ def test_utf8_corpus_regression():
|
||||
assert len(data["text"]) > 0
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
# =========================================================================
|
||||
# 7. Font size regression
|
||||
# =========================================================================
|
||||
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
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
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -242,3 +273,5 @@ def test_font_ids_unique_within_document():
|
||||
assert len(ids) == len(set(ids)), (
|
||||
f"Duplicate internalFontId values in {pdf}: {ids}"
|
||||
)
|
||||
=======
|
||||
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
|
||||
|
||||
+219
-41
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -8,32 +9,31 @@ 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -142,11 +142,11 @@ def test_extract_page_text(client: TestClient):
|
||||
assert g["fontSize"] != 1.0, f"Fake fontSize 1.0 detected for glyph: {g}"
|
||||
assert g["text"] not in ["\r", "\n"], f"Control character detected in glyph bounds: {g}"
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
@@ -165,10 +165,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)
|
||||
@@ -186,11 +186,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"]
|
||||
|
||||
@@ -210,14 +370,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"
|
||||
@@ -227,8 +403,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"]
|
||||
@@ -260,9 +435,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"]:
|
||||
@@ -299,11 +474,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
|
||||
@@ -318,8 +493,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"]
|
||||
@@ -332,6 +506,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"
|
||||
Reference in New Issue
Block a user