Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc846cf17 | ||
|
|
626156642e | ||
|
|
185cc03df7 | ||
|
|
a707fb5216 | ||
|
|
66f7cd345c | ||
|
|
178e4df163 | ||
|
|
3775359ab2 | ||
|
|
27538a9489 | ||
|
|
67b5aed642 | ||
|
|
b15aed60d1 | ||
|
|
ad51b4b270 | ||
|
|
f9ec4e1680 | ||
|
|
f8af0aa611 | ||
|
|
82a07d2e6e | ||
|
|
f667db1f75 | ||
|
|
10775936de | ||
|
|
62ad42b116 | ||
|
|
46ec78e9b4 | ||
|
|
99f87eb7cf | ||
|
|
880e56b98b | ||
|
|
ef59f2ec83 | ||
|
|
72ed04f2ed | ||
|
|
a383bd8704 | ||
|
|
90027823e4 | ||
|
|
5b034c97b8 | ||
|
|
bc3ed20a8b | ||
|
|
abcd407c35 | ||
|
|
afa8c0a702 |
@@ -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
|
||||
+3
-1
@@ -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")
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {}; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.git/
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
␍
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Vendored
+12
-8
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user