From 72ed04f2ed1aa364b736b6eb97ee98cac0e26d7c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Wed, 1 Jul 2026 17:28:00 +0530 Subject: [PATCH 01/17] fix the issue --- engine/tests/display_list_test.cpp | 129 +++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/engine/tests/display_list_test.cpp b/engine/tests/display_list_test.cpp index 8ed2834..611eb12 100644 --- a/engine/tests/display_list_test.cpp +++ b/engine/tests/display_list_test.cpp @@ -1,5 +1,8 @@ #include #include +#include +#include +#include "../src/parser/content_stream_parser.hpp" #include #include @@ -20,6 +23,38 @@ public: void visit(const DrawImageCommand&) override { calls.push_back("DrawImage"); } }; +class PathVerifyVisitor : public CommandVisitor { +public: + struct PathInfo { + std::string type; + size_t segmentCount = 0; + FillRule rule = FillRule::NonZero; + }; + std::vector paths; + std::vector calls; + + void visit(const SaveStateCommand&) override { calls.push_back("SaveState"); } + void visit(const RestoreStateCommand&) override { calls.push_back("RestoreState"); } + void visit(const SetTransformCommand& cmd) override { + calls.push_back("SetTransform(" + std::to_string(cmd.matrix.a) + "," + std::to_string(cmd.matrix.d) + ")"); + } + void visit(const FillRectCommand&) override {} + void visit(const DrawTextCommand&) override {} + void visit(const FillPathCommand& cmd) override { + calls.push_back("FillPath"); + paths.push_back({"Fill", cmd.path.segments().size(), cmd.rule}); + } + void visit(const StrokePathCommand& cmd) override { + calls.push_back("StrokePath"); + paths.push_back({"Stroke", cmd.path.segments().size(), FillRule::NonZero}); + } + void visit(const FillStrokePathCommand& cmd) override { + calls.push_back("FillStrokePath"); + paths.push_back({"FillStroke", cmd.path.segments().size(), cmd.rule}); + } + void visit(const DrawImageCommand&) override {} +}; + TEST(DisplayListTest, RecordAndReplay) { DisplayList list; @@ -53,3 +88,97 @@ TEST(DisplayListTest, Clear) { list.clear(); EXPECT_EQ(list.size(), 0); } + +TEST(DisplayListTest, PathObjectInterpreterStroke) { + Path path; + path.moveTo(10.0f, 20.0f); + path.lineTo(30.0f, 40.0f); + + PathObject pathObj; + pathObj.path = path; + pathObj.paintOp = PathPaintOp::Stroke; + pathObj.transform = Matrix(1.5f, 0.0f, 0.0f, 1.5f, 5.0f, 5.0f); + + DisplayList list; + PathObjectInterpreter::interpret(pathObj, list); + + PathVerifyVisitor visitor; + list.replay(visitor); + + ASSERT_EQ(visitor.calls.size(), 4); + EXPECT_EQ(visitor.calls[0], "SaveState"); + EXPECT_EQ(visitor.calls[1], "SetTransform(1.500000,1.500000)"); + EXPECT_EQ(visitor.calls[2], "StrokePath"); + EXPECT_EQ(visitor.calls[3], "RestoreState"); + + ASSERT_EQ(visitor.paths.size(), 1); + EXPECT_EQ(visitor.paths[0].type, "Stroke"); + EXPECT_EQ(visitor.paths[0].segmentCount, 2); +} + +TEST(DisplayListTest, PathObjectInterpreterFillEvenOdd) { + Path path; + path.addRect(0.0f, 0.0f, 10.0f, 10.0f); + + PathObject pathObj; + pathObj.path = path; + pathObj.paintOp = PathPaintOp::Fill; + pathObj.fillRule = FillRule::EvenOdd; + pathObj.transform = Matrix(1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f); + + DisplayList list; + PathObjectInterpreter::interpret(pathObj, list); + + PathVerifyVisitor visitor; + list.replay(visitor); + + ASSERT_EQ(visitor.calls.size(), 4); + EXPECT_EQ(visitor.calls[2], "FillPath"); + + ASSERT_EQ(visitor.paths.size(), 1); + EXPECT_EQ(visitor.paths[0].type, "Fill"); + EXPECT_EQ(visitor.paths[0].segmentCount, 5); // moveTo + 3 lineTo + close + EXPECT_EQ(visitor.paths[0].rule, FillRule::EvenOdd); +} + +TEST(DisplayListTest, ContentStreamParserPaths) { + DisplayList list; + ContentStreamParser parser; + + // Parse stroke path (m, l, S) + parser.parse("10 20 m 30 40 l S", list); + + // Parse fill path (re, f) + parser.parse("5 6 7 8 re f", list); + + // Parse fill and stroke (m, c, B) + parser.parse("1 2 m 3 4 5 6 7 8 c B", list); + + PathVerifyVisitor visitor; + list.replay(visitor); + + // S -> StrokePath + // f -> FillPath + // B -> FillPath, StrokePath + ASSERT_EQ(visitor.calls.size(), 4); + EXPECT_EQ(visitor.calls[0], "StrokePath"); + EXPECT_EQ(visitor.calls[1], "FillPath"); + EXPECT_EQ(visitor.calls[2], "FillPath"); + EXPECT_EQ(visitor.calls[3], "StrokePath"); + + ASSERT_EQ(visitor.paths.size(), 4); + + // 1st: 10 20 m 30 40 l S -> MoveTo, LineTo + EXPECT_EQ(visitor.paths[0].type, "Stroke"); + EXPECT_EQ(visitor.paths[0].segmentCount, 2); + + // 2nd: 5 6 7 8 re f -> MoveTo, 3xLineTo, Close + EXPECT_EQ(visitor.paths[1].type, "Fill"); + EXPECT_EQ(visitor.paths[1].segmentCount, 5); + + // 3rd & 4th: 1 2 m 3 4 5 6 7 8 c B -> MoveTo, CubicBezierTo + EXPECT_EQ(visitor.paths[2].type, "Fill"); + EXPECT_EQ(visitor.paths[2].segmentCount, 2); + EXPECT_EQ(visitor.paths[3].type, "Stroke"); + EXPECT_EQ(visitor.paths[3].segmentCount, 2); +} From 880e56b98b7f8c4756301e1271e7d1018a639ce3 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Tue, 7 Jul 2026 11:25:09 +0530 Subject: [PATCH 02/17] Tile-based rendering (partial page invalidation) --- A.pgm | Bin 1203 -> 0 bytes bindings/python/pdfengine_py.cpp | 18 ++++++++- engine/include/pdfengine/pdf_document.hpp | 17 ++++++++- engine/src/parser/pdfium_document.hpp | 4 +- engine/src/parser/pdfium_edit.cpp | 36 ++++++++++++++++-- engine/src/parser/pdfium_page.cpp | 43 ++++++++++++++++++++++ gateway/app/routers/edits.py | 4 +- gateway/app/routers/render.py | 38 +++++++++++++++++++ 8 files changed, 152 insertions(+), 8 deletions(-) delete mode 100644 A.pgm diff --git a/A.pgm b/A.pgm deleted file mode 100644 index dfbd6663308af7d6fbbe842dfb5a99e4cc1fa6d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1203 zcmZvcPbdUY9LJwc(j>BLTmK3KiKS!&+T9tO-7XUlOB< zBbSa)LB?E^+bg4yD&#Cf*P-xpeT`%dt{1axQ`ixW6m-C6ze2>F zK&e&mXWT>xdM$d93T%=42EJrC<7!iOpynYIWUXoMAn#OSi$l;W%gj-m9*K|gG~l#F zs;IHWW9V71beRp$;PjyF%=sSdNTD$}8C8}czp p_P*Q)ueOD|6QVzp?@}QasL}6^$s1qaobV-3Nap<4=%KX<-yaCmZmj?S diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 96fea69..c8ec1a4 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -359,6 +359,11 @@ PYBIND11_MODULE(pdfengine, m) { return py::make_tuple(img.width, img.height, py::bytes(reinterpret_cast(img.data.data()), img.data.size())); }, py::arg("dpi"), py::arg("y_top_pt"), py::arg("height_pt") = 0.0) + .def("render_tile", [](const pdfengine::PdfPage& self, int dpi, double xPt, double yPt, double wPt, double hPt) { + auto img = get_or_throw(self.renderTile(dpi, xPt, yPt, wPt, hPt)); + return py::make_tuple(img.width, img.height, + py::bytes(reinterpret_cast(img.data.data()), img.data.size())); + }, py::arg("dpi"), py::arg("xPt"), py::arg("yPt"), py::arg("wPt"), py::arg("hPt")) .def("extract_document_model", [](const pdfengine::PdfPage& self) { return get_or_throw(self.extractDocumentModel()); }) @@ -483,7 +488,18 @@ PYBIND11_MODULE(pdfengine, m) { return py::bytes(reinterpret_cast(res->data()), res->size()); }, py::arg("internal_font_id")) .def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) { - get_or_throw(self.applyEdits(editsJson)); + auto regions = get_or_throw(self.applyEdits(editsJson)); + py::list py_regions; + for (const auto& r : regions) { + py::dict d; + d["pageIndex"] = r.pageIndex; + d["x"] = r.x; + d["y"] = r.y; + d["width"] = r.width; + d["height"] = r.height; + py_regions.append(d); + } + return py_regions; }, py::arg("edits_json")) .def("save_incremental", [](const pdfengine::PdfDocument& self) { std::vector res = get_or_throw(self.saveIncremental()); diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 67eed1b..ec5a280 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -60,6 +60,14 @@ struct DevicePoint { int y; }; +struct InvalidatedRegion { + int pageIndex; + double x; + double y; + double width; + double height; +}; + struct GlyphBounds { std::string text; double x; @@ -172,6 +180,12 @@ public: return std::unexpected(EngineError::Unknown); } + [[nodiscard]] virtual std::expected + renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const { + (void)dpi; (void)xPt; (void)yPt; (void)wPt; (void)hPt; + return std::unexpected(EngineError::Unknown); + } + [[nodiscard]] virtual std::expected extractText() const = 0; [[nodiscard]] virtual std::expected, EngineError> extractTextWithBounds() const = 0; @@ -256,7 +270,8 @@ public: [[nodiscard]] virtual std::expected, std::string> getResolvedFont(const FontInfo& fontInfo) = 0; - virtual std::expected applyEdits(const std::string& editsJson) = 0; + virtual std::expected, EngineError> applyEdits(const std::string& editsJson) = 0; + [[nodiscard]] virtual std::string lastReflowLayout() const { return {}; } diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 598bae2..4ac05e1 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -48,7 +48,9 @@ public: std::expected render(int dpi = 96) const override; std::expected renderRegionRaw(int dpi, double yTopPt, double heightPt) const override; + std::expected renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const override; std::expected extractText() const override; + std::expected, EngineError> extractTextWithBounds() const override; std::expected extractDocumentModel() const override; std::expected, EngineError> getFonts() const override; @@ -100,7 +102,7 @@ public: void invalidateCaches(); - std::expected applyEdits(const std::string& editsJson) override; + std::expected, EngineError> applyEdits(const std::string& editsJson) override; std::expected, EngineError> saveIncremental() const override; std::expected, EngineError> saveFull() const override; std::expected, EngineError> saveFullForExport() const override; diff --git a/engine/src/parser/pdfium_edit.cpp b/engine/src/parser/pdfium_edit.cpp index 4b98ddf..14d8d57 100644 --- a/engine/src/parser/pdfium_edit.cpp +++ b/engine/src/parser/pdfium_edit.cpp @@ -8,7 +8,7 @@ namespace pdfengine::parser { -std::expected PdfiumDocument::applyEdits(const std::string& editsJson) { +std::expected, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) { #ifdef PDFENGINE_WITH_PDFIUM if (!doc_) { return std::unexpected(EngineError::Unknown); @@ -17,7 +17,9 @@ std::expected PdfiumDocument::applyEdits(const std::string& e lastReflowLayout_.clear(); std::vector editedPages; - auto markEdited = [&editedPages](int p) { + std::vector invalidatedRegions; + + auto markEdited = [&](int p) { if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end()) editedPages.push_back(p); }; @@ -87,6 +89,34 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (!r) { return std::unexpected(r.error()); } + + // Calculate invalidated region + bool regionFound = false; + if (op.contains("data") && op["data"].is_object()) { + const auto& data = op["data"]; + if (data.contains("x") && data.contains("y") && data.contains("width") && data.contains("height")) { + invalidatedRegions.push_back({ + pageIndex, + data.value("x", 0.0), + data.value("y", 0.0), + data.value("width", 0.0), + data.value("height", 0.0) + }); + regionFound = true; + } + } + if (!regionFound) { + auto pageOpt = getPage(pageIndex); + if (pageOpt) { + invalidatedRegions.push_back({ + pageIndex, + 0.0, + 0.0, + (*pageOpt)->width(), + (*pageOpt)->height() + }); + } + } } } catch (const nlohmann::json::parse_error& e) { spdlog::error("JSON parse error in applyEdits: {}", e.what()); @@ -98,7 +128,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e rebalanceEditedPages(editedPages); invalidateCaches(); - return {}; + return invalidatedRegions; #else (void)editsJson; return std::unexpected(EngineError::Unknown); diff --git a/engine/src/parser/pdfium_page.cpp b/engine/src/parser/pdfium_page.cpp index 36fd3cf..448a4fc 100644 --- a/engine/src/parser/pdfium_page.cpp +++ b/engine/src/parser/pdfium_page.cpp @@ -154,6 +154,49 @@ std::expected PdfiumPage::renderRegionRaw(int dpi, doubl #endif } +std::expected PdfiumPage::renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const { +#ifdef PDFENGINE_WITH_PDFIUM + if (!page_) return std::unexpected(EngineError::Unknown); + if (!limits::pageDimensionsOk(width(), height())) return std::unexpected(EngineError::RenderFailed); + if (!limits::objectCountOk(FPDFPage_CountObjects(page_))) return std::unexpected(EngineError::RenderFailed); + + const double scale = dpi / 72.0; + const int w = static_cast(wPt * scale); + const int h = static_cast(hPt * scale); + int start_x = -static_cast(xPt * scale); + int start_y = -static_cast(yPt * scale); + const int fullW = static_cast(width() * scale); + const int fullH = static_cast(height() * scale); + + if (w <= 0 || h <= 0) return std::unexpected(EngineError::RenderFailed); + if (!limits::rasterSizeOk(w, h)) return std::unexpected(EngineError::RenderFailed); + + FPDF_BITMAP bitmap = FPDFBitmap_Create(w, h, 1); + if (!bitmap) return std::unexpected(EngineError::RenderFailed); + FPDFBitmap_FillRect(bitmap, 0, 0, w, h, 0xFFFFFFFF); + FPDF_RenderPageBitmap(bitmap, page_, start_x, start_y, fullW, fullH, 0, 0); + + const auto* bgra = static_cast(FPDFBitmap_GetBuffer(bitmap)); + const int stride = FPDFBitmap_GetStride(bitmap); + std::vector rgba(static_cast(w) * h * 4); + for (int y = 0; y < h; ++y) { + const uint8_t* src = bgra + static_cast(y) * stride; + uint8_t* dst = rgba.data() + static_cast(y) * w * 4; + for (int x = 0; x < w; ++x) { + dst[x * 4 + 0] = src[x * 4 + 2]; + dst[x * 4 + 1] = src[x * 4 + 1]; + dst[x * 4 + 2] = src[x * 4 + 0]; + dst[x * 4 + 3] = src[x * 4 + 3]; + } + } + FPDFBitmap_Destroy(bitmap); + return PageImage{w, h, std::move(rgba)}; +#else + (void)dpi; (void)xPt; (void)yPt; (void)wPt; (void)hPt; + return std::unexpected(EngineError::Unknown); +#endif +} + std::expected PdfiumPage::extractText() const { #ifdef PDFENGINE_WITH_PDFIUM if (!page_) { diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 12a44ea..ec93819 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -388,7 +388,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest): edits_json = json.dumps(req_dict) doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"]) - doc_copy.apply_edits(edits_json) + invalidated_regions = doc_copy.apply_edits(edits_json) full_save_types = {"redaction", "replace_text", "reflow_paragraph"} needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", [])) @@ -401,7 +401,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest): permissions=doc_info.get("permissions"), ) - return {"success": True, "newDocumentId": new_info["id"]} + return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions} except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e finally: diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index fe56e46..415e3c4 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -38,6 +38,44 @@ def render_page( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +@router.get("/{page_index}/render-tile") +def render_page_tile( + document_id: str, + page_index: Annotated[int, Path(ge=0)], + x: float, + y: float, + width: float, + height: float, + dpi: int = 96 +) -> Response: + if not engine.is_available(): + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Engine bridge (bindings/python) not yet available.", + ) + + doc_info = document_store.get_document(document_id) + if not doc_info: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") + + try: + doc = doc_info["doc_instance"] + page = doc.get_page(page_index) + img_width, img_height, img_data = page.render_tile(dpi, x, y, width, height) + import io + from PIL import Image + img = Image.frombytes("RGBA", (img_width, img_height), img_data) + out_buf = io.BytesIO() + img.save(out_buf, format="PNG") + return Response(content=out_buf.getvalue(), media_type="image/png") + except IndexError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" + ) + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + @router.get("/{page_index}/text") def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]): if not engine.is_available(): From f667db1f75f3fc331b5404e6b61ab2e831adc2c9 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 9 Jul 2026 10:26:11 +0530 Subject: [PATCH 03/17] Render result cache (doc_hash + page + DPI) --- frontend/src/lib/gatewayService.ts | 21 ++- gateway/app/config.py | 11 ++ gateway/app/main.py | 3 +- gateway/app/routers/edits.py | 3 + gateway/app/routers/internal.py | 20 +++ gateway/app/routers/render.py | 106 ++++++++++++- gateway/app/services/render_cache.py | 222 +++++++++++++++++++++++++++ gateway/app/services/store.py | 2 + 8 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 gateway/app/routers/internal.py create mode 100644 gateway/app/services/render_cache.py diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 9d99027..eeaa4b0 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -420,7 +420,14 @@ class GatewayService { } } + private renderCache = new Map(); + async renderPage(params: RenderParams): Promise { + const cacheKey = `${params.documentId}_${params.pageIndex}_${params.zoom}_${params.rotation}`; + if (this.renderCache.has(cacheKey)) { + return this.renderCache.get(cacheKey)!; + } + try { const query = new URLSearchParams({ page: params.pageIndex.toString(), @@ -437,7 +444,19 @@ class GatewayService { if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`); const blob = await response.blob(); - return URL.createObjectURL(blob); + const objectUrl = URL.createObjectURL(blob); + + this.renderCache.set(cacheKey, objectUrl); + if (this.renderCache.size > 100) { + const firstKey = this.renderCache.keys().next().value; + if (firstKey) { + const oldUrl = this.renderCache.get(firstKey)!; + if (oldUrl.startsWith('blob:')) URL.revokeObjectURL(oldUrl); + this.renderCache.delete(firstKey); + } + } + + return objectUrl; } catch (err) { return this.generateMockPage(params.pageIndex); } diff --git a/gateway/app/config.py b/gateway/app/config.py index 6df22dc..1030d63 100644 --- a/gateway/app/config.py +++ b/gateway/app/config.py @@ -26,6 +26,17 @@ class Settings(BaseSettings): "Phase 0 default is False — routes that need the engine return 501." ), ) + + render_cache_enabled: bool = Field(default=True) + render_cache_max_entries: int = Field(default=256) + render_cache_max_bytes: int = Field( + default=536_870_912, + description="Max total bytes for the tile render cache. Set to 0 to disable byte cap." + ) + render_cache_max_entry_bytes: int = Field( + default=8_388_608, + description="Max bytes for a single cache entry. Oversized tiles are rendered but not cached." + ) @lru_cache(maxsize=1) diff --git a/gateway/app/main.py b/gateway/app/main.py index bfca32e..563b643 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app import __version__ -from app.routers import documents, edits, health, info, render +from app.routers import documents, edits, health, info, internal, render def create_app() -> FastAPI: @@ -29,6 +29,7 @@ def create_app() -> FastAPI: app.include_router(render.compat_router) app.include_router(edits.router) app.include_router(edits.compat_router) + app.include_router(internal.router) @app.get("/") def read_root(): diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index ec93819..7e4a716 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -400,6 +400,9 @@ def apply_edits_impl(document_id: str, request: EditsRequest): filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, permissions=doc_info.get("permissions"), ) + + from app.services.render_cache import tile_cache + tile_cache.invalidate_doc(doc_info.get("doc_hash", "")) return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions} except Exception as e: diff --git a/gateway/app/routers/internal.py b/gateway/app/routers/internal.py new file mode 100644 index 0000000..de21998 --- /dev/null +++ b/gateway/app/routers/internal.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter + +from app.services.render_cache import tile_cache + +router = APIRouter(prefix="/internal", tags=["internal"]) + +@router.get("/cache/stats") +def cache_stats(): + stats = tile_cache.stats() + return { + "hits": stats.hits, + "misses": stats.misses, + "evictions": stats.evictions, + "current_entries": stats.current_entries, + "current_bytes": stats.current_bytes, + "max_bytes": stats.max_bytes, + "hit_rate": stats.hit_rate, + "avg_lookup_ns": stats.avg_lookup_ns, + "avg_render_time_ns": stats.avg_render_time_ns, + } diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index 415e3c4..2fdb0cc 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -1,19 +1,30 @@ +import hashlib from typing import Annotated -from fastapi import APIRouter, HTTPException, Path, Query, Response, status +from fastapi import APIRouter, HTTPException, Path, Query, Request, Response, status from app.schemas.font import FontInfoResponse from app.services import engine from app.services.font import font_info_to_response +from app.services.render_cache import RENDERER_VERSION, RenderMode, TileCacheKey, tile_cache from app.services.store import document_store router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"]) compat_router = APIRouter(tags=["render"]) +def _etag(data: bytes) -> str: + return hashlib.sha256(data).hexdigest()[:16] + @router.get("/{page_index}/render") def render_page( - document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96 + request: Request, + document_id: str, + page_index: Annotated[int, Path(ge=0)], + dpi: int = 96, + zoom: float = 1.0, + rotation: int = 0, + render_mode: RenderMode = RenderMode.NORMAL ) -> Response: if not engine.is_available(): raise HTTPException( @@ -28,8 +39,46 @@ def render_page( try: doc = doc_info["doc_instance"] page = doc.get_page(page_index) + + key = TileCacheKey( + doc_hash=doc_info.get("doc_hash", ""), + renderer_version=RENDERER_VERSION, + page=page_index, + dpi=dpi, + zoom=zoom, + rotation=rotation, + render_mode=render_mode, + tile_x=0.0, + tile_y=0.0, + tile_w=round(page.width, 2), + tile_h=round(page.height, 2) + ) + + cached = tile_cache.get(key) + if cached: + etag_val = f'"{_etag(cached)}"' + if request.headers.get("if-none-match") == etag_val: + return Response(status_code=304) + return Response( + content=cached, + media_type="image/png", + headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"} + ) + + import time + start = time.perf_counter_ns() img = page.render(dpi) - return Response(content=img.data, media_type="image/png") + elapsed = time.perf_counter_ns() - start + + tile_cache.put(key, img.data) + tile_cache.record_render_time(elapsed) + + etag_val = f'"{_etag(img.data)}"' + return Response( + content=img.data, + media_type="image/png", + headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"} + ) except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" @@ -40,13 +89,17 @@ def render_page( @router.get("/{page_index}/render-tile") def render_page_tile( + request: Request, document_id: str, page_index: Annotated[int, Path(ge=0)], x: float, y: float, width: float, height: float, - dpi: int = 96 + dpi: int = 96, + zoom: float = 1.0, + rotation: int = 0, + render_mode: RenderMode = RenderMode.NORMAL ) -> Response: if not engine.is_available(): raise HTTPException( @@ -61,13 +114,52 @@ def render_page_tile( try: doc = doc_info["doc_instance"] page = doc.get_page(page_index) + + key = TileCacheKey( + doc_hash=doc_info.get("doc_hash", ""), + renderer_version=RENDERER_VERSION, + page=page_index, + dpi=dpi, + zoom=zoom, + rotation=rotation, + render_mode=render_mode, + tile_x=round(x, 2), + tile_y=round(y, 2), + tile_w=round(width, 2), + tile_h=round(height, 2) + ) + + cached = tile_cache.get(key) + if cached: + etag_val = f'"{_etag(cached)}"' + if request.headers.get("if-none-match") == etag_val: + return Response(status_code=304) + return Response( + content=cached, + media_type="image/png", + headers={"ETag": etag_val, "X-Cache": "HIT", "Cache-Control": "private, max-age=300"} + ) + + import time + start = time.perf_counter_ns() img_width, img_height, img_data = page.render_tile(dpi, x, y, width, height) import io from PIL import Image img = Image.frombytes("RGBA", (img_width, img_height), img_data) out_buf = io.BytesIO() img.save(out_buf, format="PNG") - return Response(content=out_buf.getvalue(), media_type="image/png") + png_bytes = out_buf.getvalue() + elapsed = time.perf_counter_ns() - start + + tile_cache.put(key, png_bytes) + tile_cache.record_render_time(elapsed) + + etag_val = f'"{_etag(png_bytes)}"' + return Response( + content=png_bytes, + media_type="image/png", + headers={"ETag": etag_val, "X-Cache": "MISS", "Cache-Control": "private, max-age=300"} + ) except IndexError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds" @@ -110,10 +202,10 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]): @compat_router.get("/render/{document_id}") def render_page_compat( - document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0 + request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0 ) -> Response: dpi = int(96 * zoom) - return render_page(document_id, page, dpi) + return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL) @router.get("/{page_index}") diff --git a/gateway/app/services/render_cache.py b/gateway/app/services/render_cache.py new file mode 100644 index 0000000..da3ba5d --- /dev/null +++ b/gateway/app/services/render_cache.py @@ -0,0 +1,222 @@ +import threading +import time +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from app.config import get_settings + +RENDERER_VERSION: int = 1 + + +class RenderMode(str, Enum): + # Current + NORMAL = "normal" + PRINT = "print" + GRAYSCALE = "grayscale" + + # Future (reserved) + ANNOTATION_ONLY = "annotation_only" + EDIT_PREVIEW = "edit_preview" + SELECTION_OVERLAY = "selection_overlay" + PROOF = "proof" + HIGH_QUALITY = "high_quality" + DRAFT = "draft" + + +@dataclass(frozen=True) +class TileCacheKey: + doc_hash: str + renderer_version: int + page: int + dpi: int + zoom: float + rotation: int + render_mode: str + tile_x: float + tile_y: float + tile_w: float + tile_h: float + + +@dataclass +class CacheStats: + hits: int = 0 + misses: int = 0 + evictions: int = 0 + current_entries: int = 0 + current_bytes: int = 0 + max_bytes: int = 0 + hit_rate: float = 0.0 + avg_lookup_ns: float = 0.0 + avg_render_time_ns: float = 0.0 + + +class _Node: + __slots__ = ["key", "value", "size", "prev", "next"] + + def __init__(self, key: TileCacheKey | None, value: bytes | None, size: int): + self.key = key + self.value = value + self.size = size + self.prev: _Node | None = None + self.next: _Node | None = None + + +class TileCache: + def __init__(self): + settings = get_settings() + self.enabled = settings.render_cache_enabled + self.max_entries = settings.render_cache_max_entries + self.max_bytes = settings.render_cache_max_bytes + self.max_entry_bytes = settings.render_cache_max_entry_bytes + + self._lock = threading.Lock() + self._cache: dict[TileCacheKey, _Node] = {} + self._head = _Node(None, None, 0) + self._tail = _Node(None, None, 0) + self._head.next = self._tail + self._tail.prev = self._head + + self._current_bytes = 0 + + # Stats + self._hits = 0 + self._misses = 0 + self._evictions = 0 + self._total_lookup_ns = 0 + self._total_render_time_ns = 0 + self._render_time_count = 0 + + def _remove(self, node: _Node): + p = node.prev + n = node.next + if p and n: + p.next = n + n.prev = p + + def _add_to_front(self, node: _Node): + first = self._head.next + if first: + self._head.next = node + node.prev = self._head + node.next = first + first.prev = node + + def _evict(self): + last = self._tail.prev + if last and last != self._head: + self._remove(last) + if last.key: + del self._cache[last.key] + self._current_bytes -= last.size + self._evictions += 1 + + def get(self, key: TileCacheKey) -> bytes | None: + if not self.enabled: + return None + + start_time = time.perf_counter_ns() + with self._lock: + node = self._cache.get(key) + if node: + self._hits += 1 + self._remove(node) + self._add_to_front(node) + res = node.value + else: + self._misses += 1 + res = None + + lookup_time = time.perf_counter_ns() - start_time + self._total_lookup_ns += lookup_time + + return res + + def put(self, key: TileCacheKey, data: bytes) -> None: + if not self.enabled: + return + + size = len(data) + if size > self.max_entry_bytes: + # Too large to cache + return + + with self._lock: + if key in self._cache: + node = self._cache[key] + self._current_bytes -= node.size + node.value = data + node.size = size + self._current_bytes += size + self._remove(node) + self._add_to_front(node) + else: + new_node = _Node(key, data, size) + self._cache[key] = new_node + self._add_to_front(new_node) + self._current_bytes += size + + # Evict if over limits + while len(self._cache) > self.max_entries or (self.max_bytes > 0 and self._current_bytes > self.max_bytes): + self._evict() + + def invalidate_doc(self, doc_hash: str) -> None: + with self._lock: + keys_to_remove = [k for k in self._cache.keys() if k.doc_hash == doc_hash] + for k in keys_to_remove: + node = self._cache[k] + self._remove(node) + self._current_bytes -= node.size + del self._cache[k] + + def invalidate_renderer_version(self, old_version: int) -> None: + with self._lock: + keys_to_remove = [k for k in self._cache.keys() if k.renderer_version == old_version] + for k in keys_to_remove: + node = self._cache[k] + self._remove(node) + self._current_bytes -= node.size + del self._cache[k] + + def clear(self) -> None: + with self._lock: + self._cache.clear() + self._head.next = self._tail + self._tail.prev = self._head + self._current_bytes = 0 + + def stats(self) -> CacheStats: + with self._lock: + hit_rate = 0.0 + total_reqs = self._hits + self._misses + if total_reqs > 0: + hit_rate = self._hits / total_reqs + + avg_lookup = 0.0 + if total_reqs > 0: + avg_lookup = self._total_lookup_ns / total_reqs + + avg_render = 0.0 + if self._render_time_count > 0: + avg_render = self._total_render_time_ns / self._render_time_count + + return CacheStats( + hits=self._hits, + misses=self._misses, + evictions=self._evictions, + current_entries=len(self._cache), + current_bytes=self._current_bytes, + max_bytes=self.max_bytes, + hit_rate=hit_rate, + avg_lookup_ns=avg_lookup, + avg_render_time_ns=avg_render, + ) + + def record_render_time(self, elapsed_ns: int): + with self._lock: + self._total_render_time_ns += elapsed_ns + self._render_time_count += 1 + + +tile_cache = TileCache() diff --git a/gateway/app/services/store.py b/gateway/app/services/store.py index 186f38b..f32e4ed 100644 --- a/gateway/app/services/store.py +++ b/gateway/app/services/store.py @@ -1,3 +1,4 @@ +import hashlib import threading import uuid from datetime import UTC, datetime @@ -57,6 +58,7 @@ class DocumentStore: info = { "id": doc_id, "filename": filename, + "doc_hash": hashlib.sha256(bytes_data).hexdigest(), "sizeBytes": len(bytes_data), "totalPages": doc_instance.page_count, "pageWidth": page_width, From 66f7cd345c1089f0aaede54e675f8916da604f3f Mon Sep 17 00:00:00 2001 From: saquib Date: Fri, 17 Jul 2026 15:13:26 +0530 Subject: [PATCH 04/17] docker --- .dockerignore | 30 ++++++++++++++++++++++++++++++ docker-compose.yml | 40 ++++++++++++++++++++++++++++++++++++++++ frontend/.dockerignore | 7 +++++++ frontend/Dockerfile | 20 ++++++++++++++++++++ gateway/.dockerignore | 13 +++++++++++++ gateway/Dockerfile | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+) create mode 100644 .dockerignore create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 gateway/.dockerignore create mode 100644 gateway/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e00d76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.git +**/.github +**/.venv +**/node_modules +**/dist +**/build +**/out +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd +**/*.log +**/.DS_Store +**/Thumbs.db +**/.vscode +**/.idea +**/coverage +**/tmp +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/CMakeUserPresets.json +**/compile_commands.json +**/vcpkg +**/third_party/pdfium/depot_tools +**/third_party/pdfium/checkout +**/third_party/pdfium/install +**/third_party/skia/depot_tools +**/third_party/skia/checkout +**/third_party/skia/install diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5358d22 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + gateway: + build: + context: ./gateway + dockerfile: Dockerfile + image: pdf-engine-gateway:dev + container_name: pdf-engine-gateway + environment: + PDFENGINE_ENVIRONMENT: dev + PDFENGINE_ENGINE_AVAILABLE: "false" + PORT: 8000 + ports: + - "8000:8000" + volumes: + - ./gateway:/home/app + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" ] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: development + image: pdf-engine-frontend:dev + container_name: pdf-engine-frontend + environment: + VITE_GATEWAY_URL: http://gateway:8000 + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - gateway + restart: unless-stopped diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..0fbfdc0 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +.git/ +.gitignore +*.log +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6df2f83 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS base +ENV NODE_ENV=development +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +FROM base AS development +COPY . . +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + +FROM base AS build +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine AS production +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/gateway/.dockerignore b/gateway/.dockerignore new file mode 100644 index 0000000..cd215ec --- /dev/null +++ b/gateway/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.venv/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +*.log +.DS_Store +.git/ +.gitignore diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 0000000..b577a8f --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PORT=8000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + pkg-config \ + git \ + libjpeg-dev \ + zlib1g-dev \ + libpng-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system app \ + && useradd --system --gid app --create-home --home-dir /home/app app + +WORKDIR /home/app + +COPY pyproject.toml README.md ./ +RUN python -m pip install --upgrade pip \ + && pip install --no-cache-dir -e ".[dev]" + +COPY app ./app +COPY tests ./tests + +RUN chown -R app:app /home/app +USER app + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] From 185cc03df7ba5b9a86aed07ed05143229b92785c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 23 Jul 2026 11:31:26 +0530 Subject: [PATCH 05/17] docker issue --- CMakeLists.txt | 4 +- docker-compose.yml | 9 +- engine/CMakeLists.txt | 37 ++++---- engine/include/pdfengine/display_list.hpp | 10 +-- engine/include/pdfengine/graphics_state.hpp | 4 +- engine/tests/CMakeLists.txt | 4 + gateway/Dockerfile | 93 +++++++++++++++++++-- third_party/pdfium/build_pdfium.sh | 20 +++-- 8 files changed, 138 insertions(+), 43 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d483f9..1b99383 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,7 +73,9 @@ find_package(freetype CONFIG REQUIRED) find_package(harfbuzz CONFIG REQUIRED) find_package(spdlog CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) -find_package(qpdf CONFIG REQUIRED) +if(PDFENGINE_WITH_QPDF) + find_package(qpdf CONFIG REQUIRED) +endif() if(WIN32 AND DEFINED VCPKG_TARGET_TRIPLET) link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/lib") link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/debug/lib") diff --git a/docker-compose.yml b/docker-compose.yml index 5358d22..b759a91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,14 @@ services: gateway: build: - context: ./gateway - dockerfile: Dockerfile + context: . + dockerfile: gateway/Dockerfile + network: host image: pdf-engine-gateway:dev container_name: pdf-engine-gateway environment: PDFENGINE_ENVIRONMENT: dev - PDFENGINE_ENGINE_AVAILABLE: "false" + PDFENGINE_ENGINE_AVAILABLE: "true" PORT: 8000 ports: - "8000:8000" @@ -29,7 +30,7 @@ services: image: pdf-engine-frontend:dev container_name: pdf-engine-frontend environment: - VITE_GATEWAY_URL: http://gateway:8000 + VITE_GATEWAY_URL: http://localhost:8000 ports: - "5173:5173" volumes: diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 3076e68..512b7de 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -5,32 +5,18 @@ configure_file( find_package(PNG REQUIRED) -add_library(pdfengine STATIC +add_library(pdfengine OBJECT src/core/engine_info.cpp src/core/graphics_state.cpp src/core/display_list.cpp src/core/path_interpreter.cpp src/core/skia_renderer.cpp - src/parser/pdfium_loader.cpp - src/parser/pdfium_document.cpp - src/parser/pdfium_internal.cpp - src/parser/pdfium_reflow.cpp - src/parser/pdfium_page.cpp - src/parser/pdfium_page_model.cpp - src/parser/pdfium_fonts.cpp - src/parser/pdfium_edit.cpp - src/parser/pdfium_edit_replace.cpp - src/parser/pdfium_edit_reflow.cpp - src/parser/pdfium_edit_annotations.cpp - src/parser/pdfium_edit_pages.cpp - src/parser/pdfium_edit_images.cpp src/parser/content_stream_parser.cpp src/parser/decoration_builder.cpp src/text/selection.cpp src/fonts/face/font_face.cpp src/fonts/face/free_type_manager.cpp src/fonts/loader/font_resolver.cpp - src/fonts/pdf_fonts/font_loader.cpp src/fonts/shaping/hb_shaper.cpp src/fonts/cache/glyph_bitmap.cpp src/fonts/cache/glyph_cache.cpp @@ -48,6 +34,7 @@ add_library(pdfengine STATIC src/fonts/pdf_fonts/encoding/cjk_collection_db.cpp ) add_library(pdfengine::pdfengine ALIAS pdfengine) +set_target_properties(pdfengine PROPERTIES POSITION_INDEPENDENT_CODE ON) target_include_directories(pdfengine PUBLIC @@ -69,8 +56,26 @@ target_link_libraries(pdfengine ) if(PDFENGINE_WITH_PDFIUM) + target_sources(pdfengine PRIVATE + src/parser/pdfium_loader.cpp + src/parser/pdfium_document.cpp + src/parser/pdfium_internal.cpp + src/parser/pdfium_reflow.cpp + src/parser/pdfium_page.cpp + src/parser/pdfium_page_model.cpp + src/parser/pdfium_fonts.cpp + src/parser/pdfium_edit.cpp + src/parser/pdfium_edit_replace.cpp + src/parser/pdfium_edit_reflow.cpp + src/parser/pdfium_edit_annotations.cpp + src/parser/pdfium_edit_pages.cpp + src/parser/pdfium_edit_images.cpp + ) target_link_libraries(pdfengine PRIVATE pdfium::pdfium) - target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM) + target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_PDFIUM) + # PDFium statically bundles its own libjpeg, zlib, etc. which conflicts with vcpkg. + # We use LLD, so we can safely allow multiple definitions to pick the first one. + target_link_options(pdfengine PUBLIC "-Wl,--allow-multiple-definition") endif() if(EMSCRIPTEN) diff --git a/engine/include/pdfengine/display_list.hpp b/engine/include/pdfengine/display_list.hpp index cd98c30..801bed2 100644 --- a/engine/include/pdfengine/display_list.hpp +++ b/engine/include/pdfengine/display_list.hpp @@ -32,34 +32,34 @@ struct SetTransformCommand : public Command { struct FillRectCommand : public Command { float x, y, width, height; - FillRectCommand(float x, float y, float w, float h) : x(x), y(y), width(w), height(h) {} + FillRectCommand(float _x, float _y, float w, float h) : x(_x), y(_y), width(w), height(h) {} void accept(CommandVisitor& visitor) const override; }; struct DrawTextCommand : public Command { std::string text; float x, y; - DrawTextCommand(std::string text, float x, float y) : text(std::move(text)), x(x), y(y) {} + DrawTextCommand(std::string _text, float _x, float _y) : text(std::move(_text)), x(_x), y(_y) {} void accept(CommandVisitor& visitor) const override; }; struct FillPathCommand : public Command { Path path; FillRule rule; - explicit FillPathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {} + explicit FillPathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {} void accept(CommandVisitor& visitor) const override; }; struct StrokePathCommand : public Command { Path path; - explicit StrokePathCommand(Path path) : path(std::move(path)) {} + explicit StrokePathCommand(Path _path) : path(std::move(_path)) {} void accept(CommandVisitor& visitor) const override; }; struct FillStrokePathCommand : public Command { Path path; FillRule rule; - explicit FillStrokePathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {} + explicit FillStrokePathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {} void accept(CommandVisitor& visitor) const override; }; diff --git a/engine/include/pdfengine/graphics_state.hpp b/engine/include/pdfengine/graphics_state.hpp index 36c59ed..9542bb2 100644 --- a/engine/include/pdfengine/graphics_state.hpp +++ b/engine/include/pdfengine/graphics_state.hpp @@ -11,8 +11,8 @@ struct Matrix { float e = 0.0f, f = 0.0f; Matrix() = default; - Matrix(float a, float b, float c, float d, float e, float f) - : a(a), b(b), c(c), d(d), e(e), f(f) {} + Matrix(float _a, float _b, float _c, float _d, float _e, float _f) + : a(_a), b(_b), c(_c), d(_d), e(_e), f(_f) {} [[nodiscard]] Matrix multiply(const Matrix& other) const noexcept; diff --git a/engine/tests/CMakeLists.txt b/engine/tests/CMakeLists.txt index 2970ed4..e1b053d 100644 --- a/engine/tests/CMakeLists.txt +++ b/engine/tests/CMakeLists.txt @@ -38,6 +38,10 @@ if(PDFENGINE_WITH_SKIA) target_link_libraries(pdfengine_smoke PRIVATE skia::skia) endif() +if(PDFENGINE_WITH_PDFIUM) + target_link_libraries(pdfengine_smoke PRIVATE pdfium::pdfium) +endif() + if(PDFENGINE_WITH_QPDF) target_link_libraries(pdfengine_smoke PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG) endif() diff --git a/gateway/Dockerfile b/gateway/Dockerfile index b577a8f..2b3e8f1 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -1,10 +1,9 @@ -FROM python:3.11-slim +# Stage 1: Builder +FROM python:3.11-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PORT=8000 + VCPKG_FORCE_SYSTEM_BINARIES=1 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -16,19 +15,99 @@ RUN apt-get update \ libjpeg-dev \ zlib1g-dev \ libpng-dev \ + curl \ + zip \ + unzip \ + tar \ + autoconf \ + autoconf-archive \ + automake \ + libtool \ + lld \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/bin/ld.lld /usr/bin/ld + +WORKDIR /build + +# Install vcpkg +RUN git clone https://github.com/microsoft/vcpkg.git /opt/vcpkg \ + && /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics +ENV VCPKG_ROOT=/opt/vcpkg + +# Cache vcpkg dependencies in a separate layer +COPY vcpkg.json ./ +RUN --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/opt/vcpkg/downloads \ + /opt/vcpkg/vcpkg install --triplet x64-linux + +# Install depot_tools globally with caching +RUN --mount=type=cache,target=/opt/depot_tools \ + if [ ! -d /opt/depot_tools/.git ]; then \ + git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git /opt/depot_tools; \ + fi +ENV PATH="/opt/depot_tools:${PATH}" + +# Build PDFium for Linux (heavily cached, keeping the huge source tree out of the image layer) +COPY third_party/pdfium/ ./third_party/pdfium/ +RUN --mount=type=cache,target=/build/third_party/pdfium/checkout \ + ./third_party/pdfium/build_pdfium.sh + +# Copy everything needed for the engine and bindings build +COPY CMakeLists.txt CMakePresets.json ./ +COPY cmake/ ./cmake/ +COPY engine/ ./engine/ +COPY bindings/ ./bindings/ +COPY gateway/ ./gateway/ +COPY corpus/ ./corpus/ + +# Configure CMake with tests enabled +RUN --mount=type=cache,target=/root/.cache \ + --mount=type=cache,target=/opt/vcpkg/downloads \ + cmake --preset linux-release \ + -DPDFENGINE_BUILD_TESTS=ON \ + -DPDFENGINE_WITH_PDFIUM=ON \ + -DPDFENGINE_WITH_SKIA=OFF \ + -DPDFENGINE_WITH_QPDF=ON \ + -DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake + +# Build all targets including tests and pdfengine_py +RUN cmake --build out/build/linux-release + +# Stage 2: Tester +FROM builder AS tester +RUN ctest --test-dir out/build/linux-release --output-on-failure + +# Stage 3: Runtime +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PORT=8000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libjpeg62-turbo \ + zlib1g \ + libpng16-16 \ && rm -rf /var/lib/apt/lists/* +# Install the extension globally so it's not shadowed by the volume mount ./gateway:/home/app +COPY --from=tester /build/gateway/pdfengine*.so /usr/local/lib/python3.11/site-packages/ + RUN groupadd --system app \ && useradd --system --gid app --create-home --home-dir /home/app app WORKDIR /home/app -COPY pyproject.toml README.md ./ +# Copy gateway Python code +COPY gateway/pyproject.toml gateway/README.md ./ RUN python -m pip install --upgrade pip \ && pip install --no-cache-dir -e ".[dev]" -COPY app ./app -COPY tests ./tests +COPY gateway/app ./app +COPY gateway/tests ./tests RUN chown -R app:app /home/app USER app diff --git a/third_party/pdfium/build_pdfium.sh b/third_party/pdfium/build_pdfium.sh index aebdbba..57f25e1 100644 --- a/third_party/pdfium/build_pdfium.sh +++ b/third_party/pdfium/build_pdfium.sh @@ -35,8 +35,8 @@ CHECKOUT_DIR="${BUILD_ROOT}/checkout" echo ">> Build root: ${BUILD_ROOT}" # --- 1. Read and validate the pinned revision ------------------------------- -PDFIUM_REPO="$(grep -E '^PDFIUM_REPO=' "${PINNED_FILE}" | cut -d= -f2-)" -PDFIUM_COMMIT="$(grep -E '^PDFIUM_COMMIT=' "${PINNED_FILE}" | cut -d= -f2-)" +PDFIUM_REPO="$(grep -E '^PDFIUM_REPO=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')" +PDFIUM_COMMIT="$(grep -E '^PDFIUM_COMMIT=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')" if [[ -z "${PDFIUM_COMMIT}" || "${PDFIUM_COMMIT}" == "REPLACE_WITH_PINNED_COMMIT_SHA" ]]; then echo "ERROR: PDFium revision is not pinned. Edit pdfium.pinned first (see README.md)." >&2 exit 1 @@ -45,12 +45,14 @@ echo ">> PDFium pinned at ${PDFIUM_COMMIT}" # --- 2. depot_tools --------------------------------------------------------- mkdir -p "${BUILD_ROOT}" -if [[ ! -d "${DEPOT_TOOLS_DIR}" ]]; then - echo ">> Cloning depot_tools" - git clone --depth 1 https://chromium.googlesource.com/chromium/tools/depot_tools.git \ - "${DEPOT_TOOLS_DIR}" +if ! command -v gclient &> /dev/null; then + if [[ ! -d "${DEPOT_TOOLS_DIR}/.git" ]]; then + echo ">> Cloning depot_tools" + git clone --depth 1 https://chromium.googlesource.com/chromium/tools/depot_tools.git \ + "${DEPOT_TOOLS_DIR}" + fi + export PATH="${DEPOT_TOOLS_DIR}:${PATH}" fi -export PATH="${DEPOT_TOOLS_DIR}:${PATH}" # Do NOT set DEPOT_TOOLS_UPDATE=0 — depot_tools is designed to self-manage, and # on first use it must bootstrap. Reproducibility comes from the pinned PDFium # revision below, not from freezing depot_tools. @@ -58,9 +60,11 @@ export PATH="${DEPOT_TOOLS_DIR}:${PATH}" # Git settings injected per-process via GIT_CONFIG_* so the user's global git # config is never touched. core.autocrlf=false avoids gclient seeing dependency # checkouts as "uncommitted changes" on platforms where autocrlf is enabled. -export GIT_CONFIG_COUNT=2 +export GIT_CONFIG_COUNT=4 export GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false export GIT_CONFIG_KEY_1=core.filemode GIT_CONFIG_VALUE_1=false +export GIT_CONFIG_KEY_2=http.postBuffer GIT_CONFIG_VALUE_2=1048576000 +export GIT_CONFIG_KEY_3=core.compression GIT_CONFIG_VALUE_3=0 # --- 3. Fetch / sync the PDFium tree ---------------------------------------- mkdir -p "${CHECKOUT_DIR}" From 626156642ef37e2297db7ccdbff874384113681b Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 23 Jul 2026 12:15:20 +0530 Subject: [PATCH 06/17] fix --- engine/tests/image_xobject_test.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/engine/tests/image_xobject_test.cpp b/engine/tests/image_xobject_test.cpp index 216bbfb..ff01886 100644 --- a/engine/tests/image_xobject_test.cpp +++ b/engine/tests/image_xobject_test.cpp @@ -436,6 +436,24 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) { QPDF qpdf; auto objects = buildFirstPageObjects(qpdf, pdf); + + // DEBUG: Parse again just to print + { + Lexer lexer(content); + auto tokens = lexer.tokenize(); + ContentParser parser(tokens); + auto operations = parser.parse(); + for (const auto& op : operations) { + std::cout << "DEBUG OP: " << op.op << " operands: "; + for (const auto& operand : op.operands) { + if (operand->type == AstNodeType::Number) std::cout << operand->numberValue << " "; + else if (operand->type == AstNodeType::Name) std::cout << "/" << operand->stringValue << " "; + else std::cout << "[type " << (int)operand->type << "] "; + } + std::cout << "\n"; + } + } + auto images = imageObjects(objects); ASSERT_EQ(images.size(), 2u); From ffc846cf17e778b2693ac485ffee17ab20b06873 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 23 Jul 2026 13:53:22 +0530 Subject: [PATCH 07/17] fix --- engine/tests/image_xobject_test.cpp | 19 ++----------------- gateway/Dockerfile | 2 ++ 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/engine/tests/image_xobject_test.cpp b/engine/tests/image_xobject_test.cpp index ff01886..bd9994f 100644 --- a/engine/tests/image_xobject_test.cpp +++ b/engine/tests/image_xobject_test.cpp @@ -262,7 +262,8 @@ std::string decodedStream(QPDFObjectHandle contents) { } std::vector> buildPageObjects(QPDFObjectHandle page) { - Lexer lexer(decodedStream(page.getKey("/Contents"))); + std::string decodedContent = decodedStream(page.getKey("/Contents")); + Lexer lexer(decodedContent); auto tokens = lexer.tokenize(); ContentParser parser(tokens); auto operations = parser.parse(); @@ -437,22 +438,6 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) { QPDF qpdf; auto objects = buildFirstPageObjects(qpdf, pdf); - // DEBUG: Parse again just to print - { - Lexer lexer(content); - auto tokens = lexer.tokenize(); - ContentParser parser(tokens); - auto operations = parser.parse(); - for (const auto& op : operations) { - std::cout << "DEBUG OP: " << op.op << " operands: "; - for (const auto& operand : op.operands) { - if (operand->type == AstNodeType::Number) std::cout << operand->numberValue << " "; - else if (operand->type == AstNodeType::Name) std::cout << "/" << operand->stringValue << " "; - else std::cout << "[type " << (int)operand->type << "] "; - } - std::cout << "\n"; - } - } auto images = imageObjects(objects); ASSERT_EQ(images.size(), 2u); diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 2b3e8f1..575caa4 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update \ ninja-build \ pkg-config \ git \ + fonts-liberation \ libjpeg-dev \ zlib1g-dev \ libpng-dev \ @@ -91,6 +92,7 @@ RUN apt-get update \ libjpeg62-turbo \ zlib1g \ libpng16-16 \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* # Install the extension globally so it's not shadowed by the volume mount ./gateway:/home/app From 671697d161c6cf4cd8c71c13099397fc1691418d Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Thu, 23 Jul 2026 17:02:45 +0530 Subject: [PATCH 08/17] feat: implement gateway service and add font extraction validation tests --- PDF Editor Timeline.xlsx.bak | Bin 46685 -> 0 bytes docker-compose.yml | 8 ++++---- frontend/.env | 1 + frontend/src/lib/gatewayService.ts | 2 +- gateway/Dockerfile | 6 +++--- gateway/README.md | 8 ++++---- gateway/tests/api_validate.py | 2 +- gateway/tests/font_api_validation.py | 2 +- gateway/tests/validation_output.txt | 2 +- scripts/start_gateway.ps1 | 2 +- 10 files changed, 17 insertions(+), 16 deletions(-) delete mode 100644 PDF Editor Timeline.xlsx.bak create mode 100644 frontend/.env diff --git a/PDF Editor Timeline.xlsx.bak b/PDF Editor Timeline.xlsx.bak deleted file mode 100644 index b473c78f9e170875cd8cee37e0cb0c0b2a6741c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46685 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 UIh_7VpFjY?b7az0|Mls=01 { diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 575caa4..be56642 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -85,7 +85,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PORT=8000 + PORT=8765 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -114,6 +114,6 @@ COPY gateway/tests ./tests RUN chown -R app:app /home/app USER app -EXPOSE 8000 +EXPOSE 8765 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8765"] diff --git a/gateway/README.md b/gateway/README.md index 5cc9358..1da891e 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -59,7 +59,7 @@ Run the service: Using the startup script (Windows): ```powershell -# Default port (8000) +# Default port (8765) powershell -File scripts/start_gateway.ps1 # Custom port (e.g. 8080) @@ -68,7 +68,7 @@ powershell -File scripts/start_gateway.ps1 -Port 8080 Or run directly via uvicorn (cross-platform): ```sh -# Default port (8000) +# Default port (8765) uvicorn app.main:app --reload # Custom port (e.g. 8080) @@ -100,8 +100,8 @@ Additionally, the gateway startup scripts and frontend configuration support: | Var | Default | Meaning | |--------------------|-------------------------|--------------------------------------------------| -| `PORT` | `8000` | Gateway listening port (used by `start_gateway.ps1`). | -| `VITE_GATEWAY_URL` | `http://127.0.0.1:8000` | URL of the gateway API (used by the frontend). | +| `PORT` | `8765` | Gateway listening port (used by `start_gateway.ps1`). | +| `VITE_GATEWAY_URL` | `http://127.0.0.1:8765` | URL of the gateway API (used by the frontend). | A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored via the repo-wide `.venv/` and Python rules — add `.env` to your local diff --git a/gateway/tests/api_validate.py b/gateway/tests/api_validate.py index a8fe917..38b29f2 100644 --- a/gateway/tests/api_validate.py +++ b/gateway/tests/api_validate.py @@ -8,7 +8,7 @@ import urllib.request sys.path.insert(0, "gateway") -BASE = os.environ.get("GATEWAY_URL", "http://localhost:8000") +BASE = os.environ.get("GATEWAY_URL", "http://localhost:8765") def http_get(url): diff --git a/gateway/tests/font_api_validation.py b/gateway/tests/font_api_validation.py index 269dd15..dd15d5c 100644 --- a/gateway/tests/font_api_validation.py +++ b/gateway/tests/font_api_validation.py @@ -12,7 +12,7 @@ import time import httpx -BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000") +BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8765") CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts")) TARGET_PDFS = [ diff --git a/gateway/tests/validation_output.txt b/gateway/tests/validation_output.txt index a4a8bb8..70cf14e 100644 --- a/gateway/tests/validation_output.txt +++ b/gateway/tests/validation_output.txt @@ -1,6 +1,6 @@ ================================================================================ FONT EXTRACTION API VALIDATION - Server: http://localhost:8000 + Server: http://localhost:8765 Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts Timestamp: 2026-06-02 12:36:47 ================================================================================ diff --git a/scripts/start_gateway.ps1 b/scripts/start_gateway.ps1 index d8c5a0d..f21d7b7 100644 --- a/scripts/start_gateway.ps1 +++ b/scripts/start_gateway.ps1 @@ -10,7 +10,7 @@ if ($Port -le 0) { if ($envPort) { $Port = $envPort } else { - $Port = 8000 + $Port = 8765 } } From 79f32024dafe91cd941e6f4ea4a15ae36dc8e917 Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Fri, 24 Jul 2026 11:50:10 +0530 Subject: [PATCH 09/17] feat: configure Vite with React, Tailwind CSS, and allowed development hosts --- frontend/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0616e59..a67019f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + allowedHosts: ['pdf-dev.maskantech.in'], + }, }) From 00c8839cdcda7b22441b94dc97652e94723070c7 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Mon, 27 Jul 2026 11:28:53 +0530 Subject: [PATCH 10/17] cursor issue --- frontend/src/viewer/ParagraphEditor.tsx | 3 ++- frontend/src/viewer/TextEditLayer.tsx | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index fb34082..4eba4d0 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -497,7 +497,8 @@ export const ParagraphEditor: React.FC = ({ if (!hasPreview) setHasPreview(true); if (!initialCaretApplied.current) { initialCaretApplied.current = true; - if (caretClick && lay) setGlobalCaretOffset(el, globalFromPoint(caretClick.x, caretClick.y, lay, el.textContent ?? '')); + const fullLen = (el.textContent ?? '').length; + setGlobalCaretOffset(el, fullLen); } positionCaret(); }; diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index 80f10f4..b267a96 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -379,7 +379,6 @@ export const TextEditLayer: React.FC = ({ const [fontFamily, setFontFamily] = useState('inherit'); const committedRef = useRef(false); const inputRef = useRef(null); - const caretIdxRef = useRef(null); const modelRef = useRef(null); const [paraEdit, setParaEdit] = useState<{ para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number; @@ -511,7 +510,6 @@ export const TextEditLayer: React.FC = ({ } committedRef.current = false; const fb = fallbackFamily(run.fontName); - caretIdxRef.current = caretIndexFromX(run.text, `${displayFontSize(run) * zoom}px ${fb}`, clickX); setEditing(i); setValue(run.text); setFontFamily(fb); @@ -702,11 +700,8 @@ export const TextEditLayer: React.FC = ({ value={value} onChange={(e) => setValue(e.target.value)} onFocus={(e) => { - const idx = caretIdxRef.current; - if (idx != null) { - e.currentTarget.setSelectionRange(idx, idx); - caretIdxRef.current = null; - } + const end = e.currentTarget.value.length; + e.currentTarget.setSelectionRange(end, end); }} onBlur={commit} onKeyDown={(e) => { From 94b7f28d9cb0ff218726e4173310b97b9c79d486 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Mon, 27 Jul 2026 15:16:50 +0530 Subject: [PATCH 11/17] fix --- frontend/src/viewer/CanvasLayer.tsx | 36 ----------------------------- 1 file changed, 36 deletions(-) diff --git a/frontend/src/viewer/CanvasLayer.tsx b/frontend/src/viewer/CanvasLayer.tsx index c184e12..296e82d 100644 --- a/frontend/src/viewer/CanvasLayer.tsx +++ b/frontend/src/viewer/CanvasLayer.tsx @@ -60,43 +60,7 @@ export const CanvasLayer: React.FC = ({ ctx.drawImage(img, 0, 0, width, height); ctx.restore(); - // Render vector ops on top - if (vectorOps && vectorOps.length > 0) { - ctx.save(); - ctx.translate(0, height); - ctx.scale(1, -1); - ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)'; - ctx.fillStyle = 'rgba(0, 0, 255, 0.2)'; - ctx.lineWidth = 1; - ctx.beginPath(); - for (const op of vectorOps) { - if (op.op === 'm' && op.args && op.args.length === 2) { - ctx.moveTo(op.args[0], op.args[1]); - } else if (op.op === 'l' && op.args && op.args.length === 2) { - ctx.lineTo(op.args[0], op.args[1]); - } else if (op.op === 'c' && op.args && op.args.length === 6) { - ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]); - } else if (op.op === 're' && op.args && op.args.length === 4) { - ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]); - } else if (op.op === 'h') { - ctx.closePath(); - } else if (op.op === 'S' || op.op === 's') { - ctx.stroke(); - ctx.beginPath(); - } else if (op.op === 'f' || op.op === 'F') { - ctx.fill(); - ctx.beginPath(); - } else if (op.op === 'B' || op.op === 'b') { - ctx.fill(); - ctx.stroke(); - ctx.beginPath(); - } else if (op.op === 'n') { - ctx.beginPath(); - } - } - ctx.restore(); - } onRenderComplete?.(pageIndex); }; From dde7829966e59e60a85025bb9128b6c36ea28977 Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Wed, 29 Jul 2026 18:46:28 +0530 Subject: [PATCH 12/17] feat: implement PDF viewer interface with annotation layer, thumbnail management, and toolbar components --- frontend/src/App.tsx | 68 +- frontend/src/components/InspectorPanel.tsx | 300 ++-- frontend/src/components/Thumbnail.tsx | 52 +- frontend/src/components/ToolRail.tsx | 28 +- frontend/src/components/Toolbar.tsx | 88 +- frontend/src/components/TopBar.tsx | 85 +- .../src/components/VersionHistoryModal.tsx | 59 + .../custom/CustomConfirmationModal.tsx | 10 +- frontend/src/components/icons.tsx | 5 + frontend/src/components/ui.tsx | 30 +- frontend/src/index.css | 70 +- frontend/src/lib/useTheme.ts | 34 + frontend/src/viewer/AnnotationLayer.tsx | 13 +- frontend/src/viewer/FloatingTextToolbar.tsx | 38 +- frontend/src/viewer/OverlayLayer.tsx | 24 +- frontend/src/viewer/PDFViewer.tsx | 1371 ++++++++++------- 16 files changed, 1372 insertions(+), 903 deletions(-) create mode 100644 frontend/src/components/VersionHistoryModal.tsx create mode 100644 frontend/src/lib/useTheme.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01833d1..d298d4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import type { InspectorTab } from './components/InspectorPanel'; import { SignatureModal } from './components/SignatureModal'; import { RedactPagesModal } from './components/RedactPagesModal'; import { AboutModal } from './components/AboutModal'; +import { VersionHistoryModal } from './components/VersionHistoryModal'; import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal'; import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal'; @@ -57,6 +58,7 @@ function App() { const [metadata, setMetadata] = useState(null); const [fonts, setFonts] = useState([]); const [outline, setOutline] = useState([]); + const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [backendHealthy, setBackendHealthy] = useState(null); const [engineReady, setEngineReady] = useState(false); const [inspectorTab, setInspectorTab] = useState('pages'); @@ -68,6 +70,7 @@ function App() { const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false); const [aboutModalOpen, setAboutModalOpen] = useState(false); const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null); + const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); const [activeStamp, setActiveStamp] = useState(null); const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area'); const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null); @@ -464,6 +467,9 @@ function App() { const handleMarkRedaction = (pageIndex: number, bounds: Rect) => { setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]); + if (activeTool !== 'redact') { + setActiveTool('redact'); + } }; const handleApplyRedactions = () => { @@ -512,11 +518,18 @@ function App() { const handleExport = async () => { if (!activeDoc) return; if (!can('canCopy')) { denyToast('Exporting'); return; } - try { - await gatewayService.exportDocument(selectedDocId, activeDoc.filename); - } catch (e) { - console.error('Export failed', e); - } + setConfirmState({ + title: 'Export Document', + message: `Are you sure you want to export "${activeDoc.filename}"?`, + confirmLabel: 'Export', + onConfirm: async () => { + try { + await gatewayService.exportDocument(selectedDocId, activeDoc.filename); + } catch (e) { + console.error('Export failed', e); + } + }, + }); }; const handlePrint = async () => { @@ -545,16 +558,7 @@ function App() { }; const toggleInspector = () => { - setIsInspectorOpen((prev) => { - const next = !prev; - setTimeout(() => { - const w = activeDoc?.pageWidth || 612; - const inspectorWidth = next ? 322 : 0; - const avail = window.innerWidth - inspectorWidth - 48; - setZoom(Math.max(0.25, Math.min(3, avail / w))); - }, 50); - return next; - }); + setIsInspectorOpen((prev) => !prev); }; const fitWidth = () => { @@ -618,6 +622,7 @@ function App() { canExport={can('canCopy')} canAssemble={can('canAssemble')} onUpload={handleUpload} + onShowVersionHistory={() => setVersionHistoryModalOpen(true)} isInspectorOpen={isInspectorOpen} onToggleInspector={toggleInspector} /> @@ -647,6 +652,19 @@ function App() { onApplyRedactions={handleApplyRedactions} onClearRedactions={() => setPendingRedactions([])} onRedactPages={() => setRedactPagesModalOpen(true)} + selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)} + onUpdateAnnotation={(patch) => { + const a = annotations.find(x => x.id === selectedAnnotationId); + if (a) handleUpdateAnnotation({ ...a, ...patch }); + }} + onDeleteAnnotation={() => { + const a = annotations.find(x => x.id === selectedAnnotationId); + if (a) { + handleDeleteAnnotation(a); + setSelectedAnnotationId(null); + } + }} + onDeselectAnnotation={() => setSelectedAnnotationId(null)} />
@@ -687,9 +705,13 @@ function App() { searchCurrentMatch={searchCurrentMatch} onAnnotationAdded={handleAnnotationAdded} onAnnotationUpdate={handleUpdateAnnotation} - onAnnotationClick={() => { - setInspectorTab('notes'); - if (!isInspectorOpen) setIsInspectorOpen(true); + onAnnotationClick={(anno) => { + if (activeTool === 'select') { + setSelectedAnnotationId(anno.id); + } else { + setInspectorTab('notes'); + if (!isInspectorOpen) setIsInspectorOpen(true); + } }} onPageVisible={setCurrentPage} onMarkRedaction={handleMarkRedaction} @@ -726,6 +748,8 @@ function App() { { + const index = hist.stack.findIndex(id => id === docId); + if (index !== -1) { + setHist(h => ({ ...h, index })); + preservePageRef.current = true; + } + }} /> )}
diff --git a/frontend/src/components/InspectorPanel.tsx b/frontend/src/components/InspectorPanel.tsx index e414860..f3061a0 100644 --- a/frontend/src/components/InspectorPanel.tsx +++ b/frontend/src/components/InspectorPanel.tsx @@ -3,17 +3,20 @@ import React from 'react'; import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService'; import type { Annotation } from '../viewer/AnnotationLayer'; import { Thumbnail } from './Thumbnail'; -import { EmptyState, Popover } from './ui'; +import { EmptyState } from './ui'; import { PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon, - ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, + ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon, } from './icons'; +import { useTheme } from '../lib/useTheme'; -export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms'; +export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history'; interface InspectorPanelProps { activeTab: InspectorTab; onTabChange: (t: InspectorTab) => void; + isExpanded: boolean; + onExpandedChange: (expanded: boolean) => void; documents: DocumentInfo[]; selectedDocumentId: string; @@ -48,6 +51,9 @@ interface InspectorPanelProps { metadata: DocumentMetadata | null; permissions?: PDFPermissions; fonts: FontInfo[]; + + history?: { stack: string[]; index: number }; + onRestoreHistory?: (docId: string) => void; } const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [ @@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo { id: 'fonts', label: 'Fonts', icon: }, { id: 'outline', label: 'Outline', icon: }, { id: 'forms', label: 'Forms', icon: , stub: true }, + { id: 'history', label: 'History', icon: }, ]; function formatBytes(bytes?: number) { @@ -70,79 +77,77 @@ function formatBytes(bytes?: number) { export const InspectorPanel: React.FC = (p) => { const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId); const activeDef = TABS.find((t) => t.id === p.activeTab)!; + const { theme, toggleTheme } = useTheme(); return ( -