28 Commits
Author SHA1 Message Date
saqib mir ffc846cf17 fix 2026-07-23 13:53:22 +05:30
saqib mir 626156642e fix 2026-07-23 12:15:20 +05:30
saqib mir 185cc03df7 docker issue 2026-07-23 11:31:26 +05:30
saqib mir a707fb5216 Merge branch 'deploy' of https://gitea.maskantech.in/gitea_admin/pdf into saqib 2026-07-17 15:22:12 +05:30
maskantech 66f7cd345c docker 2026-07-17 15:13:26 +05:30
saqib mir 178e4df163 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into saqib 2026-07-17 11:11:07 +05:30
furqan 3775359ab2 Merge pull request 'feat: implement PDF viewer components and scaffolding for interactive document editing' (#78) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/78
2026-07-10 06:53:32 +00:00
azeeee05 27538a9489 feat: implement PDF viewer components and scaffolding for interactive document editing 2026-07-10 12:22:09 +05:30
saqib mir 67b5aed642 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into saqib 2026-07-10 11:20:16 +05:30
furqan b15aed60d1 Merge pull request 'azeem' (#77) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/77
2026-07-10 05:49:09 +00:00
azeeee05 ad51b4b270 feat: implement PDF viewer, floating text toolbar, and document editing gateway router 2026-07-09 12:39:47 +05:30
azeeee05 f9ec4e1680 feat: implement redaction UI and backend engine support for area-based PDF redactions 2026-07-09 11:51:28 +05:30
furqan f8af0aa611 Merge pull request 'azeem' (#75) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/75
2026-07-09 05:10:37 +00:00
azeeee05 82a07d2e6e feat: implement PDF annotation extraction and inspector API endpoints via C++ bindings 2026-07-09 10:38:26 +05:30
saqib mir f667db1f75 Render result cache (doc_hash + page + DPI) 2026-07-09 10:26:11 +05:30
saqib mir 10775936de Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into saqib 2026-07-09 10:25:24 +05:30
azeeee05 62ad42b116 feat: implement backend and frontend support for PDF text overlays and annotations 2026-07-08 15:53:17 +05:30
furqan 46ec78e9b4 Merge pull request 'feat: implement PDF page parsing, text extraction, and document modeling logic' (#74) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/74
2026-07-08 09:47:32 +00:00
azeeee05 99f87eb7cf feat: implement PDF page parsing, text extraction, and document modeling logic 2026-07-07 19:03:11 +05:30
saqib mir 880e56b98b Tile-based rendering (partial page invalidation) 2026-07-07 11:25:09 +05:30
saqib mir ef59f2ec83 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into saqib 2026-07-01 17:28:21 +05:30
saqib mir 72ed04f2ed fix the issue 2026-07-01 17:28:00 +05:30
furqan a383bd8704 Merge pull request 'azeem' (#72) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/72
2026-07-01 09:39:29 +00:00
azeeee05 90027823e4 chore: remove obsolete feature inventory documentationl 2026-07-01 15:07:38 +05:30
azeeee05 5b034c97b8 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into azeem 2026-07-01 15:06:08 +05:30
azeeee05 bc3ed20a8b done the signature module 2026-07-01 15:05:37 +05:30
furqan abcd407c35 Merge pull request 'furqan' (#71) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/71
2026-06-30 15:49:48 +00:00
azeeee05 afa8c0a702 feat: implement PDF toolbar, viewer components, and redaction support layer 2026-06-30 11:32:15 +05:30
51 changed files with 3038 additions and 334 deletions
+30
View File
@@ -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
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -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")
+33 -2
View File
@@ -341,12 +341,27 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
.def_readonly("thickness", &pdfengine::PdfPage::AnnotationInfo::thickness)
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
.def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags)
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions);
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions)
.def_property_readonly("quad_points", [](const pdfengine::PdfPage::AnnotationInfo& self) {
py::list out;
for (const auto& quad : self.quadPoints) {
py::list quad_list;
for (const auto& pt : quad) {
py::dict d;
d["x"] = pt.x;
d["y"] = pt.y;
quad_list.append(d);
}
out.append(quad_list);
}
return out;
});
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
.def_property_readonly("width", &pdfengine::PdfPage::width)
@@ -359,6 +374,11 @@ PYBIND11_MODULE(pdfengine, m) {
return py::make_tuple(img.width, img.height,
py::bytes(reinterpret_cast<const char*>(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<const char*>(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 +503,18 @@ PYBIND11_MODULE(pdfengine, m) {
return py::bytes(reinterpret_cast<const char*>(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<uint8_t> res = get_or_throw(self.saveIncremental());
+41
View File
@@ -0,0 +1,41 @@
services:
gateway:
build:
context: .
dockerfile: gateway/Dockerfile
network: host
image: pdf-engine-gateway:dev
container_name: pdf-engine-gateway
environment:
PDFENGINE_ENVIRONMENT: dev
PDFENGINE_ENGINE_AVAILABLE: "true"
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://localhost:8000
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- gateway
restart: unless-stopped
+21 -16
View File
@@ -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)
+5 -5
View File
@@ -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;
};
+2 -2
View File
@@ -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;
+19 -1
View File
@@ -5,6 +5,7 @@
#include <string>
#include <vector>
#include <cstdint>
#include <array>
namespace pdfengine {
@@ -60,6 +61,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 +181,12 @@ public:
return std::unexpected(EngineError::Unknown);
}
[[nodiscard]] virtual std::expected<PageImage, EngineError>
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<std::string, EngineError> extractText() const = 0;
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
@@ -197,7 +212,9 @@ public:
std::string content;
std::string timestamp;
int pageIndex = 0;
double thickness = 0.0;
std::vector<std::vector<Point2D>> paths;
std::vector<std::array<Point2D, 4>> quadPoints;
std::string fieldName;
std::string fieldValue;
@@ -256,7 +273,8 @@ public:
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
getResolvedFont(const FontInfo& fontInfo) = 0;
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
virtual std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) = 0;
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
+4 -1
View File
@@ -48,7 +48,9 @@ public:
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
std::expected<PageImage, EngineError> renderRegionRaw(int dpi, double yTopPt, double heightPt) const override;
std::expected<PageImage, EngineError> renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const override;
std::expected<std::string, EngineError> extractText() const override;
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
std::expected<PageModel, EngineError> extractDocumentModel() const override;
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
@@ -100,7 +102,7 @@ public:
void invalidateCaches();
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) override;
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
@@ -164,6 +166,7 @@ private:
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_stamp(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
+36 -4
View File
@@ -8,7 +8,7 @@
namespace pdfengine::parser {
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
std::expected<std::vector<InvalidatedRegion>, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
@@ -17,7 +17,9 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
lastReflowLayout_.clear();
std::vector<int> editedPages;
auto markEdited = [&editedPages](int p) {
std::vector<InvalidatedRegion> invalidatedRegions;
auto markEdited = [&](int p) {
if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end())
editedPages.push_back(p);
};
@@ -42,7 +44,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
static const std::set<std::string> kContentOps = {
"replace_text", "reflow_paragraph", "text_overlay", "add_text",
"replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp",
"underline", "strikeout", "squiggly", "redaction",
"image_overlay", "highlight", "free_text", "comment", "freehand"};
if (kContentOps.count(type)) markEdited(pageIndex);
@@ -54,6 +56,8 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
r = applyOp_reflow(op, pageIndex);
} else if (type == "text_overlay" || type == "add_text") {
r = applyOp_textOverlay(op, pageIndex);
} else if (type == "stamp") {
r = applyOp_stamp(op, pageIndex);
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
r = applyOp_decoration(op, pageIndex);
} else if (type == "redaction") {
@@ -87,6 +91,34 @@ std::expected<void, EngineError> 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 +130,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
rebalanceEditedPages(editedPages);
invalidateCaches();
return {};
return invalidatedRegions;
#else
(void)editsJson;
return std::unexpected(EngineError::Unknown);
@@ -1,4 +1,7 @@
#include "parser/pdfium_internal.hpp"
#include <chrono>
#include <iomanip>
#include <sstream>
namespace pdfengine::parser {
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_stamp(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("stamp operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string text = data.value("text", "");
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", 30.0);
double fontSize = data.value("fontSize", 18.0);
std::string textColor = data.value("textColor", "#000000");
std::string bgColor = data.value("backgroundColor", "#ffffff");
std::string borderColor = data.value("borderColor", "#000000");
bool includeDate = data.value("includeDate", false);
if (includeDate) {
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M");
text += "\n" + ss.str();
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for stamp", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// Background rect
FPDF_PAGEOBJECT bgRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
unsigned int r=0, g=0, b=0;
parseHexColor(bgColor, r, g, b);
FPDFPageObj_SetFillColor(bgRect, r, g, b, 255);
FPDFPath_SetDrawMode(bgRect, FPDF_FILLMODE_WINDING, 0);
FPDFPage_InsertObject(page, bgRect);
// Border rect
FPDF_PAGEOBJECT borderRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
parseHexColor(borderColor, r, g, b);
FPDFPageObj_SetStrokeColor(borderRect, r, g, b, 255);
FPDFPageObj_SetStrokeWidth(borderRect, 2.5f);
FPDFPath_SetDrawMode(borderRect, 0, 1);
FPDFPage_InsertObject(page, borderRect);
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, "Helvetica-Bold");
std::vector<std::string> lines;
std::stringstream textStream(text);
std::string line;
while(std::getline(textStream, line, '\n')) {
lines.push_back(line);
}
float startY = static_cast<float>(y + height - fontSize * 1.1);
parseHexColor(textColor, r, g, b);
for (size_t i = 0; i < lines.size(); i++) {
float currentFontSize = static_cast<float>(i == 0 ? fontSize : fontSize * 0.5);
FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, currentFontSize);
FPDFPageObj_SetFillColor(textObj, r, g, b, 255);
auto utf16 = utf8_to_utf16le(lines[i]);
FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
float left=0, bottom=0, right=0, top=0;
FPDFPageObj_GetBounds(textObj, &left, &bottom, &right, &top);
float textWidth = right - left;
float textX = static_cast<float>(x + width / 2.0 - textWidth / 2.0);
float textY = startY - (i * fontSize * 0.7f);
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, textX, textY);
FPDFPage_InsertObject(page, textObj);
}
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after stamp");
}
FPDF_ClosePage(page);
return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
std::string type = op.value("type", "");
@@ -129,6 +227,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohma
int subtype = FPDF_ANNOT_UNDERLINE;
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
else if (type == "highlight") subtype = FPDF_ANNOT_HIGHLIGHT;
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
if (!annot) {
@@ -383,6 +482,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_freehand(const nlohmann
float thickness = static_cast<float>(data.value("thickness", 2.0));
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
std::string tStr = std::to_string(thickness);
auto tUtf16 = utf8_to_utf16le(tStr);
FPDFAnnot_SetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
bool anyPoints = false;
@@ -549,6 +652,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
if (data.contains("thickness")) {
float thickness = static_cast<float>(data["thickness"].get<double>());
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
std::string tStr = std::to_string(thickness);
auto tUtf16 = utf8_to_utf16le(tStr);
FPDFAnnot_SetStringValue(targetAnnot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
}
if (data.contains("text")) {
+5 -11
View File
@@ -133,22 +133,19 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
if (pageCount() <= 1) {
spdlog::error("Cannot delete the only page in the document");
return std::unexpected(EngineError::Unknown);
}
FPDFPage_Delete(doc_, pageIndex);
return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
#endif
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_reorder operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
@@ -170,10 +167,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
return std::unexpected(EngineError::Unknown);
}
return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
#endif
}
}
+43
View File
@@ -154,6 +154,49 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
#endif
}
std::expected<PageImage, EngineError> 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<int>(wPt * scale);
const int h = static_cast<int>(hPt * scale);
int start_x = -static_cast<int>(xPt * scale);
int start_y = -static_cast<int>(yPt * scale);
const int fullW = static_cast<int>(width() * scale);
const int fullH = static_cast<int>(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<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
const int stride = FPDFBitmap_GetStride(bitmap);
std::vector<uint8_t> rgba(static_cast<size_t>(w) * h * 4);
for (int y = 0; y < h; ++y) {
const uint8_t* src = bgra + static_cast<size_t>(y) * stride;
uint8_t* dst = rgba.data() + static_cast<size_t>(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<std::string, EngineError> PdfiumPage::extractText() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
+30
View File
@@ -600,6 +600,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
}
if (subtype == FPDF_ANNOT_INK) {
float h_radius = 0, v_radius = 0, border_width = 1.0f;
info.thickness = 2.0; // default
unsigned long ctLen = FPDFAnnot_GetStringValue(annot, "CustomThickness", nullptr, 0);
if (ctLen > 2) {
std::vector<char16_t> ctBuf(ctLen / 2);
FPDFAnnot_GetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WCHAR*>(ctBuf.data()), ctLen);
std::string ctStr = utf16le_to_utf8(ctBuf.data(), ctBuf.size());
try {
info.thickness = std::stod(ctStr);
} catch (...) {}
} else if (FPDFAnnot_GetBorder(annot, &h_radius, &v_radius, &border_width)) {
info.thickness = border_width;
}
const double pageH = height();
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
for (unsigned long s = 0; s < strokeCount; ++s) {
@@ -614,6 +629,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
}
if (!stroke.empty()) info.paths.push_back(std::move(stroke));
}
} else if (subtype == FPDF_ANNOT_HIGHLIGHT || subtype == FPDF_ANNOT_STRIKEOUT || subtype == FPDF_ANNOT_UNDERLINE || subtype == FPDF_ANNOT_SQUIGGLY) {
const double pageH = height();
size_t quadCount = FPDFAnnot_CountAttachmentPoints(annot);
for (size_t q = 0; q < quadCount; ++q) {
FS_QUADPOINTSF quad;
if (FPDFAnnot_GetAttachmentPoints(annot, q, &quad)) {
std::array<Point2D, 4> pts = {{
{static_cast<double>(quad.x1), pageH - static_cast<double>(quad.y1)},
{static_cast<double>(quad.x2), pageH - static_cast<double>(quad.y2)},
{static_cast<double>(quad.x3), pageH - static_cast<double>(quad.y3)},
{static_cast<double>(quad.x4), pageH - static_cast<double>(quad.y4)}
}};
info.quadPoints.push_back(pts);
}
}
}
result.push_back(info);
+4
View File
@@ -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()
+129
View File
@@ -1,5 +1,8 @@
#include <gtest/gtest.h>
#include <pdfengine/display_list.hpp>
#include <pdfengine/path_interpreter.hpp>
#include <pdfengine/content_object.hpp>
#include "../src/parser/content_stream_parser.hpp"
#include <string>
#include <vector>
@@ -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<PathInfo> paths;
std::vector<std::string> 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);
}
+4 -1
View File
@@ -262,7 +262,8 @@ std::string decodedStream(QPDFObjectHandle contents) {
}
std::vector<std::unique_ptr<ContentObject>> 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();
@@ -436,6 +437,8 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 2u);
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.vite/
.git/
.gitignore
*.log
.DS_Store
+20
View File
@@ -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;"]
+163 -50
View File
@@ -5,8 +5,9 @@ import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { RedactPagesModal } from './components/RedactPagesModal';
import { AboutModal } from './components/AboutModal';
import { ToastViewport } from './components/ui';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
import { PDFViewer } from './viewer/PDFViewer';
@@ -18,9 +19,9 @@ import { PasswordModal } from './components/PasswordModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools';
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
@@ -38,8 +39,7 @@ function App() {
const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (label: string) =>
toast(`${label} is not permitted by this document's restrictions`, 'error');
const denyToast = (_label: string) => {};
const disabledTools = new Set<ToolId>();
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
@@ -65,9 +65,11 @@ function App() {
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -83,15 +85,21 @@ function App() {
if (!canUndo) return;
preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
toast('Undo', 'info', 1200);
};
const redo = () => {
if (!canRedo) return;
preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
toast('Redo', 'info', 1200);
};
useEffect(() => {
if (hist.stack.length > 0) {
localStorage.setItem('pdf_hist', JSON.stringify(hist));
}
}, [hist]);
useEffect(() => {
gatewayService.getHealth()
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
@@ -104,7 +112,23 @@ function App() {
setIsLoading(true);
const docs = await gatewayService.listDocuments();
setDocuments(docs);
if (docs.length > 0) openDocument(docs[0].id);
if (docs.length > 0) {
// Attempt to load from localStorage, otherwise fallback to the most recent document
const savedHist = localStorage.getItem('pdf_hist');
if (savedHist) {
try {
const parsedHist = JSON.parse(savedHist);
if (parsedHist.stack && parsedHist.stack.length > 0 && docs.some((d: any) => d.id === parsedHist.stack[parsedHist.index])) {
setHist(parsedHist);
return;
}
} catch (e) {
console.error('Failed to parse history', e);
}
}
// Default to the most recent document (last in list)
openDocument(docs[docs.length - 1].id);
}
} catch (e) {
console.error('Failed to load documents', e);
} finally {
@@ -140,6 +164,7 @@ function App() {
content: a.content,
timestamp: a.timestamp,
pageIndex: a.pageIndex,
quadPoints: a.quad_points || a.quadPoints,
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
fieldName: a.fieldName,
fieldValue: a.fieldValue,
@@ -213,18 +238,16 @@ function App() {
gatewayService.listDocuments().then(setDocuments).catch(() => {});
};
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
if (!selectedDocId) return;
setIsSaving(true);
try {
const result = await gatewayService.applyEdits(selectedDocId, ops);
if (result.success) {
adoptNewDocument(result.newDocumentId);
if (successMsg) toast(successMsg, 'success');
}
} catch (e) {
console.error('Edit failed', e);
toast('Edit failed — check the gateway connection', 'error');
} finally {
setIsSaving(false);
}
@@ -267,22 +290,33 @@ function App() {
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
const quadPoints = lines.map((line) => {
const quadPointsBackend = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly };
return { x1: lx, y1: ly, x2: lx + lw, y2: ly, x3: lx, y3: ly + lh, x4: lx + lw, y4: ly + lh };
});
const newAnnos = lines.map(line => ({
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const qPointsFront = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
if (lx < minX) minX = lx;
if (ly < minY) minY = ly;
if (lx + lw > maxX) maxX = lx + lw;
if (ly + lh > maxY) maxY = ly + lh;
return [{ x: lx, y: ly }, { x: lx + lw, y: ly }, { x: lx, y: ly + lh }, { x: lx + lw, y: ly + lh }];
});
const newAnno = {
id: rid('locdec'),
type,
pageIndex,
bbox: { x: line.x / zoom, y: line.y / zoom, width: line.width / zoom, height: line.height / zoom },
bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
quadPoints: qPointsFront,
color,
author: 'Current User',
} as Annotation));
setAnnotations(prev => [...prev, ...newAnnos]);
} as Annotation;
setAnnotations(prev => [...prev, newAnno]);
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]);
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints: quadPointsBackend, color, author: 'Current User' } }]);
};
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
@@ -320,24 +354,80 @@ function App() {
if (!activeStamp) return;
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
const fontSize = 22;
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
const height = fontSize * 1.5;
const padding = 12; // visual padding
const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length
const labelWidth = activeStamp.label.length * fontSize * 0.7;
const contentWidth = Math.max(labelWidth, dateWidth);
const width = Math.max(80, contentWidth) + (padding * 2);
const height = fontSize * 1.5 + (padding * 2);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('stamp'), type: 'text_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
id: rid('stamp'), type: 'stamp', pageIndex,
data: {
x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height,
text: activeStamp.label,
textColor: activeStamp.textColor,
backgroundColor: activeStamp.backgroundColor,
borderColor: activeStamp.borderColor,
fontSize,
includeDate: true // We can toggle this later, true by default for now
},
}], `Stamp “${activeStamp.label}” placed`);
};
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
const handleRedactPages = (pagesString: string) => {
if (!activeDoc) return;
const ranges = pagesString.split(',').map(s => s.trim());
const pagesToRedact = new Set<number>();
for (const r of ranges) {
if (r.includes('-')) {
const parts = r.split('-');
if (parts.length === 2) {
const start = parseInt(parts[0], 10);
const end = parseInt(parts[1], 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1);
}
}
}
} else {
const val = parseInt(r, 10);
if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) {
pagesToRedact.add(val - 1);
}
}
}
const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = [];
pagesToRedact.forEach(pageIndex => {
const pInfo = activeDoc.pages?.[pageIndex];
if (!pInfo) return;
newRedactions.push({
id: rid('redmark'),
pageIndex,
bounds: {
x: 0,
y: 0,
width: pInfo.width,
height: pInfo.height
}
});
});
if (newRedactions.length > 0) {
setPendingRedactions(p => [...p, ...newRedactions]);
} else {
}
setRedactPagesModalOpen(false);
};
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
if (!pendingSignature) return;
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
const width = 160;
const height = width / (pendingSignature.aspect || 3);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('sig'), type: 'image_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url },
data: { x: pdfRect.x, y: pdfRect.y, width: pdfRect.width, height: pdfRect.height, imageData: pendingSignature.url },
}], 'Signature placed');
setActiveTool('select');
};
@@ -351,7 +441,7 @@ function App() {
const handleDeletePage = (pageIndex: number) => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
if (activeDoc.totalPages <= 1) { return; }
setConfirmState({
title: 'Delete page?',
message: `Page ${pageIndex + 1} will be removed from this document.`,
@@ -370,19 +460,31 @@ function App() {
setCurrentPage(to);
};
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
const [pendingRedactions, setPendingRedactions] = useState<{ id: string, pageIndex: number, bounds: Rect }[]>([]);
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
};
const handleApplyRedactions = () => {
if (!activeDoc) return;
if (!can('canModify')) { denyToast('Redaction'); return; }
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
if (pendingRedactions.length === 0) return;
setConfirmState({
title: 'Redact area?',
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
confirmLabel: 'Redact', danger: true,
title: 'Apply Redactions?',
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
confirmLabel: 'Apply', danger: true,
onConfirm: () => {
applyOps([{
id: rid('redact'), type: 'redaction', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
}], 'Area redacted');
const ops = pendingRedactions.map(mark => {
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
return {
id: mark.id, type: 'redaction' as const, pageIndex: mark.pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#000000' },
};
});
applyOps(ops, 'Redactions applied');
setPendingRedactions([]);
setActiveTool('select');
},
});
@@ -394,14 +496,13 @@ function App() {
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
openDocument(newDoc.id);
toast(`Opened ${newDoc.filename}`, 'success');
setPasswordPrompt(null);
} catch (e) {
if (e instanceof PasswordError) {
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
toast('Upload failed', 'error');
}
} finally {
setIsLoading(false);
@@ -413,10 +514,8 @@ function App() {
if (!can('canCopy')) { denyToast('Exporting'); return; }
try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
toast('Exported', 'success');
} catch (e) {
console.error('Export failed', e);
toast('Export failed', 'error');
}
};
@@ -424,7 +523,7 @@ function App() {
if (!activeDoc) return;
if (!can('canPrint')) { denyToast('Printing'); return; }
try {
toast('Preparing print...', 'info');
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
@@ -442,7 +541,6 @@ function App() {
document.body.appendChild(iframe);
} catch (e) {
console.error('Print failed', e);
toast('Print failed', 'error');
}
};
@@ -541,8 +639,14 @@ function App() {
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
activeStamp={activeStamp}
onSelectStamp={setActiveStamp}
redactionMode={redactionMode}
onRedactionModeChange={setRedactionMode}
pendingRedactionCount={pendingRedactions.length}
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
/>
<div className="relative min-h-0 flex-1">
@@ -562,8 +666,11 @@ function App() {
pagesInfo={activeDoc.pages}
activeTool={activeTool}
toolSettings={toolSettings}
redactionMode={redactionMode}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
signatureImageUrl={pendingSignature?.url}
signatureAspect={pendingSignature?.aspect}
activeStamp={activeStamp}
annotations={annotations}
canCopy={can('canCopy')}
onFieldChange={(id, value, i) => {
@@ -585,7 +692,9 @@ function App() {
if (!isInspectorOpen) setIsInspectorOpen(true);
}}
onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea}
onMarkRedaction={handleMarkRedaction}
pendingRedactions={pendingRedactions}
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
onPlaceText={handlePlaceText}
onEditText={handleEditText}
onReflowParagraph={handleReflowParagraph}
@@ -656,10 +765,15 @@ function App() {
setPendingSignature({ url, aspect });
setSignatureModalOpen(false);
setActiveTool('signature');
toast('Signature ready — click on the page to place it', 'info');
}}
/>
<RedactPagesModal
open={redactPagesModalOpen}
onClose={() => setRedactPagesModalOpen(false)}
onConfirm={handleRedactPages}
/>
<AboutModal
open={aboutModalOpen}
onClose={() => setAboutModalOpen(false)}
@@ -672,7 +786,6 @@ function App() {
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
onClose={() => setPasswordPrompt(null)}
/>
<ToastViewport />
</div>
);
}
+1 -1
View File
@@ -208,7 +208,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
</CustomButton>
{onUpdate && (a.type === 'highlight' || a.type === 'ink' || a.type === 'comment') && (
{onUpdate && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
<div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}>
<input
type="color"
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Modal } from './ui';
import { CustomButton } from './custom/CustomButton';
interface RedactPagesModalProps {
open: boolean;
onClose: () => void;
onConfirm: (pagesString: string) => void;
}
export const RedactPagesModal: React.FC<RedactPagesModalProps> = ({ open, onClose, onConfirm }) => {
const [pagesString, setPagesString] = useState('');
return (
<Modal open={open} onClose={onClose} title="Redact Pages">
<div className="flex w-[400px] flex-col gap-4 p-5">
<p className="text-[13px] text-[#4b5563]">
Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction.
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[11.5px] font-semibold text-[#18212e]">Pages</label>
<input
type="text"
className="h-[36px] w-full rounded-[8px] border border-[#d1d5db] bg-white px-3 text-[13.5px] shadow-sm outline-none transition-colors focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb]"
placeholder="e.g. 1, 3-5"
value={pagesString}
onChange={(e) => setPagesString(e.target.value)}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2 border-t border-[#ebedf0] pt-4">
<CustomButton variant="outline" onClick={onClose}>
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={() => {
if (pagesString.trim()) {
onConfirm(pagesString.trim());
setPagesString('');
}
}}
disabled={!pagesString.trim()}
>
Mark for Redaction
</CustomButton>
</div>
</div>
</Modal>
);
};
+766 -113
View File
@@ -1,82 +1,276 @@
import { CustomButton } from './custom/CustomButton';
import React, { useRef, useState } from 'react';
import { Modal } from './ui';
import { toast } from '../lib/toast';
import React, { useRef, useState, useEffect, useCallback } from 'react';
/* ─── Types ─────────────────────────────────────────────── */
interface SignatureModalProps {
open: boolean;
onClose: () => void;
onConfirm: (dataUrl: string, aspect: number) => void;
}
type Mode = 'draw' | 'type' | 'upload';
type Mode = 'draw' | 'type' | 'upload' | 'saved';
interface SavedSig {
id: string;
dataUrl: string;
aspect: number;
label: string;
createdAt: number;
}
/* ─── Constants ──────────────────────────────────────────── */
const STORAGE_KEY = 'pdf_saved_signatures';
const CANVAS_W = 960;
const CANVAS_H = 320;
const SIGNATURE_FONTS: { label: string; family: string; css: string }[] = [
{ label: 'Script', family: 'Great Vibes', css: '"Great Vibes", cursive' },
{ label: 'Elegant', family: 'Pacifico', css: '"Pacifico", cursive' },
{ label: 'Classic', family: 'Dancing Script', css: '"Dancing Script", cursive' },
{ label: 'Formal', family: 'Pinyon Script', css: '"Pinyon Script", cursive' },
{ label: 'Bold', family: 'Satisfy', css: '"Satisfy", cursive' },
{ label: 'Handwrite', family: 'Caveat', css: '"Caveat", cursive' },
];
const INK_COLORS = [
{ label: 'Black', value: '#0f172a' },
{ label: 'Navy', value: '#1e3a8a' },
{ label: 'Blue', value: '#2563eb' },
{ label: 'Ink', value: '#312e81' },
];
const THICKNESS_OPTIONS = [
{ label: 'Thin', value: 1.5 },
{ label: 'Medium', value: 2.8 },
{ label: 'Thick', value: 4.5 },
];
/* ─── Google Fonts loader ────────────────────────────────── */
function useGoogleFonts() {
useEffect(() => {
const id = 'sig-google-fonts';
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href =
'https://fonts.googleapis.com/css2?family=Great+Vibes&family=Pacifico&family=Dancing+Script:wght@700&family=Pinyon+Script&family=Satisfy&family=Caveat:wght@700&display=swap';
document.head.appendChild(link);
}, []);
}
/* ─── Saved signatures helpers ───────────────────────────── */
function loadSaved(): SavedSig[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
catch { return []; }
}
function persistSaved(sigs: SavedSig[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sigs.slice(0, 8))); } catch {}
}
/* ─── Point smoothing (Catmull-Rom) ─────────────────────── */
type Pt = { x: number; y: number; p: number };
function midPt(a: Pt, b: Pt): Pt {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 };
}
function strokePoints(ctx: CanvasRenderingContext2D, pts: Pt[], color: string, baseThickness: number) {
if (pts.length < 2) return;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = color;
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1];
const curr = pts[i];
const mid = midPt(prev, curr);
const pressure = (prev.p + curr.p) / 2;
ctx.lineWidth = baseThickness * (0.6 + pressure * 0.8);
ctx.beginPath();
if (i === 1) {
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(mid.x, mid.y);
} else {
const prevMid = midPt(pts[i - 2], prev);
ctx.moveTo(prevMid.x, prevMid.y);
ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y);
}
ctx.stroke();
}
}
/* ────────────────────────────────────────────────────────── */
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
useGoogleFonts();
/* tabs */
const [mode, setMode] = useState<Mode>('draw');
const [typed, setTyped] = useState('');
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
/* draw */
const canvasRef = useRef<HTMLCanvasElement>(null);
const strokes = useRef<Pt[][]>([]);
const currentStroke = useRef<Pt[]>([]);
const drawing = useRef(false);
const hasInk = useRef(false);
const [inkColor, setInkColor] = useState(INK_COLORS[0].value);
const [thickness, setThickness] = useState(THICKNESS_OPTIONS[1].value);
const resetLocal = () => { setMode('draw'); setTyped(''); setUploaded(null); hasInk.current = false; };
const handleClose = () => { resetLocal(); onClose(); };
/* type */
const [typed, setTyped] = useState('');
const [fontIdx, setFontIdx] = useState(0);
const [typeColor, setTypeColor] = useState(INK_COLORS[0].value);
const clearCanvas = () => {
/* upload */
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
const [dragging, setDragging] = useState(false);
/* saved */
const [savedSigs, setSavedSigs] = useState<SavedSig[]>([]);
/* load saved on open */
useEffect(() => { if (open) setSavedSigs(loadSaved()); }, [open]);
/* redraw canvas after color/thickness change */
const redrawAll = useCallback(() => {
const c = canvasRef.current;
if (!c) return;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
for (const stroke of strokes.current) {
strokePoints(ctx, stroke, inkColor, thickness);
}
}, [inkColor, thickness]);
useEffect(() => { if (mode === 'draw') redrawAll(); }, [inkColor, thickness, mode, redrawAll]);
/* ── reset on close ── */
const resetLocal = () => {
setMode('draw');
setTyped('');
setUploaded(null);
hasInk.current = false;
strokes.current = [];
currentStroke.current = [];
const c = canvasRef.current;
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
};
const handleClose = () => { resetLocal(); onClose(); };
/* ── canvas clear ── */
const clearCanvas = () => {
strokes.current = [];
currentStroke.current = [];
hasInk.current = false;
const c = canvasRef.current;
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
};
const pos = (e: React.PointerEvent) => {
/* ── pointer helpers ── */
const canvasPos = (e: React.PointerEvent): Pt => {
const c = canvasRef.current!;
const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left) * (c.width / r.width), y: (e.clientY - r.top) * (c.height / r.height) };
return {
x: (e.clientX - r.left) * (c.width / r.width),
y: (e.clientY - r.top) * (c.height / r.height),
p: e.pressure > 0 ? e.pressure : 0.5,
};
};
const onDown = (e: React.PointerEvent) => {
drawing.current = true;
const ctx = canvasRef.current!.getContext('2d')!;
const { x, y } = pos(e);
ctx.beginPath(); ctx.moveTo(x, y);
const pt = canvasPos(e);
currentStroke.current = [pt];
(e.target as Element).setPointerCapture(e.pointerId);
};
const onMove = (e: React.PointerEvent) => {
if (!drawing.current) return;
const pt = canvasPos(e);
currentStroke.current.push(pt);
const ctx = canvasRef.current!.getContext('2d')!;
const { x, y } = pos(e);
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
const stroke = currentStroke.current;
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
hasInk.current = true;
};
const onUp = () => { drawing.current = false; };
const handleConfirm = () => {
if (mode === 'draw') {
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; }
const c = canvasRef.current!;
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'type') {
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
const c = document.createElement('canvas');
c.width = 600; c.height = 200;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = '#1b2430';
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'upload') {
if (!uploaded) { toast('Upload an image first', 'error'); return; }
onConfirm(uploaded.url, uploaded.aspect);
const onUp = () => {
if (drawing.current && currentStroke.current.length > 0) {
strokes.current.push([...currentStroke.current]);
currentStroke.current = [];
}
drawing.current = false;
};
/* ── build final dataUrl from typed ── */
const buildTypedCanvas = () => {
const c = document.createElement('canvas');
c.width = 900; c.height = 240;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = typeColor;
ctx.font = `bold 100px ${SIGNATURE_FONTS[fontIdx].css}`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(typed.trim() || 'Preview', c.width / 2, c.height / 2);
return { dataUrl: c.toDataURL('image/png'), aspect: c.width / c.height };
};
/* ── confirm ── */
const handleConfirm = () => {
let dataUrl = '';
let aspect = 3;
if (mode === 'draw') {
if (!hasInk.current) return;
const c = canvasRef.current!;
dataUrl = c.toDataURL('image/png');
aspect = c.width / c.height;
} else if (mode === 'type') {
if (!typed.trim()) return;
const res = buildTypedCanvas();
dataUrl = res.dataUrl; aspect = res.aspect;
} else if (mode === 'upload') {
if (!uploaded) return;
dataUrl = uploaded.url; aspect = uploaded.aspect;
} else if (mode === 'saved') {
return;
}
saveToStorage(dataUrl, aspect);
onConfirm(dataUrl, aspect);
resetLocal();
};
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
const saveToStorage = (dataUrl: string, aspect: number) => {
const prev = loadSaved();
const entry: SavedSig = {
id: `sig_${Date.now()}`,
dataUrl,
aspect,
label: mode === 'type' ? (typed.trim() || 'Signature') : 'Signature',
createdAt: Date.now(),
};
const updated = [entry, ...prev.filter((s) => s.dataUrl !== dataUrl)];
persistSaved(updated);
};
const handleUseSaved = (s: SavedSig) => {
saveToStorage(s.dataUrl, s.aspect); // bump to top
onConfirm(s.dataUrl, s.aspect);
resetLocal();
};
const handleDeleteSaved = (id: string) => {
const updated = savedSigs.filter((s) => s.id !== id);
setSavedSigs(updated);
persistSaved(updated);
};
/* ── upload handlers ── */
const processFile = (f: File) => {
const reader = new FileReader();
reader.onload = () => {
const url = reader.result as string;
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
img.src = url;
};
reader.readAsDataURL(f);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) processFile(f);
e.target.value = '';
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setDragging(false);
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith('image/')) processFile(f);
};
/* ── tab labels ── */
const tabs: { id: Mode; label: string; icon: React.ReactNode }[] = [
{
id: 'draw', label: 'Draw',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
},
{
id: 'type', label: 'Type',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>,
},
{
id: 'upload', label: 'Upload',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>,
},
{
id: 'saved', label: 'Saved',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
},
];
/* ── Baseline guide on canvas ── */
useEffect(() => {
if (!open || mode !== 'draw') return;
const c = canvasRef.current;
if (!c || hasInk.current || strokes.current.length > 0) return;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
// baseline guide
ctx.beginPath();
ctx.setLineDash([8, 10]);
ctx.strokeStyle = '#c7d2e0';
ctx.lineWidth = 1.5;
ctx.moveTo(60, c.height * 0.72);
ctx.lineTo(c.width - 60, c.height * 0.72);
ctx.stroke();
ctx.setLineDash([]);
}, [open, mode]);
/* ── Render ── */
if (!open) return null;
return (
<Modal
open={open}
onClose={handleClose}
title="Create signature"
width={580}
footer={
<>
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
</>
}
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
onMouseDown={handleClose}
>
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
{m}
</CustomButton>
))}
<div
className="relative flex flex-col overflow-hidden"
style={{
width: 660,
maxHeight: '92vh',
borderRadius: 16,
background: '#ffffff',
boxShadow: '0 32px 80px rgba(10,15,30,0.28), 0 0 0 1px rgba(0,0,0,0.07)',
animation: 'sigModalIn 0.22s cubic-bezier(0.34,1.4,0.64,1)',
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* ── Header ── */}
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 24px 16px',
borderBottom: '1px solid #edf0f5',
background: '#ffffff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 34, height: 34, borderRadius: 8,
background: '#eef4ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
</div>
<div>
<h2 style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', margin: 0, lineHeight: 1.2 }}>
Add Signature
</h2>
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0, marginTop: 1 }}>
Draw, type, or upload your signature
</p>
</div>
</div>
<button
onClick={handleClose}
style={{
width: 30, height: 30, borderRadius: 8, border: '1px solid #edf0f5',
background: '#f8fafc', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: '#64748b',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f1f5f9'; (e.currentTarget as HTMLButtonElement).style.color = '#0f172a'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; (e.currentTarget as HTMLButtonElement).style.color = '#64748b'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* ── Tab bar ── */}
<div style={{ display: 'flex', gap: 4, padding: '12px 20px 0', background: '#f8fafc', borderBottom: '1px solid #edf0f5' }}>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setMode(t.id)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: '8px 8px 0 0',
border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
transition: 'all 0.15s',
background: mode === t.id ? '#ffffff' : 'transparent',
color: mode === t.id ? '#2563eb' : '#64748b',
borderBottom: mode === t.id ? '2px solid #2563eb' : '2px solid transparent',
boxShadow: mode === t.id ? '0 -2px 12px rgba(37,99,235,0.07), inset 0 0 0 1px rgba(37,99,235,0.08)' : 'none',
position: 'relative', bottom: -1,
}}
>
{t.icon}
{t.label}
{t.id === 'saved' && savedSigs.length > 0 && (
<span style={{
background: '#2563eb', color: '#fff', borderRadius: 20,
fontSize: 9, fontWeight: 700, padding: '1px 5px', lineHeight: 1.6,
}}>
{savedSigs.length}
</span>
)}
</button>
))}
</div>
{/* ── Body ── */}
<div style={{ padding: '20px 24px', overflowY: 'auto', flex: 1, minHeight: 0 }}>
{/* ══ DRAW ══ */}
{mode === 'draw' && (
<div>
{/* Controls row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
{/* Ink color */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Ink</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{INK_COLORS.map((c) => (
<button
key={c.value}
title={c.label}
onClick={() => setInkColor(c.value)}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c.value, border: 'none', cursor: 'pointer',
outline: inkColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
outlineOffset: 3, transition: 'all 0.12s',
}}
/>
))}
</div>
</div>
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
{/* Thickness */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Thickness</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{THICKNESS_OPTIONS.map((o) => (
<button
key={o.value}
title={o.label}
onClick={() => setThickness(o.value)}
style={{
width: 32, height: 30, border: 'none', borderRadius: 6, cursor: 'pointer',
background: thickness === o.value ? '#eff6ff' : '#ffffff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: thickness === o.value ? 'inset 0 0 0 1.5px #2563eb' : 'inset 0 0 0 1px #e2e8f0',
transition: 'all 0.12s',
}}
>
<div style={{
width: 18, height: o.value * 0.9,
borderRadius: 99,
background: thickness === o.value ? '#2563eb' : '#64748b',
transition: 'all 0.12s',
}} />
</button>
))}
</div>
</div>
</div>
{/* Clear */}
<CustomButton variant="outline" size="sm" onClick={clearCanvas}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
Clear
</CustomButton>
</div>
{/* Canvas */}
<div style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: '0 0 0 1.5px #e2e8f0, 0 4px 20px rgba(0,0,0,0.05)' }}>
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
style={{
width: '100%', height: 200,
display: 'block', cursor: 'crosshair',
background: '#f8fafc',
touchAction: 'none',
}}
/>
{!hasInk.current && strokes.current.length === 0 && (
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 6,
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#c7d2e0" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<p style={{ fontSize: 12, color: '#b8c5d6', fontWeight: 500, margin: 0 }}>Sign here</p>
</div>
)}
</div>
<p style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, textAlign: 'center' }}>
Use mouse, stylus, or finger pressure sensitivity supported
</p>
</div>
)}
{/* ══ TYPE ══ */}
{mode === 'type' && (
<div>
{/* Name input */}
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your full name"
maxLength={60}
style={{
width: '100%', boxSizing: 'border-box',
padding: '11px 14px', borderRadius: 9,
border: '1.5px solid #e2e8f0', background: '#f8fafc',
fontSize: 14, fontWeight: 500, color: '#0f172a', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={(e) => (e.currentTarget.style.borderColor = '#2563eb')}
onBlur={(e) => (e.currentTarget.style.borderColor = '#e2e8f0')}
/>
{/* Font picker */}
<div style={{ marginTop: 14 }}>
<p style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
Style
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{SIGNATURE_FONTS.map((f, i) => (
<button
key={f.family}
onClick={() => setFontIdx(i)}
style={{
padding: '10px 14px', borderRadius: 9,
border: fontIdx === i ? '1.5px solid #2563eb' : '1.5px solid #e2e8f0',
background: fontIdx === i ? '#eff6ff' : '#f8fafc',
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s',
display: 'flex', flexDirection: 'column', gap: 3,
}}
>
<span style={{ fontSize: 10, fontWeight: 600, color: fontIdx === i ? '#2563eb' : '#94a3b8', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{f.label}
</span>
<span style={{
fontFamily: f.css, fontSize: 26, color: typeColor,
lineHeight: 1.2, display: 'block', maxWidth: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{typed.trim() || 'Preview'}
</span>
</button>
))}
</div>
</div>
{/* Ink color */}
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Color</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{INK_COLORS.map((c) => (
<button
key={c.value}
title={c.label}
onClick={() => setTypeColor(c.value)}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c.value, border: 'none', cursor: 'pointer',
outline: typeColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
outlineOffset: 3, transition: 'all 0.12s',
}}
/>
))}
</div>
</div>
{/* Live preview */}
<div style={{
marginTop: 16, height: 90, borderRadius: 10,
background: 'linear-gradient(180deg,#f8faff 0%,#eef2fb 100%)',
border: '1.5px dashed #c7d7f5',
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
}}>
<span style={{
fontFamily: SIGNATURE_FONTS[fontIdx].css,
fontSize: 54, color: typeColor, lineHeight: 1,
maxWidth: '90%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{typed.trim() || 'Your Signature'}
</span>
</div>
</div>
)}
{/* ══ UPLOAD ══ */}
{mode === 'upload' && (
<div>
<label
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
minHeight: 220, borderRadius: 12, cursor: 'pointer',
border: `2px dashed ${dragging ? '#2563eb' : uploaded ? '#c7d7f5' : '#cbd5e1'}`,
background: dragging ? '#eff6ff' : uploaded ? '#f8faff' : '#f8fafc',
transition: 'all 0.18s', gap: 10,
}}
>
{uploaded ? (
<>
<img
src={uploaded.url}
alt="signature preview"
style={{ maxHeight: 160, maxWidth: '85%', objectFit: 'contain', borderRadius: 6 }}
/>
<span style={{ fontSize: 12, color: '#2563eb', fontWeight: 600, marginTop: 4 }}>
Click to replace
</span>
</>
) : (
<>
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg,#eff6ff,#e0e7ff)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
</svg>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>
Drop image here or click to browse
</p>
<p style={{ fontSize: 11, color: '#94a3b8', margin: '4px 0 0' }}>
PNG with transparent background gives the best result
</p>
</div>
<span style={{
fontSize: 11, fontWeight: 600, color: '#2563eb',
padding: '6px 14px', borderRadius: 7, background: '#eff6ff',
border: '1px solid #bfdbfe',
}}>
Choose file
</span>
</>
)}
<input type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
</label>
{uploaded && (
<button
onClick={() => setUploaded(null)}
style={{
marginTop: 10, width: '100%', padding: '7px 0', borderRadius: 8,
border: '1px solid #fee2e2', background: '#fff5f5', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#dc2626',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
transition: 'all 0.15s',
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
Remove image
</button>
)}
</div>
)}
{/* ══ SAVED ══ */}
{mode === 'saved' && (
<div>
{savedSigs.length === 0 ? (
<div style={{
minHeight: 200, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 10,
}}>
<div style={{
width: 52, height: 52, borderRadius: 14, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>No saved signatures</p>
<p style={{ fontSize: 12, color: '#94a3b8', margin: 0 }}>Your signatures will appear here after use</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{savedSigs.map((s) => (
<div
key={s.id}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 10,
border: '1.5px solid #edf0f5', background: '#f8fafc',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#bfdbfe')}
onMouseLeave={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#edf0f5')}
>
{/* Thumbnail */}
<div style={{
width: 120, height: 52, borderRadius: 8, overflow: 'hidden',
background: '#fff', border: '1px solid #e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<img
src={s.dataUrl}
alt={s.label}
style={{ maxWidth: '95%', maxHeight: '90%', objectFit: 'contain' }}
/>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', margin: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.label}
</p>
<p style={{ fontSize: 11, color: '#94a3b8', margin: '2px 0 0' }}>
{new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<CustomButton
variant="primary"
size="sm"
onClick={() => handleUseSaved(s)}
>
Use
</CustomButton>
<CustomButton
variant="outline"
size="sm"
onClick={() => handleDeleteSaved(s.id)}
title="Delete"
style={{ padding: '0 8px' }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
</CustomButton>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Footer ── */}
{mode !== 'saved' && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 24px', borderTop: '1px solid #edf0f5',
background: '#f8fafc',
}}>
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0 }}>
Visual signature placed as image overlay
</p>
<div style={{ display: 'flex', gap: 8 }}>
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
<CustomButton
variant="primary"
onClick={handleConfirm}
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Use Signature
</CustomButton>
</div>
</div>
)}
{/* Keyframe animation */}
<style>{`
@keyframes sigModalIn {
from { opacity: 0; transform: scale(0.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
{mode === 'draw' && (
<div>
<canvas
ref={canvasRef}
width={920}
height={300}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
style={{ cursor: 'crosshair' }}
/>
<div className="mt-2 flex justify-between">
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
</div>
</div>
)}
{mode === 'type' && (
<div>
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your name"
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
/>
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9]">
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: '#18212e' }}>
{typed || 'Preview'}
</span>
</div>
</div>
)}
{mode === 'upload' && (
<div>
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9] text-[#5b6573] hover:border-[#2563eb]">
{uploaded ? (
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
) : (
<>
<span className="text-[13px] font-semibold">Click to upload an image</span>
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
</>
)}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
</div>
)}
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only not a certified e-signature.</p>
</Modal>
</div>
);
};
+16 -14
View File
@@ -1,7 +1,7 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId } from '../lib/tools';
import { toast } from '../lib/toast';
import { Popover } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
@@ -9,22 +9,23 @@ import {
UnderlineIcon, StrikeoutIcon, SquigglyIcon
} from './icons';
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
interface ToolDef { id: ToolId; label: string; shortLabel?: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
const TOOLS: (ToolDef | 'divider')[] = [
{ id: 'select', label: 'Select & copy text', shortcut: 'V', icon: <SelectIcon /> },
{ id: 'select', label: 'Select & copy text', shortLabel: 'Select', shortcut: 'V', icon: <SelectIcon /> },
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
'divider',
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
{ id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
{
id: 'edit_text',
label: 'Edit text',
shortLabel: 'Edit',
shortcut: 'E',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@@ -36,6 +37,7 @@ const TOOLS: (ToolDef | 'divider')[] = [
{
id: 'stream_edit',
label: 'Raw Text (beta)',
shortLabel: 'Raw',
shortcut: 'Q',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@@ -66,7 +68,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
aria-pressed={active}
aria-disabled={disabled}
onClick={onClick}
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
disabled
? 'cursor-not-allowed text-[#c5cad1]'
: active
@@ -75,15 +77,15 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
}`}
>
{active && !disabled && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
</CustomButton>
);
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
const pickTool = (id: ToolId) => {
if (disabledTools?.has(id)) {
toast("This tool is not permitted by this document's restrictions", 'error');
return;
}
onToolChange(id);
@@ -91,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
};
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[#ebedf0] bg-[#ffffff]" style={{ width: '80px', paddingTop: '14px', paddingBottom: '12px' }}>
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1" />
<div className="flex-1 shrink-0 min-h-[16px]" />
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<InfoIcon size={19} />
</CustomButton>
@@ -108,7 +110,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<HelpIcon size={19} />
</CustomButton>
)}
+48 -7
View File
@@ -1,6 +1,6 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId, ToolSettings } from '../lib/tools';
import type { ToolId, ToolSettings, StampPreset } from '../lib/tools';
import { STAMP_PRESETS } from '../lib/tools';
import { ColorSwatches, Slider } from './ui';
import {
@@ -15,8 +15,14 @@ interface ToolbarProps {
onSettingsChange: (patch: Partial<ToolSettings>) => void;
onOpenSignature: () => void;
hasSignature: boolean;
activeStamp: string | null;
onSelectStamp: (label: string, color: string) => void;
activeStamp?: StampPreset | null;
onSelectStamp: (stamp: StampPreset) => void;
redactionMode?: 'area' | 'text';
onRedactionModeChange?: (mode: 'area' | 'text') => void;
pendingRedactionCount?: number;
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
}
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
@@ -54,6 +60,8 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
}) => {
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
@@ -150,9 +158,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<>
<div className="flex items-center gap-2">
{STAMP_PRESETS.map((s) => (
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s)}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
{s.label}
</CustomButton>
))}
@@ -161,7 +169,40 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</>
)}
{activeTool === 'redact' && <Hint tone="warn"> Drag a box to permanently remove content underneath.</Hint>}
{activeTool === 'redact' && (
<>
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('area')}
>
Area
</button>
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('text')}
>
Text
</button>
</div>
<CustomButton variant="outline" size="sm" onClick={onRedactPages}>
Redact Pages
</CustomButton>
<Divider />
{pendingRedactionCount > 0 ? (
<>
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
</CustomButton>
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
Clear
</CustomButton>
</>
) : (
<Hint tone="warn"> Select text or drag a box to permanently remove content.</Hint>
)}
</>
)}
</div>
</div>
);
+2 -30
View File
@@ -1,7 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { subscribeToasts, dismissToast } from '../lib/toast';
import type { ToastItem } from '../lib/toast';
import { XIcon, CheckIcon, InfoIcon } from './icons';
import { XIcon } from './icons';
import { CustomButton } from './custom/CustomButton';
interface PopoverProps {
@@ -189,30 +187,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
</Modal>
);
const toastStyles: Record<ToastItem['kind'], string> = {
info: 'border-[#ebedf0] bg-[#18212e] text-white',
success: 'border-transparent bg-[#16a34a] text-white',
error: 'border-transparent bg-[#dc2626] text-white',
};
export const ToastViewport: React.FC = () => {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => subscribeToasts(setItems), []);
return (
<div className="pointer-events-none fixed bottom-5 left-1/2 z-[300] flex -translate-x-1/2 flex-col items-center gap-2">
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${toastStyles[t.kind]}`}
style={{ animation: 'toastIn 0.18s ease-out' }}
>
{t.kind === 'success' && <CheckIcon size={15} />}
{t.kind === 'error' && <XIcon size={15} />}
{t.kind === 'info' && <InfoIcon size={15} />}
<span>{t.message}</span>
<CustomButton variant="unstyled" className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></CustomButton>
</div>
))}
</div>
);
};
+7
View File
@@ -24,6 +24,13 @@
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
/* Micro scrollbar (used by narrow sidebars) */
.scroll-micro { scrollbar-width: thin; }
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
/* Animations */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
+35 -1
View File
@@ -136,6 +136,20 @@ export interface TextOverlayData {
color: string;
}
export interface StampData {
text: string;
x: number;
y: number;
width: number;
height: number;
textColor: string;
backgroundColor: string;
borderColor: string;
fontSize: number;
includeDate: boolean;
timestamp?: string;
}
export interface RedactionData {
x: number;
y: number;
@@ -207,6 +221,7 @@ export interface PageReorderData {
export type EditOperationDataMap = {
text_overlay: TextOverlayData;
stamp: StampData;
redaction: RedactionData;
image_overlay: ImageOverlayData;
highlight: HighlightData;
@@ -420,7 +435,14 @@ class GatewayService {
}
}
private renderCache = new Map<string, string>();
async renderPage(params: RenderParams): Promise<string> {
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 +459,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);
}
+4 -2
View File
@@ -194,8 +194,10 @@ export class TextSelectionModel {
}
const rects: SelRect[] = [];
for (const arr of byLine.values()) {
const x = Math.min(...arr.map((g) => g.x));
const right = Math.max(...arr.map((g) => g.right));
const nonSpace = arr.filter(g => !/^\s+$/.test(g.text));
const measureArr = nonSpace.length > 0 ? nonSpace : arr;
const x = Math.min(...measureArr.map((g) => g.x));
const right = Math.max(...measureArr.map((g) => g.right));
const band = this.lines[arr[0].line];
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
}
+14 -7
View File
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
q: 'stream_edit',
};
export const STAMP_PRESETS = [
{ label: 'APPROVED', color: '#16a34a' },
{ label: 'DRAFT', color: '#6b7280' },
{ label: 'CONFIDENTIAL', color: '#dc2626' },
{ label: 'REVIEWED', color: '#2563eb' },
{ label: 'FINAL', color: '#7c3aed' },
{ label: 'VOID', color: '#dc2626' },
export interface StampPreset {
label: string;
textColor: string;
backgroundColor: string;
borderColor: string;
}
export const STAMP_PRESETS: StampPreset[] = [
{ label: 'APPROVED', textColor: '#16a34a', backgroundColor: '#dcfce7', borderColor: '#16a34a' },
{ label: 'DRAFT', textColor: '#6b7280', backgroundColor: '#f3f4f6', borderColor: '#6b7280' },
{ label: 'CONFIDENTIAL', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
{ label: 'REVIEWED', textColor: '#2563eb', backgroundColor: '#dbeafe', borderColor: '#2563eb' },
{ label: 'FINAL', textColor: '#7c3aed', backgroundColor: '#ede9fe', borderColor: '#7c3aed' },
{ label: 'VOID', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
];
+53 -7
View File
@@ -12,6 +12,7 @@ export interface Annotation {
content?: string;
timestamp?: string;
paths?: { x: number; y: number }[][];
quadPoints?: { x: number; y: number }[][];
pageIndex?: number;
fieldName?: string;
@@ -114,19 +115,28 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onPointerDown={(e) => handlePointerDown(e, anno)}
onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
style={{
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
width: `${scaledBbox.width}px`,
height: `${scaledBbox.height}px`,
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
pointerEvents: 'auto',
}}
title={tooltipText}
>
{anno.type === 'highlight' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMin * zoom - scaledBbox.y;
return <div key={i} className="absolute" style={{ left, top, width: (xMax - xMin) * zoom, height: (yMax - yMin) * zoom, backgroundColor: anno.color || '#ffeb3b', opacity: anno.opacity ?? 0.4, mixBlendMode: 'multiply' }} />
})}
{anno.type === 'comment' && (
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
@@ -134,9 +144,27 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
</svg>
</div>
)}
{anno.type === 'strikeout' && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
{anno.type === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
{anno.type === 'squiggly' && (
{anno.type === 'strikeout' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
{anno.type === 'strikeout' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMin * zoom - scaledBbox.y + ((yMax - yMin) * zoom / 2);
return <div key={i} className="absolute h-[1.5px] opacity-[0.85] -translate-y-1/2" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#dc2626' }} />
})}
{anno.type === 'underline' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
return <div key={i} className="absolute h-[1.5px] opacity-[0.85]" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#2563eb' }} />
})}
{anno.type === 'squiggly' && (!anno.quadPoints || anno.quadPoints.length === 0) && (
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
<defs>
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
@@ -146,6 +174,24 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
</svg>
)}
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
return (
<svg key={i} style={{position: 'absolute', left, top, width: (xMax - xMin) * zoom, height: 4, opacity: 0.85}} xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`sq-${anno.id}-${i}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
<path d="M 0 2 Q 1.5 0 3 2 T 6 2" fill="none" stroke={anno.color || '#16a34a'} strokeWidth="1.2" />
</pattern>
</defs>
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id}-${i})`} />
</svg>
)
})}
{anno.type === 'signature' && (
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+206
View File
@@ -0,0 +1,206 @@
import React, { useState, useRef, useEffect } from 'react';
import type { Rect } from '../lib/coordinateMapping';
interface FloatingTextToolbarProps {
selection: { text: string; bbox: Rect; lines: Rect[] };
onAction: (action: 'copy' | 'comment' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'redact' | 'edit', overrideColor?: string) => void;
}
const COLORS = [
'#facc15', '#4ade80', '#2dd4bf', '#a78bfa', '#e879f9',
'#fb923c', '#60a5fa', '#f472b6', '#22d3ee', '#34d399',
'#16a34a', '#a855f7', '#2563eb', '#fef08a', '#ef4444',
'#ffffff', '#e5e5e5', '#a3a3a3', '#52525b', '#000000',
];
export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ selection, onAction }) => {
const { bbox } = selection;
// Calculate position: just above the bounding box
const top = bbox.y - 48; // 48px above
const left = bbox.x;
const [openDropdown, setOpenDropdown] = useState<'highlight' | 'underline' | 'strikeout' | null>(null);
const [toolColors, setToolColors] = useState({
highlight: '#facc15',
underline: '#f43f5e',
strikeout: '#ef4444',
});
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenDropdown(null);
}
};
if (openDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [openDropdown]);
const handleAction = (action: 'highlight' | 'underline' | 'strikeout') => {
onAction(action, toolColors[action]);
setOpenDropdown(null);
};
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="grid grid-cols-5 gap-1">
{COLORS.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
onAction(action, c);
setOpenDropdown(null);
}}
/>
))}
</div>
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
</div>
</div>
</div>
);
return (
<div
ref={menuRef}
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
e.stopPropagation();
}}
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Copy"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Add Comment"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="12" y1="9" x2="12" y2="15" />
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="relative flex items-center group">
<button
onClick={() => handleAction('highlight')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Highlight"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 11-6 6v3h9l3-3"/>
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>
<path d="M12 21h10" strokeWidth="3" stroke={toolColors.highlight} />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'highlight' && <ColorPicker action="highlight" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('underline')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Underline"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4v6a6 6 0 0 0 12 0V4"/>
<line x1="4" y1="20" x2="20" y2="20" stroke={toolColors.underline} strokeWidth="3" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'underline' && <ColorPicker action="underline" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('strikeout')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Strikeout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4H9a3 3 0 0 0-2.83 4"/>
<path d="M14 12a4 4 0 0 1 0 8H6"/>
<line x1="4" y1="12" x2="20" y2="12" stroke={toolColors.strikeout} strokeWidth="2.5" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<button
onClick={() => onAction('redact')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Redact Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z"/>
<path d="M3 3l18 18"/>
</svg>
Redact Text
</button>
<button
onClick={() => onAction('edit')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Edit Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
<path d="M15 5l4 4"/>
</svg>
Edit Text
</button>
</div>
);
};
+14 -5
View File
@@ -2,6 +2,7 @@ import { CustomButton } from '../components/custom/CustomButton';
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping';
import type { StampPreset } from '../lib/tools';
interface OverlayLayerProps {
pageIndex: number;
@@ -14,11 +15,12 @@ interface OverlayLayerProps {
textColor: string;
fontSize: number;
hasSignature: boolean;
activeStamp: string | null;
activeStamp: StampPreset | null;
onAnnotationAdded?: (anno: Annotation) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
/** Called when user clicks to begin interactive signature placement */
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
}
const TEXTBOX_WIDTH_PTS = 200;
@@ -28,7 +30,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
pageIndex, width, height, activeTool, zoom,
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
}) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
@@ -88,7 +90,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
} else if (activeTool === 'stamp' && activeStamp) {
onPlaceStamp?.(pageIndex, coords);
} else if (activeTool === 'signature' && hasSignature) {
onPlaceSignature?.(pageIndex, coords);
// Pass viewport coordinates (relative to page) to begin interactive placement
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
onBeginPlacement?.(pageIndex, {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
@@ -135,7 +144,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp}</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp.label}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
+176 -26
View File
@@ -11,11 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
import type { ToolSettings } from '../lib/tools';
import { toast } from '../lib/toast';
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
import { RedactionLayer } from './RedactionLayer';
import { StreamEditLayer } from './StreamEditLayer';
import { wasmFreeDocument } from '../lib/pdfiumEngine';
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
import type { PlacementRect } from './SignaturePlacementOverlay';
import { FloatingTextToolbar } from './FloatingTextToolbar';
interface PDFViewerProps {
documentId: string;
@@ -24,10 +27,14 @@ interface PDFViewerProps {
pageHeight: number;
zoom: number;
pagesInfo?: PageInfo[];
activeTool: string;
activeTool: ToolId;
toolSettings: ToolSettings;
redactionMode?: 'area' | 'text';
hasSignature: boolean;
activeStamp: string | null;
/** The data-URL of the pending signature image (for the placement preview) */
signatureImageUrl?: string;
signatureAspect?: number;
activeStamp: StampPreset | null;
annotations: Annotation[];
searchQuery?: string;
searchResults?: SearchResult[];
@@ -36,13 +43,16 @@ interface PDFViewerProps {
onAnnotationUpdate?: (anno: Annotation) => void;
onAnnotationClick?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
onMarkRedaction?: (pageIndex: number, bounds: Rect) => void;
pendingRedactions?: { id: string, pageIndex: number, bounds: Rect }[];
onRemoveRedaction?: (id: string) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onStreamDocumentChanged?: (newDocumentId: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
/** Called with final PDF-space rect after user commits interactive placement */
onPlaceSignature?: (pageIndex: number, pdfRect: Rect, rotation: number) => void;
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
canCopy?: boolean;
@@ -70,7 +80,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
pagesInfo,
activeTool,
toolSettings,
redactionMode = 'area',
hasSignature,
signatureImageUrl,
signatureAspect = 3,
activeStamp,
annotations,
searchQuery,
@@ -80,7 +93,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onAnnotationUpdate,
onAnnotationClick,
onPageVisible,
onRedactArea,
onMarkRedaction,
pendingRedactions = [],
onRemoveRedaction,
onPlaceText,
onEditText,
onReflowParagraph,
@@ -100,6 +115,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const prevDocumentIdRef = useRef<string>(documentId);
const inFlightRenderRef = useRef<Set<number>>(new Set());
const inFlightTextRef = useRef<Set<number>>(new Set());
/** Active interactive signature placement state */
const [activePlacement, setActivePlacement] = useState<{
pageIndex: number;
rect: PlacementRect;
} | null>(null);
const [textSelection, setTextSelection] = useState<{
pageIndex: number;
text: string;
bbox: Rect;
lines: Rect[];
} | null>(null);
useEffect(() => {
setPageTexts({});
const prev = prevDocumentIdRef.current;
@@ -294,18 +323,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
}, [textToolActive, visiblePages, documentId, pageTexts]);
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
// This is still called on Ctrl+C for select mode
if (activeTool === 'select') {
if (!canCopy) {
toast("Copying is not permitted by this document's restrictions", 'error');
return;
}
if (!canCopy) return;
if (text.trim()) {
navigator.clipboard?.writeText(text).then(
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
() => toast('Copy failed — clipboard unavailable', 'error'),
);
} else {
toast('No selectable text in that area', 'info');
navigator.clipboard?.writeText(text).catch(() => {});
}
return;
}
@@ -334,7 +356,90 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
toolSettings.squigglyColor;
onDecorateText?.(pageIndex, lines, activeTool, color);
return;
}
if (activeTool === 'redact') {
if (lines.length > 0) {
lines.forEach(line => {
onMarkRedaction?.(pageIndex, {
x: line.x / zoom,
y: line.y / zoom,
width: line.width / zoom,
height: line.height / zoom,
});
});
}
return;
}
};
const handleToolbarAction = (action: string, overrideColor?: string) => {
if (!textSelection) return;
const { pageIndex, text, bbox, lines } = textSelection;
switch (action) {
case 'copy':
if (!canCopy) break;
navigator.clipboard?.writeText(text).catch(() => {});
break;
case 'highlight': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'highlight',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom,
},
color: overrideColor || toolSettings.highlightColor,
opacity: toolSettings.highlightOpacity,
author: 'Current User',
content: text,
};
onAnnotationAdded?.(newAnno);
break;
}
case 'underline':
onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor);
break;
case 'strikeout':
onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor);
break;
case 'squiggly':
onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor);
break;
case 'redact':
onMarkRedaction?.(pageIndex, {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom
});
break;
case 'edit':
break;
case 'comment': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'comment',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: 24,
height: 24,
},
color: '#facc15',
author: 'Current User',
content: 'New Comment\n\n' + text,
};
onAnnotationAdded?.(newAnno);
break;
}
}
setTextSelection(null);
};
const [isPanning, setIsPanning] = useState(false);
@@ -452,27 +557,36 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onFieldChange={onFieldChange}
/>
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && (
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || (activeTool === 'redact' && redactionMode === 'text')) && (
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
glyphs={pageTexts[page.index] || []}
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? 'highlight' : 'select'}
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
/>
)}
{activeTool === 'redact' && (
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
{textSelection && textSelection.pageIndex === page.index && (
<FloatingTextToolbar
selection={textSelection}
onAction={(action, color) => handleToolbarAction(action as any, color)}
/>
)}
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
/>
<OverlayLayer
pageIndex={page.index}
width={page.width}
@@ -488,7 +602,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onAnnotationAdded={onAnnotationAdded}
onPlaceText={onPlaceText}
onPlaceStamp={onPlaceStamp}
onPlaceSignature={onPlaceSignature}
onBeginPlacement={(pageIdx, viewportPt) => {
// Compute a sensible initial rect centred on the click point
const pageInfo = pagesInfo?.[pageIdx];
const pgW = pageInfo ? pageInfo.width * zoom : page.width;
const pgH = pageInfo ? pageInfo.height * zoom : page.height;
const sigW = Math.min(180 * zoom, pgW * 0.5);
const sigH = sigW / (signatureAspect || 3);
const x = Math.max(0, Math.min(pgW - sigW, viewportPt.x - sigW / 2));
const y = Math.max(0, Math.min(pgH - sigH, viewportPt.y - sigH / 2));
setActivePlacement({
pageIndex: pageIdx,
rect: { x, y, width: sigW, height: sigH, rotation: 0 },
});
}}
/>
{activeTool === 'edit_text' && (
@@ -540,6 +667,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
/>
{/* Interactive signature placement overlay */}
{activePlacement && activePlacement.pageIndex === page.index && signatureImageUrl && (
<SignaturePlacementOverlay
imageUrl={signatureImageUrl}
aspect={signatureAspect || 3}
initialRect={activePlacement.rect}
pageWidth={page.width}
pageHeight={page.height}
onCommit={(finalRect) => {
setActivePlacement(null);
// Convert viewport rect to PDF-space coordinates
const pageInfo = pagesInfo?.[page.index];
const pageHPts = pageInfo ? pageInfo.height : (pageHeight || 792);
const pdfX = finalRect.x / zoom;
const pdfW = finalRect.width / zoom;
const pdfH = finalRect.height / zoom;
const pdfY = pageHPts - (finalRect.y / zoom) - pdfH;
onPlaceSignature?.(page.index, { x: pdfX, y: pdfY, width: pdfW, height: pdfH }, finalRect.rotation);
}}
onCancel={() => setActivePlacement(null)}
/>
)}
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
+28 -1
View File
@@ -6,12 +6,18 @@ interface RedactionLayerProps {
width: number;
height: number;
onRedactionSelected: (bounds: Rect) => void;
pendingRedactions?: { id: string, bounds: Rect }[];
onRemoveRedaction?: (id: string) => void;
mode?: 'area' | 'text';
}
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
width,
height,
onRedactionSelected,
pendingRedactions = [],
onRemoveRedaction,
mode = 'area',
}) => {
const [dragStart, setDragStart] = useState<Point | null>(null);
const [redactBox, setRedactBox] = useState<Rect | null>(null);
@@ -62,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
left: 0,
width: `${width}px`,
height: `${height}px`,
cursor: 'crosshair',
cursor: mode === 'area' ? 'crosshair' : 'default',
zIndex: 25,
pointerEvents: mode === 'area' ? 'auto' : 'none',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -86,6 +93,26 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
}}
/>
)}
{pendingRedactions.map((redaction) => (
<div
key={redaction.id}
className="group absolute"
style={{
left: `${redaction.bounds.x}px`,
top: `${redaction.bounds.y}px`,
width: `${redaction.bounds.width}px`,
height: `${redaction.bounds.height}px`,
border: '2px solid #ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.2)',
zIndex: 30,
cursor: 'pointer',
pointerEvents: 'auto',
}}
>
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
</div>
))}
</div>
);
};
+13 -4
View File
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
glyphs: Glyph[];
mode?: 'select' | 'highlight';
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
}
const SEL_START_EVT = 'pdf-selection-start';
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
glyphs,
mode = 'select',
onTextSelected,
onSelectionChange,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
setBox(null);
drag.current = null;
boxStart.current = null;
}, []);
onSelectionChange?.(null);
}, [onSelectionChange]);
useEffect(() => {
const onOther = (e: Event) => {
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
const cur = selRef.current;
if (!cur || cur.start === cur.end) {
setSel(null);
onSelectionChange?.(null);
return;
}
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (mode === 'highlight') {
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
setSel(null);
onSelectionChange?.(null);
} else if (mode === 'select') {
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
}
};
@@ -0,0 +1,308 @@
/**
* SignaturePlacementOverlay
* Adobe-style interactive signature placement: drag to reposition,
* corner/edge handles to resize, rotation handle to rotate.
* Sits as an absolute child inside the page div.
*/
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { CustomButton } from '../components/custom/CustomButton';
export interface PlacementRect {
/** All values in viewport-pixels relative to the page div */
x: number;
y: number;
width: number;
height: number;
rotation: number; // degrees
}
interface Props {
imageUrl: string;
aspect: number;
initialRect: PlacementRect;
pageWidth: number; // viewport px
pageHeight: number; // viewport px
onCommit: (rect: PlacementRect) => void;
onCancel: () => void;
}
type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'move';
const MIN_SIZE = 40;
const HANDLE_CURSORS: Record<Handle, string> = {
nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize',
e: 'ew-resize', se: 'nwse-resize', s: 'ns-resize',
sw: 'nesw-resize', w: 'ew-resize',
rotate: 'grab', move: 'move',
};
const HANDLE_POSITIONS: Record<Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }> = {
nw: { left: '-5px', top: '-5px' },
n: { left: 'calc(50% - 5px)', top: '-5px' },
ne: { left: 'calc(100% - 5px)', top: '-5px' },
e: { left: 'calc(100% - 5px)', top: 'calc(50% - 5px)' },
se: { left: 'calc(100% - 5px)', top: 'calc(100% - 5px)' },
s: { left: 'calc(50% - 5px)', top: 'calc(100% - 5px)' },
sw: { left: '-5px', top: 'calc(100% - 5px)' },
w: { left: '-5px', top: 'calc(50% - 5px)' },
};
export const SignaturePlacementOverlay: React.FC<Props> = ({
imageUrl, aspect, initialRect, pageWidth, pageHeight, onCommit, onCancel,
}) => {
const [rect, setRect] = useState<PlacementRect>(initialRect);
const rectRef = useRef(rect);
rectRef.current = rect;
const dragRef = useRef<{
handle: Handle;
startX: number; startY: number;
startRect: PlacementRect;
centerX: number; centerY: number;
} | null>(null);
/* Clamp rect to page bounds */
const clamp = useCallback((r: PlacementRect): PlacementRect => {
const w = Math.max(MIN_SIZE, r.width);
const h = Math.max(MIN_SIZE, r.height);
const x = Math.max(0, Math.min(pageWidth - w, r.x));
const y = Math.max(0, Math.min(pageHeight - h, r.y));
return { ...r, x, y, width: w, height: h };
}, [pageWidth, pageHeight]);
const onPointerDown = useCallback((e: React.PointerEvent, handle: Handle) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const r = rectRef.current;
dragRef.current = {
handle,
startX: e.clientX,
startY: e.clientY,
startRect: { ...r },
centerX: r.x + r.width / 2,
centerY: r.y + r.height / 2,
};
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
const { startRect, handle } = d;
setRect(() => {
let { x, y, width, height, rotation } = startRect;
if (handle === 'move') {
x = startRect.x + dx;
y = startRect.y + dy;
} else if (handle === 'rotate') {
// Angle from center to current mouse (uses dragRef center, not local cx/cy)
const angle = Math.atan2(
(e.clientY - d.centerY),
(e.clientX - d.centerX),
) * 180 / Math.PI + 90;
rotation = angle;
} else {
// Resize — maintain aspect ratio on corners if Shift pressed (we do it always for corners)
const isCorner = ['nw', 'ne', 'se', 'sw'].includes(handle);
switch (handle) {
case 'n': y = startRect.y + dy; height = startRect.height - dy; break;
case 's': height = startRect.height + dy; break;
case 'e': width = startRect.width + dx; break;
case 'w': x = startRect.x + dx; width = startRect.width - dx; break;
case 'nw': x = startRect.x + dx; y = startRect.y + dy; width = startRect.width - dx; height = startRect.height - dy; break;
case 'ne': y = startRect.y + dy; width = startRect.width + dx; height = startRect.height - dy; break;
case 'se': width = startRect.width + dx; height = startRect.height + dy; break;
case 'sw': x = startRect.x + dx; width = startRect.width - dx; height = startRect.height + dy; break;
}
if (isCorner && e.shiftKey) {
// Lock aspect ratio
const newAspect = width / height;
if (newAspect > aspect) width = height * aspect;
else height = width / aspect;
}
}
return clamp({ x, y, width, height, rotation });
});
};
const onUp = () => { dragRef.current = null; };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [clamp, aspect]);
/* Keyboard: Escape = cancel, Enter = commit */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter') { e.preventDefault(); onCommit(rectRef.current); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel, onCommit]);
const { x, y, width, height, rotation } = rect;
const cx = x + width / 2;
return (
<>
{/* Dim everything outside the signature */}
<div
style={{
position: 'absolute', inset: 0, zIndex: 60,
cursor: 'default',
background: 'rgba(15,23,42,0.18)',
pointerEvents: 'auto',
}}
onPointerDown={(e) => { e.stopPropagation(); onCancel(); }}
/>
{/* Signature overlay box */}
<div
style={{
position: 'absolute',
left: x, top: y, width, height,
zIndex: 61,
transform: `rotate(${rotation}deg)`,
transformOrigin: `${width / 2}px ${height / 2}px`,
boxSizing: 'border-box',
outline: '2px solid #2563eb',
outlineOffset: '-1px',
borderRadius: 3,
cursor: 'move',
pointerEvents: 'auto',
userSelect: 'none',
}}
onPointerDown={(e) => onPointerDown(e, 'move')}
>
{/* Signature image */}
<img
src={imageUrl}
alt="signature"
draggable={false}
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
/>
{/* Corner + edge handles */}
{(Object.entries(HANDLE_POSITIONS) as [Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }][]).map(([h, pos]) => (
<div
key={h}
onPointerDown={(e) => onPointerDown(e, h)}
style={{
position: 'absolute',
left: pos.left, top: pos.top,
width: 10, height: 10,
background: '#ffffff',
border: '2px solid #2563eb',
borderRadius: 2,
cursor: HANDLE_CURSORS[h],
zIndex: 2,
boxShadow: '0 1px 4px rgba(0,0,0,0.18)',
touchAction: 'none',
}}
/>
))}
{/* Rotation handle — sits above the top center */}
<div
onPointerDown={(e) => onPointerDown(e, 'rotate')}
title="Rotate"
style={{
position: 'absolute',
left: 'calc(50% - 9px)',
top: -34,
width: 18, height: 18,
background: '#2563eb',
borderRadius: '50%',
cursor: 'grab',
zIndex: 3,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(37,99,235,0.4)',
touchAction: 'none',
}}
>
{/* Rotation stem */}
<div style={{
position: 'absolute', left: '50%', top: '100%',
width: 1.5, height: 14,
background: '#2563eb',
transform: 'translateX(-50%)',
}} />
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'relative', zIndex: 1 }}>
<path d="M21.5 2v6h-6"/><path d="M21.34 15.57a10 10 0 1 1-.57-8.38"/>
</svg>
</div>
{/* Size badge */}
<div style={{
position: 'absolute',
bottom: -22, left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.75)',
color: '#fff',
fontSize: 10,
fontWeight: 600,
padding: '2px 7px',
borderRadius: 99,
whiteSpace: 'nowrap',
pointerEvents: 'none',
fontFamily: 'monospace',
}}>
{Math.round(width)} × {Math.round(height)}
</div>
</div>
{/* Action bar — centered below the signature box */}
<div
style={{
position: 'absolute',
left: cx - 130,
top: y + height + 48,
zIndex: 62,
display: 'flex',
gap: 8,
alignItems: 'center',
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 12,
padding: '8px 12px',
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
pointerEvents: 'auto',
whiteSpace: 'nowrap',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Keyboard hint */}
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>
Drag · Resize · <kbd style={{ background: '#f1f5f9', padding: '1px 4px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 10 }}>Shift</kbd> lock ratio
</span>
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
{/* Cancel */}
<CustomButton variant="outline" size="sm" onClick={onCancel}>
Cancel <span style={{ opacity: 0.5, fontSize: 10 }}>Esc</span>
</CustomButton>
{/* Confirm */}
<CustomButton variant="primary" size="sm" onClick={() => onCommit(rect)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Apply Signature
<span style={{ opacity: 0.6, fontSize: 10 }}></span>
</CustomButton>
</div>
</>
);
};
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { gatewayService } from '../lib/gatewayService';
import type { TextObjectResponse } from '../lib/gatewayService';
import { toast } from '../lib/toast';
import { loadPdfFont } from '../lib/fontFaceLoader';
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
if (newText === obj.text) return;
try {
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
toast('Text updated successfully', 'success');
if (res.newDocumentId && onDocumentChanged) {
onDocumentChanged(res.newDocumentId);
} else {
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
onEditSuccess();
}
} catch (e: any) {
toast(`Failed to update text: ${e.message}`, 'error');
console.error('Failed to update text:', e.message);
}
};
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.pyo
*.pyd
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
*.log
.DS_Store
.git/
.gitignore
+119
View File
@@ -0,0 +1,119 @@
# Stage 1: Builder
FROM python:3.11-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
VCPKG_FORCE_SYSTEM_BINARIES=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
pkg-config \
git \
fonts-liberation \
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 \
fonts-liberation \
&& 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 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 gateway/app ./app
COPY gateway/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"]
+11
View File
@@ -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)
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.routers import documents, edits, health, 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():
+2
View File
@@ -111,7 +111,9 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
content=a.content,
timestamp=getattr(a, "timestamp", None),
pageIndex=a.page_index,
thickness=getattr(a, "thickness", None),
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
quadPoints=getattr(a, "quad_points", []),
fieldName=getattr(a, "field_name", None),
fieldValue=getattr(a, "field_value", None),
fieldType=getattr(a, "field_type", None),
+46 -5
View File
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
fontFamily: str
color: str
class StampData(BaseModel):
text: str
x: float
y: float
width: float
height: float
textColor: str
backgroundColor: str
borderColor: str
fontSize: float = Field(..., gt=0)
includeDate: bool = False
timestamp: str | None = None
class RedactionData(BaseModel):
x: float
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
pageIndex: int = Field(..., ge=0)
data: TextOverlayData
class StampOperation(BaseModel):
id: str
type: Literal["stamp"]
pageIndex: int = Field(..., ge=0)
data: StampData
class RedactionOperation(BaseModel):
id: str
@@ -283,8 +302,23 @@ class SquigglyOperation(BaseModel):
pageIndex: int = Field(..., ge=0)
data: DecorationData
class SignatureData(BaseModel):
x: float
y: float
width: float
height: float
image_data: str | None = None
author: str = "Signer"
class SignatureOperation(BaseModel):
id: str
type: Literal["signature"]
pageIndex: int = Field(..., ge=0)
data: SignatureData
EditOperation = Annotated[
TextOverlayOperation
| StampOperation
| RedactionOperation
| ImageOverlayOperation
| HighlightOperation
@@ -301,7 +335,9 @@ EditOperation = Annotated[
| ReflowParagraphOperation
| UnderlineOperation
| StrikeoutOperation
| SquigglyOperation,
| SquigglyOperation
| SignatureOperation
,
Field(discriminator="type"),
]
@@ -313,8 +349,8 @@ class EditsRequest(BaseModel):
_OP_PERMISSION = {
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
"image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
"update_field": "canFillForms",
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
@@ -361,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1]
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
@@ -371,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
bgra_bytes = img_bgra.tobytes()
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
temp_path = temp_path.replace("\\", "/")
created_temp_files.append(temp_path)
try:
with os.fdopen(fd, "wb") as tmp:
@@ -388,7 +426,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", []))
@@ -400,8 +438,11 @@ 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"]}
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:
+20
View File
@@ -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,
}
+135 -5
View File
@@ -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,127 @@ 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"
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@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,
zoom: float = 1.0,
rotation: int = 0,
render_mode: RenderMode = RenderMode.NORMAL
) -> 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)
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")
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"
@@ -72,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}")
+2
View File
@@ -13,7 +13,9 @@ class AnnotationResponse(BaseModel):
content: str
timestamp: str | None = None
pageIndex: int
thickness: float | None = None
paths: list[list[dict[str, float]]] = []
quadPoints: list[list[dict[str, float]]] = []
fieldName: str | None = None
fieldValue: str | None = None
+222
View File
@@ -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()
+2
View File
@@ -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,
+12 -8
View File
@@ -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}"