feat: implemented page rotation and deletion
This commit is contained in:
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <spdlog/spdlog.h>
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <fstream>
|
||||||
|
#include <filesystem>
|
||||||
#include <csetjmp>
|
#include <csetjmp>
|
||||||
|
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
@@ -560,6 +562,29 @@ void parseHexColor(const std::string& hex, unsigned int& r, unsigned int& g, uns
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> base64Decode(const std::string& encoded) {
|
||||||
|
std::vector<uint8_t> decoded;
|
||||||
|
int T[256];
|
||||||
|
std::fill(std::begin(T), std::end(T), -1);
|
||||||
|
for (int i = 0; i < 64; ++i) {
|
||||||
|
T[static_cast<unsigned char>("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[i])] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
int val = 0;
|
||||||
|
int valb = -8;
|
||||||
|
for (char c : encoded) {
|
||||||
|
unsigned char uc = static_cast<unsigned char>(c);
|
||||||
|
if (T[uc] == -1) continue;
|
||||||
|
val = (val << 6) + T[uc];
|
||||||
|
valb += 6;
|
||||||
|
if (valb >= 0) {
|
||||||
|
decoded.push_back(static_cast<uint8_t>((val >> valb) & 0xFF));
|
||||||
|
valb -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace pdfengine {
|
namespace pdfengine {
|
||||||
@@ -1085,7 +1110,99 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
} else if (type == "redaction") {
|
} else if (type == "redaction") {
|
||||||
spdlog::info("Parsed redaction edit operation (stub)");
|
spdlog::info("Parsed redaction edit operation (stub)");
|
||||||
} else if (type == "image_overlay") {
|
} 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") {
|
} else if (type == "highlight") {
|
||||||
spdlog::info("Parsed highlight edit operation (stub)");
|
spdlog::info("Parsed highlight edit operation (stub)");
|
||||||
} else if (type == "free_text") {
|
} else if (type == "free_text") {
|
||||||
@@ -1095,7 +1212,54 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
} else if (type == "freehand") {
|
} else if (type == "freehand") {
|
||||||
spdlog::info("Parsed freehand edit operation (stub)");
|
spdlog::info("Parsed freehand edit operation (stub)");
|
||||||
} else if (type == "page_rotation") {
|
} 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 {
|
} else {
|
||||||
spdlog::warn("Unsupported edit operation type: {}", type);
|
spdlog::warn("Unsupported edit operation type: {}", type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,237 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
|
|||||||
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
|
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) {
|
TEST(FontDiagnosticsTest, IntrospectionAccuracy) {
|
||||||
SKIP_IF_NO_PDFIUM();
|
SKIP_IF_NO_PDFIUM();
|
||||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||||
|
|||||||
+116
-4
@@ -17,7 +17,6 @@ function App() {
|
|||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
const [zoom, setZoom] = useState<number>(1.0);
|
const [zoom, setZoom] = useState<number>(1.0);
|
||||||
const [rotation, setRotation] = useState<number>(0);
|
|
||||||
const [activeTool, setActiveTool] = useState<string>('select');
|
const [activeTool, setActiveTool] = useState<string>('select');
|
||||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||||
|
|
||||||
@@ -148,14 +147,125 @@ function App() {
|
|||||||
setSidebarTab('annotations');
|
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 (
|
return (
|
||||||
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
|
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
|
||||||
{/* Top Navigation / Toolbar */}
|
{/* Top Navigation / Toolbar */}
|
||||||
<Toolbar
|
<Toolbar
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
onZoomChange={setZoom}
|
onZoomChange={setZoom}
|
||||||
rotation={rotation}
|
rotation={0}
|
||||||
onRotationChange={setRotation}
|
onRotationChange={handleRotateClick}
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
onActiveToolChange={setActiveTool}
|
onActiveToolChange={setActiveTool}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
@@ -182,6 +292,8 @@ function App() {
|
|||||||
onNavigateToPage={(pageIndex) => {
|
onNavigateToPage={(pageIndex) => {
|
||||||
viewerRef.current?.scrollToPage(pageIndex);
|
viewerRef.current?.scrollToPage(pageIndex);
|
||||||
}}
|
}}
|
||||||
|
onDeletePage={handleDeletePage}
|
||||||
|
onReorderPage={handleReorderPage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main PDF Scroll Viewer Area */}
|
{/* Main PDF Scroll Viewer Area */}
|
||||||
@@ -199,7 +311,7 @@ function App() {
|
|||||||
documentId={activeDoc.id}
|
documentId={activeDoc.id}
|
||||||
totalPages={activeDoc.totalPages}
|
totalPages={activeDoc.totalPages}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
rotation={rotation}
|
pagesInfo={activeDoc.pages}
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface SidebarProps {
|
|||||||
activeTab: 'documents' | 'annotations' | 'outline';
|
activeTab: 'documents' | 'annotations' | 'outline';
|
||||||
setActiveTab: (tab: 'documents' | 'annotations' | 'outline') => void;
|
setActiveTab: (tab: 'documents' | 'annotations' | 'outline') => void;
|
||||||
onNavigateToPage?: (pageIndex: number) => void;
|
onNavigateToPage?: (pageIndex: number) => void;
|
||||||
|
onDeletePage?: (pageIndex: number) => void;
|
||||||
|
onReorderPage?: (pageIndex: number, destPageIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
export const Sidebar: React.FC<SidebarProps> = ({
|
||||||
@@ -23,6 +25,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
onNavigateToPage,
|
onNavigateToPage,
|
||||||
|
onDeletePage,
|
||||||
|
onReorderPage,
|
||||||
}) => {
|
}) => {
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
@@ -112,6 +116,18 @@ export const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
documentId={selectedDocumentId}
|
documentId={selectedDocumentId}
|
||||||
pageIndex={idx}
|
pageIndex={idx}
|
||||||
onClick={() => onNavigateToPage?.(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>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ interface ThumbnailProps {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onMoveUp?: () => void;
|
||||||
|
onMoveDown?: () => void;
|
||||||
|
totalPages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Thumbnail: React.FC<ThumbnailProps> = ({
|
export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||||
documentId,
|
documentId,
|
||||||
pageIndex,
|
pageIndex,
|
||||||
onClick,
|
onClick,
|
||||||
|
onDelete,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
totalPages,
|
||||||
}) => {
|
}) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -48,18 +56,73 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="thumbnail-card" onClick={onClick}>
|
<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 ? (
|
{loading ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-900">
|
<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" />
|
<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>
|
<span className="thumbnail-label">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
) : imageUrl ? (
|
) : imageUrl ? (
|
||||||
<img
|
<>
|
||||||
src={imageUrl}
|
<img
|
||||||
alt={`Page ${pageIndex + 1}`}
|
src={imageUrl}
|
||||||
className="w-full h-full object-cover"
|
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">
|
<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}>
|
<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.
|
* Provides endpoints for document CRUD, rendering, metadata retrieval, and edits.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface PageInfo {
|
||||||
|
index: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocumentInfo {
|
export interface DocumentInfo {
|
||||||
id: string;
|
id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
@@ -12,6 +18,7 @@ export interface DocumentInfo {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
status: 'processing' | 'ready' | 'error';
|
status: 'processing' | 'ready' | 'error';
|
||||||
|
pages?: PageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderParams {
|
export interface RenderParams {
|
||||||
@@ -96,6 +103,12 @@ export interface PageRotationData {
|
|||||||
rotation: 0 | 90 | 180 | 270;
|
rotation: 0 | 90 | 180 | 270;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageDeletionData {}
|
||||||
|
|
||||||
|
export interface PageReorderData {
|
||||||
|
destPageIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type EditOperationDataMap = {
|
export type EditOperationDataMap = {
|
||||||
text_overlay: TextOverlayData;
|
text_overlay: TextOverlayData;
|
||||||
redaction: RedactionData;
|
redaction: RedactionData;
|
||||||
@@ -105,6 +118,8 @@ export type EditOperationDataMap = {
|
|||||||
comment: StickyNoteData;
|
comment: StickyNoteData;
|
||||||
freehand: FreehandData;
|
freehand: FreehandData;
|
||||||
page_rotation: PageRotationData;
|
page_rotation: PageRotationData;
|
||||||
|
page_deletion: PageDeletionData;
|
||||||
|
page_reorder: PageReorderData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditOperationType = keyof EditOperationDataMap;
|
export type EditOperationType = keyof EditOperationDataMap;
|
||||||
@@ -173,6 +188,7 @@ class GatewayService {
|
|||||||
totalPages: 5, // Mocked total pages
|
totalPages: 5, // Mocked total pages
|
||||||
uploadedAt: new Date().toISOString(),
|
uploadedAt: new Date().toISOString(),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
pages: Array.from({ length: 5 }, (_, i) => ({ index: i, width: 612, height: 792 })),
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
@@ -257,6 +273,7 @@ class GatewayService {
|
|||||||
totalPages: 12,
|
totalPages: 12,
|
||||||
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
|
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
pages: Array.from({ length: 12 }, (_, i) => ({ index: i, width: 612, height: 792 })),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sample-doc-2',
|
id: 'sample-doc-2',
|
||||||
@@ -265,6 +282,7 @@ class GatewayService {
|
|||||||
totalPages: 54,
|
totalPages: 54,
|
||||||
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
|
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
pages: Array.from({ length: 54 }, (_, i) => ({ index: i, width: 612, height: 792 })),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sample-doc-3',
|
id: 'sample-doc-3',
|
||||||
@@ -273,6 +291,7 @@ class GatewayService {
|
|||||||
totalPages: 4,
|
totalPages: 4,
|
||||||
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
||||||
status: 'ready',
|
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 { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||||
import type { Rect } from '../lib/coordinateMapping';
|
import type { Rect } from '../lib/coordinateMapping';
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
|
import type { PageInfo } from '../lib/gatewayService';
|
||||||
|
|
||||||
interface PDFViewerProps {
|
interface PDFViewerProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
rotation: number;
|
pagesInfo?: PageInfo[];
|
||||||
activeTool: string;
|
activeTool: string;
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
@@ -37,7 +38,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
documentId,
|
documentId,
|
||||||
totalPages,
|
totalPages,
|
||||||
zoom,
|
zoom,
|
||||||
rotation,
|
pagesInfo,
|
||||||
activeTool,
|
activeTool,
|
||||||
annotations,
|
annotations,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -49,6 +50,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
||||||
const [containerHeight, setContainerHeight] = useState(800);
|
const [containerHeight, setContainerHeight] = useState(800);
|
||||||
|
|
||||||
|
// Reset cached page renders when switching documents
|
||||||
|
useEffect(() => {
|
||||||
|
setRenderedPages([]);
|
||||||
|
}, [documentId]);
|
||||||
|
|
||||||
// Standard Page Dimensions: Letter size is 612x792 pt
|
// Standard Page Dimensions: Letter size is 612x792 pt
|
||||||
const basePageWidth = 612;
|
const basePageWidth = 612;
|
||||||
const basePageHeight = 792;
|
const basePageHeight = 792;
|
||||||
@@ -60,10 +66,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
let currentTop = 0;
|
let currentTop = 0;
|
||||||
|
|
||||||
for (let i = 0; i < totalPages; i++) {
|
for (let i = 0; i < totalPages; i++) {
|
||||||
// Swapped width/height if rotated 90 or 270 degrees
|
const pageInfo = pagesInfo?.[i];
|
||||||
const isSwapped = (rotation / 90) % 2 !== 0;
|
const w = pageInfo ? pageInfo.width : basePageWidth;
|
||||||
const w = isSwapped ? basePageHeight : basePageWidth;
|
const h = pageInfo ? pageInfo.height : basePageHeight;
|
||||||
const h = isSwapped ? basePageWidth : basePageHeight;
|
|
||||||
|
|
||||||
layouts.push({
|
layouts.push({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -75,7 +80,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
currentTop += (h * zoom) + pageGap;
|
currentTop += (h * zoom) + pageGap;
|
||||||
}
|
}
|
||||||
return layouts;
|
return layouts;
|
||||||
}, [totalPages, zoom, rotation]);
|
}, [totalPages, zoom, pagesInfo]);
|
||||||
|
|
||||||
const totalContentHeight = useMemo(() => {
|
const totalContentHeight = useMemo(() => {
|
||||||
if (pageLayouts.length === 0) return 0;
|
if (pageLayouts.length === 0) return 0;
|
||||||
@@ -164,7 +169,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
documentId,
|
documentId,
|
||||||
pageIndex: page.index,
|
pageIndex: page.index,
|
||||||
zoom,
|
zoom,
|
||||||
rotation,
|
rotation: 0, // already rotated physically on backend
|
||||||
});
|
});
|
||||||
return { index: page.index, url };
|
return { index: page.index, url };
|
||||||
})
|
})
|
||||||
@@ -185,7 +190,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [visiblePages, documentId, zoom, rotation, renderedPages]);
|
}, [visiblePages, documentId, zoom, renderedPages]);
|
||||||
|
|
||||||
const handleTextSelection = (text: string, bbox: Rect) => {
|
const handleTextSelection = (text: string, bbox: Rect) => {
|
||||||
if (activeTool === 'highlight') {
|
if (activeTool === 'highlight') {
|
||||||
@@ -269,7 +274,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
rotation={rotation}
|
rotation={0} // already rotated physically on backend
|
||||||
width={page.width}
|
width={page.width}
|
||||||
height={page.height}
|
height={page.height}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app import __version__
|
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:
|
def create_app() -> FastAPI:
|
||||||
@@ -36,10 +36,10 @@ def create_app() -> FastAPI:
|
|||||||
"name": "PDF Engine Gateway",
|
"name": "PDF Engine Gateway",
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
"docs": "/docs"
|
"docs": "/docs",
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from typing import List
|
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||||
from fastapi import APIRouter, HTTPException, status, File, UploadFile
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
@@ -7,6 +6,13 @@ from app.services.store import document_store
|
|||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
|
class PageInfoResponse(BaseModel):
|
||||||
|
index: int
|
||||||
|
width: float
|
||||||
|
height: float
|
||||||
|
|
||||||
|
|
||||||
class DocumentInfoResponse(BaseModel):
|
class DocumentInfoResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
filename: str
|
filename: str
|
||||||
@@ -14,95 +20,102 @@ class DocumentInfoResponse(BaseModel):
|
|||||||
totalPages: int
|
totalPages: int
|
||||||
uploadedAt: str
|
uploadedAt: str
|
||||||
status: str
|
status: str
|
||||||
|
pages: list[PageInfoResponse] = []
|
||||||
|
|
||||||
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
|
|
||||||
if not engine.is_available():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="Engine bridge (bindings/python) not yet available."
|
|
||||||
)
|
|
||||||
|
|
||||||
bytes_data = await file.read()
|
|
||||||
try:
|
|
||||||
pdfengine = engine.require()
|
|
||||||
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
|
|
||||||
info = document_store.add_document(file.filename, bytes_data, doc)
|
|
||||||
return DocumentInfoResponse(
|
|
||||||
id=info["id"],
|
|
||||||
filename=info["filename"],
|
|
||||||
sizeBytes=info["sizeBytes"],
|
|
||||||
totalPages=info["totalPages"],
|
|
||||||
uploadedAt=info["uploadedAt"],
|
|
||||||
status=info["status"]
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
detail = str(e)
|
|
||||||
if "Password required" in detail:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required")
|
|
||||||
elif "Invalid password" in detail:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {str(e)}")
|
|
||||||
|
|
||||||
@router.get("", response_model=List[DocumentInfoResponse])
|
def make_document_response(d: dict) -> DocumentInfoResponse:
|
||||||
def list_documents() -> List[DocumentInfoResponse]:
|
pages_list = []
|
||||||
if not engine.is_available():
|
if "doc_instance" in d:
|
||||||
raise HTTPException(
|
doc = d["doc_instance"]
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
for i in range(doc.page_count):
|
||||||
detail="Engine bridge (bindings/python) not yet available."
|
try:
|
||||||
)
|
page = doc.get_page(i)
|
||||||
|
pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height))
|
||||||
docs = document_store.list_documents()
|
except Exception:
|
||||||
return [
|
pass
|
||||||
DocumentInfoResponse(
|
|
||||||
id=d["id"],
|
|
||||||
filename=d["filename"],
|
|
||||||
sizeBytes=d["sizeBytes"],
|
|
||||||
totalPages=d["totalPages"],
|
|
||||||
uploadedAt=d["uploadedAt"],
|
|
||||||
status=d["status"]
|
|
||||||
)
|
|
||||||
for d in docs
|
|
||||||
]
|
|
||||||
|
|
||||||
@router.get("/{document_id}", response_model=DocumentInfoResponse)
|
|
||||||
def get_document(document_id: str) -> DocumentInfoResponse:
|
|
||||||
if not engine.is_available():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
||||||
detail="Engine bridge (bindings/python) not yet available."
|
|
||||||
)
|
|
||||||
|
|
||||||
d = document_store.get_document(document_id)
|
|
||||||
if not d:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
|
||||||
|
|
||||||
return DocumentInfoResponse(
|
return DocumentInfoResponse(
|
||||||
id=d["id"],
|
id=d["id"],
|
||||||
filename=d["filename"],
|
filename=d["filename"],
|
||||||
sizeBytes=d["sizeBytes"],
|
sizeBytes=d["sizeBytes"],
|
||||||
totalPages=d["totalPages"],
|
totalPages=d["totalPages"],
|
||||||
uploadedAt=d["uploadedAt"],
|
uploadedAt=d["uploadedAt"],
|
||||||
status=d["status"]
|
status=d["status"],
|
||||||
|
pages=pages_list,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_document(file: UploadFile = File(...), password: str = "") -> DocumentInfoResponse:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
bytes_data = await file.read()
|
||||||
|
try:
|
||||||
|
pdfengine = engine.require()
|
||||||
|
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
|
||||||
|
info = document_store.add_document(file.filename, bytes_data, doc)
|
||||||
|
return make_document_response(info)
|
||||||
|
except ValueError as e:
|
||||||
|
detail = str(e)
|
||||||
|
if "Password required" in detail:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Password required"
|
||||||
|
)
|
||||||
|
elif "Invalid password" in detail:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to load PDF: {e!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DocumentInfoResponse])
|
||||||
|
def list_documents() -> list[DocumentInfoResponse]:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
docs = document_store.list_documents()
|
||||||
|
return [make_document_response(d) for d in docs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{document_id}", response_model=DocumentInfoResponse)
|
||||||
|
def get_document(document_id: str) -> DocumentInfoResponse:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
d = document_store.get_document(document_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
return make_document_response(d)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{document_id}")
|
@router.delete("/{document_id}")
|
||||||
def delete_document(document_id: str):
|
def delete_document(document_id: str):
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
deleted = document_store.delete_document(document_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
class DocumentMetadataResponse(BaseModel):
|
class DocumentMetadataResponse(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
author: str
|
author: str
|
||||||
@@ -111,18 +124,19 @@ class DocumentMetadataResponse(BaseModel):
|
|||||||
creation_date: str
|
creation_date: str
|
||||||
modification_date: str
|
modification_date: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse)
|
@router.get("/{document_id}/metadata", response_model=DocumentMetadataResponse)
|
||||||
def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
|
def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
d = document_store.get_document(document_id)
|
||||||
if not d:
|
if not d:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = d["doc_instance"]
|
doc = d["doc_instance"]
|
||||||
meta = doc.metadata
|
meta = doc.metadata
|
||||||
@@ -132,11 +146,12 @@ def get_document_metadata(document_id: str) -> DocumentMetadataResponse:
|
|||||||
creator=meta.creator,
|
creator=meta.creator,
|
||||||
producer=meta.producer,
|
producer=meta.producer,
|
||||||
creation_date=meta.creation_date,
|
creation_date=meta.creation_date,
|
||||||
modification_date=meta.modification_date
|
modification_date=meta.modification_date,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
class FontInfoResponse(BaseModel):
|
class FontInfoResponse(BaseModel):
|
||||||
fontName: str
|
fontName: str
|
||||||
type: str
|
type: str
|
||||||
@@ -158,18 +173,21 @@ class FontInfoResponse(BaseModel):
|
|||||||
descent: float
|
descent: float
|
||||||
capHeight: float
|
capHeight: float
|
||||||
|
|
||||||
@router.get("/{document_id}/fonts", response_model=List[FontInfoResponse])
|
|
||||||
def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1) -> List[FontInfoResponse]:
|
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
|
||||||
|
def get_document_fonts(
|
||||||
|
document_id: str, start_page: int = 0, end_page: int = -1
|
||||||
|
) -> list[FontInfoResponse]:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
fonts = doc.get_fonts(start_page, end_page)
|
fonts = doc.get_fonts(start_page, end_page)
|
||||||
@@ -193,7 +211,7 @@ def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1
|
|||||||
flags=f.flags,
|
flags=f.flags,
|
||||||
ascent=f.ascent,
|
ascent=f.ascent,
|
||||||
descent=f.descent,
|
descent=f.descent,
|
||||||
capHeight=f.cap_height
|
capHeight=f.cap_height,
|
||||||
)
|
)
|
||||||
for f in fonts
|
for f in fonts
|
||||||
]
|
]
|
||||||
@@ -202,4 +220,4 @@ def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1
|
|||||||
except IndexError as e:
|
except IndexError 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))
|
||||||
except Exception as e:
|
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))
|
||||||
|
|||||||
+128
-35
@@ -1,17 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
from typing import List, Any
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents/{document_id}/edits", tags=["edits"])
|
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"])
|
compat_router = APIRouter(tags=["edits"])
|
||||||
|
|
||||||
|
|
||||||
class TextOverlayData(BaseModel):
|
class TextOverlayData(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
x: float
|
x: float
|
||||||
@@ -22,6 +21,7 @@ class TextOverlayData(BaseModel):
|
|||||||
fontFamily: str
|
fontFamily: str
|
||||||
color: str
|
color: str
|
||||||
|
|
||||||
|
|
||||||
class RedactionData(BaseModel):
|
class RedactionData(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
@@ -29,6 +29,7 @@ class RedactionData(BaseModel):
|
|||||||
height: float
|
height: float
|
||||||
fillColor: str = "#000000"
|
fillColor: str = "#000000"
|
||||||
|
|
||||||
|
|
||||||
class ImageOverlayData(BaseModel):
|
class ImageOverlayData(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
@@ -36,6 +37,7 @@ class ImageOverlayData(BaseModel):
|
|||||||
height: float
|
height: float
|
||||||
imageData: str
|
imageData: str
|
||||||
|
|
||||||
|
|
||||||
class HighlightQuadPoint(BaseModel):
|
class HighlightQuadPoint(BaseModel):
|
||||||
x1: float
|
x1: float
|
||||||
y1: float
|
y1: float
|
||||||
@@ -46,12 +48,14 @@ class HighlightQuadPoint(BaseModel):
|
|||||||
x4: float
|
x4: float
|
||||||
y4: float
|
y4: float
|
||||||
|
|
||||||
|
|
||||||
class HighlightData(BaseModel):
|
class HighlightData(BaseModel):
|
||||||
quadPoints: List[HighlightQuadPoint]
|
quadPoints: list[HighlightQuadPoint]
|
||||||
color: str
|
color: str
|
||||||
opacity: float = 1.0
|
opacity: float = 1.0
|
||||||
author: str
|
author: str
|
||||||
content: Optional[str] = None
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FreeTextData(BaseModel):
|
class FreeTextData(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
@@ -62,127 +66,216 @@ class FreeTextData(BaseModel):
|
|||||||
fontSize: float = 12.0
|
fontSize: float = 12.0
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
|
|
||||||
|
|
||||||
class StickyNoteData(BaseModel):
|
class StickyNoteData(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
author: str
|
author: str
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class FreehandPoint(BaseModel):
|
class FreehandPoint(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
|
|
||||||
|
|
||||||
class FreehandData(BaseModel):
|
class FreehandData(BaseModel):
|
||||||
paths: List[List[FreehandPoint]]
|
paths: list[list[FreehandPoint]]
|
||||||
color: str
|
color: str
|
||||||
thickness: float
|
thickness: float
|
||||||
|
|
||||||
|
|
||||||
class PageRotationData(BaseModel):
|
class PageRotationData(BaseModel):
|
||||||
rotation: Literal[0, 90, 180, 270]
|
rotation: Literal[0, 90, 180, 270]
|
||||||
|
|
||||||
|
|
||||||
class TextOverlayOperation(BaseModel):
|
class TextOverlayOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["text_overlay"]
|
type: Literal["text_overlay"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: TextOverlayData
|
data: TextOverlayData
|
||||||
|
|
||||||
|
|
||||||
class RedactionOperation(BaseModel):
|
class RedactionOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["redaction"]
|
type: Literal["redaction"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: RedactionData
|
data: RedactionData
|
||||||
|
|
||||||
|
|
||||||
class ImageOverlayOperation(BaseModel):
|
class ImageOverlayOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["image_overlay"]
|
type: Literal["image_overlay"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: ImageOverlayData
|
data: ImageOverlayData
|
||||||
|
|
||||||
|
|
||||||
class HighlightOperation(BaseModel):
|
class HighlightOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["highlight"]
|
type: Literal["highlight"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: HighlightData
|
data: HighlightData
|
||||||
|
|
||||||
|
|
||||||
class FreeTextOperation(BaseModel):
|
class FreeTextOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["free_text"]
|
type: Literal["free_text"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: FreeTextData
|
data: FreeTextData
|
||||||
|
|
||||||
|
|
||||||
class CommentOperation(BaseModel):
|
class CommentOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["comment"]
|
type: Literal["comment"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: StickyNoteData
|
data: StickyNoteData
|
||||||
|
|
||||||
|
|
||||||
class FreehandOperation(BaseModel):
|
class FreehandOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["freehand"]
|
type: Literal["freehand"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: FreehandData
|
data: FreehandData
|
||||||
|
|
||||||
|
|
||||||
class PageRotationOperation(BaseModel):
|
class PageRotationOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["page_rotation"]
|
type: Literal["page_rotation"]
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
data: PageRotationData
|
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[
|
EditOperation = Annotated[
|
||||||
Union[
|
TextOverlayOperation
|
||||||
TextOverlayOperation,
|
| RedactionOperation
|
||||||
RedactionOperation,
|
| ImageOverlayOperation
|
||||||
ImageOverlayOperation,
|
| HighlightOperation
|
||||||
HighlightOperation,
|
| FreeTextOperation
|
||||||
FreeTextOperation,
|
| CommentOperation
|
||||||
CommentOperation,
|
| FreehandOperation
|
||||||
FreehandOperation,
|
| PageRotationOperation
|
||||||
PageRotationOperation
|
| PageDeletionOperation
|
||||||
],
|
| PageReorderOperation,
|
||||||
Field(discriminator="type")
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class EditsRequest(BaseModel):
|
class EditsRequest(BaseModel):
|
||||||
version: Literal["1.0"]
|
version: Literal["1.0"]
|
||||||
operations: List[EditOperation]
|
operations: list[EditOperation]
|
||||||
|
|
||||||
|
|
||||||
def apply_edits_impl(document_id: str, request: EditsRequest):
|
def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
|
created_temp_files = []
|
||||||
try:
|
try:
|
||||||
pdfengine = engine.require()
|
pdfengine = engine.require()
|
||||||
doc = doc_info["doc_instance"]
|
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)
|
doc.apply_edits(edits_json)
|
||||||
|
|
||||||
new_bytes = doc.save_incremental()
|
new_bytes = doc.save_incremental()
|
||||||
|
|
||||||
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
|
||||||
|
|
||||||
new_info = document_store.add_document(
|
new_info = document_store.add_document(
|
||||||
filename=doc_info["filename"],
|
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc
|
||||||
bytes_data=new_bytes,
|
|
||||||
doc_instance=new_doc
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"success": True, "newDocumentId": new_info["id"]}
|
return {"success": True, "newDocumentId": new_info["id"]}
|
||||||
except Exception as e:
|
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("")
|
@router.post("")
|
||||||
def apply_edits(document_id: str, request: EditsRequest):
|
def apply_edits(document_id: str, request: EditsRequest):
|
||||||
return apply_edits_impl(document_id, request)
|
return apply_edits_impl(document_id, request)
|
||||||
|
|
||||||
|
|
||||||
@compat_router.post("/edits/{document_id}")
|
@compat_router.post("/edits/{document_id}")
|
||||||
def apply_edits_compat(document_id: str, request: EditsRequest):
|
def apply_edits_compat(document_id: str, request: EditsRequest):
|
||||||
return apply_edits_impl(document_id, request)
|
return apply_edits_impl(document_id, request)
|
||||||
|
|||||||
@@ -5,28 +5,30 @@ from app.services import engine
|
|||||||
|
|
||||||
router = APIRouter(prefix="/engine", tags=["info"])
|
router = APIRouter(prefix="/engine", tags=["info"])
|
||||||
|
|
||||||
|
|
||||||
class EngineInfoResponse(BaseModel):
|
class EngineInfoResponse(BaseModel):
|
||||||
version: str
|
version: str
|
||||||
build_info: str
|
build_info: str
|
||||||
has_pdfium: bool
|
has_pdfium: bool
|
||||||
has_skia: bool
|
has_skia: bool
|
||||||
|
|
||||||
|
|
||||||
@router.get("/info", response_model=EngineInfoResponse)
|
@router.get("/info", response_model=EngineInfoResponse)
|
||||||
def get_engine_info() -> EngineInfoResponse:
|
def get_engine_info() -> EngineInfoResponse:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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()
|
pdfengine = engine.require()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return EngineInfoResponse(
|
return EngineInfoResponse(
|
||||||
version=pdfengine.engine_version(),
|
version=pdfengine.engine_version(),
|
||||||
build_info=pdfengine.engine_build_info(),
|
build_info=pdfengine.engine_build_info(),
|
||||||
has_pdfium=pdfengine.engine_has_pdfium(),
|
has_pdfium=pdfengine.engine_has_pdfium(),
|
||||||
has_skia=pdfengine.engine_has_skia()
|
has_skia=pdfengine.engine_has_skia(),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|||||||
@@ -1,47 +1,50 @@
|
|||||||
from typing import List
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
from fastapi import APIRouter, HTTPException, status, Response
|
|
||||||
|
|
||||||
|
from app.routers.documents import FontInfoResponse
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
from app.routers.documents import FontInfoResponse
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||||
compat_router = APIRouter(tags=["render"])
|
compat_router = APIRouter(tags=["render"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}/render")
|
@router.get("/{page_index}/render")
|
||||||
def render_page(document_id: str, page_index: int, dpi: int = 96) -> Response:
|
def render_page(document_id: str, page_index: int, dpi: int = 96) -> Response:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
img = page.render(dpi)
|
img = page.render(dpi)
|
||||||
return Response(content=img.data, media_type="image/png")
|
return Response(content=img.data, media_type="image/png")
|
||||||
except IndexError:
|
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:
|
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))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}/text")
|
@router.get("/{page_index}/text")
|
||||||
def extract_page_text(document_id: str, page_index: int):
|
def extract_page_text(document_id: str, page_index: int):
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
@@ -52,87 +55,119 @@ def extract_page_text(document_id: str, page_index: int):
|
|||||||
glyphs = page.extract_text_with_bounds()
|
glyphs = page.extract_text_with_bounds()
|
||||||
return {"text": text, "glyphs": glyphs}
|
return {"text": text, "glyphs": glyphs}
|
||||||
except IndexError:
|
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:
|
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))
|
||||||
|
|
||||||
|
|
||||||
@compat_router.get("/render/{document_id}")
|
@compat_router.get("/render/{document_id}")
|
||||||
def render_page_compat(document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0) -> Response:
|
def render_page_compat(
|
||||||
|
document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
|
||||||
|
) -> Response:
|
||||||
dpi = int(96 * zoom)
|
dpi = int(96 * zoom)
|
||||||
return render_page(document_id, page, dpi)
|
return render_page(document_id, page, dpi)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}")
|
@router.get("/{page_index}")
|
||||||
def get_page_info(document_id: str, page_index: int):
|
def get_page_info(document_id: str, page_index: int):
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
return {"width": page.width, "height": page.height}
|
return {"width": page.width, "height": page.height}
|
||||||
except IndexError:
|
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:
|
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))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}/transform/page-to-device")
|
@router.get("/{page_index}/transform/page-to-device")
|
||||||
def transform_page_to_device(document_id: str, page_index: int, x: float, y: float, device_width: int, device_height: int, rotate: int = 0):
|
def transform_page_to_device(
|
||||||
|
document_id: str,
|
||||||
|
page_index: int,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
device_width: int,
|
||||||
|
device_height: int,
|
||||||
|
rotate: int = 0,
|
||||||
|
):
|
||||||
if not engine.is_available():
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdfengine = engine.require()
|
pdfengine = engine.require()
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
|
|
||||||
pt = pdfengine.Point2D(x=x, y=y)
|
pt = pdfengine.Point2D(x=x, y=y)
|
||||||
res = page.page_to_device(pt, device_width, device_height, rotate)
|
res = page.page_to_device(pt, device_width, device_height, rotate)
|
||||||
return {"x": res.x, "y": res.y}
|
return {"x": res.x, "y": res.y}
|
||||||
except Exception as e:
|
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))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}/transform/device-to-page")
|
@router.get("/{page_index}/transform/device-to-page")
|
||||||
def transform_device_to_page(document_id: str, page_index: int, x: int, y: int, device_width: int, device_height: int, rotate: int = 0):
|
def transform_device_to_page(
|
||||||
|
document_id: str,
|
||||||
|
page_index: int,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
device_width: int,
|
||||||
|
device_height: int,
|
||||||
|
rotate: int = 0,
|
||||||
|
):
|
||||||
if not engine.is_available():
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdfengine = engine.require()
|
pdfengine = engine.require()
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
|
|
||||||
pt = pdfengine.DevicePoint(x=x, y=y)
|
pt = pdfengine.DevicePoint(x=x, y=y)
|
||||||
res = page.device_to_page(pt, device_width, device_height, rotate)
|
res = page.device_to_page(pt, device_width, device_height, rotate)
|
||||||
return {"x": res.x, "y": res.y}
|
return {"x": res.x, "y": res.y}
|
||||||
except Exception as e:
|
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))
|
||||||
@router.get("/{page_index}/fonts", response_model=List[FontInfoResponse])
|
|
||||||
def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
|
|
||||||
|
@router.get("/{page_index}/fonts", response_model=list[FontInfoResponse])
|
||||||
|
def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
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)
|
doc_info = document_store.get_document(document_id)
|
||||||
if not doc_info:
|
if not doc_info:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
@@ -157,7 +192,7 @@ def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
|
|||||||
flags=f.flags,
|
flags=f.flags,
|
||||||
ascent=f.ascent,
|
ascent=f.ascent,
|
||||||
descent=f.descent,
|
descent=f.descent,
|
||||||
capHeight=f.cap_height
|
capHeight=f.cap_height,
|
||||||
)
|
)
|
||||||
for f in fonts
|
for f in fonts
|
||||||
]
|
]
|
||||||
@@ -165,4 +200,3 @@ def get_page_fonts(document_id: str, page_index: int) -> List[FontInfoResponse]:
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
|
||||||
except Exception as e:
|
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))
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class DocumentStore:
|
class DocumentStore:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._lock = threading.Lock()
|
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())
|
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 = {
|
info = {
|
||||||
"id": doc_id,
|
"id": doc_id,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
@@ -20,19 +21,19 @@ class DocumentStore:
|
|||||||
"uploadedAt": uploaded_at,
|
"uploadedAt": uploaded_at,
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"doc_instance": doc_instance,
|
"doc_instance": doc_instance,
|
||||||
"bytes_data": bytes_data
|
"bytes_data": bytes_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._documents[doc_id] = info
|
self._documents[doc_id] = info
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
def get_document(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
def get_document(self, doc_id: str) -> dict[str, Any] | None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return self._documents.get(doc_id)
|
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:
|
with self._lock:
|
||||||
return list(self._documents.values())
|
return list(self._documents.values())
|
||||||
|
|
||||||
@@ -43,4 +44,5 @@ class DocumentStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
document_store = DocumentStore()
|
document_store = DocumentStore()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"pydantic==2.10.4",
|
"pydantic==2.10.4",
|
||||||
"pydantic-settings==2.7.1",
|
"pydantic-settings==2.7.1",
|
||||||
"python-multipart==0.0.19",
|
"python-multipart==0.0.19",
|
||||||
|
"pillow==10.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -48,6 +49,8 @@ select = [
|
|||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
"E501", # line length handled by formatter
|
"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]
|
[tool.ruff.format]
|
||||||
|
|||||||
+122
-77
@@ -1,8 +1,14 @@
|
|||||||
"""Live API validation script for the PDF engine gateway."""
|
"""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):
|
def http_get(url):
|
||||||
try:
|
try:
|
||||||
@@ -11,21 +17,23 @@ def http_get(url):
|
|||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
return json.loads(e.read()), e.code
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
|
||||||
def multipart_upload(url, filepath, filename):
|
def multipart_upload(url, filepath, filename):
|
||||||
boundary = 'Xboundary1234X'
|
boundary = "Xboundary1234X"
|
||||||
with open(filepath, 'rb') as f:
|
with open(filepath, "rb") as f:
|
||||||
file_data = f.read()
|
file_data = f.read()
|
||||||
header = (
|
header = (
|
||||||
'--' + boundary + '\r\n'
|
"--" + boundary + "\r\n"
|
||||||
'Content-Disposition: form-data; name="file"; filename="' + filename + '"\r\n'
|
'Content-Disposition: form-data; name="file"; filename="' + filename + '"\r\n'
|
||||||
'Content-Type: application/pdf\r\n\r\n'
|
"Content-Type: application/pdf\r\n\r\n"
|
||||||
).encode('utf-8')
|
).encode("utf-8")
|
||||||
footer = ('\r\n--' + boundary + '--\r\n').encode('utf-8')
|
footer = ("\r\n--" + boundary + "--\r\n").encode("utf-8")
|
||||||
body = header + file_data + footer
|
body = header + file_data + footer
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=body,
|
url,
|
||||||
headers={'Content-Type': 'multipart/form-data; boundary=' + boundary},
|
data=body,
|
||||||
method='POST'
|
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
|
||||||
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
resp = urllib.request.urlopen(req)
|
resp = urllib.request.urlopen(req)
|
||||||
@@ -33,20 +41,23 @@ def multipart_upload(url, filepath, filename):
|
|||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
return json.loads(e.read()), e.code
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
# 1. Health check
|
# 1. Health check
|
||||||
health, status = http_get(f'{BASE}/health')
|
health, status = http_get(f"{BASE}/health")
|
||||||
print(f"[HEALTH] status={status} response={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
|
# 2. Upload hello_world.pdf
|
||||||
print("\n=== UPLOADING 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"Upload status={status}")
|
||||||
print(f"Response: {doc}")
|
print(f"Response: {doc}")
|
||||||
doc_id = doc.get('id')
|
doc_id = doc.get("id")
|
||||||
results['upload'] = {'status': status, 'passed': status == 201, 'doc_id': doc_id}
|
results["upload"] = {"status": status, "passed": status == 201, "doc_id": doc_id}
|
||||||
|
|
||||||
if not doc_id:
|
if not doc_id:
|
||||||
print("FATAL: No doc_id, cannot continue")
|
print("FATAL: No doc_id, cannot continue")
|
||||||
@@ -54,15 +65,30 @@ if not doc_id:
|
|||||||
|
|
||||||
# 3. GET /documents/{id}/fonts
|
# 3. GET /documents/{id}/fonts
|
||||||
print(f"\n=== GET /documents/{doc_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)}")
|
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 = [
|
ALL_FONT_FIELDS = [
|
||||||
'fontName','type','isEmbedded','isSubset','isVertical',
|
"fontName",
|
||||||
'encoding','hasToUnicode','cmapName','cidSystemInfo','subsetTag',
|
"type",
|
||||||
'sourceType','substitutedFrom','substitutedTo','normalizedFamily',
|
"isEmbedded",
|
||||||
'internalFontId','flags','ascent','descent','capHeight'
|
"isSubset",
|
||||||
|
"isVertical",
|
||||||
|
"encoding",
|
||||||
|
"hasToUnicode",
|
||||||
|
"cmapName",
|
||||||
|
"cidSystemInfo",
|
||||||
|
"subsetTag",
|
||||||
|
"sourceType",
|
||||||
|
"substitutedFrom",
|
||||||
|
"substitutedTo",
|
||||||
|
"normalizedFamily",
|
||||||
|
"internalFontId",
|
||||||
|
"flags",
|
||||||
|
"ascent",
|
||||||
|
"descent",
|
||||||
|
"capHeight",
|
||||||
]
|
]
|
||||||
|
|
||||||
schema_errors = []
|
schema_errors = []
|
||||||
@@ -71,137 +97,156 @@ for f in fonts:
|
|||||||
if missing:
|
if missing:
|
||||||
schema_errors.append(f"Missing fields: {missing} in font {f.get('fontName')}")
|
schema_errors.append(f"Missing fields: {missing} in font {f.get('fontName')}")
|
||||||
# Type checks
|
# Type checks
|
||||||
for boolField in ['isEmbedded','isSubset','isVertical','hasToUnicode']:
|
for boolField in ["isEmbedded", "isSubset", "isVertical", "hasToUnicode"]:
|
||||||
if not isinstance(f.get(boolField), bool):
|
if not isinstance(f.get(boolField), bool):
|
||||||
schema_errors.append(f"Field {boolField} should be bool, got {type(f.get(boolField))}")
|
schema_errors.append(f"Field {boolField} should be bool, got {type(f.get(boolField))}")
|
||||||
for floatField in ['ascent','descent','capHeight']:
|
for floatField in ["ascent", "descent", "capHeight"]:
|
||||||
if not isinstance(f.get(floatField), (int, float)):
|
if not isinstance(f.get(floatField), int | float):
|
||||||
schema_errors.append(f"Field {floatField} should be 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")
|
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(
|
||||||
print(f" encoding={f.get('encoding')} cmapName={f.get('cmapName')} cidSystemInfo={f.get('cidSystemInfo')}")
|
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" subsetTag={f.get('subsetTag')} sourceType={f.get('sourceType')} normalizedFamily={f.get('normalizedFamily')}")
|
)
|
||||||
|
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" internalFontId={f.get('internalFontId')} flags={f.get('flags')}")
|
||||||
print(f" ascent={f.get('ascent')} descent={f.get('descent')} capHeight={f.get('capHeight')}")
|
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'}")
|
print(f"Schema errors: {schema_errors or 'None'}")
|
||||||
|
|
||||||
# 4. GET /documents/{id}/pages/0/fonts
|
# 4. GET /documents/{id}/pages/0/fonts
|
||||||
print(f"\n=== GET /documents/{doc_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)}")
|
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
|
# Consistency check: doc-level vs page-level
|
||||||
if len(fonts) != len(pg_fonts):
|
if len(fonts) != len(pg_fonts):
|
||||||
print(f"WARNING: doc-level fonts ({len(fonts)}) != page-level 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:
|
else:
|
||||||
print("Consistency: doc-level and page-level font counts match OK")
|
print("Consistency: doc-level and page-level font counts match OK")
|
||||||
|
|
||||||
# 5. GET /documents/{id}/pages/0/text
|
# 5. GET /documents/{id}/pages/0/text
|
||||||
print(f"\n=== GET /documents/{doc_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}")
|
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', '')
|
text = text_data.get("text", "")
|
||||||
glyphs = text_data.get('glyphs', [])
|
glyphs = text_data.get("glyphs", [])
|
||||||
print(f"Text: {repr(text[:80])}")
|
print(f"Text: {text[:80]!r}")
|
||||||
print(f"Glyph count: {len(glyphs)}")
|
print(f"Glyph count: {len(glyphs)}")
|
||||||
|
|
||||||
glyph_errors = []
|
glyph_errors = []
|
||||||
for i, g in enumerate(glyphs):
|
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:
|
if field not in g:
|
||||||
glyph_errors.append(f"Glyph {i} missing field {field}")
|
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')}")
|
glyph_errors.append(f"Glyph {i} '{g.get('text')}' has fontSize <= 0: {g.get('fontSize')}")
|
||||||
if g.get('text','').strip():
|
if g.get("text", "").strip() and (g.get("w", 0) <= 0 or g.get("h", 0) <= 0):
|
||||||
if g.get('w', 0) <= 0 or g.get('h', 0) <= 0:
|
glyph_errors.append(
|
||||||
glyph_errors.append(f"Glyph {i} '{g.get('text')}' has zero bounds w={g.get('w')} h={g.get('h')}")
|
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("fontSize", 0) > 200:
|
||||||
glyph_errors.append(f"Glyph {i} unrealistic fontSize={g.get('fontSize')}")
|
glyph_errors.append(f"Glyph {i} unrealistic fontSize={g.get('fontSize')}")
|
||||||
|
|
||||||
results['page_text']['glyph_count'] = len(glyphs)
|
results["page_text"]["glyph_count"] = len(glyphs)
|
||||||
results['page_text']['glyph_errors'] = glyph_errors
|
results["page_text"]["glyph_errors"] = glyph_errors
|
||||||
print(f"Glyph validation errors: {glyph_errors or 'None'}")
|
print(f"Glyph validation errors: {glyph_errors or 'None'}")
|
||||||
if glyphs:
|
if glyphs:
|
||||||
print(f"Sample glyphs: {glyphs[:3]}")
|
print(f"Sample glyphs: {glyphs[:3]}")
|
||||||
|
|
||||||
# 6. Test 404 for non-existent document
|
# 6. Test 404 for non-existent document
|
||||||
print("\n=== TEST 404 ===")
|
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)")
|
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
|
# 7. Test page out of bounds
|
||||||
print("\n=== 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)")
|
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
|
# 8. Upload vertical_text.pdf and check vertical detection
|
||||||
print("\n=== UPLOADING vertical_text.pdf ===")
|
print("\n=== UPLOADING vertical_text.pdf ===")
|
||||||
vert_doc, status = multipart_upload(f'{BASE}/documents', 'corpus/fonts/vertical_text.pdf', 'vertical_text.pdf')
|
vert_doc, status = multipart_upload(
|
||||||
vert_id = vert_doc.get('id')
|
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}")
|
print(f"Upload status={status} doc_id={vert_id}")
|
||||||
if 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)}):")
|
print(f"Vertical PDF fonts ({len(vert_fonts)}):")
|
||||||
for vf in 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
|
# 9. Upload latin_extended.pdf and validate metrics
|
||||||
print("\n=== UPLOADING latin_extended.pdf ===")
|
print("\n=== UPLOADING latin_extended.pdf ===")
|
||||||
lat_doc, status = multipart_upload(f'{BASE}/documents', 'corpus/fonts/latin_extended.pdf', 'latin_extended.pdf')
|
lat_doc, status = multipart_upload(
|
||||||
lat_id = lat_doc.get('id')
|
f"{BASE}/documents", "corpus/fonts/latin_extended.pdf", "latin_extended.pdf"
|
||||||
|
)
|
||||||
|
lat_id = lat_doc.get("id")
|
||||||
if lat_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)}):")
|
print(f"Latin extended fonts ({len(lat_fonts)}):")
|
||||||
for lf in lat_fonts:
|
for lf in lat_fonts:
|
||||||
ascent_ok = lf['ascent'] > 0
|
ascent_ok = lf["ascent"] > 0
|
||||||
descent_ok = lf['descent'] < 0
|
descent_ok = lf["descent"] < 0
|
||||||
cap_ok = lf['capHeight'] > 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'})")
|
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)
|
# 10. Repeated requests (cache consistency)
|
||||||
print("\n=== CACHE CONSISTENCY (3 repeated font requests) ===")
|
print("\n=== CACHE CONSISTENCY (3 repeated font requests) ===")
|
||||||
responses = []
|
responses = []
|
||||||
for _ in range(3):
|
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))
|
responses.append(len(fonts_rep))
|
||||||
print(f"Font counts on repeated requests: {responses}")
|
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
|
# 11. Invalid PDF upload
|
||||||
print("\n=== INVALID PDF UPLOAD ===")
|
print("\n=== INVALID PDF UPLOAD ===")
|
||||||
boundary = 'Xboundary1234X'
|
boundary = "Xboundary1234X"
|
||||||
garbage = b'NOT A PDF FILE AT ALL 12345'
|
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()
|
header = (
|
||||||
footer = ('\r\n--' + boundary + '--\r\n').encode()
|
"--"
|
||||||
|
+ 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(
|
req = urllib.request.Request(
|
||||||
f'{BASE}/documents',
|
f"{BASE}/documents",
|
||||||
data=header + garbage + footer,
|
data=header + garbage + footer,
|
||||||
headers={'Content-Type': 'multipart/form-data; boundary=' + boundary},
|
headers={"Content-Type": "multipart/form-data; boundary=" + boundary},
|
||||||
method='POST'
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
resp = urllib.request.urlopen(req)
|
resp = urllib.request.urlopen(req)
|
||||||
bad_result = json.loads(resp.read()), resp.status
|
bad_result = json.loads(resp.read()), resp.status
|
||||||
print(f"UNEXPECTED SUCCESS: {bad_result}")
|
print(f"UNEXPECTED SUCCESS: {bad_result}")
|
||||||
results['invalid_upload'] = {'passed': False}
|
results["invalid_upload"] = {"passed": False}
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
err_detail = json.loads(e.read())
|
err_detail = json.loads(e.read())
|
||||||
print(f"Invalid PDF correctly rejected: status={e.code} detail={err_detail}")
|
print(f"Invalid PDF correctly rejected: status={e.code} detail={err_detail}")
|
||||||
results['invalid_upload'] = {'status': e.code, 'passed': e.code == 400}
|
results["invalid_upload"] = {"status": e.code, "passed": e.code == 400}
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("VALIDATION SUMMARY")
|
print("VALIDATION SUMMARY")
|
||||||
print("="*60)
|
print("=" * 60)
|
||||||
for test, res in results.items():
|
for test, res in results.items():
|
||||||
passed = res.get('passed', '?')
|
passed = res.get("passed", "?")
|
||||||
status = res.get('status', '-')
|
status = res.get("status", "-")
|
||||||
print(f" {'PASS' if passed else 'FAIL'}: {test:35s} status={status}")
|
print(f" {'PASS' if passed else 'FAIL'}: {test:35s} status={status}")
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
import pytest
|
import contextlib
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
# Add both the root directory and the gateway directory to PYTHONPATH
|
# 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('..'))
|
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(os.path.join(os.path.dirname(__file__), "../..")))
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(ImportError):
|
||||||
from app.main import app
|
from app.main import app
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def get_doc_id(filename: str) -> str:
|
def get_doc_id(filename: str) -> str:
|
||||||
# First ensure the document is loaded
|
# First ensure the document is loaded
|
||||||
filepath = os.path.abspath(f"../corpus/fonts/{filename}")
|
filepath = os.path.abspath(f"../corpus/fonts/{filename}")
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
resp = client.post(
|
resp = client.post("/documents", files={"file": (filename, f, "application/pdf")})
|
||||||
"/documents",
|
|
||||||
files={"file": (filename, f, "application/pdf")}
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201, f"Failed to load {filename}: {resp.json()}"
|
assert resp.status_code == 201, f"Failed to load {filename}: {resp.json()}"
|
||||||
return resp.json()["id"]
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_is_vertical_regression():
|
def test_is_vertical_regression():
|
||||||
# 1. Verify Identity-V fonts are detected correctly
|
# 1. Verify Identity-V fonts are detected correctly
|
||||||
doc_id = get_doc_id("vertical_text.pdf")
|
doc_id = get_doc_id("vertical_text.pdf")
|
||||||
@@ -36,7 +34,7 @@ def test_is_vertical_regression():
|
|||||||
font = fonts[0]
|
font = fonts[0]
|
||||||
assert font["isVertical"] is True
|
assert font["isVertical"] is True
|
||||||
assert font["encoding"] == "Identity-V"
|
assert font["encoding"] == "Identity-V"
|
||||||
|
|
||||||
# 2. Verify horizontal fonts are not falsely detected as vertical
|
# 2. Verify horizontal fonts are not falsely detected as vertical
|
||||||
doc_id_h = get_doc_id("utf-8.pdf")
|
doc_id_h = get_doc_id("utf-8.pdf")
|
||||||
resp_h = client.get(f"/documents/{doc_id_h}/fonts")
|
resp_h = client.get(f"/documents/{doc_id_h}/fonts")
|
||||||
@@ -45,6 +43,7 @@ def test_is_vertical_regression():
|
|||||||
for f in fonts_h:
|
for f in fonts_h:
|
||||||
assert f["isVertical"] is False
|
assert f["isVertical"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_internal_font_id_regression():
|
def test_internal_font_id_regression():
|
||||||
# Verify subset fonts do not duplicate subset prefixes
|
# Verify subset fonts do not duplicate subset prefixes
|
||||||
doc_id = get_doc_id("text_font.pdf")
|
doc_id = get_doc_id("text_font.pdf")
|
||||||
@@ -55,7 +54,10 @@ def test_internal_font_id_regression():
|
|||||||
for font in fonts:
|
for font in fonts:
|
||||||
if font.get("isSubset"):
|
if font.get("isSubset"):
|
||||||
assert font["subsetTag"] in font["fontName"]
|
assert font["subsetTag"] in font["fontName"]
|
||||||
assert not font["internalFontId"].startswith(font["subsetTag"] + "_" + font["subsetTag"])
|
assert not font["internalFontId"].startswith(
|
||||||
|
font["subsetTag"] + "_" + font["subsetTag"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_cid_collection_regression():
|
def test_cid_collection_regression():
|
||||||
# Verify Adobe collections
|
# Verify Adobe collections
|
||||||
@@ -67,6 +69,7 @@ def test_cid_collection_regression():
|
|||||||
if font.get("cidSystemInfo") and font.get("cidSystemInfo") != "None":
|
if font.get("cidSystemInfo") and font.get("cidSystemInfo") != "None":
|
||||||
assert "Adobe-" in font["cidSystemInfo"]
|
assert "Adobe-" in font["cidSystemInfo"]
|
||||||
|
|
||||||
|
|
||||||
def test_utf8_corpus_regression():
|
def test_utf8_corpus_regression():
|
||||||
doc_id = get_doc_id("utf-8.pdf")
|
doc_id = get_doc_id("utf-8.pdf")
|
||||||
resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||||
@@ -75,9 +78,10 @@ def test_utf8_corpus_regression():
|
|||||||
assert len(data["glyphs"]) > 0
|
assert len(data["glyphs"]) > 0
|
||||||
assert len(data["text"]) > 0
|
assert len(data["text"]) > 0
|
||||||
|
|
||||||
|
|
||||||
def test_font_size_regression():
|
def test_font_size_regression():
|
||||||
doc_id = get_doc_id("utf-8.pdf")
|
doc_id = get_doc_id("utf-8.pdf")
|
||||||
resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
resp = client.get(f"/documents/{doc_id}/pages/0/text")
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
sizes = set(g["fontSize"] for g in data["glyphs"])
|
sizes = set(g["fontSize"] for g in data["glyphs"])
|
||||||
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
|
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
|
||||||
|
|||||||
@@ -28,4 +28,4 @@ def test_health_does_not_require_engine(client: TestClient) -> None:
|
|||||||
assert "pdfengine" not in sys.modules
|
assert "pdfengine" not in sys.modules
|
||||||
finally:
|
finally:
|
||||||
if had_pdfengine and pdfengine_module is not None:
|
if had_pdfengine and pdfengine_module is not None:
|
||||||
sys.modules["pdfengine"] = pdfengine_module
|
sys.modules["pdfengine"] = pdfengine_module
|
||||||
|
|||||||
+223
-45
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import contextlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
@@ -8,34 +9,33 @@ from app.services.store import document_store
|
|||||||
|
|
||||||
has_pdfium = False
|
has_pdfium = False
|
||||||
if engine.is_available():
|
if engine.is_available():
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
has_pdfium = engine.require().engine_has_pdfium()
|
has_pdfium = engine.require().engine_has_pdfium()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(
|
pytestmark = pytest.mark.skipif(
|
||||||
not engine.is_available() or not has_pdfium,
|
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"
|
CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus"
|
||||||
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
|
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clean_store():
|
def clean_store():
|
||||||
with document_store._lock:
|
with document_store._lock:
|
||||||
document_store._documents.clear()
|
document_store._documents.clear()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_success(client: TestClient):
|
def test_upload_document_success(client: TestClient):
|
||||||
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
|
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
|
||||||
|
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert "id" in payload
|
assert "id" in payload
|
||||||
@@ -44,20 +44,20 @@ def test_upload_document_success(client: TestClient):
|
|||||||
assert payload["totalPages"] == 1
|
assert payload["totalPages"] == 1
|
||||||
assert payload["status"] == "ready"
|
assert payload["status"] == "ready"
|
||||||
|
|
||||||
|
|
||||||
def test_upload_document_invalid(client: TestClient):
|
def test_upload_document_invalid(client: TestClient):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": ("test.pdf", b"not-a-pdf-file-content", "application/pdf")}
|
||||||
files={"file": ("test.pdf", b"not-a-pdf-file-content", "application/pdf")}
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert "invalid pdf" in response.json()["detail"].lower()
|
assert "invalid pdf" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_list_and_get_document(client: TestClient):
|
def test_list_and_get_document(client: TestClient):
|
||||||
# Upload one
|
# Upload one
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
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")
|
fake_resp = client.get("/documents/non-existent-uuid")
|
||||||
assert fake_resp.status_code == 404
|
assert fake_resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_delete_document(client: TestClient):
|
def test_delete_document(client: TestClient):
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
doc_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
@@ -94,11 +94,11 @@ def test_delete_document(client: TestClient):
|
|||||||
get_resp = client.get(f"/documents/{doc_id}")
|
get_resp = client.get(f"/documents/{doc_id}")
|
||||||
assert get_resp.status_code == 404
|
assert get_resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_render_page_standard_and_compat(client: TestClient):
|
def test_render_page_standard_and_compat(client: TestClient):
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
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")
|
fail_resp = client.get(f"/documents/{doc_id}/pages/5/render")
|
||||||
assert fail_resp.status_code == 404
|
assert fail_resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_extract_page_text(client: TestClient):
|
def test_extract_page_text(client: TestClient):
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
doc_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
@@ -138,11 +138,11 @@ def test_extract_page_text(client: TestClient):
|
|||||||
for key in ["text", "x", "y", "w", "h", "fontSize"]:
|
for key in ["text", "x", "y", "w", "h", "fontSize"]:
|
||||||
assert key in first_glyph
|
assert key in first_glyph
|
||||||
|
|
||||||
|
|
||||||
def test_apply_edits_and_incremental_save(client: TestClient):
|
def test_apply_edits_and_incremental_save(client: TestClient):
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
doc_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
@@ -161,10 +161,10 @@ def test_apply_edits_and_incremental_save(client: TestClient):
|
|||||||
"height": 20.0,
|
"height": 20.0,
|
||||||
"fontSize": 14.0,
|
"fontSize": 14.0,
|
||||||
"fontFamily": "Helvetica",
|
"fontFamily": "Helvetica",
|
||||||
"color": "#000000"
|
"color": "#000000",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
|
||||||
@@ -182,11 +182,171 @@ def test_apply_edits_and_incremental_save(client: TestClient):
|
|||||||
assert text_resp.status_code == 200
|
assert text_resp.status_code == 200
|
||||||
assert "Edited Text Annotation" in text_resp.json()["text"]
|
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):
|
def test_get_document_and_page_fonts(client: TestClient):
|
||||||
with open(HELLO_WORLD_PDF, "rb") as f:
|
with open(HELLO_WORLD_PDF, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
||||||
files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
doc_id = upload_resp.json()["id"]
|
doc_id = upload_resp.json()["id"]
|
||||||
|
|
||||||
@@ -195,7 +355,7 @@ def test_get_document_and_page_fonts(client: TestClient):
|
|||||||
assert fonts_resp.status_code == 200
|
assert fonts_resp.status_code == 200
|
||||||
fonts = fonts_resp.json()
|
fonts = fonts_resp.json()
|
||||||
assert isinstance(fonts, list)
|
assert isinstance(fonts, list)
|
||||||
|
|
||||||
# 2. Page level fonts
|
# 2. Page level fonts
|
||||||
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
|
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
|
||||||
assert page_fonts_resp.status_code == 200
|
assert page_fonts_resp.status_code == 200
|
||||||
@@ -206,14 +366,30 @@ def test_get_document_and_page_fonts(client: TestClient):
|
|||||||
if len(fonts) > 0:
|
if len(fonts) > 0:
|
||||||
f = fonts[0]
|
f = fonts[0]
|
||||||
fields = [
|
fields = [
|
||||||
"fontName", "type", "isEmbedded", "isSubset", "isVertical",
|
"fontName",
|
||||||
"encoding", "hasToUnicode", "cmapName", "cidSystemInfo", "subsetTag",
|
"type",
|
||||||
"sourceType", "substitutedFrom", "substitutedTo", "normalizedFamily",
|
"isEmbedded",
|
||||||
"internalFontId", "flags", "ascent", "descent", "capHeight"
|
"isSubset",
|
||||||
|
"isVertical",
|
||||||
|
"encoding",
|
||||||
|
"hasToUnicode",
|
||||||
|
"cmapName",
|
||||||
|
"cidSystemInfo",
|
||||||
|
"subsetTag",
|
||||||
|
"sourceType",
|
||||||
|
"substitutedFrom",
|
||||||
|
"substitutedTo",
|
||||||
|
"normalizedFamily",
|
||||||
|
"internalFontId",
|
||||||
|
"flags",
|
||||||
|
"ascent",
|
||||||
|
"descent",
|
||||||
|
"capHeight",
|
||||||
]
|
]
|
||||||
for field in fields:
|
for field in fields:
|
||||||
assert field in f
|
assert field in f
|
||||||
|
|
||||||
|
|
||||||
def test_font_size_and_diagnostics_advanced(client: TestClient):
|
def test_font_size_and_diagnostics_advanced(client: TestClient):
|
||||||
utf8_pdf = CORPUS_DIR / "fonts" / "utf-8.pdf"
|
utf8_pdf = CORPUS_DIR / "fonts" / "utf-8.pdf"
|
||||||
vertical_pdf = CORPUS_DIR / "fonts" / "vertical_text.pdf"
|
vertical_pdf = CORPUS_DIR / "fonts" / "vertical_text.pdf"
|
||||||
@@ -223,8 +399,7 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
|
|||||||
# 1. Upload utf-8.pdf containing standard CJK/fonts with subsetting
|
# 1. Upload utf-8.pdf containing standard CJK/fonts with subsetting
|
||||||
with open(utf8_pdf, "rb") as f:
|
with open(utf8_pdf, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (utf8_pdf.name, f, "application/pdf")}
|
||||||
files={"file": (utf8_pdf.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
assert upload_resp.status_code == 201
|
assert upload_resp.status_code == 201
|
||||||
doc_id = upload_resp.json()["id"]
|
doc_id = upload_resp.json()["id"]
|
||||||
@@ -256,9 +431,9 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
|
|||||||
assert isinstance(font["normalizedFamily"], str)
|
assert isinstance(font["normalizedFamily"], str)
|
||||||
assert isinstance(font["internalFontId"], str)
|
assert isinstance(font["internalFontId"], str)
|
||||||
assert isinstance(font["flags"], int)
|
assert isinstance(font["flags"], int)
|
||||||
assert isinstance(font["ascent"], (int, float))
|
assert isinstance(font["ascent"], int | float)
|
||||||
assert isinstance(font["descent"], (int, float))
|
assert isinstance(font["descent"], int | float)
|
||||||
assert isinstance(font["capHeight"], (int, float))
|
assert isinstance(font["capHeight"], int | float)
|
||||||
|
|
||||||
# Check subset tagging format if font is a subset
|
# Check subset tagging format if font is a subset
|
||||||
if font["isSubset"]:
|
if font["isSubset"]:
|
||||||
@@ -295,11 +470,11 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
|
|||||||
for glyph in glyphs:
|
for glyph in glyphs:
|
||||||
assert isinstance(glyph["text"], str)
|
assert isinstance(glyph["text"], str)
|
||||||
assert len(glyph["text"]) > 0
|
assert len(glyph["text"]) > 0
|
||||||
assert isinstance(glyph["x"], (int, float))
|
assert isinstance(glyph["x"], int | float)
|
||||||
assert isinstance(glyph["y"], (int, float))
|
assert isinstance(glyph["y"], int | float)
|
||||||
assert isinstance(glyph["w"], (int, float))
|
assert isinstance(glyph["w"], int | float)
|
||||||
assert isinstance(glyph["h"], (int, float))
|
assert isinstance(glyph["h"], int | float)
|
||||||
assert isinstance(glyph["fontSize"], (int, float))
|
assert isinstance(glyph["fontSize"], int | float)
|
||||||
|
|
||||||
# Font sizes must be positive and realistic
|
# Font sizes must be positive and realistic
|
||||||
assert glyph["fontSize"] > 0
|
assert glyph["fontSize"] > 0
|
||||||
@@ -314,8 +489,7 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
|
|||||||
if vertical_pdf.exists():
|
if vertical_pdf.exists():
|
||||||
with open(vertical_pdf, "rb") as f:
|
with open(vertical_pdf, "rb") as f:
|
||||||
upload_resp = client.post(
|
upload_resp = client.post(
|
||||||
"/documents",
|
"/documents", files={"file": (vertical_pdf.name, f, "application/pdf")}
|
||||||
files={"file": (vertical_pdf.name, f, "application/pdf")}
|
|
||||||
)
|
)
|
||||||
assert upload_resp.status_code == 201
|
assert upload_resp.status_code == 201
|
||||||
vert_doc_id = upload_resp.json()["id"]
|
vert_doc_id = upload_resp.json()["id"]
|
||||||
@@ -328,6 +502,10 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
|
|||||||
for font in vert_fonts:
|
for font in vert_fonts:
|
||||||
if font["isVertical"]:
|
if font["isVertical"]:
|
||||||
has_vertical = True
|
has_vertical = True
|
||||||
assert "-V" in font["encoding"] or "-V" in font["cmapName"] or "Identity-V" in font["encoding"]
|
assert (
|
||||||
|
"-V" in font["encoding"]
|
||||||
|
or "-V" in font["cmapName"]
|
||||||
|
or "Identity-V" in font["encoding"]
|
||||||
|
)
|
||||||
|
|
||||||
assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"
|
assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"
|
||||||
|
|||||||
Reference in New Issue
Block a user