Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a766d6d0eb | ||
|
|
9134b5e205 | ||
|
|
676fec5c8b | ||
|
|
bcdc1fca14 | ||
|
|
33ecf9be84 | ||
|
|
6a40ec738d | ||
|
|
da872bf4eb | ||
|
|
ca3d124b0d | ||
|
|
dde7829966 | ||
|
|
94b7f28d9c | ||
|
|
00c8839cdc | ||
|
|
79f32024da | ||
|
|
15b756e7a2 | ||
|
|
671697d161 | ||
|
|
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(harfbuzz CONFIG REQUIRED)
|
||||||
find_package(spdlog CONFIG REQUIRED)
|
find_package(spdlog CONFIG REQUIRED)
|
||||||
find_package(nlohmann_json 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)
|
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}/lib")
|
||||||
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/debug/lib")
|
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/debug/lib")
|
||||||
|
|||||||
@@ -6,14 +6,21 @@
|
|||||||
"name": "win-local",
|
"name": "win-local",
|
||||||
"displayName": "Windows • Debug (local — build dir outside OneDrive/spaces)",
|
"displayName": "Windows • Debug (local — build dir outside OneDrive/spaces)",
|
||||||
"inherits": "windows-debug",
|
"inherits": "windows-debug",
|
||||||
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local"
|
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local",
|
||||||
|
"cacheVariables": {
|
||||||
|
"PDFENGINE_WITH_PDFIUM": "ON",
|
||||||
|
"PDFENGINE_WITH_QPDF": "ON"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "win-local-pdfium",
|
"name": "win-local-pdfium",
|
||||||
"displayName": "Windows • RelWithDebInfo + PDFium (static CRT — build dir outside OneDrive/spaces)",
|
"displayName": "Windows • RelWithDebInfo + PDFium (static CRT — build dir outside OneDrive/spaces)",
|
||||||
"inherits": "windows-release",
|
"inherits": "windows-release",
|
||||||
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local-pdfium",
|
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local-pdfium",
|
||||||
"cacheVariables": { "PDFENGINE_WITH_PDFIUM": "ON" }
|
"cacheVariables": {
|
||||||
|
"PDFENGINE_WITH_PDFIUM": "ON",
|
||||||
|
"PDFENGINE_WITH_QPDF": "ON"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"buildPresets": [
|
"buildPresets": [
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
---
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -6,7 +6,7 @@ find_package(pybind11 CONFIG REQUIRED)
|
|||||||
# Declare the python module target. We name the target pdfengine_py to avoid
|
# Declare the python module target. We name the target pdfengine_py to avoid
|
||||||
# target collision with the static C++ library pdfengine, but we set the
|
# target collision with the static C++ library pdfengine, but we set the
|
||||||
# OUTPUT_NAME to pdfengine to produce the correct importable module.
|
# OUTPUT_NAME to pdfengine to produce the correct importable module.
|
||||||
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp)
|
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp $<TARGET_OBJECTS:pdfengine>)
|
||||||
|
|
||||||
set_target_properties(pdfengine_py PROPERTIES
|
set_target_properties(pdfengine_py PROPERTIES
|
||||||
OUTPUT_NAME "pdfengine"
|
OUTPUT_NAME "pdfengine"
|
||||||
|
|||||||
@@ -341,12 +341,27 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
||||||
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
||||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
|
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
|
||||||
|
.def_readonly("thickness", &pdfengine::PdfPage::AnnotationInfo::thickness)
|
||||||
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
|
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
|
||||||
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
|
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
|
||||||
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
|
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
|
||||||
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
|
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
|
||||||
.def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags)
|
.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")
|
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
||||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||||
@@ -359,6 +374,11 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
return py::make_tuple(img.width, img.height,
|
return py::make_tuple(img.width, img.height,
|
||||||
py::bytes(reinterpret_cast<const char*>(img.data.data()), img.data.size()));
|
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)
|
}, 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) {
|
.def("extract_document_model", [](const pdfengine::PdfPage& self) {
|
||||||
return get_or_throw(self.extractDocumentModel());
|
return get_or_throw(self.extractDocumentModel());
|
||||||
})
|
})
|
||||||
@@ -483,8 +503,22 @@ PYBIND11_MODULE(pdfengine, m) {
|
|||||||
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
|
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
|
||||||
}, py::arg("internal_font_id"))
|
}, py::arg("internal_font_id"))
|
||||||
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
|
.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"))
|
}, py::arg("edits_json"))
|
||||||
|
.def("last_reflow_layout", [](const pdfengine::PdfDocument& self) {
|
||||||
|
return self.lastReflowLayout();
|
||||||
|
})
|
||||||
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
|
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
|
||||||
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
|
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
|
||||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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: 8765
|
||||||
|
ports:
|
||||||
|
- "8765:8765"
|
||||||
|
volumes:
|
||||||
|
- ./gateway:/home/app
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/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:8765
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
restart: unless-stopped
|
||||||
+31
-28
@@ -5,32 +5,19 @@ configure_file(
|
|||||||
|
|
||||||
find_package(PNG REQUIRED)
|
find_package(PNG REQUIRED)
|
||||||
|
|
||||||
add_library(pdfengine STATIC
|
add_library(pdfengine OBJECT
|
||||||
src/core/engine_info.cpp
|
src/core/engine_info.cpp
|
||||||
src/core/graphics_state.cpp
|
src/core/graphics_state.cpp
|
||||||
src/core/display_list.cpp
|
src/core/display_list.cpp
|
||||||
src/core/path_interpreter.cpp
|
src/core/path_interpreter.cpp
|
||||||
src/core/skia_renderer.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/content_stream_parser.cpp
|
||||||
src/parser/decoration_builder.cpp
|
src/parser/decoration_builder.cpp
|
||||||
src/text/selection.cpp
|
src/text/selection.cpp
|
||||||
|
src/text/text_layout_engine.cpp
|
||||||
src/fonts/face/font_face.cpp
|
src/fonts/face/font_face.cpp
|
||||||
src/fonts/face/free_type_manager.cpp
|
src/fonts/face/free_type_manager.cpp
|
||||||
src/fonts/loader/font_resolver.cpp
|
src/fonts/loader/font_resolver.cpp
|
||||||
src/fonts/pdf_fonts/font_loader.cpp
|
|
||||||
src/fonts/shaping/hb_shaper.cpp
|
src/fonts/shaping/hb_shaper.cpp
|
||||||
src/fonts/cache/glyph_bitmap.cpp
|
src/fonts/cache/glyph_bitmap.cpp
|
||||||
src/fonts/cache/glyph_cache.cpp
|
src/fonts/cache/glyph_cache.cpp
|
||||||
@@ -46,8 +33,33 @@ add_library(pdfengine STATIC
|
|||||||
src/fonts/pdf_fonts/encoding/encoding.cpp
|
src/fonts/pdf_fonts/encoding/encoding.cpp
|
||||||
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
|
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
|
||||||
src/fonts/pdf_fonts/encoding/cjk_collection_db.cpp
|
src/fonts/pdf_fonts/encoding/cjk_collection_db.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_session.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/qpdf/qpdf_extractor.cpp
|
||||||
|
src/qpdf/qpdf_font_extractor.cpp
|
||||||
|
src/qpdf/qpdf_writer.cpp
|
||||||
|
src/qpdf/qpdf_resource_resolver.cpp
|
||||||
|
src/core/image_decoder.cpp
|
||||||
|
src/parser/lexer.cpp
|
||||||
|
src/parser/parser.cpp
|
||||||
|
src/parser/content_builder.cpp
|
||||||
|
src/serializer/content_serializer.cpp
|
||||||
|
src/serializer/ast_serializer.cpp
|
||||||
)
|
)
|
||||||
add_library(pdfengine::pdfengine ALIAS pdfengine)
|
add_library(pdfengine::pdfengine ALIAS pdfengine)
|
||||||
|
set_target_properties(pdfengine PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
target_include_directories(pdfengine
|
target_include_directories(pdfengine
|
||||||
PUBLIC
|
PUBLIC
|
||||||
@@ -70,7 +82,10 @@ target_link_libraries(pdfengine
|
|||||||
|
|
||||||
if(PDFENGINE_WITH_PDFIUM)
|
if(PDFENGINE_WITH_PDFIUM)
|
||||||
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
|
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()
|
endif()
|
||||||
|
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
@@ -93,18 +108,6 @@ if(PDFENGINE_WITH_QPDF)
|
|||||||
if(NOT TARGET jpeg)
|
if(NOT TARGET jpeg)
|
||||||
add_library(jpeg ALIAS JPEG::JPEG)
|
add_library(jpeg ALIAS JPEG::JPEG)
|
||||||
endif()
|
endif()
|
||||||
target_sources(pdfengine PRIVATE
|
|
||||||
src/qpdf/qpdf_extractor.cpp
|
|
||||||
src/qpdf/qpdf_font_extractor.cpp
|
|
||||||
src/qpdf/qpdf_writer.cpp
|
|
||||||
src/qpdf/qpdf_resource_resolver.cpp
|
|
||||||
src/core/image_decoder.cpp
|
|
||||||
src/parser/lexer.cpp
|
|
||||||
src/parser/parser.cpp
|
|
||||||
src/parser/content_builder.cpp
|
|
||||||
src/serializer/content_serializer.cpp
|
|
||||||
src/serializer/ast_serializer.cpp
|
|
||||||
)
|
|
||||||
target_link_libraries(pdfengine PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
target_link_libraries(pdfengine PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
||||||
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_QPDF)
|
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_QPDF)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -32,34 +32,34 @@ struct SetTransformCommand : public Command {
|
|||||||
|
|
||||||
struct FillRectCommand : public Command {
|
struct FillRectCommand : public Command {
|
||||||
float x, y, width, height;
|
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;
|
void accept(CommandVisitor& visitor) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct DrawTextCommand : public Command {
|
struct DrawTextCommand : public Command {
|
||||||
std::string text;
|
std::string text;
|
||||||
float x, y;
|
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;
|
void accept(CommandVisitor& visitor) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct FillPathCommand : public Command {
|
struct FillPathCommand : public Command {
|
||||||
Path path;
|
Path path;
|
||||||
FillRule rule;
|
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;
|
void accept(CommandVisitor& visitor) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct StrokePathCommand : public Command {
|
struct StrokePathCommand : public Command {
|
||||||
Path path;
|
Path path;
|
||||||
explicit StrokePathCommand(Path path) : path(std::move(path)) {}
|
explicit StrokePathCommand(Path _path) : path(std::move(_path)) {}
|
||||||
void accept(CommandVisitor& visitor) const override;
|
void accept(CommandVisitor& visitor) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct FillStrokePathCommand : public Command {
|
struct FillStrokePathCommand : public Command {
|
||||||
Path path;
|
Path path;
|
||||||
FillRule rule;
|
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;
|
void accept(CommandVisitor& visitor) const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
#include <expected>
|
||||||
|
#include <cstdint>
|
||||||
|
#include "pdfengine/pdf_document.hpp"
|
||||||
|
|
||||||
|
namespace pdfengine {
|
||||||
|
|
||||||
|
struct Rect {
|
||||||
|
double x = 0.0;
|
||||||
|
double y = 0.0;
|
||||||
|
double width = 0.0;
|
||||||
|
double height = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
using RectList = std::vector<Rect>;
|
||||||
|
|
||||||
|
struct ParagraphBounds {
|
||||||
|
Rect rect;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlyphInfo {
|
||||||
|
uint32_t glyphId = 0;
|
||||||
|
uint32_t cluster = 0;
|
||||||
|
double x = 0.0;
|
||||||
|
double y = 0.0;
|
||||||
|
double advance = 0.0;
|
||||||
|
double width = 0.0;
|
||||||
|
double ascent = 0.0;
|
||||||
|
double descent = 0.0;
|
||||||
|
std::string text;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct LineInfo {
|
||||||
|
int id = 0;
|
||||||
|
Rect rect;
|
||||||
|
double baselineY = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CaretState {
|
||||||
|
int offset = 0;
|
||||||
|
Rect rect;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal comprehensive layout state
|
||||||
|
struct LayoutResult {
|
||||||
|
ParagraphBounds bounds;
|
||||||
|
std::vector<LineInfo> lines;
|
||||||
|
std::vector<GlyphInfo> glyphs;
|
||||||
|
std::vector<Rect> selectionRects;
|
||||||
|
CaretState caret;
|
||||||
|
RectList dirtyRects;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stable, lightweight view for WASM export
|
||||||
|
struct LayoutView {
|
||||||
|
std::vector<LineInfo> lines;
|
||||||
|
std::vector<GlyphInfo> glyphs;
|
||||||
|
CaretState caret;
|
||||||
|
RectList dirtyRects;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditSession {
|
||||||
|
public:
|
||||||
|
virtual ~EditSession() = default;
|
||||||
|
|
||||||
|
static std::shared_ptr<EditSession> StartEditSession(
|
||||||
|
std::shared_ptr<PdfDocument> doc,
|
||||||
|
int pageIndex,
|
||||||
|
const std::string& paraId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returns a stable, lightweight view of the layout
|
||||||
|
virtual LayoutView GetLayoutView() const = 0;
|
||||||
|
|
||||||
|
// Geometry queries against the cached layout
|
||||||
|
// offset represents the caret insertion point (between glyphs)
|
||||||
|
virtual int HitTest(double x, double y) const = 0;
|
||||||
|
virtual Rect GetCaretRect(int offset) const = 0;
|
||||||
|
virtual std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const = 0;
|
||||||
|
|
||||||
|
// Mutates paragraph, marks cache dirty, recalculates
|
||||||
|
virtual void ApplyEdit(const std::string& editOpJson) = 0;
|
||||||
|
|
||||||
|
// Rendering & Lifecycle
|
||||||
|
virtual std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const = 0;
|
||||||
|
virtual bool CommitEdit() = 0;
|
||||||
|
virtual void CancelEdit() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pdfengine
|
||||||
@@ -11,8 +11,8 @@ struct Matrix {
|
|||||||
float e = 0.0f, f = 0.0f;
|
float e = 0.0f, f = 0.0f;
|
||||||
|
|
||||||
Matrix() = default;
|
Matrix() = default;
|
||||||
Matrix(float a, float b, float c, float d, float e, float 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) {}
|
: a(_a), b(_b), c(_c), d(_d), e(_e), f(_f) {}
|
||||||
|
|
||||||
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
|
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <expected>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <array>
|
||||||
|
|
||||||
|
#if __has_include(<expected>)
|
||||||
|
#include <expected>
|
||||||
|
#elif __has_include(<tl/expected.hpp>)
|
||||||
|
#include <tl/expected.hpp>
|
||||||
|
namespace std {
|
||||||
|
using tl::expected;
|
||||||
|
using tl::unexpected;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace pdfengine {
|
namespace pdfengine {
|
||||||
|
|
||||||
@@ -60,6 +70,14 @@ struct DevicePoint {
|
|||||||
int y;
|
int y;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct InvalidatedRegion {
|
||||||
|
int pageIndex;
|
||||||
|
double x;
|
||||||
|
double y;
|
||||||
|
double width;
|
||||||
|
double height;
|
||||||
|
};
|
||||||
|
|
||||||
struct GlyphBounds {
|
struct GlyphBounds {
|
||||||
std::string text;
|
std::string text;
|
||||||
double x;
|
double x;
|
||||||
@@ -172,6 +190,12 @@ public:
|
|||||||
return std::unexpected(EngineError::Unknown);
|
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::string, EngineError> extractText() const = 0;
|
||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
||||||
@@ -197,7 +221,9 @@ public:
|
|||||||
std::string content;
|
std::string content;
|
||||||
std::string timestamp;
|
std::string timestamp;
|
||||||
int pageIndex = 0;
|
int pageIndex = 0;
|
||||||
|
double thickness = 0.0;
|
||||||
std::vector<std::vector<Point2D>> paths;
|
std::vector<std::vector<Point2D>> paths;
|
||||||
|
std::vector<std::array<Point2D, 4>> quadPoints;
|
||||||
|
|
||||||
std::string fieldName;
|
std::string fieldName;
|
||||||
std::string fieldValue;
|
std::string fieldValue;
|
||||||
@@ -256,8 +282,10 @@ public:
|
|||||||
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
|
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
|
||||||
getResolvedFont(const FontInfo& fontInfo) = 0;
|
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 validateLayout(int pageIndex, const std::string& jsonStr) { (void)pageIndex; (void)jsonStr; return "{}"; }
|
||||||
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
|
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
|
||||||
|
|
||||||
[[nodiscard]] virtual bool lastReflowOverflowed() const { return false; }
|
[[nodiscard]] virtual bool lastReflowOverflowed() const { return false; }
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "pdfengine/edit_session.hpp"
|
||||||
|
#include "fonts/shaping/hb_shaper.hpp"
|
||||||
|
#include "fonts/face/font_face.hpp"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace pdfengine::text {
|
||||||
|
|
||||||
|
struct LayoutConstraints {
|
||||||
|
double columnLeft = 0.0;
|
||||||
|
double columnRight = 0.0;
|
||||||
|
double firstBaselineY = 0.0;
|
||||||
|
double leading = 0.0;
|
||||||
|
std::string align = "left";
|
||||||
|
double hangingIndent = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class LineBreaker {
|
||||||
|
public:
|
||||||
|
LineBreaker(const LayoutConstraints& constraints);
|
||||||
|
|
||||||
|
// Processes a stream of shaped glyphs and applies line breaking
|
||||||
|
void ProcessRun(
|
||||||
|
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
|
||||||
|
const std::string& runText,
|
||||||
|
double fontSize,
|
||||||
|
const fonts::FontFace& face,
|
||||||
|
double scale
|
||||||
|
);
|
||||||
|
|
||||||
|
// Finalizes the layout and populates the LayoutResult
|
||||||
|
void Finalize(LayoutResult& outLayout);
|
||||||
|
|
||||||
|
private:
|
||||||
|
LayoutConstraints constraints_;
|
||||||
|
double currentX_;
|
||||||
|
double currentY_;
|
||||||
|
|
||||||
|
// Internal state for word wrapping
|
||||||
|
std::vector<GlyphInfo> currentLineGlyphs_;
|
||||||
|
std::vector<LineInfo> finishedLines_;
|
||||||
|
std::vector<GlyphInfo> allGlyphs_;
|
||||||
|
|
||||||
|
void CommitLine();
|
||||||
|
};
|
||||||
|
|
||||||
|
class TextLayoutEngine {
|
||||||
|
public:
|
||||||
|
TextLayoutEngine();
|
||||||
|
|
||||||
|
// Computes layout for a given text run (simplified for single font for now)
|
||||||
|
LayoutResult ComputeLayout(
|
||||||
|
const std::string& text,
|
||||||
|
const LayoutConstraints& constraints,
|
||||||
|
fonts::FontFace& fontFace,
|
||||||
|
double fontSize
|
||||||
|
);
|
||||||
|
|
||||||
|
private:
|
||||||
|
fonts::HbShaper shaper_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pdfengine::text
|
||||||
@@ -48,7 +48,9 @@ public:
|
|||||||
|
|
||||||
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
|
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> 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::string, EngineError> extractText() const override;
|
||||||
|
|
||||||
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
||||||
std::expected<PageModel, EngineError> extractDocumentModel() const override;
|
std::expected<PageModel, EngineError> extractDocumentModel() const override;
|
||||||
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
|
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
|
||||||
@@ -100,7 +102,8 @@ public:
|
|||||||
|
|
||||||
void invalidateCaches();
|
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::string validateLayout(int pageIndex, const std::string& jsonStr) override;
|
||||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const 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> saveFull() const override;
|
||||||
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
|
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
|
||||||
@@ -149,13 +152,14 @@ private:
|
|||||||
double fontSize, const std::vector<uint32_t>& codepoints,
|
double fontSize, const std::vector<uint32_t>& codepoints,
|
||||||
const std::vector<int>& srcObjects = {},
|
const std::vector<int>& srcObjects = {},
|
||||||
FPDF_FONT reuseFont = nullptr);
|
FPDF_FONT reuseFont = nullptr);
|
||||||
|
#endif
|
||||||
|
|
||||||
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
|
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
|
||||||
mutable std::mutex reconstructedFontsMutex_;
|
mutable std::mutex reconstructedFontsMutex_;
|
||||||
void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) override;
|
void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) override;
|
||||||
std::expected<std::vector<uint8_t>, EngineError> getReconstructedFontData(const std::string& internalFontId) override;
|
std::expected<std::vector<uint8_t>, EngineError> getReconstructedFontData(const std::string& internalFontId) override;
|
||||||
const fonts::pdf_fonts::ReconstructedFont* lookupReconFont(const std::string& internalFontId);
|
const fonts::pdf_fonts::ReconstructedFont* lookupReconFont(const std::string& internalFontId);
|
||||||
#ifdef PDFENGINE_WITH_QPDF
|
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||||
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -164,6 +168,7 @@ private:
|
|||||||
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
|
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_reflow(const nlohmann::json& op, int pageIndex);
|
||||||
std::expected<void, EngineError> applyOp_textOverlay(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_decoration(const nlohmann::json& op, int pageIndex);
|
||||||
std::expected<void, EngineError> applyOp_redaction(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);
|
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
|
||||||
@@ -177,7 +182,6 @@ private:
|
|||||||
std::expected<void, EngineError> applyOp_pageReorder(const nlohmann::json& op, int pageIndex);
|
std::expected<void, EngineError> applyOp_pageReorder(const nlohmann::json& op, int pageIndex);
|
||||||
std::expected<void, EngineError> applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex);
|
std::expected<void, EngineError> applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex);
|
||||||
std::expected<void, EngineError> applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex);
|
std::expected<void, EngineError> applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex);
|
||||||
#endif
|
|
||||||
};
|
};
|
||||||
|
|
||||||
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8);
|
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
namespace pdfengine::parser {
|
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
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
if (!doc_) {
|
if (!doc_) {
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
@@ -17,7 +17,9 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
lastReflowLayout_.clear();
|
lastReflowLayout_.clear();
|
||||||
|
|
||||||
std::vector<int> editedPages;
|
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())
|
if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end())
|
||||||
editedPages.push_back(p);
|
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 = {
|
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",
|
"underline", "strikeout", "squiggly", "redaction",
|
||||||
"image_overlay", "highlight", "free_text", "comment", "freehand"};
|
"image_overlay", "highlight", "free_text", "comment", "freehand"};
|
||||||
if (kContentOps.count(type)) markEdited(pageIndex);
|
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);
|
r = applyOp_reflow(op, pageIndex);
|
||||||
} else if (type == "text_overlay" || type == "add_text") {
|
} else if (type == "text_overlay" || type == "add_text") {
|
||||||
r = applyOp_textOverlay(op, pageIndex);
|
r = applyOp_textOverlay(op, pageIndex);
|
||||||
|
} else if (type == "stamp") {
|
||||||
|
r = applyOp_stamp(op, pageIndex);
|
||||||
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
||||||
r = applyOp_decoration(op, pageIndex);
|
r = applyOp_decoration(op, pageIndex);
|
||||||
} else if (type == "redaction") {
|
} else if (type == "redaction") {
|
||||||
@@ -87,6 +91,34 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
if (!r) {
|
if (!r) {
|
||||||
return std::unexpected(r.error());
|
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) {
|
} catch (const nlohmann::json::parse_error& e) {
|
||||||
spdlog::error("JSON parse error in applyEdits: {}", e.what());
|
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);
|
rebalanceEditedPages(editedPages);
|
||||||
invalidateCaches();
|
invalidateCaches();
|
||||||
return {};
|
return invalidatedRegions;
|
||||||
#else
|
#else
|
||||||
(void)editsJson;
|
(void)editsJson;
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#include "parser/pdfium_internal.hpp"
|
#include "parser/pdfium_internal.hpp"
|
||||||
|
#include <chrono>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
|
|||||||
#endif
|
#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) {
|
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
|
||||||
#ifdef PDFENGINE_WITH_PDFIUM
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
std::string type = op.value("type", "");
|
std::string type = op.value("type", "");
|
||||||
@@ -129,6 +227,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohma
|
|||||||
int subtype = FPDF_ANNOT_UNDERLINE;
|
int subtype = FPDF_ANNOT_UNDERLINE;
|
||||||
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
|
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
|
||||||
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
|
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
|
||||||
|
else if (type == "highlight") subtype = FPDF_ANNOT_HIGHLIGHT;
|
||||||
|
|
||||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
|
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
|
||||||
if (!annot) {
|
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));
|
float thickness = static_cast<float>(data.value("thickness", 2.0));
|
||||||
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
|
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;
|
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
|
||||||
bool anyPoints = false;
|
bool anyPoints = false;
|
||||||
|
|
||||||
@@ -549,6 +652,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
|
|||||||
if (data.contains("thickness")) {
|
if (data.contains("thickness")) {
|
||||||
float thickness = static_cast<float>(data["thickness"].get<double>());
|
float thickness = static_cast<float>(data["thickness"].get<double>());
|
||||||
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
|
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")) {
|
if (data.contains("text")) {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
|
|||||||
|
|
||||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||||
#ifdef PDFENGINE_WITH_PDFIUM
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
|
(void)op;
|
||||||
if (pageCount() <= 1) {
|
if (pageCount() <= 1) {
|
||||||
spdlog::error("Cannot delete the only page in the document");
|
spdlog::error("Cannot delete the only page in the document");
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "parser/pdfium_internal.hpp"
|
#include "parser/pdfium_internal.hpp"
|
||||||
|
#include "fonts/face/free_type_manager.hpp"
|
||||||
|
#include "fonts/face/font_face.hpp"
|
||||||
|
#include "pdfengine/text_layout_engine.hpp"
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
|
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
|
||||||
@@ -10,7 +12,17 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
}
|
}
|
||||||
auto data = op["data"];
|
auto data = op["data"];
|
||||||
|
|
||||||
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector<double> advances; };
|
struct RunStyle {
|
||||||
|
std::string text;
|
||||||
|
std::string internalFontId;
|
||||||
|
double fontSize;
|
||||||
|
unsigned int r, g, b;
|
||||||
|
std::vector<double> advances;
|
||||||
|
// When set (and advances.size() == advanceSeedText.size()), advances are
|
||||||
|
// metrics for advanceSeedText; merge onto text via LCP/LCS so typing
|
||||||
|
// preserves kerning on the unchanged prefix/suffix.
|
||||||
|
std::string advanceSeedText;
|
||||||
|
};
|
||||||
std::vector<RunStyle> runs;
|
std::vector<RunStyle> runs;
|
||||||
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
||||||
r = 0; g = 0; b = 0;
|
r = 0; g = 0; b = 0;
|
||||||
@@ -30,11 +42,12 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
RunStyle rs;
|
RunStyle rs;
|
||||||
rs.text = rj.value("text", "");
|
rs.text = rj.value("text", "");
|
||||||
rs.internalFontId = rj.value("internalFontId", "");
|
rs.internalFontId = rj.value("internalFontId", "");
|
||||||
rs.fontSize = rj.value("fontSize", 12.0);
|
rs.fontSize = rj.value("fontSize", 0.0);
|
||||||
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
|
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
|
||||||
if (rj.contains("advances") && rj["advances"].is_array()) {
|
if (rj.contains("advances") && rj["advances"].is_array()) {
|
||||||
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
|
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
|
||||||
}
|
}
|
||||||
|
rs.advanceSeedText = rj.value("advanceSeedText", "");
|
||||||
runs.push_back(std::move(rs));
|
runs.push_back(std::move(rs));
|
||||||
};
|
};
|
||||||
std::vector<std::vector<int>> providedLines;
|
std::vector<std::vector<int>> providedLines;
|
||||||
@@ -142,47 +155,46 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
|
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
double exactNominal = 0.0;
|
||||||
std::unordered_map<std::string, double> exactByBaseName;
|
double exactScaleX = 1.0;
|
||||||
std::vector<double> exactSizes;
|
double exactScaleY = 1.0;
|
||||||
for (int idx : paragraphSet) {
|
for (int idx : paragraphSet) {
|
||||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||||
|
float nom = 0;
|
||||||
|
if (FPDFTextObj_GetFontSize(o, &nom) && nom > 0.1) {
|
||||||
FS_MATRIX mtx;
|
FS_MATRIX mtx;
|
||||||
if (!FPDFPageObj_GetMatrix(o, &mtx)) continue;
|
if (FPDFPageObj_GetMatrix(o, &mtx)) {
|
||||||
float nominal = 0.0f;
|
exactScaleX = std::sqrt(static_cast<double>(mtx.a) * mtx.a + static_cast<double>(mtx.b) * mtx.b);
|
||||||
if (!FPDFTextObj_GetFontSize(o, &nominal)) continue;
|
exactScaleY = std::sqrt(static_cast<double>(mtx.c) * mtx.c + static_cast<double>(mtx.d) * mtx.d);
|
||||||
double scale = std::sqrt(static_cast<double>(mtx.a) * mtx.a +
|
exactNominal = nom;
|
||||||
static_cast<double>(mtx.b) * mtx.b);
|
break;
|
||||||
double exact = static_cast<double>(nominal) * scale;
|
|
||||||
if (exact <= 0.1) continue;
|
|
||||||
exactSizes.push_back(exact);
|
|
||||||
FPDF_FONT fo = FPDFTextObj_GetFont(o);
|
|
||||||
if (!fo) continue;
|
|
||||||
size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0);
|
|
||||||
if (nl == 0) continue;
|
|
||||||
std::vector<char> nb(nl);
|
|
||||||
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
|
|
||||||
std::string bn(nb.data());
|
|
||||||
if (!exactByBaseName.count(bn)) exactByBaseName[bn] = exact;
|
|
||||||
}
|
}
|
||||||
double paraExact = 0.0;
|
|
||||||
if (!exactSizes.empty()) {
|
|
||||||
std::sort(exactSizes.begin(), exactSizes.end());
|
|
||||||
paraExact = exactSizes[exactSizes.size() / 2];
|
|
||||||
}
|
|
||||||
if (paraExact > 0.1) {
|
|
||||||
for (auto& rs : runs) {
|
|
||||||
std::string bn = baseNameFromInternalFontId(rs.internalFontId);
|
|
||||||
auto it = exactByBaseName.find(bn);
|
|
||||||
rs.fontSize = (it != exactByBaseName.end() && it->second > 0.1) ? it->second : paraExact;
|
|
||||||
}
|
|
||||||
spdlog::info("reflow_paragraph: size-exact override paraExact={:.2f} ({} font(s))",
|
|
||||||
paraExact, exactByBaseName.size());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double textAspect = 1.0;
|
||||||
|
if (exactNominal > 0.1 && exactScaleY > 0.001) {
|
||||||
|
double trueVerticalSize = exactNominal * exactScaleY;
|
||||||
|
for (auto& rs : runs) {
|
||||||
|
spdlog::info("[STAGE_3_WASM_INPUT] fontSize={:.2f}, trueVerticalSize={:.2f}, exactNominal={:.2f}, exactScaleY={:.4f}", rs.fontSize, trueVerticalSize, exactNominal, exactScaleY);
|
||||||
|
if (rs.fontSize <= 0.0 || rs.fontSize < trueVerticalSize * 0.85) {
|
||||||
|
rs.fontSize = trueVerticalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textAspect = exactScaleX / exactScaleY;
|
||||||
|
} else {
|
||||||
|
for (auto& rs : runs) {
|
||||||
|
if (rs.fontSize <= 0.0) {
|
||||||
|
rs.fontSize = 12.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spdlog::info("[FONT_METRICS_DEBUG] exactNominal={:.2f}, exactScaleX={:.2f}, exactScaleY={:.2f}, trueVerticalSize={:.2f}, textAspect={:.2f}", exactNominal, exactScaleX, exactScaleY, exactNominal * exactScaleY, textAspect);
|
||||||
|
|
||||||
std::vector<EmissionFont> runFonts(runs.size());
|
std::vector<EmissionFont> runFonts(runs.size());
|
||||||
|
// utf8_to_utf16le appends a trailing NUL for PDFium wide-string APIs.
|
||||||
|
// That terminator is not text content and must not enter coverage checks.
|
||||||
auto toCodepoints = [](const std::string& s) {
|
auto toCodepoints = [](const std::string& s) {
|
||||||
auto u16 = utf8_to_utf16le(s);
|
auto u16 = utf8_to_utf16le(s);
|
||||||
std::vector<uint32_t> cps;
|
std::vector<uint32_t> cps;
|
||||||
@@ -193,6 +205,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
|
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
|
||||||
else i += 1;
|
else i += 1;
|
||||||
} else i += 1;
|
} else i += 1;
|
||||||
|
if (cp == 0) continue;
|
||||||
cps.push_back(cp);
|
cps.push_back(cp);
|
||||||
}
|
}
|
||||||
return cps;
|
return cps;
|
||||||
@@ -241,7 +254,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
if (text.empty()) return out;
|
if (text.empty()) return out;
|
||||||
auto& rf = runFonts[runIdx];
|
auto& rf = runFonts[runIdx];
|
||||||
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
||||||
double scale = size / static_cast<double>(kRefSize);
|
double scale = (size * textAspect) / static_cast<double>(kRefSize);
|
||||||
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
||||||
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
||||||
if (face) {
|
if (face) {
|
||||||
@@ -259,18 +272,88 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Forensic flag: force whole-run emission while keeping client advances.
|
||||||
|
// Set data.forensicForceWholeRun=true to isolate whether runPerChar causes the visual change.
|
||||||
|
const bool forensicForceWholeRun = data.value("forensicForceWholeRun", false);
|
||||||
std::vector<std::vector<double>> runCharAdv(runs.size());
|
std::vector<std::vector<double>> runCharAdv(runs.size());
|
||||||
std::vector<char> runPerChar(runs.size(), 0);
|
std::vector<char> runPerChar(runs.size(), 0);
|
||||||
|
|
||||||
|
// Merge seed advances onto edited text: keep LCP/LCS metrics, HB-fill the edit middle.
|
||||||
|
// (Logic inlined in the run loop so unchanged glyphs are never passed to HarfBuzz.)
|
||||||
|
|
||||||
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
||||||
if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) {
|
const auto& rs = runs[ri];
|
||||||
runCharAdv[ri] = runs[ri].advances;
|
bool usedClient = false;
|
||||||
auto natural = perCharAdvances(ri, runs[ri].text);
|
|
||||||
|
// Prefer client/seed advances for UNCHANGED glyphs. Only HarfBuzz-shape
|
||||||
|
// characters that are not covered by seed metrics (the edited middle).
|
||||||
|
if (rs.advances.size() == rs.text.size() && !rs.text.empty()) {
|
||||||
|
runCharAdv[ri] = rs.advances;
|
||||||
|
usedClient = true;
|
||||||
|
} else if (!rs.advanceSeedText.empty()
|
||||||
|
&& rs.advances.size() == rs.advanceSeedText.size()
|
||||||
|
&& !rs.text.empty()) {
|
||||||
|
// Shape only the middle gap; prefix/suffix keep seed advances.
|
||||||
|
size_t p = 0;
|
||||||
|
const auto& seed = rs.advanceSeedText;
|
||||||
|
while (p < seed.size() && p < rs.text.size() && seed[p] == rs.text[p]) ++p;
|
||||||
|
size_t s = 0;
|
||||||
|
while (s < seed.size() - p && s < rs.text.size() - p
|
||||||
|
&& seed[seed.size() - 1 - s] == rs.text[rs.text.size() - 1 - s]) ++s;
|
||||||
|
runCharAdv[ri].assign(rs.text.size(), 0.0);
|
||||||
|
for (size_t i = 0; i < p; ++i) runCharAdv[ri][i] = rs.advances[i];
|
||||||
|
for (size_t i = 0; i < s; ++i)
|
||||||
|
runCharAdv[ri][rs.text.size() - 1 - i] = rs.advances[seed.size() - 1 - i];
|
||||||
|
if (p + s < rs.text.size()) {
|
||||||
|
std::string middle = rs.text.substr(p, rs.text.size() - p - s);
|
||||||
|
auto midNat = perCharAdvances(ri, middle);
|
||||||
|
for (size_t i = 0; i < midNat.size(); ++i) runCharAdv[ri][p + i] = midNat[i];
|
||||||
|
}
|
||||||
|
usedClient = true;
|
||||||
|
spdlog::info("[ADVANCE_SEED_MERGE] run={} seedLen={} textLen={} "
|
||||||
|
"prefixKept={} suffixKept={} (unchanged glyphs not reshaped)",
|
||||||
|
ri, seed.size(), rs.text.size(), p, s);
|
||||||
|
} else if (!rs.advances.empty() && !rs.text.empty()
|
||||||
|
&& rs.advances.size() < rs.text.size()) {
|
||||||
|
// Prefix-only advances (append without advanceSeedText).
|
||||||
|
runCharAdv[ri] = perCharAdvances(ri, rs.text);
|
||||||
|
for (size_t c = 0; c < rs.advances.size(); ++c) runCharAdv[ri][c] = rs.advances[c];
|
||||||
|
usedClient = true;
|
||||||
|
spdlog::info("[ADVANCE_PREFIX_KEEP] run={} prefixAdv={} textLen={} "
|
||||||
|
"(unchanged prefix kept; only suffix reshaped)",
|
||||||
|
ri, rs.advances.size(), rs.text.size());
|
||||||
|
} else if (!rs.advances.empty() && !rs.text.empty()
|
||||||
|
&& rs.advances.size() > rs.text.size()) {
|
||||||
|
// Truncate (end-delete without advanceSeedText).
|
||||||
|
runCharAdv[ri].assign(rs.advances.begin(),
|
||||||
|
rs.advances.begin() + static_cast<std::ptrdiff_t>(rs.text.size()));
|
||||||
|
usedClient = true;
|
||||||
|
} else {
|
||||||
|
// FIRST MUTATION SITE when client advances are missing/mismatched:
|
||||||
|
// HarfBuzz recomputes advances for EVERY glyph, including unchanged ones.
|
||||||
|
runCharAdv[ri] = perCharAdvances(ri, rs.text);
|
||||||
|
spdlog::warn("[ADVANCE_RECOMPUTE_ALL] run={} textLen={} advLen={} "
|
||||||
|
"FIRST_MUTATION=perCharAdvances full reshape (no client advances)",
|
||||||
|
ri, rs.text.size(), rs.advances.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedClient) {
|
||||||
|
// Client advances (esp. PDF TJ kerning) diverge from HarfBuzz naturals.
|
||||||
|
// Emit per-glyph so those advances are applied; do NOT replace them.
|
||||||
|
auto natural = perCharAdvances(ri, rs.text);
|
||||||
bool diverges = natural.size() != runCharAdv[ri].size();
|
bool diverges = natural.size() != runCharAdv[ri].size();
|
||||||
for (size_t c = 0; !diverges && c < natural.size(); ++c)
|
for (size_t c = 0; !diverges && c < natural.size(); ++c)
|
||||||
if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true;
|
if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true;
|
||||||
runPerChar[ri] = diverges ? 1 : 0;
|
runPerChar[ri] = (forensicForceWholeRun ? 0 : (diverges ? 1 : 0));
|
||||||
|
spdlog::info("[FORENSIC_RUNPERCHAR] run={} textLen={} advLen={} seedLen={} diverges={} forceWhole={} runPerChar={} natural0={:.4f} client0={:.4f} measureFace={}",
|
||||||
|
ri, rs.text.size(), rs.advances.size(), rs.advanceSeedText.size(), diverges,
|
||||||
|
forensicForceWholeRun, (int)runPerChar[ri],
|
||||||
|
natural.empty() ? -1.0 : natural[0],
|
||||||
|
runCharAdv[ri].empty() ? -1.0 : runCharAdv[ri][0],
|
||||||
|
(bool)(runFonts[ri].measureFace));
|
||||||
} else {
|
} else {
|
||||||
runCharAdv[ri] = perCharAdvances(ri, runs[ri].text);
|
spdlog::info("[FORENSIC_RUNPERCHAR] run={} textLen={} advLen={} -> recomputed advances (no client match) runPerChar=0 forceWhole={}",
|
||||||
|
ri, rs.text.size(), rs.advances.size(), forensicForceWholeRun);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
auto charAdvAt = [&](int ri, size_t off) -> double {
|
auto charAdvAt = [&](int ri, size_t off) -> double {
|
||||||
@@ -336,6 +419,8 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
lineCont.push_back(firstLineOfSeg ? 0 : 1);
|
lineCont.push_back(firstLineOfSeg ? 0 : 1);
|
||||||
lineSegEnd.push_back(segEnd);
|
lineSegEnd.push_back(segEnd);
|
||||||
};
|
};
|
||||||
|
spdlog::info("[KEYSTROKE_BACKEND_DEBUG] columnLeft={:.2f}, columnRight={:.2f}, columnWidth={:.2f}, firstBaselineY={:.2f}, oldLineCount={}, hangingIndent={:.2f}",
|
||||||
|
columnLeft, columnRight, columnWidth, firstBaselineY, oldLineCount, hangingIndent);
|
||||||
for (size_t k = 0; k < allWords.size(); ++k) {
|
for (size_t k = 0; k < allWords.size(); ++k) {
|
||||||
size_t wi = allWords[k];
|
size_t wi = allWords[k];
|
||||||
if (wi == kHardBreak) { // forced break: end the current line (may be blank)
|
if (wi == kHardBreak) { // forced break: end the current line (may be blank)
|
||||||
@@ -346,6 +431,8 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
double effW = columnWidth - (firstLineOfSeg ? 0.0 : hangingIndent);
|
double effW = columnWidth - (firstLineOfSeg ? 0.0 : hangingIndent);
|
||||||
double gap = (cur.empty() || prevWord == kHardBreak) ? 0.0 : words[prevWord].spaceAfter;
|
double gap = (cur.empty() || prevWord == kHardBreak) ? 0.0 : words[prevWord].spaceAfter;
|
||||||
if (!cur.empty() && curW + gap + words[wi].width > effW) {
|
if (!cur.empty() && curW + gap + words[wi].width > effW) {
|
||||||
|
spdlog::info("[KEYSTROKE_BACKEND_WRAP] Wrapped at word index {} ('{}'): curW={:.2f}, gap={:.2f}, wordW={:.2f}, sum={:.2f} > effW={:.2f} (columnWidth={:.2f})",
|
||||||
|
wi, words[wi].segs.empty() ? "" : words[wi].segs[0].text, curW, gap, words[wi].width, curW + gap + words[wi].width, effW, columnWidth);
|
||||||
pushLine(0);
|
pushLine(0);
|
||||||
cur.clear(); firstLineOfSeg = false;
|
cur.clear(); firstLineOfSeg = false;
|
||||||
cur.push_back(wi); curW = words[wi].width;
|
cur.push_back(wi); curW = words[wi].width;
|
||||||
@@ -421,20 +508,71 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
lineText.push_back(' ');
|
lineText.push_back(' ');
|
||||||
adv.push_back(gap);
|
adv.push_back(gap);
|
||||||
}
|
}
|
||||||
double segX = x;
|
|
||||||
for (auto& seg : words[wi].segs) {
|
for (auto& seg : words[wi].segs) {
|
||||||
if (lineFontSize <= 0.0) lineFontSize = runs[seg.runIdx].fontSize;
|
double segX = x;
|
||||||
FPDF_FONT font = runFonts[seg.runIdx].font;
|
FPDF_FONT font = runFonts[seg.runIdx].font;
|
||||||
|
double emitFontSize = runs[seg.runIdx].fontSize;
|
||||||
|
double mtxScaleX = textAspect;
|
||||||
|
double mtxScaleY = 1.0;
|
||||||
|
if (exactNominal > 0.1 && exactScaleY > 0.001) {
|
||||||
|
emitFontSize = exactNominal;
|
||||||
|
mtxScaleX = exactScaleX;
|
||||||
|
mtxScaleY = exactScaleY;
|
||||||
|
lineFontSize = exactNominal * exactScaleY;
|
||||||
|
} else {
|
||||||
|
lineFontSize = (std::max)(lineFontSize, runs[seg.runIdx].fontSize);
|
||||||
|
}
|
||||||
|
spdlog::info("[STAGE_4_REFLOW_OUTPUT] lineFontSize={:.2f}, emitFontSize={:.2f}, mtxScaleY={:.4f}", lineFontSize, emitFontSize, mtxScaleY);
|
||||||
auto emitObj = [&](const std::string& s, double atX) {
|
auto emitObj = [&](const std::string& s, double atX) {
|
||||||
if (!font || s.empty()) return;
|
if (!font || s.empty()) return;
|
||||||
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(runs[seg.runIdx].fontSize));
|
std::string baseFontName;
|
||||||
|
{
|
||||||
|
size_t nl = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||||
|
if (nl > 0) {
|
||||||
|
std::vector<char> nb(nl);
|
||||||
|
if (FPDFFont_GetBaseFontName(font, nb.data(), nl) > 0)
|
||||||
|
baseFontName = nb.data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto& ef = runFonts[seg.runIdx];
|
||||||
|
spdlog::info("[EMIT_FONT] text='{}' baseFont='{}' fontPtr={} measureFacePtr={} "
|
||||||
|
"hasResolved={} fontSize={:.4f} atX={:.4f} baselineY={:.4f} "
|
||||||
|
"mtxScaleX={:.4f} mtxScaleY={:.4f} runPerChar={} internalFontId='{}'",
|
||||||
|
s, baseFontName, (void*)font,
|
||||||
|
(void*)(ef.measureFace ? ef.measureFace.get() : nullptr),
|
||||||
|
(bool)ef.resolved, emitFontSize, atX, baselineY,
|
||||||
|
mtxScaleX, mtxScaleY, (int)runPerChar[seg.runIdx],
|
||||||
|
runs[seg.runIdx].internalFontId);
|
||||||
|
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(emitFontSize));
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
|
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
|
||||||
auto u16 = utf8_to_utf16le(s); u16.push_back(0);
|
auto u16 = utf8_to_utf16le(s); u16.push_back(0);
|
||||||
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
|
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
|
||||||
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, atX, baselineY);
|
FPDFPageObj_Transform(obj, mtxScaleX, 0.0, 0.0, mtxScaleY, atX, baselineY);
|
||||||
repagSetParaId(doc_, obj, paraId);
|
repagSetParaId(doc_, obj, paraId);
|
||||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||||
|
FS_MATRIX objMtx;
|
||||||
|
FPDFPageObj_GetMatrix(obj, &objMtx);
|
||||||
|
float objFS = 0;
|
||||||
|
FPDFTextObj_GetFontSize(obj, &objFS);
|
||||||
|
float l = 0, b = 0, r = 0, t = 0;
|
||||||
|
FPDFPageObj_GetBounds(obj, &l, &b, &r, &t);
|
||||||
|
FPDF_FONT objFont = FPDFTextObj_GetFont(obj);
|
||||||
|
std::string objBase;
|
||||||
|
if (objFont) {
|
||||||
|
size_t nl = FPDFFont_GetBaseFontName(objFont, nullptr, 0);
|
||||||
|
if (nl > 0) {
|
||||||
|
std::vector<char> nb(nl);
|
||||||
|
if (FPDFFont_GetBaseFontName(objFont, nb.data(), nl) > 0)
|
||||||
|
objBase = nb.data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spdlog::info("[EDITED_TEXT_OBJECT_DEBUG] text='{}' objBaseFont='{}' fontSize={:.2f}, "
|
||||||
|
"matrix=[{:.4f}, {:.4f}, {:.4f}, {:.4f}, {:.4f}, {:.4f}], "
|
||||||
|
"bbox=[{:.2f}, {:.2f}, {:.2f}, {:.2f}]",
|
||||||
|
s, objBase, objFS,
|
||||||
|
objMtx.a, objMtx.b, objMtx.c, objMtx.d, objMtx.e, objMtx.f,
|
||||||
|
l, b, r, t);
|
||||||
};
|
};
|
||||||
if (runPerChar[seg.runIdx]) {
|
if (runPerChar[seg.runIdx]) {
|
||||||
double gx = segX;
|
double gx = segX;
|
||||||
@@ -475,6 +613,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
}
|
}
|
||||||
lastReflowLayout_ = nlohmann::json{
|
lastReflowLayout_ = nlohmann::json{
|
||||||
{"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump();
|
{"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump();
|
||||||
|
spdlog::info("[STAGE_5_SERIALIZED_JSON] {}", lastReflowLayout_);
|
||||||
|
|
||||||
if (!FPDFPage_GenerateContent(page)) {
|
if (!FPDFPage_GenerateContent(page)) {
|
||||||
spdlog::error("Failed to generate page content after reflow_paragraph");
|
spdlog::error("Failed to generate page content after reflow_paragraph");
|
||||||
@@ -500,4 +639,61 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string PdfiumDocument::validateLayout(int pageIndex, const std::string& jsonStr) {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
|
try {
|
||||||
|
auto data = nlohmann::json::parse(jsonStr);
|
||||||
|
if (!data.is_object()) return "{}";
|
||||||
|
|
||||||
|
std::string text = "";
|
||||||
|
std::string internalFontId = "";
|
||||||
|
double fontSize = 12.0;
|
||||||
|
|
||||||
|
if (data.contains("runs") && data["runs"].is_array() && !data["runs"].empty()) {
|
||||||
|
auto run = data["runs"][0];
|
||||||
|
text = run.value("text", "");
|
||||||
|
internalFontId = run.value("internalFontId", "");
|
||||||
|
fontSize = run.value("fontSize", 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
text::LayoutConstraints constraints;
|
||||||
|
constraints.columnLeft = data.value("columnLeft", 0.0);
|
||||||
|
constraints.columnRight = data.value("columnRight", 0.0);
|
||||||
|
constraints.firstBaselineY = data.value("firstBaselineY", 0.0);
|
||||||
|
constraints.leading = data.value("leading", 0.0);
|
||||||
|
|
||||||
|
auto fontDataRes = getFontData(internalFontId);
|
||||||
|
auto face = std::make_shared<fonts::FontFace>();
|
||||||
|
if (!fontDataRes || !face->loadFromMemory(*fontDataRes)) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
text::TextLayoutEngine engine;
|
||||||
|
auto layout = engine.ComputeLayout(text, constraints, *face, fontSize);
|
||||||
|
|
||||||
|
nlohmann::json out;
|
||||||
|
out["lines"] = nlohmann::json::array();
|
||||||
|
for (const auto& l : layout.lines) {
|
||||||
|
out["lines"].push_back({
|
||||||
|
{"rect", {{"x", l.rect.x}, {"y", l.rect.y}, {"width", l.rect.width}, {"height", l.rect.height}}}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out["glyphs"] = nlohmann::json::array();
|
||||||
|
for (const auto& g : layout.glyphs) {
|
||||||
|
out["glyphs"].push_back({
|
||||||
|
{"x", g.x}, {"y", g.y}, {"width", g.width}, {"advance", g.advance},
|
||||||
|
{"ascent", g.ascent}, {"descent", g.descent}, {"cluster", g.cluster},
|
||||||
|
{"text", g.text}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.dump();
|
||||||
|
} catch (...) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
(void)pageIndex; (void)jsonStr;
|
||||||
|
return "{}";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#include "pdfengine/edit_session.hpp"
|
||||||
|
#include "parser/pdfium_edit_session.hpp"
|
||||||
|
#include "parser/pdfium_document.hpp"
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace pdfengine {
|
||||||
|
|
||||||
|
std::shared_ptr<EditSession> EditSession::StartEditSession(
|
||||||
|
std::shared_ptr<PdfDocument> doc,
|
||||||
|
int pageIndex,
|
||||||
|
const std::string& paraId
|
||||||
|
) {
|
||||||
|
if (!doc) {
|
||||||
|
spdlog::error("StartEditSession: null document");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return std::make_shared<parser::PdfiumEditSession>(doc, pageIndex, paraId);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine
|
||||||
|
|
||||||
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
|
PdfiumEditSession::PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId)
|
||||||
|
: doc_(doc), pageIndex_(pageIndex), paraId_(paraId) {
|
||||||
|
spdlog::info("Started edit session on page {} for para {}", pageIndex_, paraId_);
|
||||||
|
|
||||||
|
// In a full implementation, we'd extract text, bounds, and fonts from doc_ using paraId_.
|
||||||
|
// For now, populate dummy state for unit testing the architecture.
|
||||||
|
currentText_ = "Mock initial text for paragraph.";
|
||||||
|
constraints_.columnLeft = 100.0;
|
||||||
|
constraints_.columnRight = 400.0;
|
||||||
|
constraints_.firstBaselineY = 700.0;
|
||||||
|
constraints_.leading = 14.0;
|
||||||
|
isDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
PdfiumEditSession::~PdfiumEditSession() {}
|
||||||
|
|
||||||
|
void PdfiumEditSession::RebuildLayoutIfNeeded() const {
|
||||||
|
if (!isDirty_) return;
|
||||||
|
|
||||||
|
if (currentFace_) {
|
||||||
|
cachedLayout_ = layoutEngine_.ComputeLayout(
|
||||||
|
currentText_, constraints_, *currentFace_, currentFontSize_
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Mock fallback if face not loaded yet
|
||||||
|
cachedLayout_.lines.clear();
|
||||||
|
cachedLayout_.glyphs.clear();
|
||||||
|
|
||||||
|
GlyphInfo g;
|
||||||
|
g.x = constraints_.columnLeft;
|
||||||
|
g.y = constraints_.firstBaselineY;
|
||||||
|
g.width = 5.0;
|
||||||
|
g.advance = 6.0;
|
||||||
|
g.ascent = 10.0;
|
||||||
|
g.descent = -2.0;
|
||||||
|
g.cluster = 0;
|
||||||
|
|
||||||
|
if (!currentText_.empty()) {
|
||||||
|
LineInfo line;
|
||||||
|
line.id = 0;
|
||||||
|
line.baselineY = constraints_.firstBaselineY;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < currentText_.size(); ++i) {
|
||||||
|
g.cluster = static_cast<uint32_t>(i);
|
||||||
|
g.text = currentText_.substr(i, 1);
|
||||||
|
cachedLayout_.glyphs.push_back(g);
|
||||||
|
g.x += g.advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.rect = Rect{constraints_.columnLeft, constraints_.firstBaselineY - g.ascent,
|
||||||
|
g.x - constraints_.columnLeft, g.ascent - g.descent};
|
||||||
|
cachedLayout_.lines.push_back(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDirty_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutView PdfiumEditSession::GetLayoutView() const {
|
||||||
|
RebuildLayoutIfNeeded();
|
||||||
|
return LayoutView{
|
||||||
|
cachedLayout_.lines,
|
||||||
|
cachedLayout_.glyphs,
|
||||||
|
cachedLayout_.caret,
|
||||||
|
cachedLayout_.dirtyRects
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
int PdfiumEditSession::HitTest(double x, double y) const {
|
||||||
|
RebuildLayoutIfNeeded();
|
||||||
|
|
||||||
|
// Find closest glyph by checking boundary/midpoint logic
|
||||||
|
int closestCluster = 0;
|
||||||
|
double minDistanceSq = 1e9;
|
||||||
|
|
||||||
|
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||||
|
// Find distance to bounding box
|
||||||
|
double dx = 0.0;
|
||||||
|
if (x < glyph.x) dx = glyph.x - x;
|
||||||
|
else if (x > glyph.x + glyph.advance) dx = x - (glyph.x + glyph.advance);
|
||||||
|
|
||||||
|
double gy = glyph.y - glyph.ascent;
|
||||||
|
double gh = glyph.ascent - glyph.descent;
|
||||||
|
double dy = 0.0;
|
||||||
|
if (y < gy) dy = gy - y;
|
||||||
|
else if (y > gy + gh) dy = y - (gy + gh);
|
||||||
|
|
||||||
|
double distSq = dx * dx + dy * dy;
|
||||||
|
if (distSq < minDistanceSq) {
|
||||||
|
minDistanceSq = distSq;
|
||||||
|
// Midpoint logic: if past midpoint, insertion point is after this glyph
|
||||||
|
if (x > glyph.x + glyph.advance / 2.0) {
|
||||||
|
closestCluster = glyph.cluster + 1;
|
||||||
|
} else {
|
||||||
|
closestCluster = glyph.cluster;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestCluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect PdfiumEditSession::GetCaretRect(int offset) const {
|
||||||
|
RebuildLayoutIfNeeded();
|
||||||
|
|
||||||
|
if (cachedLayout_.glyphs.empty()) {
|
||||||
|
return Rect{constraints_.columnLeft, constraints_.firstBaselineY - 10.0, 1.0, 12.0};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caret is positioned *between* glyphs at the insertion point `offset`.
|
||||||
|
// If offset matches a cluster, place it at the left edge of that glyph.
|
||||||
|
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||||
|
if (glyph.cluster == static_cast<uint32_t>(offset)) {
|
||||||
|
return Rect{glyph.x, glyph.y - glyph.ascent, 1.0, glyph.ascent - glyph.descent};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If offset is beyond all glyphs, place it at the right edge of the last glyph.
|
||||||
|
const auto& lastGlyph = cachedLayout_.glyphs.back();
|
||||||
|
return Rect{lastGlyph.x + lastGlyph.advance, lastGlyph.y - lastGlyph.ascent, 1.0, lastGlyph.ascent - lastGlyph.descent};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Rect> PdfiumEditSession::GetSelectionRects(int startOffset, int endOffset) const {
|
||||||
|
RebuildLayoutIfNeeded();
|
||||||
|
(void)startOffset; (void)endOffset;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void PdfiumEditSession::ApplyEdit(const std::string& editOpJson) {
|
||||||
|
// Phase 1.1: Modify state and mark dirty
|
||||||
|
spdlog::info("ApplyEdit called with {}", editOpJson);
|
||||||
|
|
||||||
|
// Very naive edit implementation for testing
|
||||||
|
// e.g. editOpJson = "append:A"
|
||||||
|
if (editOpJson.find("append:") == 0) {
|
||||||
|
currentText_ += editOpJson.substr(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
isDirty_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> PdfiumEditSession::RenderDirtyRegion(int dpi, const Rect& region) const {
|
||||||
|
(void)dpi; (void)region;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PdfiumEditSession::CommitEdit() {
|
||||||
|
spdlog::info("CommitEdit called");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PdfiumEditSession::CancelEdit() {
|
||||||
|
spdlog::info("CancelEdit called");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine::parser
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "pdfengine/edit_session.hpp"
|
||||||
|
#include "pdfengine/text_layout_engine.hpp"
|
||||||
|
#include "parser/pdfium_internal.hpp"
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
|
class PdfiumEditSession : public EditSession {
|
||||||
|
public:
|
||||||
|
PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId);
|
||||||
|
~PdfiumEditSession() override;
|
||||||
|
|
||||||
|
LayoutView GetLayoutView() const override;
|
||||||
|
int HitTest(double x, double y) const override;
|
||||||
|
Rect GetCaretRect(int offset) const override;
|
||||||
|
std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const override;
|
||||||
|
|
||||||
|
void ApplyEdit(const std::string& editOpJson) override;
|
||||||
|
|
||||||
|
std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const override;
|
||||||
|
bool CommitEdit() override;
|
||||||
|
void CancelEdit() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void RebuildLayoutIfNeeded() const;
|
||||||
|
|
||||||
|
std::shared_ptr<PdfDocument> doc_;
|
||||||
|
int pageIndex_;
|
||||||
|
std::string paraId_;
|
||||||
|
|
||||||
|
// Live editing state
|
||||||
|
std::string currentText_;
|
||||||
|
text::LayoutConstraints constraints_;
|
||||||
|
std::shared_ptr<fonts::FontFace> currentFace_;
|
||||||
|
double currentFontSize_ = 12.0;
|
||||||
|
|
||||||
|
// Layout cache and engine
|
||||||
|
mutable text::TextLayoutEngine layoutEngine_;
|
||||||
|
mutable LayoutResult cachedLayout_;
|
||||||
|
mutable bool isDirty_ = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace pdfengine::parser
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace pdfengine::parser {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
#ifdef PDFENGINE_WITH_PDFIUM
|
|
||||||
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
fonts::pdf_fonts::ReconstructedFont rf;
|
fonts::pdf_fonts::ReconstructedFont rf;
|
||||||
if (!sfnt.empty()) {
|
if (!sfnt.empty()) {
|
||||||
rf.ok = true;
|
rf.ok = true;
|
||||||
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
|
|||||||
}
|
}
|
||||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||||
reconstructedFonts_[internalFontId] = std::move(rf);
|
reconstructedFonts_[internalFontId] = std::move(rf);
|
||||||
|
#else
|
||||||
|
(void)internalFontId; (void)sfnt;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
std::expected<std::vector<uint8_t>, EngineError>
|
std::expected<std::vector<uint8_t>, EngineError>
|
||||||
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
const auto* rf = lookupReconFont(internalFontId);
|
const auto* rf = lookupReconFont(internalFontId);
|
||||||
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
#else
|
||||||
|
(void)internalFontId;
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
|
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
|
||||||
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
#ifdef PDFENGINE_WITH_QPDF
|
#ifdef PDFENGINE_WITH_QPDF
|
||||||
return getReconstructedEmbeddedFont(internalFontId);
|
return getReconstructedEmbeddedFont(internalFontId);
|
||||||
#else
|
#else
|
||||||
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
|
|||||||
auto it = reconstructedFonts_.find(internalFontId);
|
auto it = reconstructedFonts_.find(internalFontId);
|
||||||
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
||||||
#endif
|
#endif
|
||||||
}
|
#else
|
||||||
|
(void)internalFontId;
|
||||||
|
return nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||||
const fonts::pdf_fonts::ReconstructedFont*
|
const fonts::pdf_fonts::ReconstructedFont*
|
||||||
@@ -103,12 +115,23 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||||
if (out.resolved) {
|
if (out.resolved) {
|
||||||
for (uint32_t cp : codepoints) {
|
for (uint32_t cp : codepoints) {
|
||||||
|
// U+0000 is a string terminator, never glyph content.
|
||||||
|
if (cp == 0) continue;
|
||||||
if (!out.resolved->hasGlyph(cp)) {
|
if (!out.resolved->hasGlyph(cp)) {
|
||||||
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
|
if (isSubsetFont) {
|
||||||
|
subsetLacksGlyphs = true;
|
||||||
|
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' subset MISSING codepoint U+{:04X} (char='{}')",
|
||||||
|
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
|
||||||
|
} else {
|
||||||
|
fontSupportsAll = false;
|
||||||
|
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' full font MISSING codepoint U+{:04X} (char='{}')",
|
||||||
|
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fontSupportsAll = false;
|
fontSupportsAll = false;
|
||||||
|
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' no resolved face - forcing system fallback", internalFontId);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string cacheKey;
|
std::string cacheKey;
|
||||||
@@ -178,7 +201,30 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CRITICAL FIX: Even though we are emitting with a system font (because the PDF subset
|
||||||
|
// font does not contain the newly typed glyph), we must still use the ORIGINAL embedded
|
||||||
|
// subset font face for HarfBuzz shaping / advance measurement. This preserves the exact
|
||||||
|
// glyph advance widths, ascent, descent, and visual size of all original characters so
|
||||||
|
// the text does not visually shrink or change weight after the first keystroke.
|
||||||
|
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
|
||||||
|
std::vector<uint8_t> origBytes;
|
||||||
|
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
||||||
|
perObj.has_value() && !perObj->empty()) {
|
||||||
|
origBytes = std::move(*perObj);
|
||||||
|
} else if (auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||||
|
fontDataRes.has_value() && !fontDataRes.value().empty()) {
|
||||||
|
origBytes = std::move(fontDataRes.value());
|
||||||
}
|
}
|
||||||
|
if (!origBytes.empty()) {
|
||||||
|
auto mf = std::make_shared<fonts::FontFace>();
|
||||||
|
if (mf->loadFromMemory(origBytes)) {
|
||||||
|
out.measureFace = mf;
|
||||||
|
spdlog::info("[FONT_METRICS_FIX] internalFontId='{}' preserving original embedded face as measureFace despite system emission font - visual size preserved", internalFontId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!out.font && matchedFontInfo && matchedFontInfo->isEmbedded &&
|
if (!out.font && matchedFontInfo && matchedFontInfo->isEmbedded &&
|
||||||
classifyFontFidelity(*matchedFontInfo) != "exact") {
|
classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||||
const auto* rf = lookupReconFont(internalFontId);
|
const auto* rf = lookupReconFont(internalFontId);
|
||||||
@@ -211,7 +257,24 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
|
|
||||||
if (!out.font) {
|
if (!out.font) {
|
||||||
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||||
|
spdlog::info("[FONT_FALLBACK_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} subsetLacksGlyphs={} fontSupportsAll={} cacheKey='{}' fallbackStd='{}'",
|
||||||
|
internalFontId, useEmbedded, useSystem,
|
||||||
|
matchedFontInfo ? matchedFontInfo->isSubset : false,
|
||||||
|
(bool)(!out.resolved ? false : (isSubsetFont ? subsetLacksGlyphs : !fontSupportsAll)),
|
||||||
|
(bool)fontSupportsAll, cacheKey, fontName);
|
||||||
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||||
|
// Standard fonts have no SFNT bytes → measureFace cannot be created from them.
|
||||||
|
spdlog::info("[MEASUREFACE_NULL] reason=standard_font_fallback internalFontId='{}' "
|
||||||
|
"isEmbedded={} matched={} useEmbedded={} useSystem={} fontName='{}' "
|
||||||
|
"fontPtr={} — no TTF/OTF bytes available for HarfBuzz measureFace",
|
||||||
|
internalFontId,
|
||||||
|
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
|
||||||
|
(bool)matchedFontInfo, useEmbedded, useSystem, fontName, (void*)out.font);
|
||||||
|
} else {
|
||||||
|
spdlog::info("[FONT_LOAD_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} measureFace={} cacheKey='{}'",
|
||||||
|
internalFontId, useEmbedded, useSystem,
|
||||||
|
matchedFontInfo ? matchedFontInfo->isSubset : false,
|
||||||
|
(bool)out.measureFace, cacheKey);
|
||||||
}
|
}
|
||||||
if (out.font) {
|
if (out.font) {
|
||||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||||
@@ -220,8 +283,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
||||||
auto mf = std::make_shared<fonts::FontFace>();
|
auto mf = std::make_shared<fonts::FontFace>();
|
||||||
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
||||||
|
} else if (!out.measureFace) {
|
||||||
|
spdlog::info("[MEASUREFACE_NULL] reason=no_sfnt_bytes_in_buffer internalFontId='{}' cacheKey='{}' "
|
||||||
|
"useEmbedded={} useSystem={} — emit font loaded but measureFace still null",
|
||||||
|
internalFontId, cacheKey, useEmbedded, useSystem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
spdlog::info("[LOAD_EMISSION_FONT_RESULT] internalFontId='{}' fontPtr={} measureFacePtr={} "
|
||||||
|
"useEmbedded={} useSystem={} isEmbedded={} isSubset={}",
|
||||||
|
internalFontId, (void*)out.font,
|
||||||
|
(void*)(out.measureFace ? out.measureFace.get() : nullptr),
|
||||||
|
useEmbedded, useSystem,
|
||||||
|
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
|
||||||
|
matchedFontInfo ? matchedFontInfo->isSubset : false);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -136,7 +136,76 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
|||||||
const auto* bgra = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
const auto* bgra = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||||
const int stride = FPDFBitmap_GetStride(bitmap);
|
const int stride = FPDFBitmap_GetStride(bitmap);
|
||||||
std::vector<uint8_t> rgba(static_cast<size_t>(w) * regionH * 4);
|
std::vector<uint8_t> rgba(static_cast<size_t>(w) * regionH * 4);
|
||||||
|
|
||||||
|
bool allAlpha255 = true;
|
||||||
|
size_t non255Count = 0;
|
||||||
for (int y = 0; y < regionH; ++y) {
|
for (int y = 0; y < regionH; ++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];
|
||||||
|
if (src[x * 4 + 3] != 255) {
|
||||||
|
allAlpha255 = false;
|
||||||
|
non255Count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 1. Region width: {}, height: {} (dpi: {}, yTopPt: {:.2f}, heightPt: {:.2f}, pagePt: {:.2f}x{:.2f})",
|
||||||
|
w, regionH, dpi, yTopPt, heightPt, width(), height());
|
||||||
|
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 2. Initialized with opaque white fill (0xFFFFFFFF): TRUE (via FPDFBitmap_FillRect)");
|
||||||
|
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 3. Content scope: Entire page vertical slice from yTopPx={} to yTopPx+regionH={} (renders ALL page objects in slice, not just edited paragraph)",
|
||||||
|
yTopPx, yTopPx + regionH);
|
||||||
|
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 4. Alpha transparency check: allAlpha255={}, non255Count={}",
|
||||||
|
allAlpha255 ? "TRUE (100% opaque, alpha=255 everywhere)" : "FALSE", non255Count);
|
||||||
|
|
||||||
|
std::vector<uint8_t> pngBytes = encodeBgraToPng(bgra, w, regionH, stride);
|
||||||
|
if (!pngBytes.empty()) {
|
||||||
|
FILE* f = fopen("debug_preview_region.png", "wb");
|
||||||
|
if (f) {
|
||||||
|
fwrite(pngBytes.data(), 1, pngBytes.size(), f);
|
||||||
|
fclose(f);
|
||||||
|
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] Saved preview bitmap to debug_preview_region.png (size: {} bytes)", pngBytes.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FPDFBitmap_Destroy(bitmap);
|
||||||
|
return PageImage{w, regionH, std::move(rgba)};
|
||||||
|
#else
|
||||||
|
(void)dpi; (void)yTopPt; (void)heightPt;
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
#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;
|
const uint8_t* src = bgra + static_cast<size_t>(y) * stride;
|
||||||
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
|
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
|
||||||
for (int x = 0; x < w; ++x) {
|
for (int x = 0; x < w; ++x) {
|
||||||
@@ -147,9 +216,9 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FPDFBitmap_Destroy(bitmap);
|
FPDFBitmap_Destroy(bitmap);
|
||||||
return PageImage{w, regionH, std::move(rgba)};
|
return PageImage{w, h, std::move(rgba)};
|
||||||
#else
|
#else
|
||||||
(void)dpi; (void)yTopPt; (void)heightPt;
|
(void)dpi; (void)xPt; (void)yPt; (void)wPt; (void)hPt;
|
||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|||||||
if (it != objToIndex.end()) {
|
if (it != objToIndex.end()) {
|
||||||
g.pageObjectIndex = it->second;
|
g.pageObjectIndex = it->second;
|
||||||
}
|
}
|
||||||
|
FS_MATRIX mtx;
|
||||||
|
if (FPDFPageObj_GetMatrix(textObj, &mtx)) {
|
||||||
|
double scaleY1 = std::hypot(static_cast<double>(mtx.c), static_cast<double>(mtx.d));
|
||||||
|
double scaleY2 = std::hypot(static_cast<double>(mtx.a), static_cast<double>(mtx.b));
|
||||||
|
double scaleY = (std::max)(scaleY1, scaleY2);
|
||||||
|
if (scaleY > 0.001) {
|
||||||
|
double trueSize = g.fontSize * scaleY;
|
||||||
|
if (trueSize > 0.1) g.fontSize = trueSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (g.bboxH > g.fontSize * 0.8) {
|
||||||
|
g.fontSize = (std::max)(g.fontSize, g.bboxH);
|
||||||
}
|
}
|
||||||
|
|
||||||
documentGlyphs.push_back(g);
|
documentGlyphs.push_back(g);
|
||||||
@@ -600,6 +613,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (subtype == FPDF_ANNOT_INK) {
|
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();
|
const double pageH = height();
|
||||||
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
||||||
for (unsigned long s = 0; s < strokeCount; ++s) {
|
for (unsigned long s = 0; s < strokeCount; ++s) {
|
||||||
@@ -614,6 +642,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
|||||||
}
|
}
|
||||||
if (!stroke.empty()) info.paths.push_back(std::move(stroke));
|
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);
|
result.push_back(info);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#include "pdfengine/text_layout_engine.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace pdfengine::text {
|
||||||
|
|
||||||
|
LineBreaker::LineBreaker(const LayoutConstraints& constraints)
|
||||||
|
: constraints_(constraints),
|
||||||
|
currentX_(constraints.columnLeft),
|
||||||
|
currentY_(constraints.firstBaselineY) {}
|
||||||
|
|
||||||
|
void LineBreaker::ProcessRun(
|
||||||
|
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
|
||||||
|
const std::string& runText,
|
||||||
|
double fontSize,
|
||||||
|
const fonts::FontFace& face,
|
||||||
|
double scale
|
||||||
|
) {
|
||||||
|
// Basic line breaker mimicking the HarfBuzz scaling loop from pdfium_edit_reflow
|
||||||
|
// In a full implementation, we'd find spaces and wrap. For now, just lay out linearly
|
||||||
|
// and wrap aggressively at columnRight.
|
||||||
|
|
||||||
|
FT_Face ftFace = face.getFace();
|
||||||
|
double upm = ftFace->units_per_EM;
|
||||||
|
double fontAscent = (ftFace->ascender / upm) * 1000.0 * scale;
|
||||||
|
double fontDescent = (ftFace->descender / upm) * 1000.0 * scale;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < shapedGlyphs.size(); ++i) {
|
||||||
|
const auto& glyph = shapedGlyphs[i];
|
||||||
|
|
||||||
|
// Very rudimentary word wrap for demonstration
|
||||||
|
if (currentX_ + glyph.advanceX * scale > constraints_.columnRight && !currentLineGlyphs_.empty()) {
|
||||||
|
CommitLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
GlyphInfo info;
|
||||||
|
info.glyphId = glyph.glyphIndex;
|
||||||
|
info.cluster = glyph.clusterIndex;
|
||||||
|
info.x = currentX_ + glyph.offsetX * scale;
|
||||||
|
info.y = currentY_ + glyph.offsetY * scale;
|
||||||
|
info.advance = glyph.advanceX * scale;
|
||||||
|
info.width = info.advance; // Simplification
|
||||||
|
info.ascent = fontAscent;
|
||||||
|
info.descent = fontDescent;
|
||||||
|
|
||||||
|
// Extract substring for this cluster if possible
|
||||||
|
if (info.cluster < runText.size()) {
|
||||||
|
info.text = runText.substr(info.cluster, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLineGlyphs_.push_back(info);
|
||||||
|
currentX_ += info.advance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LineBreaker::CommitLine() {
|
||||||
|
if (currentLineGlyphs_.empty()) return;
|
||||||
|
|
||||||
|
LineInfo line;
|
||||||
|
line.id = static_cast<int>(finishedLines_.size());
|
||||||
|
line.baselineY = currentY_;
|
||||||
|
|
||||||
|
if (!currentLineGlyphs_.empty()) {
|
||||||
|
line.rect.x = currentLineGlyphs_.front().x;
|
||||||
|
line.rect.y = currentY_ - currentLineGlyphs_.front().ascent;
|
||||||
|
line.rect.width = currentX_ - line.rect.x;
|
||||||
|
line.rect.height = currentLineGlyphs_.front().ascent - currentLineGlyphs_.front().descent;
|
||||||
|
}
|
||||||
|
|
||||||
|
finishedLines_.push_back(line);
|
||||||
|
|
||||||
|
for (auto& g : currentLineGlyphs_) {
|
||||||
|
allGlyphs_.push_back(g);
|
||||||
|
}
|
||||||
|
currentLineGlyphs_.clear();
|
||||||
|
|
||||||
|
// Advance to next line
|
||||||
|
currentX_ = constraints_.columnLeft;
|
||||||
|
currentY_ -= constraints_.leading; // Assuming standard PDF Y-up coordinates
|
||||||
|
}
|
||||||
|
|
||||||
|
void LineBreaker::Finalize(LayoutResult& outLayout) {
|
||||||
|
CommitLine(); // Commit any remaining glyphs
|
||||||
|
|
||||||
|
outLayout.lines = std::move(finishedLines_);
|
||||||
|
outLayout.glyphs = std::move(allGlyphs_);
|
||||||
|
|
||||||
|
// Compute bounding box
|
||||||
|
if (!outLayout.lines.empty()) {
|
||||||
|
double minX = outLayout.lines.front().rect.x;
|
||||||
|
double maxX = minX;
|
||||||
|
double minY = outLayout.lines.front().rect.y;
|
||||||
|
double maxY = minY + outLayout.lines.front().rect.height;
|
||||||
|
|
||||||
|
for (const auto& line : outLayout.lines) {
|
||||||
|
if (line.rect.x < minX) minX = line.rect.x;
|
||||||
|
if (line.rect.x + line.rect.width > maxX) maxX = line.rect.x + line.rect.width;
|
||||||
|
if (line.rect.y < minY) minY = line.rect.y;
|
||||||
|
if (line.rect.y + line.rect.height > maxY) maxY = line.rect.y + line.rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
outLayout.bounds.rect.x = minX;
|
||||||
|
outLayout.bounds.rect.y = minY;
|
||||||
|
outLayout.bounds.rect.width = maxX - minX;
|
||||||
|
outLayout.bounds.rect.height = maxY - minY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextLayoutEngine::TextLayoutEngine() = default;
|
||||||
|
|
||||||
|
LayoutResult TextLayoutEngine::ComputeLayout(
|
||||||
|
const std::string& text,
|
||||||
|
const LayoutConstraints& constraints,
|
||||||
|
fonts::FontFace& fontFace,
|
||||||
|
double fontSize
|
||||||
|
) {
|
||||||
|
LayoutResult result;
|
||||||
|
|
||||||
|
if (text.empty() || constraints.columnRight <= constraints.columnLeft) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1000 is the standard reference size used in HarfBuzz scaling in this engine
|
||||||
|
constexpr unsigned int kRefSize = 1000;
|
||||||
|
auto shapedGlyphs = shaper_.shapeRun(text, fontFace, kRefSize);
|
||||||
|
|
||||||
|
double scale = fontSize / static_cast<double>(kRefSize);
|
||||||
|
|
||||||
|
LineBreaker breaker(constraints);
|
||||||
|
breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale);
|
||||||
|
breaker.Finalize(result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine::text
|
||||||
@@ -9,6 +9,7 @@ add_executable(pdfengine_smoke
|
|||||||
document_load_test.cpp
|
document_load_test.cpp
|
||||||
page_render_test.cpp
|
page_render_test.cpp
|
||||||
document_edit_test.cpp
|
document_edit_test.cpp
|
||||||
|
edit_session_test.cpp
|
||||||
font_diagnostics_test.cpp
|
font_diagnostics_test.cpp
|
||||||
text_encoding_test.cpp
|
text_encoding_test.cpp
|
||||||
skia_renderer_test.cpp
|
skia_renderer_test.cpp
|
||||||
@@ -38,6 +39,10 @@ if(PDFENGINE_WITH_SKIA)
|
|||||||
target_link_libraries(pdfengine_smoke PRIVATE skia::skia)
|
target_link_libraries(pdfengine_smoke PRIVATE skia::skia)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
if(PDFENGINE_WITH_PDFIUM)
|
||||||
|
target_link_libraries(pdfengine_smoke PRIVATE pdfium::pdfium)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(PDFENGINE_WITH_QPDF)
|
if(PDFENGINE_WITH_QPDF)
|
||||||
target_link_libraries(pdfengine_smoke PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
target_link_libraries(pdfengine_smoke PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
#include <pdfengine/display_list.hpp>
|
#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 <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -20,6 +23,38 @@ public:
|
|||||||
void visit(const DrawImageCommand&) override { calls.push_back("DrawImage"); }
|
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) {
|
TEST(DisplayListTest, RecordAndReplay) {
|
||||||
DisplayList list;
|
DisplayList list;
|
||||||
|
|
||||||
@@ -53,3 +88,97 @@ TEST(DisplayListTest, Clear) {
|
|||||||
list.clear();
|
list.clear();
|
||||||
EXPECT_EQ(list.size(), 0);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#include "document_test_helpers.hpp"
|
||||||
|
#include "pdfengine/pdf_document.hpp"
|
||||||
|
#include "pdfengine/edit_session.hpp"
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace pdfengine {
|
||||||
|
|
||||||
|
TEST(EditSessionTest, StartEditSessionAndBasics) {
|
||||||
|
SKIP_IF_NO_PDFIUM();
|
||||||
|
|
||||||
|
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||||
|
ASSERT_TRUE(docRes.has_value());
|
||||||
|
auto doc = *docRes;
|
||||||
|
|
||||||
|
auto session = EditSession::StartEditSession(doc, 0, "para_1");
|
||||||
|
ASSERT_NE(session, nullptr);
|
||||||
|
|
||||||
|
// Initial Layout check (Mocked initial text: "Mock initial text for paragraph.")
|
||||||
|
auto layout = session->GetLayoutView();
|
||||||
|
EXPECT_FALSE(layout.lines.empty());
|
||||||
|
EXPECT_FALSE(layout.glyphs.empty());
|
||||||
|
|
||||||
|
// HitTest against mocked coordinates
|
||||||
|
// We mocked columnLeft at 100.0, advance 6.0 per char
|
||||||
|
// Char 0 at x=100.0
|
||||||
|
int hit = session->HitTest(102.0, 700.0);
|
||||||
|
EXPECT_EQ(hit, 0); // Closest to first cluster
|
||||||
|
|
||||||
|
// Char 1 at x=106.0
|
||||||
|
hit = session->HitTest(108.0, 700.0);
|
||||||
|
EXPECT_EQ(hit, 1); // Closest to second cluster
|
||||||
|
|
||||||
|
// GetCaretRect
|
||||||
|
Rect caret0 = session->GetCaretRect(0);
|
||||||
|
EXPECT_EQ(caret0.x, 100.0);
|
||||||
|
EXPECT_EQ(caret0.y, 690.0); // 700 - 10(ascent)
|
||||||
|
|
||||||
|
Rect caret1 = session->GetCaretRect(1);
|
||||||
|
EXPECT_EQ(caret1.x, 106.0);
|
||||||
|
|
||||||
|
// ApplyEdit sets dirty flag
|
||||||
|
session->ApplyEdit("append:!");
|
||||||
|
|
||||||
|
auto layout2 = session->GetLayoutView();
|
||||||
|
EXPECT_GT(layout2.glyphs.size(), layout.glyphs.size()); // Appended '!'
|
||||||
|
|
||||||
|
bool committed = session->CommitEdit();
|
||||||
|
EXPECT_TRUE(committed);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace pdfengine
|
||||||
@@ -262,7 +262,8 @@ std::string decodedStream(QPDFObjectHandle contents) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::vector<std::unique_ptr<ContentObject>> buildPageObjects(QPDFObjectHandle page) {
|
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();
|
auto tokens = lexer.tokenize();
|
||||||
ContentParser parser(tokens);
|
ContentParser parser(tokens);
|
||||||
auto operations = parser.parse();
|
auto operations = parser.parse();
|
||||||
@@ -436,6 +437,8 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
|
|||||||
|
|
||||||
QPDF qpdf;
|
QPDF qpdf;
|
||||||
auto objects = buildFirstPageObjects(qpdf, pdf);
|
auto objects = buildFirstPageObjects(qpdf, pdf);
|
||||||
|
|
||||||
|
|
||||||
auto images = imageObjects(objects);
|
auto images = imageObjects(objects);
|
||||||
ASSERT_EQ(images.size(), 2u);
|
ASSERT_EQ(images.size(), 2u);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_GATEWAY_URL=http://127.0.0.1:8765
|
||||||
@@ -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;"]
|
||||||
+278
-61
@@ -5,8 +5,10 @@ import { Toolbar } from './components/Toolbar';
|
|||||||
import { InspectorPanel } from './components/InspectorPanel';
|
import { InspectorPanel } from './components/InspectorPanel';
|
||||||
import type { InspectorTab } from './components/InspectorPanel';
|
import type { InspectorTab } from './components/InspectorPanel';
|
||||||
import { SignatureModal } from './components/SignatureModal';
|
import { SignatureModal } from './components/SignatureModal';
|
||||||
|
import { RedactPagesModal } from './components/RedactPagesModal';
|
||||||
import { AboutModal } from './components/AboutModal';
|
import { AboutModal } from './components/AboutModal';
|
||||||
import { ToastViewport } from './components/ui';
|
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
||||||
|
|
||||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||||
import { PDFViewer } from './viewer/PDFViewer';
|
import { PDFViewer } from './viewer/PDFViewer';
|
||||||
@@ -18,9 +20,9 @@ import { PasswordModal } from './components/PasswordModal';
|
|||||||
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
|
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
|
||||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||||
import type { Rect } from './lib/coordinateMapping';
|
import type { Rect } from './lib/coordinateMapping';
|
||||||
import { toast } from './lib/toast';
|
|
||||||
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
|
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)}`;
|
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
|
||||||
@@ -38,8 +40,7 @@ function App() {
|
|||||||
|
|
||||||
const permissions = activeDoc?.permissions ?? null;
|
const permissions = activeDoc?.permissions ?? null;
|
||||||
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
||||||
const denyToast = (label: string) =>
|
const denyToast = (_label: string) => {};
|
||||||
toast(`${label} is not permitted by this document's restrictions`, 'error');
|
|
||||||
const disabledTools = new Set<ToolId>();
|
const disabledTools = new Set<ToolId>();
|
||||||
if (!can('canAnnotate'))
|
if (!can('canAnnotate'))
|
||||||
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
||||||
@@ -57,17 +58,22 @@ function App() {
|
|||||||
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
||||||
const [fonts, setFonts] = useState<FontInfo[]>([]);
|
const [fonts, setFonts] = useState<FontInfo[]>([]);
|
||||||
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
||||||
|
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||||
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
|
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
|
||||||
const [engineReady, setEngineReady] = useState(false);
|
const [engineReady, setEngineReady] = useState(false);
|
||||||
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
|
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
|
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
|
||||||
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
||||||
|
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
|
||||||
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
||||||
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
|
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
|
||||||
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
|
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
|
||||||
|
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
|
||||||
|
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
|
||||||
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
|
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -83,15 +89,21 @@ function App() {
|
|||||||
if (!canUndo) return;
|
if (!canUndo) return;
|
||||||
preservePageRef.current = true;
|
preservePageRef.current = true;
|
||||||
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
|
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
|
||||||
toast('Undo', 'info', 1200);
|
|
||||||
};
|
};
|
||||||
const redo = () => {
|
const redo = () => {
|
||||||
if (!canRedo) return;
|
if (!canRedo) return;
|
||||||
preservePageRef.current = true;
|
preservePageRef.current = true;
|
||||||
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
|
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(() => {
|
useEffect(() => {
|
||||||
gatewayService.getHealth()
|
gatewayService.getHealth()
|
||||||
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
||||||
@@ -102,9 +114,58 @@ function App() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const streamUrl = params.get('stream_url');
|
||||||
|
const uploadUrl = params.get('upload_url');
|
||||||
|
const token = params.get('token');
|
||||||
|
const resourceId = params.get('resource_id');
|
||||||
|
|
||||||
|
if (streamUrl && uploadUrl && token) {
|
||||||
|
try {
|
||||||
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || '';
|
||||||
|
const res = await fetch(`${gatewayUrl}/documents/import-remote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
stream_url: streamUrl,
|
||||||
|
upload_url: uploadUrl,
|
||||||
|
auth_token: token,
|
||||||
|
resource_id: resourceId || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const docInfo = await res.json();
|
||||||
|
setDocuments([docInfo]);
|
||||||
|
openDocument(docInfo.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Remote import failed:', res.status, await res.text().catch(() => ''));
|
||||||
|
setImportError('Failed to open the remote document.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remote import failed:', err);
|
||||||
|
setImportError('Failed to open the remote document.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const docs = await gatewayService.listDocuments();
|
const docs = await gatewayService.listDocuments();
|
||||||
setDocuments(docs);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load documents', e);
|
console.error('Failed to load documents', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,6 +201,7 @@ function App() {
|
|||||||
content: a.content,
|
content: a.content,
|
||||||
timestamp: a.timestamp,
|
timestamp: a.timestamp,
|
||||||
pageIndex: a.pageIndex,
|
pageIndex: a.pageIndex,
|
||||||
|
quadPoints: a.quad_points || a.quadPoints,
|
||||||
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
|
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
|
||||||
fieldName: a.fieldName,
|
fieldName: a.fieldName,
|
||||||
fieldValue: a.fieldValue,
|
fieldValue: a.fieldValue,
|
||||||
@@ -213,18 +275,16 @@ function App() {
|
|||||||
gatewayService.listDocuments().then(setDocuments).catch(() => {});
|
gatewayService.listDocuments().then(setDocuments).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
|
const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
|
||||||
if (!selectedDocId) return;
|
if (!selectedDocId) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await gatewayService.applyEdits(selectedDocId, ops);
|
const result = await gatewayService.applyEdits(selectedDocId, ops);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
adoptNewDocument(result.newDocumentId);
|
adoptNewDocument(result.newDocumentId);
|
||||||
if (successMsg) toast(successMsg, 'success');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Edit failed', e);
|
console.error('Edit failed', e);
|
||||||
toast('Edit failed — check the gateway connection', 'error');
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -267,22 +327,33 @@ function App() {
|
|||||||
|
|
||||||
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
|
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
|
||||||
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
|
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;
|
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'),
|
id: rid('locdec'),
|
||||||
type,
|
type,
|
||||||
pageIndex,
|
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,
|
color,
|
||||||
author: 'Current User',
|
author: 'Current User',
|
||||||
} as Annotation));
|
} as Annotation;
|
||||||
setAnnotations(prev => [...prev, ...newAnnos]);
|
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) => {
|
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
|
||||||
@@ -320,24 +391,80 @@ function App() {
|
|||||||
if (!activeStamp) return;
|
if (!activeStamp) return;
|
||||||
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
|
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
|
||||||
const fontSize = 22;
|
const fontSize = 22;
|
||||||
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
|
const padding = 12; // visual padding
|
||||||
const height = fontSize * 1.5;
|
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));
|
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
|
||||||
applyOps([{
|
applyOps([{
|
||||||
id: rid('stamp'), type: 'text_overlay', pageIndex,
|
id: rid('stamp'), type: 'stamp', pageIndex,
|
||||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
|
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`);
|
}], `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 (!pendingSignature) return;
|
||||||
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); 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([{
|
applyOps([{
|
||||||
id: rid('sig'), type: 'image_overlay', pageIndex,
|
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');
|
}], 'Signature placed');
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
};
|
};
|
||||||
@@ -351,7 +478,7 @@ function App() {
|
|||||||
const handleDeletePage = (pageIndex: number) => {
|
const handleDeletePage = (pageIndex: number) => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
if (!can('canAssemble')) { denyToast('Deleting pages'); 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({
|
setConfirmState({
|
||||||
title: 'Delete page?',
|
title: 'Delete page?',
|
||||||
message: `Page ${pageIndex + 1} will be removed from this document.`,
|
message: `Page ${pageIndex + 1} will be removed from this document.`,
|
||||||
@@ -370,19 +497,34 @@ function App() {
|
|||||||
setCurrentPage(to);
|
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 }]);
|
||||||
|
if (activeTool !== 'redact') {
|
||||||
|
setActiveTool('redact');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyRedactions = () => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
if (!can('canModify')) { denyToast('Redaction'); return; }
|
if (!can('canModify')) { denyToast('Redaction'); return; }
|
||||||
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
|
if (pendingRedactions.length === 0) return;
|
||||||
|
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: 'Redact area?',
|
title: 'Apply Redactions?',
|
||||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
|
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
|
||||||
confirmLabel: 'Redact', danger: true,
|
confirmLabel: 'Apply', danger: true,
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
applyOps([{
|
const ops = pendingRedactions.map(mark => {
|
||||||
id: rid('redact'), type: 'redaction', pageIndex,
|
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
|
||||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
|
return {
|
||||||
}], 'Area redacted');
|
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');
|
setActiveTool('select');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -394,37 +536,68 @@ function App() {
|
|||||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||||
setDocuments((prev) => [newDoc, ...prev]);
|
setDocuments((prev) => [newDoc, ...prev]);
|
||||||
openDocument(newDoc.id);
|
openDocument(newDoc.id);
|
||||||
toast(`Opened ${newDoc.filename}`, 'success');
|
|
||||||
setPasswordPrompt(null);
|
setPasswordPrompt(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PasswordError) {
|
if (e instanceof PasswordError) {
|
||||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||||
} else {
|
} else {
|
||||||
console.error('Upload failed', e);
|
console.error('Upload failed', e);
|
||||||
toast('Upload failed', 'error');
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
|
||||||
|
// Grab the token that was injected into the URL when this iframe was opened.
|
||||||
|
// This is always fresher than whatever the gateway has cached.
|
||||||
|
const urlToken = urlParams.get('token') || undefined;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!activeDoc) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (isRemote) {
|
||||||
|
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
|
||||||
|
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
|
||||||
|
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
|
||||||
|
} else {
|
||||||
|
// When running locally outside an iframe, edits are already auto-saved to the gateway DB.
|
||||||
|
// We just simulate a save delay to provide UI feedback, instead of downloading the file.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Save failed', e);
|
||||||
|
alert('Save failed: ' + String(e));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
||||||
|
setConfirmState({
|
||||||
|
title: 'Export Document',
|
||||||
|
message: `Are you sure you want to export "${activeDoc.filename}"?`,
|
||||||
|
confirmLabel: 'Export',
|
||||||
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||||
toast('Exported', 'success');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Export failed', e);
|
console.error('Export failed', e);
|
||||||
toast('Export failed', 'error');
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
if (!activeDoc) return;
|
if (!activeDoc) return;
|
||||||
if (!can('canPrint')) { denyToast('Printing'); return; }
|
if (!can('canPrint')) { denyToast('Printing'); return; }
|
||||||
try {
|
try {
|
||||||
toast('Preparing print...', 'info');
|
|
||||||
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
||||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -442,21 +615,11 @@ function App() {
|
|||||||
document.body.appendChild(iframe);
|
document.body.appendChild(iframe);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Print failed', e);
|
console.error('Print failed', e);
|
||||||
toast('Print failed', 'error');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleInspector = () => {
|
const toggleInspector = () => {
|
||||||
setIsInspectorOpen((prev) => {
|
setIsInspectorOpen((prev) => !prev);
|
||||||
const next = !prev;
|
|
||||||
setTimeout(() => {
|
|
||||||
const w = activeDoc?.pageWidth || 612;
|
|
||||||
const inspectorWidth = next ? 322 : 0;
|
|
||||||
const avail = window.innerWidth - inspectorWidth - 48;
|
|
||||||
setZoom(Math.max(0.25, Math.min(3, avail / w)));
|
|
||||||
}, 50);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitWidth = () => {
|
const fitWidth = () => {
|
||||||
@@ -520,6 +683,8 @@ function App() {
|
|||||||
canExport={can('canCopy')}
|
canExport={can('canCopy')}
|
||||||
canAssemble={can('canAssemble')}
|
canAssemble={can('canAssemble')}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
|
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
||||||
|
onSave={isRemote ? handleSave : undefined}
|
||||||
isInspectorOpen={isInspectorOpen}
|
isInspectorOpen={isInspectorOpen}
|
||||||
onToggleInspector={toggleInspector}
|
onToggleInspector={toggleInspector}
|
||||||
/>
|
/>
|
||||||
@@ -541,8 +706,27 @@ function App() {
|
|||||||
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
||||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||||
hasSignature={!!pendingSignature}
|
hasSignature={!!pendingSignature}
|
||||||
activeStamp={activeStamp?.label ?? null}
|
activeStamp={activeStamp}
|
||||||
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
|
onSelectStamp={setActiveStamp}
|
||||||
|
redactionMode={redactionMode}
|
||||||
|
onRedactionModeChange={setRedactionMode}
|
||||||
|
pendingRedactionCount={pendingRedactions.length}
|
||||||
|
onApplyRedactions={handleApplyRedactions}
|
||||||
|
onClearRedactions={() => setPendingRedactions([])}
|
||||||
|
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||||
|
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
||||||
|
onUpdateAnnotation={(patch) => {
|
||||||
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||||
|
if (a) handleUpdateAnnotation({ ...a, ...patch });
|
||||||
|
}}
|
||||||
|
onDeleteAnnotation={() => {
|
||||||
|
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||||
|
if (a) {
|
||||||
|
handleDeleteAnnotation(a);
|
||||||
|
setSelectedAnnotationId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
@@ -562,8 +746,11 @@ function App() {
|
|||||||
pagesInfo={activeDoc.pages}
|
pagesInfo={activeDoc.pages}
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
toolSettings={toolSettings}
|
toolSettings={toolSettings}
|
||||||
|
redactionMode={redactionMode}
|
||||||
hasSignature={!!pendingSignature}
|
hasSignature={!!pendingSignature}
|
||||||
activeStamp={activeStamp?.label ?? null}
|
signatureImageUrl={pendingSignature?.url}
|
||||||
|
signatureAspect={pendingSignature?.aspect}
|
||||||
|
activeStamp={activeStamp}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
canCopy={can('canCopy')}
|
canCopy={can('canCopy')}
|
||||||
onFieldChange={(id, value, i) => {
|
onFieldChange={(id, value, i) => {
|
||||||
@@ -580,12 +767,18 @@ function App() {
|
|||||||
searchCurrentMatch={searchCurrentMatch}
|
searchCurrentMatch={searchCurrentMatch}
|
||||||
onAnnotationAdded={handleAnnotationAdded}
|
onAnnotationAdded={handleAnnotationAdded}
|
||||||
onAnnotationUpdate={handleUpdateAnnotation}
|
onAnnotationUpdate={handleUpdateAnnotation}
|
||||||
onAnnotationClick={() => {
|
onAnnotationClick={(anno) => {
|
||||||
|
if (activeTool === 'select') {
|
||||||
|
setSelectedAnnotationId(anno.id);
|
||||||
|
} else {
|
||||||
setInspectorTab('notes');
|
setInspectorTab('notes');
|
||||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onPageVisible={setCurrentPage}
|
onPageVisible={setCurrentPage}
|
||||||
onRedactArea={handleRedactArea}
|
onMarkRedaction={handleMarkRedaction}
|
||||||
|
pendingRedactions={pendingRedactions}
|
||||||
|
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
|
||||||
onPlaceText={handlePlaceText}
|
onPlaceText={handlePlaceText}
|
||||||
onEditText={handleEditText}
|
onEditText={handleEditText}
|
||||||
onReflowParagraph={handleReflowParagraph}
|
onReflowParagraph={handleReflowParagraph}
|
||||||
@@ -594,6 +787,16 @@ function App() {
|
|||||||
onPlaceSignature={handlePlaceSignature}
|
onPlaceSignature={handlePlaceSignature}
|
||||||
onDecorateText={handleDecorateText}
|
onDecorateText={handleDecorateText}
|
||||||
/>
|
/>
|
||||||
|
) : importError ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 text-red-500">
|
||||||
|
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[14px] font-semibold text-[#18212e]">Import Error</p>
|
||||||
|
<p className="text-[13px]">{importError}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
|
||||||
@@ -617,6 +820,8 @@ function App() {
|
|||||||
<InspectorPanel
|
<InspectorPanel
|
||||||
activeTab={inspectorTab}
|
activeTab={inspectorTab}
|
||||||
onTabChange={setInspectorTab}
|
onTabChange={setInspectorTab}
|
||||||
|
isExpanded={isInspectorExpanded}
|
||||||
|
onExpandedChange={setIsInspectorExpanded}
|
||||||
documents={documents}
|
documents={documents}
|
||||||
selectedDocumentId={selectedDocId}
|
selectedDocumentId={selectedDocId}
|
||||||
onSelectDocument={openDocument}
|
onSelectDocument={openDocument}
|
||||||
@@ -645,6 +850,14 @@ function App() {
|
|||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
permissions={permissions ?? undefined}
|
permissions={permissions ?? undefined}
|
||||||
fonts={fonts}
|
fonts={fonts}
|
||||||
|
history={hist}
|
||||||
|
onRestoreHistory={(docId) => {
|
||||||
|
const index = hist.stack.findIndex(id => id === docId);
|
||||||
|
if (index !== -1) {
|
||||||
|
setHist(h => ({ ...h, index }));
|
||||||
|
preservePageRef.current = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -656,10 +869,15 @@ function App() {
|
|||||||
setPendingSignature({ url, aspect });
|
setPendingSignature({ url, aspect });
|
||||||
setSignatureModalOpen(false);
|
setSignatureModalOpen(false);
|
||||||
setActiveTool('signature');
|
setActiveTool('signature');
|
||||||
toast('Signature ready — click on the page to place it', 'info');
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RedactPagesModal
|
||||||
|
open={redactPagesModalOpen}
|
||||||
|
onClose={() => setRedactPagesModalOpen(false)}
|
||||||
|
onConfirm={handleRedactPages}
|
||||||
|
/>
|
||||||
|
|
||||||
<AboutModal
|
<AboutModal
|
||||||
open={aboutModalOpen}
|
open={aboutModalOpen}
|
||||||
onClose={() => setAboutModalOpen(false)}
|
onClose={() => setAboutModalOpen(false)}
|
||||||
@@ -672,7 +890,6 @@ function App() {
|
|||||||
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
||||||
onClose={() => setPasswordPrompt(null)}
|
onClose={() => setPasswordPrompt(null)}
|
||||||
/>
|
/>
|
||||||
<ToastViewport />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import React from 'react';
|
|||||||
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
||||||
import type { Annotation } from '../viewer/AnnotationLayer';
|
import type { Annotation } from '../viewer/AnnotationLayer';
|
||||||
import { Thumbnail } from './Thumbnail';
|
import { Thumbnail } from './Thumbnail';
|
||||||
import { EmptyState, Popover } from './ui';
|
import { EmptyState } from './ui';
|
||||||
import {
|
import {
|
||||||
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
||||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon,
|
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
import { useTheme } from '../lib/useTheme';
|
||||||
|
|
||||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
|
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
|
||||||
|
|
||||||
interface InspectorPanelProps {
|
interface InspectorPanelProps {
|
||||||
activeTab: InspectorTab;
|
activeTab: InspectorTab;
|
||||||
onTabChange: (t: InspectorTab) => void;
|
onTabChange: (t: InspectorTab) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
|
||||||
documents: DocumentInfo[];
|
documents: DocumentInfo[];
|
||||||
selectedDocumentId: string;
|
selectedDocumentId: string;
|
||||||
@@ -48,6 +51,9 @@ interface InspectorPanelProps {
|
|||||||
metadata: DocumentMetadata | null;
|
metadata: DocumentMetadata | null;
|
||||||
permissions?: PDFPermissions;
|
permissions?: PDFPermissions;
|
||||||
fonts: FontInfo[];
|
fonts: FontInfo[];
|
||||||
|
|
||||||
|
history?: { stack: string[]; index: number };
|
||||||
|
onRestoreHistory?: (docId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
|
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
|
||||||
@@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo
|
|||||||
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
|
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
|
||||||
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
|
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
|
||||||
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
|
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
|
||||||
|
{ id: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
function formatBytes(bytes?: number) {
|
function formatBytes(bytes?: number) {
|
||||||
@@ -70,70 +77,66 @@ function formatBytes(bytes?: number) {
|
|||||||
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||||
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
|
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
|
||||||
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
|
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
|
<aside className="flex h-full shrink-0 flex-row-reverse bg-bg-primary">
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
{/* Icon Strip (Right Edge) */}
|
||||||
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
<div className="flex w-[48px] shrink-0 flex-col items-center gap-2 border-l border-border-primary bg-bg-secondary py-3">
|
||||||
<span className="shrink-0 text-[13px] font-bold text-[#18212e]">{activeDef.label}</span>
|
|
||||||
{p.documents.length > 0 ? (
|
|
||||||
<Popover
|
|
||||||
align="right"
|
|
||||||
width={272}
|
|
||||||
trigger={(open) => (
|
|
||||||
<CustomButton variant="unstyled" className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[8px] px-2 text-left transition-colors ${open ? 'bg-[#edeff2]' : 'hover:bg-[#f6f7f9]'}`}>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#5b6573]">{selectedDoc?.filename ?? 'Document'}</span>
|
|
||||||
<ChevronDownIcon size={13} className="shrink-0 text-[#98a1ad]" />
|
|
||||||
</CustomButton>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
|
|
||||||
{p.documents.map((d) => (
|
|
||||||
<CustomButton variant="unstyled"
|
|
||||||
key={d.id}
|
|
||||||
onClick={() => p.onSelectDocument(d.id)}
|
|
||||||
className={`flex flex-col rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] ${d.id === p.selectedDocumentId ? 'bg-[#eef4ff]' : ''}`}
|
|
||||||
>
|
|
||||||
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>{d.filename}</span>
|
|
||||||
<span className="text-[10.5px] text-[#98a1ad]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
|
|
||||||
</CustomButton>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11.5px] font-medium text-[#98a1ad]">No document</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
|
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
const active = p.activeTab === t.id;
|
const active = p.isExpanded && p.activeTab === t.id;
|
||||||
return (
|
return (
|
||||||
<CustomButton variant="unstyled"
|
<CustomButton variant="unstyled"
|
||||||
key={t.id}
|
key={t.id}
|
||||||
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
||||||
aria-label={t.label}
|
aria-label={t.label}
|
||||||
onClick={() => p.onTabChange(t.id)}
|
onClick={() => {
|
||||||
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
if (p.isExpanded && p.activeTab === t.id) {
|
||||||
|
p.onExpandedChange(false);
|
||||||
|
} else {
|
||||||
|
p.onTabChange(t.id);
|
||||||
|
p.onExpandedChange(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative flex h-9 w-9 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'bg-[#eef4ff] text-[#2563eb]'
|
? 'bg-brand-secondary text-brand-primary'
|
||||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
|
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
|
||||||
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
|
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
|
||||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||||
)}
|
)}
|
||||||
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
|
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
|
||||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||||
)}
|
)}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-[16px]" />
|
||||||
|
|
||||||
|
<CustomButton variant="unstyled"
|
||||||
|
label={theme === 'dark' ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-[8px] text-text-secondary transition-colors hover:bg-bg-tertiary hover:text-text-primary mb-2"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <SunIcon size={18} /> : <MoonIcon size={18} />}
|
||||||
|
</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
{/* Expanded Content Drawer */}
|
||||||
|
{p.isExpanded && (
|
||||||
|
<div className="flex w-[322px] min-w-0 shrink-0 flex-col border-l border-border-primary">
|
||||||
|
<div className="flex h-[48px] shrink-0 items-center justify-between border-b border-border-primary" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||||
|
<span className="shrink-0 text-[13px] font-bold text-text-primary">{activeDef.label}</span>
|
||||||
|
<CustomButton variant="unstyled" onClick={() => p.onExpandedChange(false)} className="flex h-7 w-7 items-center justify-center rounded-[6px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||||
|
<XIcon size={16} />
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto">
|
||||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||||
@@ -141,8 +144,10 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||||
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
|
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
|
||||||
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
|
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
|
||||||
|
{p.activeTab === 'history' && <HistoryTab history={p.history} onRestore={p.onRestoreHistory} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -172,7 +177,7 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
setDraggedIdx(null);
|
setDraggedIdx(null);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => setDraggedIdx(null)}
|
onDragEnd={() => setDraggedIdx(null)}
|
||||||
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-[#2563eb] [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-[#eef4ff]' : ''}`}
|
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-brand-primary [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-brand-secondary' : ''}`}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
documentId={p.documentId}
|
documentId={p.documentId}
|
||||||
@@ -197,18 +202,18 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
{annotations.map((a) => (
|
{annotations.map((a) => (
|
||||||
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 transition-colors hover:border-[#dadde2]">
|
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 transition-colors hover:border-border-secondary">
|
||||||
<CustomButton variant="unstyled" onClick={() => onNavigate(a)} className="flex flex-col gap-1.5 text-left">
|
<CustomButton variant="unstyled" onClick={() => onNavigate(a)} className="flex flex-col gap-1.5 text-left">
|
||||||
<div className="flex items-center justify-between pr-6">
|
<div className="flex items-center justify-between pr-6">
|
||||||
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide"
|
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide bg-brand-secondary text-brand-primary"
|
||||||
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
|
>{a.type}</span>
|
||||||
<span className="text-[10px] text-[#98a1ad]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
<span className="text-[10px] text-text-tertiary">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
{a.content && <p className="line-clamp-3 text-[12px] italic text-text-secondary">“{a.content}”</p>}
|
||||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
<span className="text-[10px] font-medium text-text-tertiary">{a.author}</span>
|
||||||
</CustomButton>
|
</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()}>
|
<div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -235,7 +240,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
|||||||
title="Delete annotation"
|
title="Delete annotation"
|
||||||
aria-label="Delete annotation"
|
aria-label="Delete annotation"
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(a); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(a); }}
|
||||||
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-[#98a1ad] opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
|
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-text-tertiary opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<TrashIcon size={15} />
|
<TrashIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -257,11 +262,11 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
|||||||
key={i}
|
key={i}
|
||||||
onClick={() => item.pageIndex >= 0 && onNavigate(item.pageIndex)}
|
onClick={() => item.pageIndex >= 0 && onNavigate(item.pageIndex)}
|
||||||
disabled={item.pageIndex < 0}
|
disabled={item.pageIndex < 0}
|
||||||
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-50"
|
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-bg-secondary disabled:opacity-50"
|
||||||
style={{ paddingLeft: `${8 + item.level * 14}px` }}
|
style={{ paddingLeft: `${8 + item.level * 14}px` }}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[#18212e]" title={item.title}>{item.title || '(untitled)'}</span>
|
<span className="min-w-0 flex-1 truncate text-[12.5px] text-text-primary" title={item.title}>{item.title || '(untitled)'}</span>
|
||||||
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">{item.pageIndex + 1}</span>}
|
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">{item.pageIndex + 1}</span>}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,27 +276,40 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
|||||||
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="border-b border-[#ebedf0] p-3">
|
<div className="border-b border-border-primary p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-[13px] font-bold text-[#18212e]">Find</span>
|
<span className="text-[13px] font-bold text-text-primary">Find</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-2.5 py-1.5 focus-within:border-[#2563eb]">
|
<div className="flex items-center gap-2 rounded-[8px] border border-border-primary bg-bg-secondary px-2.5 py-1.5 focus-within:border-brand-primary">
|
||||||
<SearchGlyph size={15} className="text-[#98a1ad]" />
|
<SearchGlyph size={15} className="shrink-0 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={p.searchQuery}
|
value={p.searchQuery}
|
||||||
onChange={(e) => p.onSearchQueryChange(e.target.value)}
|
onChange={(e) => p.onSearchQueryChange(e.target.value)}
|
||||||
placeholder="Search document…"
|
placeholder="Search document…"
|
||||||
className="w-full bg-transparent text-[13px] text-[#18212e] outline-none placeholder:text-[#98a1ad]"
|
className="w-full bg-transparent text-[13px] text-text-primary outline-none placeholder:text-text-tertiary"
|
||||||
/>
|
/>
|
||||||
|
{p.searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => p.onSearchQueryChange('')}
|
||||||
|
className="flex shrink-0 items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between px-0.5">
|
<div className="mt-3 flex items-center justify-between px-0.5">
|
||||||
<span className="text-[11.5px] font-medium text-[#5b6573]">
|
<span className="text-[11.5px] font-medium text-text-secondary">
|
||||||
{p.searchResults.length > 0 ? `Search results: ${p.searchCurrentMatch + 1}/${p.searchResults.length}` : (p.searchQuery ? 'No matches' : '')}
|
{p.searchResults.length > 0 ? `Search results: ${p.searchCurrentMatch + 1}/${p.searchResults.length}` : (p.searchQuery ? 'No matches' : '')}
|
||||||
</span>
|
</span>
|
||||||
{p.searchResults.length > 0 && (
|
{p.searchResults.length > 0 && (
|
||||||
<div className="flex items-center gap-1 text-[#5b6573]">
|
<div className="flex items-center gap-1 text-text-secondary">
|
||||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch - 1 + p.searchResults.length) % p.searchResults.length)}><ChevronUpIcon size={14} /></CustomButton>
|
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch - 1 + p.searchResults.length) % p.searchResults.length)}><ChevronUpIcon size={14} /></CustomButton>
|
||||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch + 1) % p.searchResults.length)}><ChevronDownIcon size={14} /></CustomButton>
|
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch + 1) % p.searchResults.length)}><ChevronDownIcon size={14} /></CustomButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,12 +317,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col gap-2 px-0.5">
|
<div className="mt-2 flex flex-col gap-2 px-0.5">
|
||||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
|
||||||
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||||
Case sensitive
|
Case sensitive
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
|
||||||
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||||
Whole words only
|
Whole words only
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +344,7 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
<span>
|
<span>
|
||||||
{parts.map((part, index) =>
|
{parts.map((part, index) =>
|
||||||
regex.test(part) ? (
|
regex.test(part) ? (
|
||||||
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-[#eef4ff] text-[#2563eb]'}`}>{part}</mark>
|
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-brand-secondary text-brand-primary'}`}>{part}</mark>
|
||||||
) : (
|
) : (
|
||||||
<span key={index}>{part}</span>
|
<span key={index}>{part}</span>
|
||||||
)
|
)
|
||||||
@@ -341,12 +359,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
return (
|
return (
|
||||||
<CustomButton variant="unstyled" key={i} onClick={() => p.onSelectSearchMatch(i)}
|
<CustomButton variant="unstyled" key={i} onClick={() => p.onSelectSearchMatch(i)}
|
||||||
className={`mb-1 flex w-full flex-col gap-1.5 rounded-[6px] px-2.5 py-2 text-left transition-colors ${
|
className={`mb-1 flex w-full flex-col gap-1.5 rounded-[6px] px-2.5 py-2 text-left transition-colors ${
|
||||||
isSelected ? 'bg-[#2563eb] text-white' : 'hover:bg-[#f6f7f9] text-[#5b6573]'
|
isSelected ? 'bg-brand-primary text-white' : 'hover:bg-bg-secondary text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-[#18212e]'}`}>
|
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-text-primary'}`}>
|
||||||
{highlightText(r.text || p.searchQuery, p.searchQuery)}
|
{highlightText(r.text || p.searchQuery, p.searchQuery)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-[#1d4ed8] text-white' : 'bg-[#edeff2] text-[#98a1ad]'}`}>
|
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-brand-hover text-white' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||||
Page {r.pageIndex + 1}
|
Page {r.pageIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
@@ -386,19 +404,19 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5 p-3">
|
<div className="flex flex-col gap-0.5 p-3">
|
||||||
{rows.map(([k, v]) => (
|
{rows.map(([k, v]) => (
|
||||||
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[#ebedf0] py-2 last:border-0">
|
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-border-primary py-2 last:border-0">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{k}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{k}</span>
|
||||||
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]">—</span>}</span>
|
<span className="break-words text-[12.5px] text-text-primary">{v && v.trim() ? v : <span className="text-text-tertiary">—</span>}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-3 border-t border-[#ebedf0] pt-3">
|
<div className="mt-3 border-t border-border-primary pt-3">
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">Security</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">Security</span>
|
||||||
{permissions?.isEncrypted
|
{permissions?.isEncrypted
|
||||||
? <span className="rounded bg-[#fdecec] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#dc2626]">{permissions.encryption}</span>
|
? <span className="rounded bg-[#fdecec] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#dc2626]">{permissions.encryption}</span>
|
||||||
: <span className="rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">Unencrypted</span>}
|
: <span className="rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">Unencrypted</span>}
|
||||||
{permissions?.ownerUnlocked && <span className="rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">Owner</span>}
|
{permissions?.ownerUnlocked && <span className="rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">Owner</span>}
|
||||||
</div>
|
</div>
|
||||||
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
|
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -406,7 +424,7 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
|||||||
const allowed = permissions[key] !== false;
|
const allowed = permissions[key] !== false;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center justify-between text-[12px]">
|
<div key={key} className="flex items-center justify-between text-[12px]">
|
||||||
<span className="text-[#5b6573]">{label}</span>
|
<span className="text-text-secondary">{label}</span>
|
||||||
<span className={allowed ? 'font-semibold text-[#16a34a]' : 'font-semibold text-[#dc2626]'}>
|
<span className={allowed ? 'font-semibold text-[#16a34a]' : 'font-semibold text-[#dc2626]'}>
|
||||||
{allowed ? 'Allowed' : 'Restricted'}
|
{allowed ? 'Allowed' : 'Restricted'}
|
||||||
</span>
|
</span>
|
||||||
@@ -415,11 +433,11 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[11px] text-[#98a1ad]">No usage restrictions.</p>
|
<p className="text-[11px] text-text-tertiary">No usage restrictions.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="pt-3 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
|
<p className="pt-3 text-[10.5px] text-text-tertiary">Document properties are read-only.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -433,10 +451,10 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
{unique.map((f, i) => (
|
{unique.map((f, i) => (
|
||||||
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
|
<div key={i} className="rounded-[8px] border border-border-primary bg-bg-secondary p-2.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
<span className="truncate font-mono text-[12px] font-semibold text-text-primary" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
||||||
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
|
{f.type && <span className="shrink-0 rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">{f.type}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
|
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
|
||||||
@@ -454,8 +472,8 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
|||||||
const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
|
const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
|
||||||
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
|
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
|
||||||
style={{
|
style={{
|
||||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
|
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : 'var(--bg-tertiary)',
|
||||||
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
|
color: warn ? '#d97706' : ok ? '#16a34a' : 'var(--text-tertiary)',
|
||||||
}}>
|
}}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -469,25 +487,65 @@ const FormsTab: React.FC<{ fields: Annotation[]; onNavigate: (a: Annotation) =>
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<p className="px-1 text-[11px] font-medium text-[#98a1ad]">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
|
<p className="px-1 text-[11px] font-medium text-text-tertiary">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
|
||||||
{fields.map((f) => (
|
{fields.map((f) => (
|
||||||
<CustomButton variant="unstyled" key={f.id} onClick={() => onNavigate(f)}
|
<CustomButton variant="unstyled" key={f.id} onClick={() => onNavigate(f)}
|
||||||
className="flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 text-left transition-colors hover:border-[#dadde2]">
|
className="flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 text-left transition-colors hover:border-border-secondary">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-[#18212e]" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
|
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-text-primary" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
|
||||||
<span className="shrink-0 rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
|
<span className="shrink-0 rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#98a1ad]">Value</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">Value</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-[12px] text-[#18212e]">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-[#98a1ad]">—</span>}</span>
|
<span className="min-w-0 flex-1 truncate text-[12px] text-text-primary">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-text-tertiary">—</span>}</span>
|
||||||
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">p.{f.pageIndex + 1}</span>}
|
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">p.{f.pageIndex + 1}</span>}
|
||||||
</div>
|
</div>
|
||||||
{f.fieldOptions && f.fieldOptions.length > 0 && (
|
{f.fieldOptions && f.fieldOptions.length > 0 && (
|
||||||
<span className="truncate text-[10px] text-[#98a1ad]">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
|
<span className="truncate text-[10px] text-text-tertiary">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
|
||||||
)}
|
)}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
))}
|
))}
|
||||||
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[#98a1ad]">Form fields are view-only — filling is on the Phase 3 roadmap.</p>
|
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-text-tertiary">Form fields are view-only — filling is on the Phase 3 roadmap.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HistoryTab: React.FC<{ history?: { stack: string[]; index: number }; onRestore?: (docId: string) => void }> = ({ history, onRestore }) => {
|
||||||
|
if (!history || history.stack.length <= 1) {
|
||||||
|
return <EmptyState icon={<HistoryIcon size={30} />} title="No version history" hint="Edits made to the document will appear here." />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3">
|
||||||
|
{history.stack.map((docId, idx) => {
|
||||||
|
const isCurrent = idx === history.index;
|
||||||
|
const originalIndex = history.stack.length - 1 - idx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`flex items-center justify-between rounded-[8px] border p-2.5 transition-colors ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-[#2563eb] bg-[#eff6ff]'
|
||||||
|
: 'border-border-primary bg-bg-secondary hover:border-border-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className={`text-[12.5px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-text-primary'}`}>
|
||||||
|
Version {originalIndex + 1} {isCurrent && '(Current)'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-tertiary">Document ID: {docId.split('_')[0]}...</span>
|
||||||
|
</div>
|
||||||
|
{!isCurrent && onRestore && (
|
||||||
|
<CustomButton
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRestore(docId)}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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 { CustomButton } from './custom/CustomButton';
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Modal } from './ui';
|
|
||||||
import { toast } from '../lib/toast';
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── Types ─────────────────────────────────────────────── */
|
||||||
interface SignatureModalProps {
|
interface SignatureModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (dataUrl: string, aspect: number) => 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 }) => {
|
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
|
||||||
|
useGoogleFonts();
|
||||||
|
|
||||||
|
/* tabs */
|
||||||
const [mode, setMode] = useState<Mode>('draw');
|
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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const strokes = useRef<Pt[][]>([]);
|
||||||
|
const currentStroke = useRef<Pt[]>([]);
|
||||||
const drawing = useRef(false);
|
const drawing = useRef(false);
|
||||||
const hasInk = 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; };
|
/* type */
|
||||||
const handleClose = () => { resetLocal(); onClose(); };
|
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;
|
const c = canvasRef.current;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
const ctx = c.getContext('2d')!;
|
const ctx = c.getContext('2d')!;
|
||||||
ctx.clearRect(0, 0, c.width, c.height);
|
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;
|
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 c = canvasRef.current!;
|
||||||
const r = c.getBoundingClientRect();
|
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) => {
|
const onDown = (e: React.PointerEvent) => {
|
||||||
drawing.current = true;
|
drawing.current = true;
|
||||||
const ctx = canvasRef.current!.getContext('2d')!;
|
const pt = canvasPos(e);
|
||||||
const { x, y } = pos(e);
|
currentStroke.current = [pt];
|
||||||
ctx.beginPath(); ctx.moveTo(x, y);
|
|
||||||
(e.target as Element).setPointerCapture(e.pointerId);
|
(e.target as Element).setPointerCapture(e.pointerId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMove = (e: React.PointerEvent) => {
|
const onMove = (e: React.PointerEvent) => {
|
||||||
if (!drawing.current) return;
|
if (!drawing.current) return;
|
||||||
|
const pt = canvasPos(e);
|
||||||
|
currentStroke.current.push(pt);
|
||||||
const ctx = canvasRef.current!.getContext('2d')!;
|
const ctx = canvasRef.current!.getContext('2d')!;
|
||||||
const { x, y } = pos(e);
|
const stroke = currentStroke.current;
|
||||||
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
|
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
|
||||||
hasInk.current = true;
|
hasInk.current = true;
|
||||||
};
|
};
|
||||||
const onUp = () => { drawing.current = false; };
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const onUp = () => {
|
||||||
if (mode === 'draw') {
|
if (drawing.current && currentStroke.current.length > 0) {
|
||||||
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; }
|
strokes.current.push([...currentStroke.current]);
|
||||||
const c = canvasRef.current!;
|
currentStroke.current = [];
|
||||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
}
|
||||||
} else if (mode === 'type') {
|
drawing.current = false;
|
||||||
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
|
};
|
||||||
|
|
||||||
|
/* ── build final dataUrl from typed ── */
|
||||||
|
const buildTypedCanvas = () => {
|
||||||
const c = document.createElement('canvas');
|
const c = document.createElement('canvas');
|
||||||
c.width = 600; c.height = 200;
|
c.width = 900; c.height = 240;
|
||||||
const ctx = c.getContext('2d')!;
|
const ctx = c.getContext('2d')!;
|
||||||
ctx.clearRect(0, 0, c.width, c.height);
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
ctx.fillStyle = '#1b2430';
|
ctx.fillStyle = typeColor;
|
||||||
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
|
ctx.font = `bold 100px ${SIGNATURE_FONTS[fontIdx].css}`;
|
||||||
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
|
ctx.textAlign = 'center';
|
||||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
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') {
|
} else if (mode === 'upload') {
|
||||||
if (!uploaded) { toast('Upload an image first', 'error'); return; }
|
if (!uploaded) return;
|
||||||
onConfirm(uploaded.url, uploaded.aspect);
|
dataUrl = uploaded.url; aspect = uploaded.aspect;
|
||||||
|
} else if (mode === 'saved') {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveToStorage(dataUrl, aspect);
|
||||||
|
onConfirm(dataUrl, aspect);
|
||||||
resetLocal();
|
resetLocal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const saveToStorage = (dataUrl: string, aspect: number) => {
|
||||||
const f = e.target.files?.[0];
|
const prev = loadSaved();
|
||||||
if (!f) return;
|
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();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const url = reader.result as string;
|
const url = reader.result as string;
|
||||||
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
|||||||
img.src = url;
|
img.src = url;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(f);
|
reader.readAsDataURL(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) processFile(f);
|
||||||
e.target.value = '';
|
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 (
|
return (
|
||||||
<Modal
|
<div
|
||||||
open={open}
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||||
onClose={handleClose}
|
style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
|
||||||
title="Create signature"
|
onMouseDown={handleClose}
|
||||||
width={580}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
|
||||||
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
|
<div
|
||||||
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
|
className="relative flex flex-col overflow-hidden"
|
||||||
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
|
style={{
|
||||||
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]'}`}>
|
width: 660,
|
||||||
{m}
|
maxHeight: '92vh',
|
||||||
</CustomButton>
|
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>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<div style={{ padding: '20px 24px', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||||
|
|
||||||
|
{/* ══ DRAW ══ */}
|
||||||
{mode === 'draw' && (
|
{mode === 'draw' && (
|
||||||
<div>
|
<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
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={920}
|
width={CANVAS_W}
|
||||||
height={300}
|
height={CANVAS_H}
|
||||||
onPointerDown={onDown}
|
onPointerDown={onDown}
|
||||||
onPointerMove={onMove}
|
onPointerMove={onMove}
|
||||||
onPointerUp={onUp}
|
onPointerUp={onUp}
|
||||||
onPointerCancel={onUp}
|
onPointerCancel={onUp}
|
||||||
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
|
style={{
|
||||||
style={{ cursor: 'crosshair' }}
|
width: '100%', height: 200,
|
||||||
|
display: 'block', cursor: 'crosshair',
|
||||||
|
background: '#f8fafc',
|
||||||
|
touchAction: 'none',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex justify-between">
|
{!hasInk.current && strokes.current.length === 0 && (
|
||||||
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
|
<div style={{
|
||||||
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, textAlign: 'center' }}>
|
||||||
|
Use mouse, stylus, or finger — pressure sensitivity supported
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ══ TYPE ══ */}
|
||||||
{mode === 'type' && (
|
{mode === 'type' && (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Name input */}
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={typed}
|
value={typed}
|
||||||
onChange={(e) => setTyped(e.target.value)}
|
onChange={(e) => setTyped(e.target.value)}
|
||||||
placeholder="Type your name"
|
placeholder="Type your full name"
|
||||||
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
|
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')}
|
||||||
/>
|
/>
|
||||||
<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' }}>
|
{/* Font picker */}
|
||||||
{typed || 'Preview'}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ══ UPLOAD ══ */}
|
||||||
{mode === 'upload' && (
|
{mode === 'upload' && (
|
||||||
<div>
|
<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]">
|
<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 ? (
|
{uploaded ? (
|
||||||
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-[13px] font-semibold">Click to upload an image</span>
|
<div style={{
|
||||||
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
|
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={handleUpload} className="hidden" />
|
<input type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
|
||||||
</label>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only — not a certified e-signature.</p>
|
{/* ══ SAVED ══ */}
|
||||||
</Modal>
|
{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>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,40 +88,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
|||||||
</CustomButton>
|
</CustomButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
{onMoveUp ? (
|
|
||||||
<CustomButton variant="unstyled"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMoveUp();
|
|
||||||
}}
|
|
||||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
|
||||||
title="Move Page Up"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
||||||
</svg>
|
|
||||||
</CustomButton>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
{onMoveDown ? (
|
|
||||||
<CustomButton variant="unstyled"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMoveDown();
|
|
||||||
}}
|
|
||||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
|
||||||
title="Move Page Down"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
||||||
</svg>
|
|
||||||
</CustomButton>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -133,7 +99,23 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
|
<div className="flex items-center justify-center gap-2 mt-1 px-1">
|
||||||
|
{onMoveUp ? (
|
||||||
|
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveUp(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Left">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</CustomButton>
|
||||||
|
) : <div className="w-4 h-4 shrink-0" />}
|
||||||
|
<span className="text-[11px] font-semibold text-[#5b6573] text-center min-w-[40px]">Page {pageIndex + 1}</span>
|
||||||
|
{onMoveDown ? (
|
||||||
|
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveDown(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Right">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</CustomButton>
|
||||||
|
) : <div className="w-4 h-4 shrink-0" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CustomButton } from './custom/CustomButton';
|
import { CustomButton } from './custom/CustomButton';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ToolId } from '../lib/tools';
|
import type { ToolId } from '../lib/tools';
|
||||||
import { toast } from '../lib/toast';
|
|
||||||
import { Popover } from './ui';
|
import { Popover } from './ui';
|
||||||
import {
|
import {
|
||||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||||
@@ -9,22 +9,23 @@ import {
|
|||||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||||
} from './icons';
|
} 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')[] = [
|
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 /> },
|
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
|
||||||
'divider',
|
'divider',
|
||||||
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
||||||
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
||||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||||
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
{ 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: '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',
|
id: 'edit_text',
|
||||||
label: 'Edit text',
|
label: 'Edit text',
|
||||||
|
shortLabel: 'Edit',
|
||||||
shortcut: 'E',
|
shortcut: 'E',
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
<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',
|
id: 'stream_edit',
|
||||||
label: 'Raw Text (beta)',
|
label: 'Raw Text (beta)',
|
||||||
|
shortLabel: 'Raw',
|
||||||
shortcut: 'Q',
|
shortcut: 'Q',
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
@@ -66,24 +68,24 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
|||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
onClick={onClick}
|
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
|
disabled
|
||||||
? 'cursor-not-allowed text-[#c5cad1]'
|
? 'cursor-not-allowed text-[#c5cad1]'
|
||||||
: active
|
: active
|
||||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
|
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||||
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
|
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{active && !disabled && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
|
{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>
|
</CustomButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
||||||
const pickTool = (id: ToolId) => {
|
const pickTool = (id: ToolId) => {
|
||||||
if (disabledTools?.has(id)) {
|
if (disabledTools?.has(id)) {
|
||||||
toast("This tool is not permitted by this document's restrictions", 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onToolChange(id);
|
onToolChange(id);
|
||||||
@@ -91,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||||
{TOOLS.map((t, i) =>
|
{TOOLS.map((t, i) =>
|
||||||
t === 'divider'
|
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-border-primary" />
|
||||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
|
: <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-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||||
<InfoIcon size={19} />
|
<InfoIcon size={19} />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
|
||||||
@@ -108,23 +110,23 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
|||||||
align="left"
|
align="left"
|
||||||
width={210}
|
width={210}
|
||||||
trigger={(open) => (
|
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-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||||
<HelpIcon size={19} />
|
<HelpIcon size={19} />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="text-[12px]">
|
<div className="text-[12px]">
|
||||||
<p className="mb-1.5 px-1 font-bold text-[#18212e]">Shortcuts</p>
|
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
|
||||||
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
|
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
|
||||||
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
|
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
|
||||||
<span className="text-[#5b6573]">{t.label}</span>
|
<span className="text-text-secondary">{t.label}</span>
|
||||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
<div className="my-1 h-px bg-border-primary" />
|
||||||
<div className="flex items-center justify-between px-1 py-0.5">
|
<div className="flex items-center justify-between px-1 py-0.5">
|
||||||
<span className="text-[#5b6573]">Undo / Redo</span>
|
<span className="text-text-secondary">Undo / Redo</span>
|
||||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CustomButton } from './custom/CustomButton';
|
import { CustomButton } from './custom/CustomButton';
|
||||||
import React from 'react';
|
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 { STAMP_PRESETS } from '../lib/tools';
|
||||||
import { ColorSwatches, Slider } from './ui';
|
import { ColorSwatches, Slider } from './ui';
|
||||||
import {
|
import {
|
||||||
@@ -15,8 +15,18 @@ interface ToolbarProps {
|
|||||||
onSettingsChange: (patch: Partial<ToolSettings>) => void;
|
onSettingsChange: (patch: Partial<ToolSettings>) => void;
|
||||||
onOpenSignature: () => void;
|
onOpenSignature: () => void;
|
||||||
hasSignature: boolean;
|
hasSignature: boolean;
|
||||||
activeStamp: string | null;
|
activeStamp?: StampPreset | null;
|
||||||
onSelectStamp: (label: string, color: string) => void;
|
onSelectStamp: (stamp: StampPreset) => void;
|
||||||
|
redactionMode?: 'area' | 'text';
|
||||||
|
onRedactionModeChange?: (mode: 'area' | 'text') => void;
|
||||||
|
pendingRedactionCount?: number;
|
||||||
|
onApplyRedactions?: () => void;
|
||||||
|
onClearRedactions?: () => void;
|
||||||
|
onRedactPages?: () => void;
|
||||||
|
selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null;
|
||||||
|
onUpdateAnnotation?: (patch: Partial<import('../viewer/AnnotationLayer').Annotation>) => void;
|
||||||
|
onDeleteAnnotation?: () => void;
|
||||||
|
onDeselectAnnotation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||||
@@ -45,34 +55,83 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||||
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{children}</span>
|
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
|
||||||
);
|
);
|
||||||
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{children}</span>
|
||||||
);
|
);
|
||||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
|
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
|
||||||
|
|
||||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||||
|
redactionMode = 'area', onRedactionModeChange,
|
||||||
|
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
|
||||||
|
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
||||||
}) => {
|
}) => {
|
||||||
const meta = TOOL_META[activeTool];
|
const meta = TOOL_META[activeTool];
|
||||||
const isRedact = activeTool === 'redact';
|
const isRedact = activeTool === 'redact';
|
||||||
|
|
||||||
|
if (selectedAnnotation) {
|
||||||
|
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||||
style={{
|
style={{
|
||||||
background: isRedact ? '#fdecec' : '#eef4ff',
|
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||||
color: isRedact ? '#dc2626' : '#2563eb',
|
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedMeta?.icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-bold text-text-primary">Edit Annotation</span>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
{(selectedAnnotation.type === 'highlight' || selectedAnnotation.type === 'underline' || selectedAnnotation.type === 'strikeout' || selectedAnnotation.type === 'squiggly' || selectedAnnotation.type === 'draw' || selectedAnnotation.type === 'textbox') && (
|
||||||
|
<>
|
||||||
|
<Label>Color</Label>
|
||||||
|
<ColorSwatches
|
||||||
|
value={selectedAnnotation.color || '#2563eb'}
|
||||||
|
onChange={(c) => onUpdateAnnotation?.({ color: c })}
|
||||||
|
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
|
||||||
|
Done
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
|
||||||
|
<span className="text-[#dc2626]">Delete</span>
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||||
|
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||||
|
style={{
|
||||||
|
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||||
|
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{meta?.icon}
|
{meta?.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] font-bold text-[#18212e]">{meta?.label}</span>
|
<span className="text-[13px] font-bold text-text-primary">{meta?.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
@@ -142,7 +201,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
|
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
{hasSignature ? <Hint>Click on the page to place it.</Hint> : <Hint>Draw, type, or upload a signature.</Hint>}
|
{hasSignature ? <Hint>Click on the page to place it.</Hint> : <Hint>Draw, type, or upload a signature.</Hint>}
|
||||||
<span className="ml-auto shrink-0 rounded-full bg-[#f6f7f9] px-2 py-0.5 text-[10px] font-semibold text-[#98a1ad]">Visual signature — not certified</span>
|
<span className="ml-auto shrink-0 rounded-full bg-bg-tertiary px-2 py-0.5 text-[10px] font-semibold text-text-tertiary">Visual signature — not certified</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -150,9 +209,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{STAMP_PRESETS.map((s) => (
|
{STAMP_PRESETS.map((s) => (
|
||||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
|
<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 === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
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-brand-primary' : ''}`}
|
||||||
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
|
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
))}
|
))}
|
||||||
@@ -161,12 +220,56 @@ 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-border-primary pr-3">
|
||||||
|
<button
|
||||||
|
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
|
||||||
|
onClick={() => onRedactionModeChange?.('area')}
|
||||||
|
>
|
||||||
|
Area
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTool !== 'redact' && pendingRedactionCount > 0 && (
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2 border-l border-border-primary pl-3">
|
||||||
|
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
|
||||||
|
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
|
||||||
|
Clear
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 py-0.5 text-[10px] font-semibold text-[#18212e]">{children}</kbd>
|
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 py-0.5 text-[10px] font-semibold text-text-primary">{children}</kbd>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React, { useRef } from 'react';
|
|||||||
import { Popover } from './ui';
|
import { Popover } from './ui';
|
||||||
import {
|
import {
|
||||||
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
|
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
|
||||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
|
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
@@ -26,6 +26,8 @@ interface TopBarProps {
|
|||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onUpload: (file: File) => void;
|
onUpload: (file: File) => void;
|
||||||
|
onShowVersionHistory?: () => void;
|
||||||
|
onSave?: () => void;
|
||||||
isInspectorOpen: boolean;
|
isInspectorOpen: boolean;
|
||||||
onToggleInspector: () => void;
|
onToggleInspector: () => void;
|
||||||
canPrint?: boolean;
|
canPrint?: boolean;
|
||||||
@@ -38,8 +40,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|||||||
export const TopBar: React.FC<TopBarProps> = ({
|
export const TopBar: React.FC<TopBarProps> = ({
|
||||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
|
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onSave,
|
||||||
isInspectorOpen, onToggleInspector,
|
|
||||||
canPrint = true, canExport = true, canAssemble = true,
|
canPrint = true, canExport = true, canAssemble = true,
|
||||||
}) => {
|
}) => {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -51,109 +52,110 @@ export const TopBar: React.FC<TopBarProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
className="flex shrink-0 items-center justify-between gap-3 border-b border-border-primary bg-bg-primary"
|
||||||
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
|
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
|
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-brand-primary text-white shadow-sm">
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
|
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">PDF Editor</span>
|
<span className="text-[14px] font-extrabold tracking-tight text-text-primary">PDF Editor</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
align="left"
|
align="left"
|
||||||
width={210}
|
width={210}
|
||||||
trigger={(open) => (
|
trigger={(open) => (
|
||||||
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||||
File <ChevronDownIcon size={14} />
|
File <ChevronDownIcon size={14} />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col text-[13px]">
|
<div className="flex flex-col text-[13px]">
|
||||||
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
||||||
|
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName}>Version History</MenuItem>
|
||||||
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
||||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
|
|
||||||
<UploadIcon size={18} />
|
|
||||||
</CustomButton>
|
|
||||||
|
|
||||||
{documentName && (
|
{documentName && (
|
||||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
|
<>
|
||||||
|
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-text-secondary" title={documentName}>
|
||||||
{documentName}
|
{documentName}
|
||||||
</span>
|
</span>
|
||||||
|
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></CustomButton>
|
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo} className="text-text-secondary hover:text-text-primary"><UndoIcon size={18} /></CustomButton>
|
||||||
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></CustomButton>
|
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo} className="text-text-secondary hover:text-text-primary"><RedoIcon size={18} /></CustomButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 rounded-[8px] bg-[#f6f7f9] p-0.5">
|
<div className="flex items-center gap-0.5 rounded-[8px] bg-bg-secondary p-0.5">
|
||||||
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></CustomButton>
|
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomOutIcon size={17} /></CustomButton>
|
||||||
<Popover
|
<Popover
|
||||||
align="left"
|
align="left"
|
||||||
width={140}
|
width={140}
|
||||||
trigger={(open) => (
|
trigger={(open) => (
|
||||||
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[#edeff2]' : 'text-[#18212e] hover:bg-[#edeff2]'}`}>
|
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-bg-tertiary' : 'text-text-primary hover:bg-bg-tertiary'}`}>
|
||||||
{Math.round(zoom * 100)}%
|
{Math.round(zoom * 100)}%
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col text-[12.5px]">
|
<div className="flex flex-col text-[12.5px]">
|
||||||
<MenuItem icon={<FitIcon size={14} />} onClick={onFitWidth}>Fit width</MenuItem>
|
|
||||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
|
||||||
{ZOOM_PRESETS.map((z) => (
|
{ZOOM_PRESETS.map((z) => (
|
||||||
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-[#f6f7f9]" onClick={() => onZoomChange(z)}>
|
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-bg-secondary" onClick={() => onZoomChange(z)}>
|
||||||
{Math.round(z * 100)}%
|
{Math.round(z * 100)}%
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
))}
|
))}
|
||||||
|
<div className="my-1 h-px bg-border-primary" />
|
||||||
|
<CustomButton variant="unstyled" className="flex items-center justify-between rounded-[6px] px-2 py-1 text-left hover:bg-bg-secondary" onClick={onFitWidth}>
|
||||||
|
Fit Width <FitIcon size={12} className="text-text-tertiary" />
|
||||||
|
</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
|
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomInIcon size={17} /></CustomButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble}><RotateIcon size={18} /></CustomButton>
|
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble} className="text-text-secondary hover:text-text-primary"><RotateIcon size={18} /></CustomButton>
|
||||||
|
|
||||||
{documentName && (
|
{documentName && (
|
||||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
|
<div className="flex items-center gap-1 text-[12px] font-semibold text-text-secondary">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min={1}
|
defaultValue={currentPage + 1}
|
||||||
max={Math.max(1, totalPages)}
|
onKeyDown={(e) => {
|
||||||
value={currentPage + 1}
|
if (e.key !== 'Enter') return;
|
||||||
onChange={(e) => {
|
const p = parseInt(e.currentTarget.value, 10);
|
||||||
const p = parseInt(e.target.value, 10);
|
|
||||||
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
|
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
|
||||||
}}
|
}}
|
||||||
className="h-7 w-9 rounded-[6px] border border-[#dadde2] bg-[#ffffff] text-center tabular-nums outline-none focus:border-[#2563eb]"
|
className="h-7 w-9 rounded-[6px] border border-border-secondary bg-bg-primary text-center tabular-nums outline-none focus:border-brand-primary text-text-primary"
|
||||||
/>
|
/>
|
||||||
<span className="text-[#98a1ad]">/ {Math.max(1, totalPages)}</span>
|
<span className="text-text-tertiary">/ {Math.max(1, totalPages)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
{onSave && (
|
||||||
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
|
<CustomButton
|
||||||
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName || !canExport}><DownloadIcon size={15} /> Export</CustomButton>
|
variant="primary"
|
||||||
<CustomButton variant="icon"
|
onClick={onSave}
|
||||||
label={isInspectorOpen ? "Hide panel" : "Show panel"}
|
disabled={isSaving || !documentName}
|
||||||
size={34}
|
className="h-8 px-3 text-[13px] font-semibold"
|
||||||
active={isInspectorOpen}
|
|
||||||
onClick={onToggleInspector}
|
|
||||||
className="text-[#5b6573] hover:text-[#18212e]"
|
|
||||||
>
|
>
|
||||||
<PagesIcon size={18} />
|
{isSaving ? 'Saving...' : 'Save'}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
|
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
|
||||||
@@ -165,14 +167,14 @@ const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?
|
|||||||
<CustomButton variant="unstyled"
|
<CustomButton variant="unstyled"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-40"
|
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-bg-secondary disabled:opacity-40 text-text-primary"
|
||||||
>
|
>
|
||||||
{icon} {children}
|
{icon} {children}
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => {
|
const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => {
|
||||||
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[#5b6573]"><SpinnerIcon size={14} /> Saving…</span>;
|
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-text-secondary"><SpinnerIcon size={14} /> Saving…</span>;
|
||||||
if (!saved) return null;
|
if (!saved) return null;
|
||||||
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] md:flex"><CheckIcon size={14} /> Saved</span>;
|
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] md:flex"><CheckIcon size={14} /> Saved</span>;
|
||||||
};
|
};
|
||||||
@@ -180,7 +182,7 @@ const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving,
|
|||||||
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
|
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
|
||||||
if (healthy && engineReady) return null;
|
if (healthy && engineReady) return null;
|
||||||
|
|
||||||
let color = '#98a1ad';
|
let color = 'var(--text-tertiary)';
|
||||||
let label = 'Offline · mock mode';
|
let label = 'Offline · mock mode';
|
||||||
let tip = 'Gateway not reachable — running on mock data.';
|
let tip = 'Gateway not reachable — running on mock data.';
|
||||||
if (healthy === null) {
|
if (healthy === null) {
|
||||||
@@ -191,7 +193,7 @@ const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> =
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex items-center gap-1.5 rounded-full border border-[#ebedf0] bg-[#f6f7f9] px-2 py-1 text-[11px] font-medium text-[#5b6573]"
|
className="flex items-center gap-1.5 rounded-full border border-border-primary bg-bg-secondary px-2 py-1 text-[11px] font-medium text-text-secondary"
|
||||||
title={tip}
|
title={tip}
|
||||||
>
|
>
|
||||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
|
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal } from './ui';
|
||||||
|
import { CustomButton } from './custom/CustomButton';
|
||||||
|
|
||||||
|
interface VersionHistoryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
history: { stack: string[]; index: number };
|
||||||
|
onRestore: (documentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({ open, onClose, history, onRestore }) => {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Version History" width={400}>
|
||||||
|
<div className="flex flex-col gap-3 p-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{history.stack.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-[#5b6573]">No version history available yet.</p>
|
||||||
|
) : (
|
||||||
|
[...history.stack].reverse().map((docId, i) => {
|
||||||
|
const isCurrent = history.index === history.stack.length - 1 - i;
|
||||||
|
const originalIndex = history.stack.length - 1 - i;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${docId}-${i}`}
|
||||||
|
className={`flex items-center justify-between rounded-[8px] border p-3 transition-colors ${
|
||||||
|
isCurrent ? 'border-[#2563eb] bg-[#eef4ff]' : 'border-[#ebedf0] bg-[#f6f7f9]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className={`text-[13px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>
|
||||||
|
Version {originalIndex + 1} {isCurrent && '(Current)'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[#5b6573]">Document ID: {docId.split('_')[0]}...</span>
|
||||||
|
</div>
|
||||||
|
{!isCurrent && (
|
||||||
|
<CustomButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onRestore(docId);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</CustomButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-[#ebedf0] p-3">
|
||||||
|
<CustomButton variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,7 +32,7 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||||
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
|
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-bg-primary shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-border-primary"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2.5 p-7 pb-6">
|
<div className="flex flex-col gap-2.5 p-7 pb-6">
|
||||||
@@ -50,13 +50,13 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">{state.title}</h2>
|
<h2 className="text-[17.5px] font-bold tracking-tight text-text-primary">{state.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">{state.message}</p>
|
<p className="pl-[52px] text-[13.5px] leading-relaxed text-text-secondary">{state.message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 bg-[#f6f7f9] px-7 py-5 border-t border-[#ebedf0]">
|
<div className="flex items-center justify-end gap-3 bg-bg-secondary px-7 py-5 border-t border-border-primary">
|
||||||
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-[#5b6573]">
|
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-text-secondary">
|
||||||
Cancel
|
Cancel
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
<CustomButton
|
<CustomButton
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const SquigglyIcon: IC = mk(<path d="M3 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3
|
|||||||
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
|
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
|
||||||
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
|
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
|
||||||
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
|
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
|
||||||
|
export const MoonIcon: IC = mk(<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
|
||||||
|
export const SunIcon: IC = mk(<><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></>);
|
||||||
|
|
||||||
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
|
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
|
||||||
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
|
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
|
||||||
@@ -56,11 +58,14 @@ export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2
|
|||||||
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
|
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
|
||||||
export const ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
|
export const ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
|
||||||
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
|
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
|
||||||
|
export const HistoryIcon: IC = mk(<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />);
|
||||||
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
|
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
|
||||||
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
|
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
|
||||||
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
|
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
|
||||||
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
|
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
|
||||||
|
|
||||||
|
export const SidebarRightIcon: IC = mk(<><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><path d="M15 8l-4 4 4 4" /><line x1="9" y1="12" x2="15" y2="12" /></>);
|
||||||
|
|
||||||
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
|
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
|
||||||
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
|
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
|
||||||
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
|
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { subscribeToasts, dismissToast } from '../lib/toast';
|
import { XIcon } from './icons';
|
||||||
import type { ToastItem } from '../lib/toast';
|
|
||||||
import { XIcon, CheckIcon, InfoIcon } from './icons';
|
|
||||||
import { CustomButton } from './custom/CustomButton';
|
import { CustomButton } from './custom/CustomButton';
|
||||||
|
|
||||||
interface PopoverProps {
|
interface PopoverProps {
|
||||||
@@ -32,7 +30,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
style={{ width }}
|
style={{ width }}
|
||||||
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-border-primary bg-bg-primary p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
||||||
align === 'right' ? 'right-0' : 'left-0'
|
align === 'right' ? 'right-0' : 'left-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -59,12 +57,12 @@ export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, p
|
|||||||
title={c}
|
title={c}
|
||||||
onClick={() => onChange(c)}
|
onClick={() => onChange(c)}
|
||||||
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
|
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
|
||||||
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
|
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-brand-primary ring-offset-1' : 'border-border-secondary'
|
||||||
}`}
|
}`}
|
||||||
style={{ background: c }}
|
style={{ background: c }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
|
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-border-secondary"
|
||||||
title="Custom color"
|
title="Custom color"
|
||||||
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
|
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
|
||||||
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
|
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
|
||||||
@@ -84,7 +82,7 @@ interface SliderProps {
|
|||||||
}
|
}
|
||||||
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
|
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
|
{label && <span className="text-[11px] font-medium text-text-secondary">{label}</span>}
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
@@ -93,9 +91,9 @@ export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onCha
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
style={{ width }}
|
style={{ width }}
|
||||||
className="accent-[#2563eb]"
|
className="accent-brand-primary"
|
||||||
/>
|
/>
|
||||||
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
|
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-text-primary">{value}{suffix}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -108,17 +106,17 @@ interface EmptyStateProps {
|
|||||||
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
|
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
|
||||||
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
|
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
|
||||||
{icon && (
|
{icon && (
|
||||||
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
|
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-bg-tertiary text-text-tertiary">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{badge && (
|
{badge && (
|
||||||
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
|
<span className="rounded-full bg-brand-secondary px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand-primary">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
|
<p className="text-[13.5px] font-semibold text-text-primary">{title}</p>
|
||||||
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
|
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-text-tertiary">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -142,15 +140,15 @@ export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, wi
|
|||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
|
||||||
<div
|
<div
|
||||||
style={{ width }}
|
style={{ width }}
|
||||||
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
|
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-border-primary bg-bg-primary shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
|
<div className="flex items-center justify-between border-b border-border-primary px-6 py-4">
|
||||||
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
|
<h2 className="text-[15px] font-bold text-text-primary">{title}</h2>
|
||||||
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
|
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto p-6">{children}</div>
|
<div className="overflow-y-auto p-6">{children}</div>
|
||||||
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
|
{footer && <div className="flex justify-end gap-2.5 border-t border-border-primary bg-bg-secondary px-6 py-4">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -186,33 +184,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
|
<p className="text-[13px] leading-relaxed text-text-secondary">{state?.message}</p>
|
||||||
</Modal>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+70
-3
@@ -1,7 +1,67 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Sleek scrollbars */
|
@theme {
|
||||||
|
--color-bg-primary: var(--bg-primary);
|
||||||
|
--color-bg-secondary: var(--bg-secondary);
|
||||||
|
--color-bg-tertiary: var(--bg-tertiary);
|
||||||
|
--color-bg-canvas: var(--bg-canvas);
|
||||||
|
|
||||||
|
--color-border-primary: var(--border-primary);
|
||||||
|
--color-border-secondary: var(--border-secondary);
|
||||||
|
|
||||||
|
--color-text-primary: var(--text-primary);
|
||||||
|
--color-text-secondary: var(--text-secondary);
|
||||||
|
--color-text-tertiary: var(--text-tertiary);
|
||||||
|
|
||||||
|
--color-brand-primary: var(--brand-primary);
|
||||||
|
--color-brand-secondary: var(--brand-secondary);
|
||||||
|
--color-brand-tertiary: var(--brand-tertiary);
|
||||||
|
--color-brand-hover: var(--brand-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f6f7f9;
|
||||||
|
--bg-tertiary: #edeff2;
|
||||||
|
--bg-canvas: #f1f2f4;
|
||||||
|
|
||||||
|
--border-primary: #ebedf0;
|
||||||
|
--border-secondary: #dadde2;
|
||||||
|
|
||||||
|
--text-primary: #18212e;
|
||||||
|
--text-secondary: #5b6573;
|
||||||
|
--text-tertiary: #98a1ad;
|
||||||
|
|
||||||
|
--brand-primary: #2563eb;
|
||||||
|
--brand-secondary: #eef4ff;
|
||||||
|
--brand-tertiary: rgba(37, 99, 235, 0.1);
|
||||||
|
--brand-hover: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--bg-primary: #18181b;
|
||||||
|
--bg-secondary: #27272a;
|
||||||
|
--bg-tertiary: #3f3f46;
|
||||||
|
--bg-canvas: #09090b;
|
||||||
|
|
||||||
|
--border-primary: #3f3f46;
|
||||||
|
--border-secondary: #52525b;
|
||||||
|
|
||||||
|
--text-primary: #f4f4f5;
|
||||||
|
--text-secondary: #a1a1aa;
|
||||||
|
--text-tertiary: #71717a;
|
||||||
|
|
||||||
|
--brand-primary: #3b82f6;
|
||||||
|
--brand-secondary: rgba(59, 130, 246, 0.15);
|
||||||
|
--brand-tertiary: rgba(59, 130, 246, 0.1);
|
||||||
|
--brand-hover: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-canvas);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}/* Sleek scrollbars */
|
||||||
.scroll-thin::-webkit-scrollbar,
|
.scroll-thin::-webkit-scrollbar,
|
||||||
.custom-scrollbar::-webkit-scrollbar,
|
.custom-scrollbar::-webkit-scrollbar,
|
||||||
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
|
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
@@ -11,19 +71,26 @@
|
|||||||
.scroll-thin::-webkit-scrollbar-thumb,
|
.scroll-thin::-webkit-scrollbar-thumb,
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||||
.viewer-viewport::-webkit-scrollbar-thumb {
|
.viewer-viewport::-webkit-scrollbar-thumb {
|
||||||
background: #dadde2;
|
background: var(--border-secondary);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
.scroll-thin::-webkit-scrollbar-thumb:hover,
|
.scroll-thin::-webkit-scrollbar-thumb:hover,
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||||
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
|
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); background-clip: padding-box; }
|
||||||
|
|
||||||
/* Hidden scrollbar (used by horizontal tab/tool strips) */
|
/* Hidden scrollbar (used by horizontal tab/tool strips) */
|
||||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
||||||
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: 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: var(--border-secondary); border-radius: 9999px; }
|
||||||
|
.scroll-micro::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ function familyName(key: string): string {
|
|||||||
return `pdf-${(h >>> 0).toString(16)}`;
|
return `pdf-${(h >>> 0).toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPdfFont(documentId: string, internalFontId: string): Promise<string | null> {
|
export function loadPdfFont(
|
||||||
|
documentId: string,
|
||||||
|
internalFontId: string,
|
||||||
|
/** Optional CSS family name (e.g. extracted BaseFont). When set, the Face is registered under this name. */
|
||||||
|
cssFamilyHint?: string,
|
||||||
|
/** Optional font-weight descriptor so bold faces match style.fontWeight. */
|
||||||
|
weight?: string | number,
|
||||||
|
): Promise<string | null> {
|
||||||
if (!internalFontId) return Promise.resolve(null);
|
if (!internalFontId) return Promise.resolve(null);
|
||||||
const key = keyOf(documentId, internalFontId);
|
const key = keyOf(documentId, internalFontId);
|
||||||
const existing = fontPromises.get(key);
|
const existing = fontPromises.get(key);
|
||||||
@@ -25,9 +32,14 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
|
|||||||
const p = (async (): Promise<string | null> => {
|
const p = (async (): Promise<string | null> => {
|
||||||
const bytes = await gatewayService.getFontData(documentId, internalFontId);
|
const bytes = await gatewayService.getFontData(documentId, internalFontId);
|
||||||
if (!bytes || bytes.byteLength === 0) return null;
|
if (!bytes || bytes.byteLength === 0) return null;
|
||||||
const family = familyName(key);
|
const hint = (cssFamilyHint || '').replace(/^[A-Z]{6}\+/, '').trim();
|
||||||
|
const family = hint
|
||||||
|
? hint.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||||
|
: familyName(key);
|
||||||
try {
|
try {
|
||||||
const face = new FontFace(family, bytes);
|
const descriptors: FontFaceDescriptors = {};
|
||||||
|
if (weight != null) descriptors.weight = String(weight);
|
||||||
|
const face = new FontFace(family, bytes, descriptors);
|
||||||
await face.load();
|
await face.load();
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
fontFaces.set(key, face);
|
fontFaces.set(key, face);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ReflowLayout } from './pdfiumEngine';
|
||||||
|
|
||||||
export interface PageInfo {
|
export interface PageInfo {
|
||||||
index: number;
|
index: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -136,6 +138,20 @@ export interface TextOverlayData {
|
|||||||
color: string;
|
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 {
|
export interface RedactionData {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -207,6 +223,7 @@ export interface PageReorderData {
|
|||||||
|
|
||||||
export type EditOperationDataMap = {
|
export type EditOperationDataMap = {
|
||||||
text_overlay: TextOverlayData;
|
text_overlay: TextOverlayData;
|
||||||
|
stamp: StampData;
|
||||||
redaction: RedactionData;
|
redaction: RedactionData;
|
||||||
image_overlay: ImageOverlayData;
|
image_overlay: ImageOverlayData;
|
||||||
highlight: HighlightData;
|
highlight: HighlightData;
|
||||||
@@ -246,6 +263,8 @@ export interface ReflowFragment {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
color: string;
|
color: string;
|
||||||
advances?: number[];
|
advances?: number[];
|
||||||
|
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||||
|
advanceSeedText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflowParagraphData {
|
export interface ReflowParagraphData {
|
||||||
@@ -318,7 +337,7 @@ class GatewayService {
|
|||||||
public baseUrl: string;
|
public baseUrl: string;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = import.meta.env.VITE_GATEWAY_URL || 'http://127.0.0.1:8000';
|
this.baseUrl = import.meta.env.VITE_GATEWAY_URL as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHealth(): Promise<{ status: string; version: string; engine_available: boolean }> {
|
async getHealth(): Promise<{ status: string; version: string; engine_available: boolean }> {
|
||||||
@@ -420,12 +439,24 @@ class GatewayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderCache = new Map<string, string>();
|
||||||
|
|
||||||
async renderPage(params: RenderParams): Promise<string> {
|
async renderPage(params: RenderParams): Promise<string> {
|
||||||
|
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||||
|
const dpi = params.dpi ?? Math.round(96 * params.zoom * dpr);
|
||||||
|
console.log("[REQUEST_DPI]", { documentId: params.documentId, pageIndex: params.pageIndex, zoom: params.zoom, dpr, dpi });
|
||||||
|
|
||||||
|
const cacheKey = `${params.documentId}_${params.pageIndex}_${params.zoom}_${params.rotation}_${dpi}`;
|
||||||
|
if (this.renderCache.has(cacheKey)) {
|
||||||
|
return this.renderCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
page: params.pageIndex.toString(),
|
page: params.pageIndex.toString(),
|
||||||
zoom: params.zoom.toString(),
|
zoom: params.zoom.toString(),
|
||||||
rotation: params.rotation.toString(),
|
rotation: params.rotation.toString(),
|
||||||
|
dpi: dpi.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
|
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
|
||||||
@@ -437,7 +468,19 @@ class GatewayService {
|
|||||||
|
|
||||||
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
|
if (!response.ok) throw new Error(`Page render failed: ${response.statusText}`);
|
||||||
const blob = await response.blob();
|
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) {
|
} catch (err) {
|
||||||
return this.generateMockPage(params.pageIndex);
|
return this.generateMockPage(params.pageIndex);
|
||||||
}
|
}
|
||||||
@@ -509,6 +552,40 @@ class GatewayService {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-memory reflow preview via gateway (same engine as save). Not persisted. */
|
||||||
|
async previewEdits(params: {
|
||||||
|
documentId: string;
|
||||||
|
operations: EditOperation[];
|
||||||
|
pageIndex: number;
|
||||||
|
dpi: number;
|
||||||
|
yTopPt: number;
|
||||||
|
}): Promise<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
yTopPt: number;
|
||||||
|
pageIndex: number;
|
||||||
|
pngBase64: string;
|
||||||
|
layout: ReflowLayout | null;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/documents/${params.documentId}/edits/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
version: '1.0',
|
||||||
|
operations: params.operations,
|
||||||
|
pageIndex: params.pageIndex,
|
||||||
|
dpi: params.dpi,
|
||||||
|
yTopPt: params.yTopPt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise<SearchResult[]> {
|
async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise<SearchResult[]> {
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
|
|
||||||
@@ -723,6 +800,19 @@ class GatewayService {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportRemoteDocument(documentId: string, freshToken?: string): Promise<any> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export-remote`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fresh_token: freshToken || null }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Remote export failed: ${response.status} - ${text}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
|
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
|
||||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
|
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
|
||||||
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
|
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
|||||||
if (!modulePromise) {
|
if (!modulePromise) {
|
||||||
modulePromise = (async () => {
|
modulePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const V = '20260630-fontfix3';
|
const V = '20260730-advseed1';
|
||||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
||||||
@@ -181,3 +181,12 @@ export async function wasmFreeDocument(documentId: string): Promise<void> {
|
|||||||
const M = await getModule();
|
const M = await getModule();
|
||||||
if (M) M.ccall('freeDocument', null, ['number'], [h]);
|
if (M) M.ccall('freeDocument', null, ['number'], [h]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function wasmDebugValidateLayout(documentId: string, pageIndex: number, jsonStr: string): Promise<string> {
|
||||||
|
const M = await getModule();
|
||||||
|
if (!M) return '{}';
|
||||||
|
const h = docHandles.get(documentId);
|
||||||
|
if (h === undefined) return '{}';
|
||||||
|
const result = M.ccall('debugValidateLayout', 'string', ['number', 'number', 'string'], [h, pageIndex, jsonStr]) as string;
|
||||||
|
return result || '{}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -194,8 +194,10 @@ export class TextSelectionModel {
|
|||||||
}
|
}
|
||||||
const rects: SelRect[] = [];
|
const rects: SelRect[] = [];
|
||||||
for (const arr of byLine.values()) {
|
for (const arr of byLine.values()) {
|
||||||
const x = Math.min(...arr.map((g) => g.x));
|
const nonSpace = arr.filter(g => !/^\s+$/.test(g.text));
|
||||||
const right = Math.max(...arr.map((g) => g.right));
|
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];
|
const band = this.lines[arr[0].line];
|
||||||
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
|
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',
|
q: 'stream_edit',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STAMP_PRESETS = [
|
export interface StampPreset {
|
||||||
{ label: 'APPROVED', color: '#16a34a' },
|
label: string;
|
||||||
{ label: 'DRAFT', color: '#6b7280' },
|
textColor: string;
|
||||||
{ label: 'CONFIDENTIAL', color: '#dc2626' },
|
backgroundColor: string;
|
||||||
{ label: 'REVIEWED', color: '#2563eb' },
|
borderColor: string;
|
||||||
{ label: 'FINAL', color: '#7c3aed' },
|
}
|
||||||
{ label: 'VOID', color: '#dc2626' },
|
|
||||||
|
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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedTheme = localStorage.getItem('app-theme') as Theme | null;
|
||||||
|
if (savedTheme) {
|
||||||
|
return savedTheme;
|
||||||
|
}
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
localStorage.setItem('app-theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { theme, toggleTheme };
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export interface Annotation {
|
|||||||
content?: string;
|
content?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
paths?: { x: number; y: number }[][];
|
paths?: { x: number; y: number }[][];
|
||||||
|
quadPoints?: { x: number; y: number }[][];
|
||||||
pageIndex?: number;
|
pageIndex?: number;
|
||||||
|
|
||||||
fieldName?: string;
|
fieldName?: string;
|
||||||
@@ -30,6 +31,7 @@ interface AnnotationLayerProps {
|
|||||||
onAnnotationClick?: (annotation: Annotation) => void;
|
onAnnotationClick?: (annotation: Annotation) => void;
|
||||||
onAnnotationUpdate?: (annotation: Annotation) => void;
|
onAnnotationUpdate?: (annotation: Annotation) => void;
|
||||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||||
|
isSelectToolActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||||
@@ -41,12 +43,13 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onAnnotationUpdate,
|
onAnnotationUpdate,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
|
isSelectToolActive,
|
||||||
}) => {
|
}) => {
|
||||||
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
|
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
|
||||||
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
|
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
const isDraggable = (type: Annotation['type']) => type === 'comment' || type === 'signature';
|
const isDraggable = (type: Annotation['type']) => isSelectToolActive || type === 'comment' || type === 'signature';
|
||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
|
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -78,7 +81,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
...anno.bbox,
|
...anno.bbox,
|
||||||
x: anno.bbox.x + dragOffset.x,
|
x: anno.bbox.x + dragOffset.x,
|
||||||
y: anno.bbox.y + dragOffset.y,
|
y: anno.bbox.y + dragOffset.y,
|
||||||
}
|
},
|
||||||
|
quadPoints: anno.quadPoints?.map(q => q.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
|
||||||
|
paths: anno.paths?.map(path => path.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onAnnotationClick?.(anno);
|
onAnnotationClick?.(anno);
|
||||||
@@ -91,7 +96,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
>
|
>
|
||||||
{annotations
|
{annotations
|
||||||
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
||||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
|
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget', 'ink'].includes(anno.type))
|
||||||
.map((anno) => {
|
.map((anno) => {
|
||||||
const scaledBbox = {
|
const scaledBbox = {
|
||||||
x: anno.bbox.x * zoom,
|
x: anno.bbox.x * zoom,
|
||||||
@@ -114,19 +119,29 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
onPointerDown={(e) => handlePointerDown(e, anno)}
|
onPointerDown={(e) => handlePointerDown(e, anno)}
|
||||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||||
onPointerUp={(e) => handlePointerUp(e, anno)}
|
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' : ''}`}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
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={{
|
style={{
|
||||||
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||||
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
|
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
|
||||||
width: `${scaledBbox.width}px`,
|
width: `${scaledBbox.width}px`,
|
||||||
height: `${scaledBbox.height}px`,
|
height: `${scaledBbox.height}px`,
|
||||||
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
|
backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
|
||||||
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
|
opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
|
||||||
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
|
mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
}}
|
}}
|
||||||
title={tooltipText}
|
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' && (
|
{anno.type === 'comment' && (
|
||||||
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
|
<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">
|
<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 +149,26 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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 === '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 === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
|
{anno.type === 'strikeout' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||||
{anno.type === 'squiggly' && (
|
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 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}}>
|
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
|
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
|
||||||
@@ -146,6 +178,23 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
|
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
|
||||||
</svg>
|
</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 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' && (
|
{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)]">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -60,43 +60,7 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
|
|||||||
ctx.drawImage(img, 0, 0, width, height);
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Render vector ops on top
|
|
||||||
if (vectorOps && vectorOps.length > 0) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(0, height);
|
|
||||||
ctx.scale(1, -1);
|
|
||||||
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
|
||||||
ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
for (const op of vectorOps) {
|
|
||||||
if (op.op === 'm' && op.args && op.args.length === 2) {
|
|
||||||
ctx.moveTo(op.args[0], op.args[1]);
|
|
||||||
} else if (op.op === 'l' && op.args && op.args.length === 2) {
|
|
||||||
ctx.lineTo(op.args[0], op.args[1]);
|
|
||||||
} else if (op.op === 'c' && op.args && op.args.length === 6) {
|
|
||||||
ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]);
|
|
||||||
} else if (op.op === 're' && op.args && op.args.length === 4) {
|
|
||||||
ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]);
|
|
||||||
} else if (op.op === 'h') {
|
|
||||||
ctx.closePath();
|
|
||||||
} else if (op.op === 'S' || op.op === 's') {
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
} else if (op.op === 'f' || op.op === 'F') {
|
|
||||||
ctx.fill();
|
|
||||||
ctx.beginPath();
|
|
||||||
} else if (op.op === 'B' || op.op === 'b') {
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
} else if (op.op === 'n') {
|
|
||||||
ctx.beginPath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRenderComplete?.(pageIndex);
|
onRenderComplete?.(pageIndex);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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-bg-secondary border border-border-primary 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-bg-secondary' : ''}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onClick={() => {
|
||||||
|
setToolColors(prev => ({ ...prev, [action]: c }));
|
||||||
|
onAction(action, c);
|
||||||
|
setOpenDropdown(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-secondary mt-1 cursor-pointer hover:text-text-primary transition-colors">More colors</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border-primary pt-2 mt-1">
|
||||||
|
<span>Opacity</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button className="hover:text-text-primary">−</button>
|
||||||
|
<span>100%</span>
|
||||||
|
<button className="hover:text-text-primary">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="absolute z-50 flex items-center gap-1 bg-bg-primary rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-border-primary text-text-primary"
|
||||||
|
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-bg-tertiary 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-bg-tertiary 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-border-primary mx-1" />
|
||||||
|
|
||||||
|
<div className="relative flex items-center group">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction('highlight')}
|
||||||
|
className="p-1.5 rounded-l hover:bg-bg-tertiary 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-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||||
|
>
|
||||||
|
<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-bg-tertiary 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-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||||
|
>
|
||||||
|
<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-bg-tertiary 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-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||||
|
>
|
||||||
|
<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-border-primary mx-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onAction('redact')}
|
||||||
|
className="px-2 py-1.5 rounded hover:bg-bg-tertiary 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-bg-tertiary 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 React, { useState, useRef } from 'react';
|
||||||
import type { Annotation } from './AnnotationLayer';
|
import type { Annotation } from './AnnotationLayer';
|
||||||
import type { Rect } from '../lib/coordinateMapping';
|
import type { Rect } from '../lib/coordinateMapping';
|
||||||
|
import type { StampPreset } from '../lib/tools';
|
||||||
|
|
||||||
interface OverlayLayerProps {
|
interface OverlayLayerProps {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -14,11 +15,12 @@ interface OverlayLayerProps {
|
|||||||
textColor: string;
|
textColor: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
hasSignature: boolean;
|
hasSignature: boolean;
|
||||||
activeStamp: string | null;
|
activeStamp: StampPreset | null;
|
||||||
onAnnotationAdded?: (anno: Annotation) => void;
|
onAnnotationAdded?: (anno: Annotation) => void;
|
||||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => 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;
|
const TEXTBOX_WIDTH_PTS = 200;
|
||||||
@@ -28,7 +30,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
|
|||||||
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||||
pageIndex, width, height, activeTool, zoom,
|
pageIndex, width, height, activeTool, zoom,
|
||||||
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
|
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
|
||||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
|
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
|
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
|
||||||
@@ -88,7 +90,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
|||||||
} else if (activeTool === 'stamp' && activeStamp) {
|
} else if (activeTool === 'stamp' && activeStamp) {
|
||||||
onPlaceStamp?.(pageIndex, coords);
|
onPlaceStamp?.(pageIndex, coords);
|
||||||
} else if (activeTool === 'signature' && hasSignature) {
|
} 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,40 +135,40 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
|||||||
onClick={handleLayerClick}
|
onClick={handleLayerClick}
|
||||||
>
|
>
|
||||||
{activeTool === 'signature' && !hasSignature && (
|
{activeTool === 'signature' && !hasSignature && (
|
||||||
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
|
<div className="absolute inset-0 bg-brand-tertiary flex items-center justify-center border-2 border-dashed border-brand-primary rounded-[6px]"><span className="bg-brand-primary text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
|
||||||
)}
|
)}
|
||||||
{activeTool === 'signature' && hasSignature && !commentPopup && (
|
{activeTool === 'signature' && hasSignature && !commentPopup && (
|
||||||
<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 signature</div>
|
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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 signature</div>
|
||||||
)}
|
)}
|
||||||
{activeTool === 'comment' && !commentPopup && (
|
{activeTool === 'comment' && !commentPopup && (
|
||||||
<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>
|
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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 && (
|
{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-text-primary text-bg-primary 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 && (
|
{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>
|
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commentPopup && (
|
{commentPopup && (
|
||||||
<div
|
<div
|
||||||
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
|
className="w-[264px] bg-bg-primary border border-border-primary rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
|
||||||
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
|
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleCommentSubmit}>
|
<form onSubmit={handleCommentSubmit}>
|
||||||
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
|
<div className="flex justify-between items-center py-[10px] px-[14px] bg-bg-secondary border-b border-border-primary">
|
||||||
<span className="text-xs font-bold">Add sticky note</span>
|
<span className="text-xs font-bold text-text-primary">Add sticky note</span>
|
||||||
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
|
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-text-tertiary hover:text-text-primary">
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
|
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
|
placeholder="Type your comment here…" className="w-full bg-transparent text-text-primary border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-text-tertiary" rows={3}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
|
||||||
/>
|
/>
|
||||||
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
|
<div className="py-[10px] px-[14px] bg-bg-secondary border-t border-border-primary flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-brand-primary text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-brand-hover">Save note</CustomButton></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -175,7 +184,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
|||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
|
||||||
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
|
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
|
||||||
}}
|
}}
|
||||||
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
|
className="absolute z-50 bg-bg-primary border-[1.5px] border-brand-primary rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
|
||||||
placeholder="Type…"
|
placeholder="Type…"
|
||||||
style={{
|
style={{
|
||||||
left: `${textBox.x * zoom}px`,
|
left: `${textBox.x * zoom}px`,
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react';
|
import React, {
|
||||||
import { CanvasLayer } from './CanvasLayer';
|
useRef,
|
||||||
import { SelectionLayer } from './SelectionLayer';
|
useEffect,
|
||||||
import { AnnotationLayer } from './AnnotationLayer';
|
useState,
|
||||||
import type { Annotation } from './AnnotationLayer';
|
useMemo,
|
||||||
import { OverlayLayer } from './OverlayLayer';
|
useCallback,
|
||||||
import { TextEditLayer } from './TextEditLayer';
|
} from "react";
|
||||||
import type { EditableRun, ReflowParagraphPayload, CommitFrame } from './TextEditLayer';
|
import { CanvasLayer } from "./CanvasLayer";
|
||||||
import type { OverflowPreviewRegion, OverflowCaret } from './ParagraphEditor';
|
import { SelectionLayer } from "./SelectionLayer";
|
||||||
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
import { AnnotationLayer } from "./AnnotationLayer";
|
||||||
import type { Rect } from '../lib/coordinateMapping';
|
import type { Annotation } from "./AnnotationLayer";
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
import { OverlayLayer } from "./OverlayLayer";
|
||||||
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
|
import { TextEditLayer } from "./TextEditLayer";
|
||||||
import type { ToolSettings } from '../lib/tools';
|
import type {
|
||||||
import { toast } from '../lib/toast';
|
EditableRun,
|
||||||
import { RedactionLayer } from './RedactionLayer';
|
ReflowParagraphPayload,
|
||||||
import { StreamEditLayer } from './StreamEditLayer';
|
CommitFrame,
|
||||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
} from "./TextEditLayer";
|
||||||
|
import type { OverflowPreviewRegion, OverflowCaret } from "./ParagraphEditor";
|
||||||
|
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, 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 {
|
interface PDFViewerProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -24,10 +37,14 @@ interface PDFViewerProps {
|
|||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
pagesInfo?: PageInfo[];
|
pagesInfo?: PageInfo[];
|
||||||
activeTool: string;
|
activeTool: ToolId;
|
||||||
toolSettings: ToolSettings;
|
toolSettings: ToolSettings;
|
||||||
|
redactionMode?: "area" | "text";
|
||||||
hasSignature: boolean;
|
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[];
|
annotations: Annotation[];
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
searchResults?: SearchResult[];
|
searchResults?: SearchResult[];
|
||||||
@@ -36,15 +53,42 @@ interface PDFViewerProps {
|
|||||||
onAnnotationUpdate?: (anno: Annotation) => void;
|
onAnnotationUpdate?: (anno: Annotation) => void;
|
||||||
onAnnotationClick?: (anno: Annotation) => void;
|
onAnnotationClick?: (anno: Annotation) => void;
|
||||||
onPageVisible?: (pageIndex: number) => 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;
|
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
|
onEditText?: (
|
||||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
pageIndex: number,
|
||||||
|
run: EditableRun,
|
||||||
|
newText: string,
|
||||||
|
disableJustify?: boolean,
|
||||||
|
) => void;
|
||||||
|
onReflowParagraph?: (
|
||||||
|
pageIndex: number,
|
||||||
|
payload: ReflowParagraphPayload,
|
||||||
|
) => void;
|
||||||
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
||||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
onPlaceStamp?: (
|
||||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
pageIndex: number,
|
||||||
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
|
pointPts: { x: number; y: number },
|
||||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
) => 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;
|
canCopy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +99,16 @@ interface PageLayout {
|
|||||||
top: number;
|
top: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateUniqueId = () => `anno_${Math.random().toString(36).substring(2, 11)}`;
|
const generateUniqueId = () =>
|
||||||
|
`anno_${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
|
||||||
export interface PDFViewerRef {
|
export interface PDFViewerRef {
|
||||||
scrollToPage: (pageIndex: number) => void;
|
scrollToPage: (pageIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
documentId,
|
documentId,
|
||||||
totalPages,
|
totalPages,
|
||||||
pageWidth,
|
pageWidth,
|
||||||
@@ -70,7 +117,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
pagesInfo,
|
pagesInfo,
|
||||||
activeTool,
|
activeTool,
|
||||||
toolSettings,
|
toolSettings,
|
||||||
|
redactionMode = "area",
|
||||||
hasSignature,
|
hasSignature,
|
||||||
|
signatureImageUrl,
|
||||||
|
signatureAspect = 3,
|
||||||
activeStamp,
|
activeStamp,
|
||||||
annotations,
|
annotations,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
@@ -80,7 +130,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onAnnotationUpdate,
|
onAnnotationUpdate,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onPageVisible,
|
onPageVisible,
|
||||||
onRedactArea,
|
onMarkRedaction,
|
||||||
|
pendingRedactions = [],
|
||||||
|
onRemoveRedaction,
|
||||||
onPlaceText,
|
onPlaceText,
|
||||||
onEditText,
|
onEditText,
|
||||||
onReflowParagraph,
|
onReflowParagraph,
|
||||||
@@ -90,39 +142,76 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onDecorateText,
|
onDecorateText,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
canCopy = true,
|
canCopy = true,
|
||||||
}, ref) => {
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
|
const [scrollPosition, setScrollPosition] = useState({
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
});
|
||||||
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
||||||
const [containerHeight, setContainerHeight] = useState(800);
|
const [containerHeight, setContainerHeight] = useState(800);
|
||||||
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
||||||
const renderedDocIdRef = useRef<string>('');
|
const renderedDocIdRef = useRef<string>("");
|
||||||
const prevDocumentIdRef = useRef<string>(documentId);
|
const prevDocumentIdRef = useRef<string>(documentId);
|
||||||
const inFlightRenderRef = useRef<Set<number>>(new Set());
|
const inFlightRenderRef = useRef<Set<number>>(new Set());
|
||||||
const inFlightTextRef = 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(() => {
|
useEffect(() => {
|
||||||
setPageTexts({});
|
setPageTexts({});
|
||||||
const prev = prevDocumentIdRef.current;
|
const prev = prevDocumentIdRef.current;
|
||||||
if (prev && prev !== documentId) wasmFreeDocument(prev);
|
if (prev && prev !== documentId) wasmFreeDocument(prev);
|
||||||
prevDocumentIdRef.current = documentId;
|
prevDocumentIdRef.current = documentId;
|
||||||
}, [documentId]);
|
}, [documentId]);
|
||||||
useEffect(() => () => { wasmFreeDocument(prevDocumentIdRef.current); }, []);
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
wasmFreeDocument(prevDocumentIdRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const [bridgeFrame, setBridgeFrame] = useState<(CommitFrame & { docId: string }) | null>(null);
|
const [bridgeFrame, setBridgeFrame] = useState<
|
||||||
|
(CommitFrame & { docId: string }) | null
|
||||||
|
>(null);
|
||||||
const bridgeFrameRef = useRef(bridgeFrame);
|
const bridgeFrameRef = useRef(bridgeFrame);
|
||||||
bridgeFrameRef.current = bridgeFrame;
|
bridgeFrameRef.current = bridgeFrame;
|
||||||
const documentIdRef = useRef(documentId);
|
const documentIdRef = useRef(documentId);
|
||||||
documentIdRef.current = documentId;
|
documentIdRef.current = documentId;
|
||||||
const handleCommitPreview = (frame: CommitFrame) => setBridgeFrame({ ...frame, docId: documentId });
|
const handleCommitPreview = (frame: CommitFrame) =>
|
||||||
|
setBridgeFrame({ ...frame, docId: documentId });
|
||||||
const handlePagePainted = useCallback((pageIndex: number) => {
|
const handlePagePainted = useCallback((pageIndex: number) => {
|
||||||
const b = bridgeFrameRef.current;
|
const b = bridgeFrameRef.current;
|
||||||
if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current) setBridgeFrame(null);
|
if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current)
|
||||||
|
setBridgeFrame(null);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { setBridgeFrame(null); }, [zoom]);
|
useEffect(() => {
|
||||||
|
setBridgeFrame(null);
|
||||||
|
}, [zoom]);
|
||||||
|
|
||||||
const [overflowPreviews, setOverflowPreviews] = useState<OverflowPreviewRegion[]>([]);
|
const [overflowPreviews, setOverflowPreviews] = useState<
|
||||||
const [overflowCaret, setOverflowCaret] = useState<OverflowCaret | null>(null);
|
OverflowPreviewRegion[]
|
||||||
useEffect(() => { setOverflowPreviews([]); setOverflowCaret(null); }, [zoom, documentId]);
|
>([]);
|
||||||
|
const [overflowCaret, setOverflowCaret] = useState<OverflowCaret | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setOverflowPreviews([]);
|
||||||
|
setOverflowCaret(null);
|
||||||
|
}, [zoom, documentId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bridgeFrame) return;
|
if (!bridgeFrame) return;
|
||||||
const t = window.setTimeout(() => setBridgeFrame(null), 6000);
|
const t = window.setTimeout(() => setBridgeFrame(null), 6000);
|
||||||
@@ -150,7 +239,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
top: currentTop,
|
top: currentTop,
|
||||||
});
|
});
|
||||||
|
|
||||||
currentTop += (h * zoom) + pageGap;
|
currentTop += h * zoom + pageGap;
|
||||||
}
|
}
|
||||||
return layouts;
|
return layouts;
|
||||||
}, [totalPages, zoom, pagesInfo]);
|
}, [totalPages, zoom, pagesInfo]);
|
||||||
@@ -174,7 +263,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
if (containerRef.current && pageLayouts[pageIndex]) {
|
if (containerRef.current && pageLayouts[pageIndex]) {
|
||||||
containerRef.current.scrollTop = pageLayouts[pageIndex].top;
|
containerRef.current.scrollTop = pageLayouts[pageIndex].top;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,9 +272,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
setContainerHeight(containerRef.current.clientHeight);
|
setContainerHeight(containerRef.current.clientHeight);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', updateSize);
|
window.addEventListener("resize", updateSize);
|
||||||
updateSize();
|
updateSize();
|
||||||
return () => window.removeEventListener('resize', updateSize);
|
return () => window.removeEventListener("resize", updateSize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { visiblePages, primaryVisiblePage } = useMemo(() => {
|
const { visiblePages, primaryVisiblePage } = useMemo(() => {
|
||||||
@@ -211,15 +300,19 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
currentVisiblePageIdx = layout.index;
|
currentVisiblePageIdx = layout.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInside = (pageBottom >= viewportTop - (layout.height * buffer)) &&
|
const isInside =
|
||||||
(pageTop <= viewportBottom + (layout.height * buffer));
|
pageBottom >= viewportTop - layout.height * buffer &&
|
||||||
|
pageTop <= viewportBottom + layout.height * buffer;
|
||||||
|
|
||||||
if (isInside) {
|
if (isInside) {
|
||||||
visible.push(layout);
|
visible.push(layout);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { visiblePages: visible, primaryVisiblePage: currentVisiblePageIdx };
|
return {
|
||||||
|
visiblePages: visible,
|
||||||
|
primaryVisiblePage: currentVisiblePageIdx,
|
||||||
|
};
|
||||||
}, [pageLayouts, scrollPosition.scrollTop, containerHeight]);
|
}, [pageLayouts, scrollPosition.scrollTop, containerHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,9 +325,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
const base = docChanged
|
const base = docChanged
|
||||||
? visiblePages
|
? visiblePages
|
||||||
: visiblePages.filter((page) => !renderedPages[page.index]);
|
: visiblePages.filter((page) => !renderedPages[page.index]);
|
||||||
const missingPages = base.filter((page) => !inFlightRenderRef.current.has(page.index));
|
const missingPages = base.filter(
|
||||||
|
(page) => !inFlightRenderRef.current.has(page.index),
|
||||||
|
);
|
||||||
if (missingPages.length === 0) return;
|
if (missingPages.length === 0) return;
|
||||||
missingPages.forEach((page) => inFlightRenderRef.current.add(page.index));
|
missingPages.forEach((page) =>
|
||||||
|
inFlightRenderRef.current.add(page.index),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const renders = await Promise.all(
|
const renders = await Promise.all(
|
||||||
@@ -246,7 +343,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
rotation: 0,
|
rotation: 0,
|
||||||
});
|
});
|
||||||
return { index: page.index, url };
|
return { index: page.index, url };
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (documentIdRef.current !== documentId) return;
|
if (documentIdRef.current !== documentId) return;
|
||||||
@@ -260,14 +357,21 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
missingPages.forEach((page) => inFlightRenderRef.current.delete(page.index));
|
missingPages.forEach((page) =>
|
||||||
|
inFlightRenderRef.current.delete(page.index),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPageImages();
|
fetchPageImages();
|
||||||
}, [visiblePages, documentId, zoom, renderedPages]);
|
}, [visiblePages, documentId, zoom, renderedPages]);
|
||||||
|
|
||||||
const textToolActive = activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly';
|
const textToolActive =
|
||||||
|
activeTool === "select" ||
|
||||||
|
activeTool === "highlight" ||
|
||||||
|
activeTool === "underline" ||
|
||||||
|
activeTool === "strikeout" ||
|
||||||
|
activeTool === "squiggly";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!textToolActive || !documentId) return;
|
if (!textToolActive || !documentId) return;
|
||||||
const fetchTexts = async () => {
|
const fetchTexts = async () => {
|
||||||
@@ -278,12 +382,17 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
missing.forEach((p) => inFlightTextRef.current.add(p.index));
|
missing.forEach((p) => inFlightTextRef.current.add(p.index));
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
missing.map(async (p) => ({ index: p.index, page: await gatewayService.getPageText(documentId, p.index) })),
|
missing.map(async (p) => ({
|
||||||
|
index: p.index,
|
||||||
|
page: await gatewayService.getPageText(documentId, p.index),
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
if (documentIdRef.current !== documentId) return;
|
if (documentIdRef.current !== documentId) return;
|
||||||
setPageTexts((prev) => {
|
setPageTexts((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
results.forEach(({ index, page }) => { next[index] = page.glyphs; });
|
results.forEach(({ index, page }) => {
|
||||||
|
next[index] = page.glyphs;
|
||||||
|
});
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -293,26 +402,24 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
fetchTexts();
|
fetchTexts();
|
||||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||||
|
|
||||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
const handleTextSelection = (
|
||||||
if (activeTool === 'select') {
|
text: string,
|
||||||
if (!canCopy) {
|
bbox: Rect,
|
||||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
lines: Rect[],
|
||||||
return;
|
pageIndex: number,
|
||||||
}
|
) => {
|
||||||
|
// This is still called on Ctrl+C for select mode
|
||||||
|
if (activeTool === "select") {
|
||||||
|
if (!canCopy) return;
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
navigator.clipboard?.writeText(text).then(
|
navigator.clipboard?.writeText(text).catch(() => {});
|
||||||
() => 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');
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTool === 'highlight') {
|
if (activeTool === "highlight") {
|
||||||
const newAnno: Annotation = {
|
const newAnno: Annotation = {
|
||||||
id: generateUniqueId(),
|
id: generateUniqueId(),
|
||||||
type: 'highlight',
|
type: "highlight",
|
||||||
pageIndex,
|
pageIndex,
|
||||||
bbox: {
|
bbox: {
|
||||||
x: bbox.x / zoom,
|
x: bbox.x / zoom,
|
||||||
@@ -322,26 +429,134 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
},
|
},
|
||||||
color: toolSettings.highlightColor,
|
color: toolSettings.highlightColor,
|
||||||
opacity: toolSettings.highlightOpacity,
|
opacity: toolSettings.highlightOpacity,
|
||||||
author: 'Current User',
|
author: "Current User",
|
||||||
content: text,
|
content: text,
|
||||||
};
|
};
|
||||||
onAnnotationAdded?.(newAnno);
|
onAnnotationAdded?.(newAnno);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') {
|
if (
|
||||||
|
activeTool === "underline" ||
|
||||||
|
activeTool === "strikeout" ||
|
||||||
|
activeTool === "squiggly"
|
||||||
|
) {
|
||||||
const color =
|
const color =
|
||||||
activeTool === 'underline' ? toolSettings.underlineColor :
|
activeTool === "underline"
|
||||||
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
|
? toolSettings.underlineColor
|
||||||
toolSettings.squigglyColor;
|
: activeTool === "strikeout"
|
||||||
|
? toolSettings.strikeoutColor
|
||||||
|
: toolSettings.squigglyColor;
|
||||||
onDecorateText?.(pageIndex, lines, activeTool, color);
|
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);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
const [panStart, setPanStart] = useState({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (activeTool === 'pan' && containerRef.current) {
|
if (activeTool === "pan" && containerRef.current) {
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
setPanStart({
|
setPanStart({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
@@ -375,14 +590,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
className={`flex-1 h-full overflow-auto bg-[#f1f2f4] flex justify-center items-start outline-none ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
className={`flex-1 h-full overflow-auto bg-bg-canvas flex justify-center items-start outline-none ${activeTool === "pan" ? (isPanning ? "cursor-grabbing" : "cursor-grab") : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full flex flex-col items-center py-7"
|
className="relative w-full flex flex-col items-center py-7"
|
||||||
style={{ height: `${totalContentHeight}px` }}
|
style={{ height: `${totalContentHeight}px` }}
|
||||||
>
|
>
|
||||||
{visiblePages.map((page) => {
|
{visiblePages.map((page) => {
|
||||||
const imageUrl = renderedPages[page.index] || '';
|
const imageUrl = renderedPages[page.index] || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -412,7 +627,12 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
src={bridgeFrame.dataUrl}
|
src={bridgeFrame.dataUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute z-20 pointer-events-none select-none"
|
className="absolute z-20 pointer-events-none select-none"
|
||||||
style={{ left: bridgeFrame.left, top: bridgeFrame.top, width: bridgeFrame.width, height: bridgeFrame.height }}
|
style={{
|
||||||
|
left: bridgeFrame.left,
|
||||||
|
top: bridgeFrame.top,
|
||||||
|
width: bridgeFrame.width,
|
||||||
|
height: bridgeFrame.height,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -424,18 +644,28 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
src={r.dataUrl}
|
src={r.dataUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute z-[37] pointer-events-none select-none"
|
className="absolute z-[37] pointer-events-none select-none"
|
||||||
style={{ left: 0, top: r.yTopPt * zoom, width: page.width, height: page.height }}
|
style={{
|
||||||
|
left: 0,
|
||||||
|
top: r.yTopPt * zoom,
|
||||||
|
width: page.width,
|
||||||
|
height: page.height,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{overflowCaret && overflowCaret.pageIndex === page.index && (
|
{overflowCaret &&
|
||||||
|
overflowCaret.pageIndex === page.index && (
|
||||||
<>
|
<>
|
||||||
<style>{`@keyframes pe-caret-blink2{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
<style>{`@keyframes pe-caret-blink2{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||||||
<div
|
<div
|
||||||
className="absolute z-[39] pointer-events-none"
|
className="absolute z-[39] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
left: overflowCaret.left, top: overflowCaret.top, height: overflowCaret.height,
|
left: overflowCaret.left,
|
||||||
width: 1.6, background: '#2563eb', animation: 'pe-caret-blink2 1s step-end infinite',
|
top: overflowCaret.top,
|
||||||
|
height: overflowCaret.height,
|
||||||
|
width: 1.6,
|
||||||
|
background: "#2563eb",
|
||||||
|
animation: "pe-caret-blink2 1s step-end infinite",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -452,26 +682,66 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onFieldChange={onFieldChange}
|
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
|
<SelectionLayer
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
width={page.width}
|
width={page.width}
|
||||||
height={page.height}
|
height={page.height}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
glyphs={pageTexts[page.index] || []}
|
glyphs={pageTexts[page.index] || []}
|
||||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? 'highlight' : 'select'}
|
mode={
|
||||||
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{textSelection &&
|
||||||
|
textSelection.pageIndex === page.index && (
|
||||||
|
<FloatingTextToolbar
|
||||||
|
selection={textSelection}
|
||||||
|
onAction={(action, color) =>
|
||||||
|
handleToolbarAction(action as any, color)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTool === 'redact' && (
|
|
||||||
<RedactionLayer
|
<RedactionLayer
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
width={page.width}
|
width={page.width}
|
||||||
height={page.height}
|
height={page.height}
|
||||||
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
|
onRedactionSelected={(bounds) =>
|
||||||
/>
|
onMarkRedaction?.(page.index, bounds)
|
||||||
|
}
|
||||||
|
pendingRedactions={pendingRedactions.filter(
|
||||||
|
(r) => r.pageIndex === page.index,
|
||||||
)}
|
)}
|
||||||
|
onRemoveRedaction={onRemoveRedaction}
|
||||||
|
mode={
|
||||||
|
activeTool === "redact" && redactionMode === "area"
|
||||||
|
? "area"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<OverlayLayer
|
<OverlayLayer
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
@@ -488,10 +758,39 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onAnnotationAdded={onAnnotationAdded}
|
onAnnotationAdded={onAnnotationAdded}
|
||||||
onPlaceText={onPlaceText}
|
onPlaceText={onPlaceText}
|
||||||
onPlaceStamp={onPlaceStamp}
|
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' && (
|
{activeTool === "edit_text" && (
|
||||||
<TextEditLayer
|
<TextEditLayer
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
@@ -508,7 +807,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTool === 'stream_edit' && (
|
{activeTool === "stream_edit" && (
|
||||||
<StreamEditLayer
|
<StreamEditLayer
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
pageIndex={page.index}
|
pageIndex={page.index}
|
||||||
@@ -519,7 +818,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
onEditSuccess={() => {
|
onEditSuccess={() => {
|
||||||
setRenderedPages((prev) => {
|
setRenderedPages((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[page.index] = '';
|
next[page.index] = "";
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setPageTexts((prev) => {
|
setPageTexts((prev) => {
|
||||||
@@ -536,15 +835,48 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
width={page.width}
|
width={page.width}
|
||||||
height={page.height}
|
height={page.height}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
searchQuery={searchQuery || ''}
|
searchQuery={searchQuery || ""}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
searchCurrentMatch={searchCurrentMatch}
|
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]">
|
<div className="w-full h-full flex flex-col items-center justify-center bg-bg-secondary text-text-secondary gap-3 rounded-[3px]">
|
||||||
<div className="w-7 h-7 border-[3px] border-[#eef4ff] border-t-[#2563eb] rounded-full animate-spin" />
|
<div className="w-7 h-7 border-[3px] border-brand-secondary border-t-brand-primary rounded-full animate-spin" />
|
||||||
<span className="text-[12px] font-semibold tracking-[0.2px] text-[#98a1ad]">Loading Page {page.index + 1}...</span>
|
<span className="text-[12px] font-semibold tracking-[0.2px] text-text-tertiary">
|
||||||
|
Loading Page {page.index + 1}...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -553,4 +885,5 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { gatewayService } from '../lib/gatewayService';
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated, wasmEnsureAuxFont } from '../lib/pdfiumEngine';
|
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated, wasmEnsureAuxFont } from '../lib/pdfiumEngine';
|
||||||
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
||||||
|
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||||
|
|
||||||
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
||||||
export interface OverflowCaret { pageIndex: number; left: number; top: number; height: number; }
|
export interface OverflowCaret { pageIndex: number; left: number; top: number; height: number; }
|
||||||
@@ -33,7 +34,10 @@ function lineAdvances(line: any): { perRun: Record<number, number[]>; anchorX: n
|
|||||||
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
|
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
|
||||||
for (let k = 0; k < seq.length; k++) {
|
for (let k = 0; k < seq.length; k++) {
|
||||||
const { ri, ci, ox } = seq[k];
|
const { ri, ci, ox } = seq[k];
|
||||||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : (line.x + line.w) - ox;
|
const gs = runs[ri]?.glyphs ?? [];
|
||||||
|
const g = gs[ci];
|
||||||
|
const charW = (g?.bbox_w && g.bbox_w > 0) ? g.bbox_w : (runs[ri]?.font_size ?? 12) * 0.5;
|
||||||
|
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : charW;
|
||||||
}
|
}
|
||||||
for (const k of Object.keys(perRun)) {
|
for (const k of Object.keys(perRun)) {
|
||||||
const ri = Number(k);
|
const ri = Number(k);
|
||||||
@@ -68,21 +72,17 @@ function median(xs: number[]): number {
|
|||||||
return s[Math.floor(s.length / 2)];
|
return s[Math.floor(s.length / 2)];
|
||||||
}
|
}
|
||||||
function paraEffSize(lines: any[]): number {
|
function paraEffSize(lines: any[]): number {
|
||||||
const heights: number[] = [];
|
|
||||||
let nominal = 0;
|
let nominal = 0;
|
||||||
for (const line of lines) for (const r of (line.runs ?? [])) {
|
for (const line of lines) for (const r of (line.runs ?? [])) {
|
||||||
nominal = Math.max(nominal, r.font_size ?? 0);
|
const sz = Math.max(r.font_size ?? 0, r.h ?? 0);
|
||||||
for (const g of (r.glyphs ?? [])) if ((g.bbox_h ?? 0) > 0.1) heights.push(g.bbox_h);
|
if (sz > nominal) nominal = sz;
|
||||||
}
|
}
|
||||||
if (!heights.length) return nominal || 12;
|
return nominal || 12;
|
||||||
heights.sort((a, b) => a - b);
|
|
||||||
const p75 = heights[Math.floor(heights.length * 0.75)];
|
|
||||||
return Math.max(nominal, p75 / 0.7);
|
|
||||||
}
|
}
|
||||||
function computeLayout(para: any): ParagraphLayout {
|
function computeLayout(para: any): ParagraphLayout {
|
||||||
const lines = para?.lines ?? [];
|
const lines = para?.lines ?? [];
|
||||||
const effSize = paraEffSize(lines);
|
const effSize = paraEffSize(lines);
|
||||||
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
|
let columnLeft = Infinity, firstBaselineY = -Infinity;
|
||||||
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
|
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
|
||||||
const seedRuns: SeedRun[] = [];
|
const seedRuns: SeedRun[] = [];
|
||||||
const origLines: OrigLine[] = [];
|
const origLines: OrigLine[] = [];
|
||||||
@@ -90,7 +90,6 @@ function computeLayout(para: any): ParagraphLayout {
|
|||||||
const line = lines[li];
|
const line = lines[li];
|
||||||
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
|
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
|
||||||
columnLeft = Math.min(columnLeft, line.x);
|
columnLeft = Math.min(columnLeft, line.x);
|
||||||
columnRight = Math.max(columnRight, line.x + line.w);
|
|
||||||
rightEdges.push(line.x + line.w);
|
rightEdges.push(line.x + line.w);
|
||||||
const lineRuns = line.runs ?? [];
|
const lineRuns = line.runs ?? [];
|
||||||
const { perRun, anchorX } = lineAdvances(line);
|
const { perRun, anchorX } = lineAdvances(line);
|
||||||
@@ -106,15 +105,20 @@ function computeLayout(para: any): ParagraphLayout {
|
|||||||
}
|
}
|
||||||
const adv = perRun[ri];
|
const adv = perRun[ri];
|
||||||
const safeColor = sanitizeTextColor(r.color);
|
const safeColor = sanitizeTextColor(r.color);
|
||||||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
const rSize = Math.max(r.font_size ?? 0, r.h ?? 0) || effSize;
|
||||||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, advances: adv });
|
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||||||
|
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, advances: adv });
|
||||||
}
|
}
|
||||||
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
|
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
|
||||||
}
|
}
|
||||||
|
const maxRightEdge = rightEdges.length ? Math.max(...rightEdges) : columnLeft + 250;
|
||||||
|
const columnRight = isFinite(maxRightEdge) ? Math.max(maxRightEdge, columnLeft + 250) : columnLeft + 250;
|
||||||
const deltas: number[] = [];
|
const deltas: number[] = [];
|
||||||
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
|
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
|
||||||
const domSize = seedRuns.find((r) => r.text.trim())?.size ?? 12;
|
const domSize = seedRuns.find((r) => r.text.trim())?.size ?? 12;
|
||||||
const leading = deltas.length ? Math.abs(median(deltas)) : domSize * 1.2;
|
// Prefer extracted line box height over browser-ish 1.2×fontSize for single-line paras.
|
||||||
|
const extractedLineH = lines.reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||||||
|
const leading = deltas.length ? Math.abs(median(deltas)) : (extractedLineH > 0.5 ? extractedLineH : domSize * 1.2);
|
||||||
const colW = columnRight - columnLeft;
|
const colW = columnRight - columnLeft;
|
||||||
let align: 'left' | 'justify' = 'left';
|
let align: 'left' | 'justify' = 'left';
|
||||||
if (lines.length >= 2) {
|
if (lines.length >= 2) {
|
||||||
@@ -155,6 +159,82 @@ function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null {
|
|||||||
|
|
||||||
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
||||||
|
|
||||||
|
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
||||||
|
|
||||||
|
function mergeSeedAdvances(
|
||||||
|
seed: string,
|
||||||
|
seedAdv: number[],
|
||||||
|
text: string,
|
||||||
|
fillWidth: number,
|
||||||
|
): number[] {
|
||||||
|
// Caret-only: keep LCP/LCS seed metrics; fill edited middle (measured or fallback).
|
||||||
|
const out = new Array<number>(text.length);
|
||||||
|
if (seedAdv.length !== seed.length) {
|
||||||
|
for (let i = 0; i < text.length; i++) out[i] = fillWidth;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let p = 0;
|
||||||
|
while (p < seed.length && p < text.length && seed[p] === text[p]) {
|
||||||
|
out[p] = seedAdv[p];
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
let s = 0;
|
||||||
|
while (
|
||||||
|
s < seed.length - p && s < text.length - p
|
||||||
|
&& seed[seed.length - 1 - s] === text[text.length - 1 - s]
|
||||||
|
) {
|
||||||
|
out[text.length - 1 - s] = seedAdv[seed.length - 1 - s];
|
||||||
|
s++;
|
||||||
|
}
|
||||||
|
for (let i = p; i < text.length - s; i++) out[i] = fillWidth;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-char widths via canvas (for caret). Engine uses HarfBuzz for the same middle. */
|
||||||
|
function measureCharAdvances(
|
||||||
|
text: string,
|
||||||
|
sizePt: number,
|
||||||
|
family: string,
|
||||||
|
weight: number,
|
||||||
|
): number[] {
|
||||||
|
if (!text) return [];
|
||||||
|
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||||||
|
const ctx = markerMeasureCanvas.getContext('2d');
|
||||||
|
if (!ctx) return Array.from({ length: text.length }, () => sizePt * 0.5);
|
||||||
|
ctx.font = `${weight} ${sizePt}px ${family}`;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const w = ctx.measureText(text[i]).width;
|
||||||
|
out.push(w > 0 ? w : sizePt * 0.5);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAdvancesForCaret(
|
||||||
|
text: string,
|
||||||
|
advances: number[] | undefined,
|
||||||
|
seedText: string | undefined,
|
||||||
|
sizePt: number,
|
||||||
|
family: string,
|
||||||
|
weight: number,
|
||||||
|
): number[] {
|
||||||
|
if (advances && advances.length === text.length) return advances.slice();
|
||||||
|
const measured = measureCharAdvances(text, sizePt, family, weight);
|
||||||
|
if (advances && seedText && advances.length === seedText.length) {
|
||||||
|
return mergeSeedAdvances(seedText, advances, text, 0).map((a, i) =>
|
||||||
|
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||||||
|
}
|
||||||
|
if (advances && advances.length > 0 && advances.length < text.length && text.startsWith(
|
||||||
|
seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length),
|
||||||
|
)) {
|
||||||
|
const seed = seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length);
|
||||||
|
return mergeSeedAdvances(seed, advances, text, 0).map((a, i) =>
|
||||||
|
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||||||
|
}
|
||||||
|
if (advances && advances.length > text.length) return advances.slice(0, text.length);
|
||||||
|
return measured;
|
||||||
|
}
|
||||||
|
|
||||||
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
||||||
const out: ReflowFragment[] = [];
|
const out: ReflowFragment[] = [];
|
||||||
const pushBreak = () => {
|
const pushBreak = () => {
|
||||||
@@ -179,11 +259,27 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: nu
|
|||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
||||||
const aRaw = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
const aRaw = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
||||||
|
const seedText = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-seed-text') : null;
|
||||||
if (aRaw) {
|
if (aRaw) {
|
||||||
try {
|
try {
|
||||||
const a = JSON.parse(aRaw) as number[];
|
const a = JSON.parse(aRaw) as number[];
|
||||||
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
|
if (Array.isArray(a) && a.length === text.length) {
|
||||||
} catch { }
|
// Unchanged (or already expanded) — keep as-is.
|
||||||
|
frag.advances = a;
|
||||||
|
} else if (Array.isArray(a) && seedText && a.length === seedText.length && text !== seedText) {
|
||||||
|
// Do NOT fill middle with size×0.5 — that locks the engine into fake advances
|
||||||
|
// and skips HarfBuzz for new glyphs (thin/narrow typed text). Send seed metrics
|
||||||
|
// + advanceSeedText so the engine HB-shapes only the edited middle.
|
||||||
|
frag.advances = a;
|
||||||
|
frag.advanceSeedText = seedText;
|
||||||
|
} else if (Array.isArray(a) && a.length > 0 && a.length < text.length) {
|
||||||
|
const seed = (seedText && seedText.length === a.length) ? seedText : text.slice(0, a.length);
|
||||||
|
frag.advances = a;
|
||||||
|
frag.advanceSeedText = seed;
|
||||||
|
} else if (Array.isArray(a) && a.length > text.length) {
|
||||||
|
frag.advances = a.slice(0, text.length);
|
||||||
|
}
|
||||||
|
} catch { /* ignore bad data-advances */ }
|
||||||
}
|
}
|
||||||
out.push(frag);
|
out.push(frag);
|
||||||
}
|
}
|
||||||
@@ -233,7 +329,6 @@ function splitSegmentsByBreak(runs: ReflowFragment[]): ReflowFragment[][] {
|
|||||||
return segs;
|
return segs;
|
||||||
}
|
}
|
||||||
|
|
||||||
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
|
||||||
function measureTextWidth(text: string, sizePt: number, family: string): number {
|
function measureTextWidth(text: string, sizePt: number, family: string): number {
|
||||||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||||||
const ctx = markerMeasureCanvas.getContext('2d');
|
const ctx = markerMeasureCanvas.getContext('2d');
|
||||||
@@ -264,37 +359,146 @@ function applyListMarkers(
|
|||||||
return { runs: out, hangingIndent, marker: lastMarker };
|
return { runs: out, hangingIndent, marker: lastMarker };
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalCaretOffset(el: HTMLElement): number {
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defensive guard for the pre-compiled WASM engine which may still contain the
|
||||||
|
* matrix-override bug (using horizontal scale instead of vertical scale to
|
||||||
|
* compute font size). If any line's fontSize is more than 20% smaller than the
|
||||||
|
* expected domSize, clamp all lines back to domSize so that:
|
||||||
|
* (a) the custom caret renders at the correct vertical position, and
|
||||||
|
* (b) the editing bounding box does not visually shrink.
|
||||||
|
*
|
||||||
|
* The authoritative fix lives in pdfium_edit_reflow.cpp (matrix-override block
|
||||||
|
* removed). This guard will become a no-op once a rebuilt WASM is deployed.
|
||||||
|
*/
|
||||||
|
function sanitizeEngineLayout(
|
||||||
|
lay: import('../lib/pdfiumEngine').ReflowLayout | null,
|
||||||
|
expectedFontSize: number,
|
||||||
|
): import('../lib/pdfiumEngine').ReflowLayout | null {
|
||||||
|
if (!lay || !lay.lines) return lay;
|
||||||
|
for (const line of lay.lines) {
|
||||||
|
console.log('[STAGE_6_SANITIZE_LAYOUT]', { lineFontSize: line.fontSize, expectedFontSize });
|
||||||
|
if (line.fontSize && expectedFontSize > 0 && line.fontSize < expectedFontSize * 0.8) {
|
||||||
|
line.fontSize = expectedFontSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalCaretOffset(el: HTMLElement, caretRefVal?: number): number {
|
||||||
|
if (typeof caretRefVal === 'number') return caretRefVal;
|
||||||
|
return (el.textContent ?? '').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live caret index inside a contentEditable — matches setGlobalCaretOffset / textContent indexing. */
|
||||||
|
function getDomCaretOffset(el: HTMLElement): number | null {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) return 0;
|
if (!sel || sel.rangeCount === 0) return null;
|
||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
if (!el.contains(range.startContainer)) return 0;
|
if (!el.contains(range.startContainer)) return null;
|
||||||
const pre = document.createRange();
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||||
pre.selectNodeContents(el);
|
let acc = 0;
|
||||||
pre.setEnd(range.startContainer, range.startOffset);
|
let node = walker.nextNode() as Text | null;
|
||||||
return pre.toString().length;
|
while (node) {
|
||||||
|
if (node === range.startContainer) return acc + range.startOffset;
|
||||||
|
acc += node.textContent?.length ?? 0;
|
||||||
|
node = walker.nextNode() as Text | null;
|
||||||
|
}
|
||||||
|
// Caret after a trailing <br> / at end of empty editor
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Caret metrics from typed runs (seed advances + measured middle). */
|
||||||
|
function caretLayoutFromRuns(
|
||||||
|
runs: ReflowFragment[],
|
||||||
|
opts: {
|
||||||
|
columnLeft: number;
|
||||||
|
columnRight: number;
|
||||||
|
firstBaselineY: number;
|
||||||
|
leading: number;
|
||||||
|
pageIndex: number;
|
||||||
|
fontSize: number;
|
||||||
|
/** Ink origin of first line (often slightly left of columnLeft). */
|
||||||
|
anchorX0?: number;
|
||||||
|
measureFamily?: string;
|
||||||
|
fontWeight?: number;
|
||||||
|
},
|
||||||
|
): ReflowLayout {
|
||||||
|
const lines: ReflowLayout['lines'] = [];
|
||||||
|
const lineStartX = opts.anchorX0 ?? opts.columnLeft;
|
||||||
|
let text = '';
|
||||||
|
let adv: number[] = [];
|
||||||
|
let x = lineStartX;
|
||||||
|
let x0 = lineStartX;
|
||||||
|
let baselineY = opts.firstBaselineY;
|
||||||
|
const fontSize = opts.fontSize;
|
||||||
|
const family = opts.measureFamily ?? 'Arial, sans-serif';
|
||||||
|
const weight = opts.fontWeight ?? 400;
|
||||||
|
const flush = () => {
|
||||||
|
lines.push({
|
||||||
|
baselineY,
|
||||||
|
x0,
|
||||||
|
fontSize,
|
||||||
|
text,
|
||||||
|
adv: adv.slice(),
|
||||||
|
pageIndex: opts.pageIndex,
|
||||||
|
});
|
||||||
|
text = '';
|
||||||
|
adv = [];
|
||||||
|
x = opts.columnLeft;
|
||||||
|
x0 = opts.columnLeft;
|
||||||
|
baselineY -= opts.leading;
|
||||||
|
};
|
||||||
|
for (const r of runs) {
|
||||||
|
if (r.text === '\n') {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const size = r.fontSize || fontSize;
|
||||||
|
const runAdv = expandAdvancesForCaret(
|
||||||
|
r.text, r.advances, r.advanceSeedText, size, family, weight,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < r.text.length; i++) {
|
||||||
|
const a = runAdv[i] ?? size * 0.5;
|
||||||
|
if (text.length > 0 && x + a > opts.columnRight + 0.01) flush();
|
||||||
|
if (text.length === 0) {
|
||||||
|
x0 = lines.length === 0 ? lineStartX : opts.columnLeft;
|
||||||
|
x = x0;
|
||||||
|
}
|
||||||
|
text += r.text[i];
|
||||||
|
adv.push(a);
|
||||||
|
x += a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text.length > 0 || lines.length === 0) flush();
|
||||||
|
return { columnLeft: opts.columnLeft, anchorPage: opts.pageIndex, lines };
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutTextMatchesEditor(lay: ReflowLayout | null, editorText: string): boolean {
|
||||||
|
if (!lay?.lines?.length) return false;
|
||||||
|
if (!lay.lines.every((l) => Array.isArray(l.adv) && l.adv.length === l.text.length)) return false;
|
||||||
|
const norm = (s: string) => s.replace(/\s+/g, '');
|
||||||
|
return norm(lay.lines.map((l) => l.text).join('')) === norm(editorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setGlobalCaretOffset(el: HTMLElement, target: number): void {
|
function setGlobalCaretOffset(el: HTMLElement, target: number): void {
|
||||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||||
let acc = 0;
|
let acc = 0;
|
||||||
let node = walker.nextNode() as Text | null;
|
let node = walker.nextNode() as Text | null;
|
||||||
const sel = window.getSelection();
|
|
||||||
while (node) {
|
while (node) {
|
||||||
const len = node.textContent?.length ?? 0;
|
const len = node.textContent?.length ?? 0;
|
||||||
if (acc + len >= target) {
|
if (acc + len >= target) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.setStart(node, Math.max(0, Math.min(target - acc, len)));
|
range.setStart(node, Math.max(0, Math.min(target - acc, len)));
|
||||||
range.collapse(true);
|
range.collapse(true);
|
||||||
|
const sel = window.getSelection();
|
||||||
sel?.removeAllRanges(); sel?.addRange(range);
|
sel?.removeAllRanges(); sel?.addRange(range);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
acc += len;
|
acc += len;
|
||||||
node = walker.nextNode() as Text | null;
|
node = walker.nextNode() as Text | null;
|
||||||
}
|
}
|
||||||
const range = document.createRange();
|
|
||||||
range.selectNodeContents(el); range.collapse(false);
|
|
||||||
sel?.removeAllRanges(); sel?.addRange(range);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||||||
@@ -308,9 +512,39 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
|||||||
return starts;
|
return starts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Caret layout from extracted PDF lines — no WASM reflow. Used on edit-entry so click is a visual no-op. */
|
||||||
|
function layoutFromOrigLines(
|
||||||
|
origLines: OrigLine[],
|
||||||
|
columnLeft: number,
|
||||||
|
pageIndex: number,
|
||||||
|
fallbackSize: number,
|
||||||
|
): ReflowLayout {
|
||||||
|
return {
|
||||||
|
columnLeft,
|
||||||
|
anchorPage: pageIndex,
|
||||||
|
lines: origLines.map((l) => {
|
||||||
|
const text = l.frags.map((f) => f.text).join('');
|
||||||
|
const adv = l.frags.flatMap((f) => {
|
||||||
|
if (f.advances && f.advances.length === f.text.length) return f.advances;
|
||||||
|
const n = f.text.length;
|
||||||
|
const approx = (f.size || fallbackSize) * 0.5;
|
||||||
|
return Array.from({ length: n }, () => approx);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
baselineY: l.baselineY,
|
||||||
|
x0: l.x,
|
||||||
|
fontSize: l.frags.find((f) => f.size > 1)?.size ?? fallbackSize,
|
||||||
|
text,
|
||||||
|
adv: adv.length === text.length ? adv : Array.from({ length: text.length }, () => fallbackSize * 0.5),
|
||||||
|
pageIndex,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||||||
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
const layout = useMemo(() => computeLayout(para), [para]);
|
const layout = useMemo(() => computeLayout(para), [para]);
|
||||||
const leading = leadingOverride ?? layout.leading;
|
const leading = leadingOverride ?? layout.leading;
|
||||||
@@ -352,16 +586,73 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const domSize = domRun?.size ?? layout.seedRuns[0]?.size ?? 12;
|
const domSize = domRun?.size ?? layout.seedRuns[0]?.size ?? 12;
|
||||||
const domColor = domRun?.color ?? '#000000';
|
const domColor = domRun?.color ?? '#000000';
|
||||||
const domFontName = domRun?.fontName ?? '';
|
const domFontName = domRun?.fontName ?? '';
|
||||||
|
|
||||||
|
// Extracted glyph/line metrics for overlay geometry (Fix 3 / Fix 4).
|
||||||
|
const paraBox = useMemo(() => {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
let ascent = 0, descent = 0;
|
||||||
|
const bl = layout.firstBaselineY;
|
||||||
|
for (const ln of (para?.lines ?? []) as any[]) {
|
||||||
|
for (const r of (ln.runs ?? [])) {
|
||||||
|
for (const g of (r.glyphs ?? [])) {
|
||||||
|
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||||||
|
minX = Math.min(minX, x0); minY = Math.min(minY, y0);
|
||||||
|
maxX = Math.max(maxX, x1); maxY = Math.max(maxY, y1);
|
||||||
|
const gbl = g.origin_y ?? ln.baseline_y ?? bl;
|
||||||
|
ascent = Math.max(ascent, y1 - gbl);
|
||||||
|
descent = Math.max(descent, gbl - y0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lineH = (para?.lines ?? []).reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||||||
|
if (!isFinite(minX)) {
|
||||||
|
return { x: layout.columnLeft, y: bl - domSize * 0.8, w: Math.max(0, columnRight - layout.columnLeft),
|
||||||
|
h: lineH || domSize * 1.2, ascent: domSize * 0.8, descent: domSize * 0.2, lineHeight: lineH || domSize * 1.2 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: minX, y: minY, w: maxX - minX, h: maxY - minY,
|
||||||
|
ascent: ascent || domSize * 0.8, descent, lineHeight: lineH > 0.5 ? lineH : (maxY - minY),
|
||||||
|
};
|
||||||
|
}, [para, layout.columnLeft, layout.firstBaselineY, columnRight, domSize]);
|
||||||
|
|
||||||
|
// Fix 3: line-height from extracted metrics (not size×1.2 / browser normal).
|
||||||
|
const lineHeightPt = leadingOverride ?? paraBox.lineHeight;
|
||||||
const fontPx = domSize * zoom;
|
const fontPx = domSize * zoom;
|
||||||
const leadingPx = leading * zoom;
|
const leadingPx = lineHeightPt * zoom;
|
||||||
|
// Fix 4: overlay geometry matches extracted paragraph bbox (not page column).
|
||||||
|
const overlayLeftPx = paraBox.x * zoom;
|
||||||
|
const overlayWidthPx = Math.max(paraBox.w, 1) * zoom;
|
||||||
|
const overlayHeightPx = Math.max(paraBox.h, lineHeightPt) * zoom;
|
||||||
|
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
||||||
|
const editorTop = firstBaselineScreen - paraBox.ascent * zoom;
|
||||||
|
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||||||
|
// Keep column metrics for reflow payload / toolbar (editing column can be wider than ink bbox).
|
||||||
const colLeftPx = columnLeft * zoom;
|
const colLeftPx = columnLeft * zoom;
|
||||||
const colWidthPx = (columnRight - columnLeft) * zoom;
|
const colWidthPx = (columnRight - columnLeft) * zoom;
|
||||||
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
|
||||||
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
|
|
||||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
|
||||||
|
|
||||||
const measureFamily = /times|serif/i.test(domFontName) ? 'Times New Roman, serif'
|
// Fix 1: use extracted PDF font name (loaded via @font-face), not a generic Arial/Times/Courier map.
|
||||||
: /courier|mono/i.test(domFontName) ? 'Courier New, monospace' : 'Arial, sans-serif';
|
const extractedFamily = (domFontName || '').replace(/^[A-Z]{6}\+/, '').trim() || 'sans-serif';
|
||||||
|
const fallbackFamily = /times|serif/i.test(extractedFamily) ? 'Times New Roman, serif'
|
||||||
|
: /courier|mono/i.test(extractedFamily) ? 'Courier New, monospace' : 'Arial, sans-serif';
|
||||||
|
const [measureFamily, setMeasureFamily] = useState(
|
||||||
|
extractedFamily !== 'sans-serif' ? `'${extractedFamily}', ${fallbackFamily}` : fallbackFamily,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fix 2: preserve PDF bold/italic weight (BoldMT etc. previously fell through as 400).
|
||||||
|
const extractedFontWeight: number = /bold|black|heavy|semibold|demibold/i.test(extractedFamily)
|
||||||
|
|| /_B(?:old)?(?:_|$)/i.test(dominantFid)
|
||||||
|
? 700
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!dominantFid || !documentId) return;
|
||||||
|
loadPdfFont(documentId, dominantFid, extractedFamily, extractedFontWeight).then((family) => {
|
||||||
|
if (cancelled || !family) return;
|
||||||
|
setMeasureFamily(`'${family}', ${fallbackFamily}`);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [documentId, dominantFid, extractedFamily, fallbackFamily, extractedFontWeight]);
|
||||||
|
|
||||||
const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => {
|
const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => {
|
||||||
const listActive = listKind !== null;
|
const listActive = listKind !== null;
|
||||||
@@ -375,17 +666,20 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
||||||
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
||||||
}
|
}
|
||||||
|
const linePositionData = (!listActive && layout.origLines && layout.origLines.length) ? {
|
||||||
|
lineX: layout.origLines.map((l) => l.x),
|
||||||
|
lineBaselineY: layout.origLines.map((l) => l.baselineY),
|
||||||
|
} : {};
|
||||||
const linesData = (!listActive && origLines && origLines.length) ? {
|
const linesData = (!listActive && origLines && origLines.length) ? {
|
||||||
lines: origLines.map((l) => l.frags.map((f) => ({
|
lines: origLines.map((l) => l.frags.map((f) => ({
|
||||||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||||||
...(f.advances ? { advances: f.advances } : {}),
|
...(f.advances ? { advances: f.advances } : {}),
|
||||||
}))),
|
}))),
|
||||||
lineX: origLines.map((l) => l.x),
|
|
||||||
lineBaselineY: origLines.map((l) => l.baselineY),
|
|
||||||
} : {};
|
} : {};
|
||||||
return {
|
return {
|
||||||
objectIndices: layout.objectIndices,
|
objectIndices: layout.objectIndices,
|
||||||
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||||||
|
...linePositionData,
|
||||||
...linesData,
|
...linesData,
|
||||||
columnLeft: effColumnLeft, columnRight,
|
columnLeft: effColumnLeft, columnRight,
|
||||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||||
@@ -403,6 +697,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }],
|
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const caretIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
|
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
|
||||||
if (!lay.lines.length) return null;
|
if (!lay.lines.length) return null;
|
||||||
const starts = lineStarts(lay, fullText);
|
const starts = lineStarts(lay, fullText);
|
||||||
@@ -448,7 +744,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const positionCaret = () => {
|
const positionCaret = () => {
|
||||||
const el = editRef.current, lay = engineLayoutRef.current;
|
const el = editRef.current, lay = engineLayoutRef.current;
|
||||||
if (!el || !lay) return;
|
if (!el || !lay) return;
|
||||||
const box = caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? '');
|
const fullText = el.textContent ?? '';
|
||||||
|
// Always follow the live contentEditable caret — caretIndexRef alone stays stuck at last click.
|
||||||
|
const live = getDomCaretOffset(el);
|
||||||
|
if (typeof live === 'number') caretIndexRef.current = live;
|
||||||
|
const idx = typeof caretIndexRef.current === 'number' ? caretIndexRef.current : fullText.length;
|
||||||
|
const box = caretBoxFor(idx, lay, fullText);
|
||||||
if (box && box.pageIndex !== pageIndex) {
|
if (box && box.pageIndex !== pageIndex) {
|
||||||
setCaretBox(null);
|
setCaretBox(null);
|
||||||
onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height });
|
onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height });
|
||||||
@@ -468,36 +769,159 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const renderPreview = async () => {
|
const renderPreview = async () => {
|
||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
|
||||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
// Edit-entry (click before typing): do NOT run identity reflow.
|
||||||
const dpi = Math.round(72 * zoom);
|
// Reflow rebuilds text objects (often runPerChar) and looks different from the PDF
|
||||||
const origLines = editedRef.current ? undefined : layout.origLines;
|
// even when the string is unchanged. Keep the page bitmap visible; caret uses extracted metrics.
|
||||||
const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines);
|
if (!editedRef.current) {
|
||||||
|
const origLay = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||||
|
engineLayoutRef.current = origLay;
|
||||||
|
if (!initialCaretApplied.current) {
|
||||||
|
initialCaretApplied.current = true;
|
||||||
|
const fullLen = (el.textContent ?? '').length;
|
||||||
|
setGlobalCaretOffset(el, fullLen);
|
||||||
|
}
|
||||||
|
positionCaret();
|
||||||
|
setHasPreview(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing preview: use gateway (same engine as save). Browser WASM is stale and
|
||||||
|
// changes font/width/height on keystroke; save then snaps back to the native look.
|
||||||
|
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||||
|
const dpi = Math.round(96 * zoom * dpr);
|
||||||
|
const runs = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||||
|
const data = buildReflowData(runs);
|
||||||
const yTopPt = bandTop / zoom;
|
const yTopPt = bandTop / zoom;
|
||||||
const { regions, layout: lay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
const operations = [{
|
||||||
if (regions.length === 0) { return; }
|
id: 'preview',
|
||||||
engineLayoutRef.current = lay;
|
type: 'reflow_paragraph' as const,
|
||||||
|
pageIndex,
|
||||||
|
data,
|
||||||
|
}];
|
||||||
|
|
||||||
|
console.log('[STAGE_1_EDITABLE_RUN]', { dominantFid, domSize, fontPx, domFontName, edited: true });
|
||||||
|
console.log('[STAGE_2_RENDER_PREVIEW_PAYLOAD]', { operations });
|
||||||
|
|
||||||
|
const preview = await gatewayService.previewEdits({
|
||||||
|
documentId,
|
||||||
|
operations,
|
||||||
|
pageIndex,
|
||||||
|
dpi,
|
||||||
|
yTopPt,
|
||||||
|
});
|
||||||
|
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let rawLay: ReflowLayout | null = null;
|
||||||
|
let drawSource: HTMLCanvasElement | HTMLImageElement | null = null;
|
||||||
|
let usedGateway = false;
|
||||||
|
|
||||||
|
if (preview?.pngBase64 && preview.width > 0 && preview.height > 0) {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('preview png decode failed'));
|
||||||
|
img.src = `data:image/png;base64,${preview.pngBase64}`;
|
||||||
|
});
|
||||||
|
width = preview.width;
|
||||||
|
height = preview.height;
|
||||||
|
rawLay = preview.layout;
|
||||||
|
drawSource = img;
|
||||||
|
usedGateway = true;
|
||||||
|
onOverflowPreview?.([]);
|
||||||
|
} catch {
|
||||||
|
usedGateway = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usedGateway) {
|
||||||
|
// Fallback only if gateway preview is unavailable.
|
||||||
|
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||||
|
if (!wasmHasDocument(documentId)) {
|
||||||
|
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||||||
|
if (bytes) await wasmLoadDocument(documentId, bytes);
|
||||||
|
}
|
||||||
|
if (!wasmHasDocument(documentId)) return;
|
||||||
|
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||||
|
const opJson = buildOpJson(runs);
|
||||||
|
const { regions, layout: wasmLay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||||||
|
if (regions.length === 0) return;
|
||||||
|
rawLay = wasmLay;
|
||||||
const r0 = regions[0];
|
const r0 = regions[0];
|
||||||
const { rgba, width, height } = r0;
|
width = r0.width;
|
||||||
if (!rgba || width <= 0 || height <= 0) { return; }
|
height = r0.height;
|
||||||
const cv = previewCanvasRef.current;
|
if (!r0.rgba || width <= 0 || height <= 0) return;
|
||||||
if (cv) {
|
const offscreen = document.createElement('canvas');
|
||||||
if (cv.width !== width) cv.width = width;
|
offscreen.width = width;
|
||||||
if (cv.height !== height) cv.height = height;
|
offscreen.height = height;
|
||||||
const ctx = cv.getContext('2d');
|
const offCtx = offscreen.getContext('2d');
|
||||||
if (ctx) {
|
if (!offCtx) return;
|
||||||
const img = ctx.createImageData(width, height);
|
const imgData = offCtx.createImageData(width, height);
|
||||||
img.data.set(rgba);
|
imgData.data.set(r0.rgba);
|
||||||
ctx.putImageData(img, 0, 0);
|
offCtx.putImageData(imgData, 0, 0);
|
||||||
}
|
drawSource = offscreen;
|
||||||
}
|
|
||||||
onOverflowPreview?.(regions.slice(1).map((rg) => ({
|
onOverflowPreview?.(regions.slice(1).map((rg) => ({
|
||||||
pageIndex: rg.pageIndex, yTopPt: rg.yTopPt, dataUrl: rgbaToDataUrl(rg.rgba, rg.width, rg.height),
|
pageIndex: rg.pageIndex, yTopPt: rg.yTopPt, dataUrl: rgbaToDataUrl(rg.rgba, rg.width, rg.height),
|
||||||
})));
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[STAGE_7_PREVIEW_DRAW]', {
|
||||||
|
fontSize: domSize,
|
||||||
|
fontPx,
|
||||||
|
via: usedGateway ? 'gateway' : 'wasm-fallback',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gateway PNG is authoritative for glyphs; caret needs per-glyph advances.
|
||||||
|
// Only trust STAGE_5 (layoutSource=reflow). Extract layouts use bbox widths and lag the caret.
|
||||||
|
const anchorX0 = layout.origLines[0]?.x ?? columnLeft;
|
||||||
|
const caretOpts = {
|
||||||
|
columnLeft,
|
||||||
|
columnRight,
|
||||||
|
firstBaselineY: layout.firstBaselineY,
|
||||||
|
leading,
|
||||||
|
pageIndex,
|
||||||
|
fontSize: domSize,
|
||||||
|
anchorX0,
|
||||||
|
measureFamily,
|
||||||
|
fontWeight: extractedFontWeight,
|
||||||
|
};
|
||||||
|
const engineLay = rawLay as (ReflowLayout & { layoutSource?: string }) | null;
|
||||||
|
const caretLay = usedGateway
|
||||||
|
? (engineLay?.layoutSource === 'reflow' && layoutTextMatchesEditor(engineLay, el.textContent ?? '')
|
||||||
|
? engineLay
|
||||||
|
: caretLayoutFromRuns(runs, caretOpts))
|
||||||
|
: rawLay;
|
||||||
|
const lay = sanitizeEngineLayout(caretLay, domSize);
|
||||||
|
engineLayoutRef.current = lay;
|
||||||
|
const cv = previewCanvasRef.current;
|
||||||
|
if (cv && drawSource) {
|
||||||
|
const displayW = pageWidthPx;
|
||||||
|
const displayH = Math.max(0, pageHeightPx - bandTop);
|
||||||
|
const targetW = Math.round(displayW * dpr);
|
||||||
|
const targetH = Math.round(displayH * dpr);
|
||||||
|
if (cv.width !== targetW) cv.width = targetW;
|
||||||
|
if (cv.height !== targetH) cv.height = targetH;
|
||||||
|
cv.style.width = `${displayW}px`;
|
||||||
|
cv.style.height = `${displayH}px`;
|
||||||
|
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.drawImage(drawSource, 0, 0, displayW, displayH);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!hasPreview) setHasPreview(true);
|
if (!hasPreview) setHasPreview(true);
|
||||||
if (!initialCaretApplied.current) {
|
if (!initialCaretApplied.current) {
|
||||||
initialCaretApplied.current = true;
|
initialCaretApplied.current = true;
|
||||||
if (caretClick && lay) setGlobalCaretOffset(el, globalFromPoint(caretClick.x, caretClick.y, lay, el.textContent ?? ''));
|
const fullLen = (el.textContent ?? '').length;
|
||||||
|
setGlobalCaretOffset(el, fullLen);
|
||||||
}
|
}
|
||||||
positionCaret();
|
positionCaret();
|
||||||
};
|
};
|
||||||
@@ -518,13 +942,13 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
// Edit-entry caret is sync (no reflow). Preload WASM in background for first keystroke.
|
||||||
|
renderPreview();
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!wasmHasDocument(documentId)) {
|
if (!wasmHasDocument(documentId)) {
|
||||||
const bytes = await gatewayService.getDocumentRaw(documentId);
|
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||||||
if (bytes) await wasmLoadDocument(documentId, bytes);
|
if (bytes && !cancelled) await wasmLoadDocument(documentId, bytes);
|
||||||
}
|
}
|
||||||
if (cancelled) return;
|
|
||||||
renderPreview();
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -539,33 +963,209 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
for (const r of layout.seedRuns) {
|
// Preserve PDF hard line breaks (not a single CSS-wrapped flow).
|
||||||
|
const lineFrags = layout.origLines.length
|
||||||
|
? layout.origLines.map((l) => l.frags.map((f) => ({
|
||||||
|
text: f.text, fid: f.fid, size: f.size, color: f.color, fontName: domFontName, advances: f.advances,
|
||||||
|
})))
|
||||||
|
: [layout.seedRuns];
|
||||||
|
lineFrags.forEach((frags, li) => {
|
||||||
|
if (li > 0) el.appendChild(document.createElement('br'));
|
||||||
|
for (const r of frags) {
|
||||||
if (!r.text) continue;
|
if (!r.text) continue;
|
||||||
const effSize = !r.text.trim() || r.size <= 1 ? domSize : r.size;
|
const effSize = !r.text.trim() || r.size <= 1 ? domSize : r.size;
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.setAttribute('data-fid', r.fid || dominantFid);
|
span.setAttribute('data-fid', r.fid || dominantFid);
|
||||||
span.setAttribute('data-size', String(effSize));
|
span.setAttribute('data-size', String(effSize));
|
||||||
span.setAttribute('data-color', r.color);
|
span.setAttribute('data-color', r.color);
|
||||||
span.setAttribute('data-fontname', r.fontName);
|
span.setAttribute('data-fontname', ('fontName' in r ? (r as { fontName?: string }).fontName : domFontName) || '');
|
||||||
if (r.advances && r.advances.length === r.text.length) span.setAttribute('data-advances', JSON.stringify(r.advances));
|
if (r.advances && r.advances.length === r.text.length) {
|
||||||
|
span.setAttribute('data-advances', JSON.stringify(r.advances));
|
||||||
|
span.setAttribute('data-seed-text', r.text);
|
||||||
|
}
|
||||||
span.style.fontSize = `${effSize * zoom}px`;
|
span.style.fontSize = `${effSize * zoom}px`;
|
||||||
span.style.color = 'transparent';
|
span.style.color = 'transparent';
|
||||||
|
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||||||
span.textContent = r.text;
|
span.textContent = r.text;
|
||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
el.focus();
|
el.focus();
|
||||||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||||
|
// Caret layout after DOM seed is ready (documentId effect may have run first on an empty editor).
|
||||||
|
if (!editedRef.current) {
|
||||||
|
engineLayoutRef.current = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||||
|
initialCaretApplied.current = true;
|
||||||
|
setGlobalCaretOffset(el, (el.textContent ?? '').length);
|
||||||
|
positionCaret();
|
||||||
|
setHasPreview(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing) ---
|
||||||
|
try {
|
||||||
|
const lines = (para?.lines ?? []) as any[];
|
||||||
|
let pdfMinX = Infinity, pdfMinY = Infinity, pdfMaxX = -Infinity, pdfMaxY = -Infinity;
|
||||||
|
let pdfBaseline = layout.firstBaselineY;
|
||||||
|
let pdfAscent = 0, pdfDescent = 0;
|
||||||
|
let pdfFontName = domFontName, pdfFontSize = domSize;
|
||||||
|
for (const ln of lines) {
|
||||||
|
for (const r of (ln.runs ?? [])) {
|
||||||
|
pdfFontName = r.font_name || pdfFontName;
|
||||||
|
pdfFontSize = Math.max(pdfFontSize, r.font_size ?? 0, r.h ?? 0);
|
||||||
|
for (const g of (r.glyphs ?? [])) {
|
||||||
|
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||||||
|
pdfMinX = Math.min(pdfMinX, x0); pdfMinY = Math.min(pdfMinY, y0);
|
||||||
|
pdfMaxX = Math.max(pdfMaxX, x1); pdfMaxY = Math.max(pdfMaxY, y1);
|
||||||
|
const bl = g.origin_y ?? ln.baseline_y ?? pdfBaseline;
|
||||||
|
pdfAscent = Math.max(pdfAscent, y1 - bl);
|
||||||
|
pdfDescent = Math.max(pdfDescent, bl - y0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pdfW = isFinite(pdfMaxX) ? pdfMaxX - pdfMinX : 0;
|
||||||
|
const pdfH = isFinite(pdfMaxY) ? pdfMaxY - pdfMinY : 0;
|
||||||
|
const pdfLineH = lines[0]?.h ?? pdfH;
|
||||||
|
const expectedBold = /bold|black|heavy/i.test(pdfFontName) || /Bold/i.test(dominantFid);
|
||||||
|
|
||||||
|
// Wait one frame so layout/scroll metrics settle after focus + children.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cs = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const pdf = {
|
||||||
|
bbox: { x: pdfMinX, y: pdfMinY, w: pdfW, h: pdfH },
|
||||||
|
lineHeightPt: pdfLineH,
|
||||||
|
ascentPt: pdfAscent,
|
||||||
|
descentPt: pdfDescent,
|
||||||
|
widthPt: pdfW,
|
||||||
|
heightPt: pdfH,
|
||||||
|
fontName: pdfFontName,
|
||||||
|
fontSizePt: pdfFontSize,
|
||||||
|
baselineY: pdfBaseline,
|
||||||
|
expectedFontWeight: expectedBold ? 700 : 400,
|
||||||
|
};
|
||||||
|
const overlayApplied = {
|
||||||
|
fontFamily: measureFamily,
|
||||||
|
fontSizePx: fontPx,
|
||||||
|
fontSizePt: domSize,
|
||||||
|
fontWeight: extractedFontWeight,
|
||||||
|
lineHeightPx: leadingPx,
|
||||||
|
lineHeightPt: lineHeightPt,
|
||||||
|
letterSpacing: '(not set)',
|
||||||
|
widthPx: overlayWidthPx,
|
||||||
|
widthPt: paraBox.w,
|
||||||
|
heightPx: overlayHeightPx,
|
||||||
|
heightPt: paraBox.h,
|
||||||
|
topPx: editorTop,
|
||||||
|
leftPx: overlayLeftPx,
|
||||||
|
ascentHeuristicPt: paraBox.ascent,
|
||||||
|
transform: 'none',
|
||||||
|
};
|
||||||
|
const overlayMeasured = {
|
||||||
|
fontFamily: cs.fontFamily,
|
||||||
|
fontSize: cs.fontSize,
|
||||||
|
fontWeight: cs.fontWeight,
|
||||||
|
lineHeight: cs.lineHeight,
|
||||||
|
letterSpacing: cs.letterSpacing,
|
||||||
|
width: cs.width,
|
||||||
|
height: cs.height,
|
||||||
|
transform: cs.transform,
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
boundingRect: { width: rect.width, height: rect.height, top: rect.top, left: rect.left },
|
||||||
|
};
|
||||||
|
|
||||||
|
const checks: { property: string; pdf: string | number; overlay: string | number; match: boolean }[] = [];
|
||||||
|
const pdfFamilyKey = (pdf.fontName || '').replace(/^[A-Z]{6}\+/, '');
|
||||||
|
const familyApplied = measureFamily;
|
||||||
|
const familyMatches =
|
||||||
|
!!pdfFamilyKey
|
||||||
|
&& (familyApplied.includes(pdfFamilyKey)
|
||||||
|
|| familyApplied.replace(/['"]/g, '').split(',')[0].trim() === pdfFamilyKey);
|
||||||
|
checks.push({
|
||||||
|
property: 'font-family',
|
||||||
|
pdf: pdf.fontName,
|
||||||
|
overlay: familyApplied,
|
||||||
|
match: familyMatches,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'font-weight',
|
||||||
|
pdf: pdf.expectedFontWeight,
|
||||||
|
overlay: parseInt(cs.fontWeight, 10) || cs.fontWeight,
|
||||||
|
match: !expectedBold || (parseInt(cs.fontWeight, 10) || 0) >= 600,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'font-size (pt)',
|
||||||
|
pdf: pdf.fontSizePt,
|
||||||
|
overlay: overlayApplied.fontSizePt,
|
||||||
|
match: Math.abs(pdf.fontSizePt - overlayApplied.fontSizePt) < 0.05,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'line-height / leading (pt)',
|
||||||
|
// Multi-line CSS uses baseline gap (leadingOverride); ink line.h is not the CSS line-height.
|
||||||
|
pdf: leadingOverride ?? pdf.lineHeightPt,
|
||||||
|
overlay: overlayApplied.lineHeightPt,
|
||||||
|
match: Math.abs((leadingOverride ?? pdf.lineHeightPt) - overlayApplied.lineHeightPt) < 0.5,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'ascent used for overlay top (pt)',
|
||||||
|
pdf: pdf.ascentPt,
|
||||||
|
overlay: overlayApplied.ascentHeuristicPt,
|
||||||
|
match: Math.abs(pdf.ascentPt - overlayApplied.ascentHeuristicPt) < 0.5,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'height (pt)',
|
||||||
|
pdf: pdf.heightPt,
|
||||||
|
overlay: overlayApplied.heightPt,
|
||||||
|
match: Math.abs(pdf.heightPt - overlayApplied.heightPt) < 0.5,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'width (pt)',
|
||||||
|
pdf: pdf.widthPt,
|
||||||
|
overlay: overlayApplied.widthPt,
|
||||||
|
match: Math.abs(pdf.widthPt - overlayApplied.widthPt) < 1.0,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'letter-spacing',
|
||||||
|
pdf: '0 / normal',
|
||||||
|
overlay: cs.letterSpacing,
|
||||||
|
match: cs.letterSpacing === 'normal' || cs.letterSpacing === '0px',
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
property: 'transform',
|
||||||
|
pdf: 'none',
|
||||||
|
overlay: cs.transform,
|
||||||
|
match: !cs.transform || cs.transform === 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = checks.find((c) => !c.match) ?? null;
|
||||||
|
console.log('[EDIT_ENTRY_OVERLAY_COMPARE]', {
|
||||||
|
pdf,
|
||||||
|
overlayApplied,
|
||||||
|
overlayMeasured,
|
||||||
|
checks,
|
||||||
|
firstPropertyThatChanges: first,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[EDIT_ENTRY_OVERLAY_COMPARE] failed', e);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fallbackVisible = wasmFailed && !hasPreview;
|
// Never paint HTML over the PDF on edit-entry. Fallback HTML was a common
|
||||||
|
// source of font/width/leading jumps when WASM was slow or unavailable.
|
||||||
|
const fallbackVisible = false;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.querySelectorAll<HTMLElement>('span[data-fid]').forEach((span) => {
|
el.querySelectorAll<HTMLElement>('span[data-fid]').forEach((span) => {
|
||||||
span.style.color = fallbackVisible ? (span.getAttribute('data-color') ?? '#000000') : 'transparent';
|
span.style.color = 'transparent';
|
||||||
|
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||||||
});
|
});
|
||||||
}, [fallbackVisible]);
|
}, [fallbackVisible]);
|
||||||
|
|
||||||
|
// Diagnostics only — does not drive a visible HTML fallback.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPreview) return;
|
if (hasPreview) return;
|
||||||
const t = window.setTimeout(() => setWasmFailed(true), 4000);
|
const t = window.setTimeout(() => setWasmFailed(true), 4000);
|
||||||
@@ -573,11 +1173,30 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
}, [hasPreview]);
|
}, [hasPreview]);
|
||||||
|
|
||||||
const composingRef = useRef(false);
|
const composingRef = useRef(false);
|
||||||
|
const ignoreInputUntilRef = useRef(Date.now() + 150);
|
||||||
|
|
||||||
const onInput = () => {
|
const onInput = () => {
|
||||||
|
// Ignore spurious input events from contentEditable population / focus.
|
||||||
|
if (Date.now() < ignoreInputUntilRef.current) return;
|
||||||
editedRef.current = true;
|
editedRef.current = true;
|
||||||
if (!edited) setEdited(true);
|
if (!edited) setEdited(true);
|
||||||
if (composingRef.current) return;
|
if (composingRef.current) return;
|
||||||
|
// Optimistic caret layout so the cursor tracks keystrokes while gateway preview is in flight.
|
||||||
|
const elNow = editRef.current;
|
||||||
|
if (elNow) {
|
||||||
|
const liveRuns = extractFlatRuns(elNow, dominantFid, domSize, domColor);
|
||||||
|
engineLayoutRef.current = caretLayoutFromRuns(liveRuns, {
|
||||||
|
columnLeft,
|
||||||
|
columnRight,
|
||||||
|
firstBaselineY: layout.firstBaselineY,
|
||||||
|
leading,
|
||||||
|
pageIndex,
|
||||||
|
fontSize: domSize,
|
||||||
|
anchorX0: layout.origLines[0]?.x ?? columnLeft,
|
||||||
|
measureFamily,
|
||||||
|
fontWeight: extractedFontWeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
positionCaret();
|
positionCaret();
|
||||||
scheduleRender();
|
scheduleRender();
|
||||||
};
|
};
|
||||||
@@ -593,7 +1212,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const onClickEditor = (e: React.MouseEvent) => {
|
const onClickEditor = (e: React.MouseEvent) => {
|
||||||
const lay = engineLayoutRef.current, el = editRef.current;
|
const lay = engineLayoutRef.current, el = editRef.current;
|
||||||
if (!lay || !el || fallbackVisible) return;
|
if (!lay || !el || fallbackVisible) return;
|
||||||
setGlobalCaretOffset(el, globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? ''));
|
const targetIdx = globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? '');
|
||||||
|
caretIndexRef.current = targetIdx;
|
||||||
|
setGlobalCaretOffset(el, targetIdx);
|
||||||
positionCaret();
|
positionCaret();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -656,7 +1277,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
{fallbackVisible && (
|
{fallbackVisible && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-[36] bg-white"
|
className="absolute z-[36] bg-white"
|
||||||
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: layout.oldLineCount * leadingPx + 8 }}
|
style={{ left: overlayLeftPx - 2, top: editorTop - 2, width: overlayWidthPx + 4, height: overlayHeightPx + 4 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<canvas
|
<canvas
|
||||||
@@ -735,12 +1356,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||||||
className="absolute z-[38] outline-none"
|
className="absolute z-[38] outline-none"
|
||||||
style={{
|
style={{
|
||||||
left: colLeftPx, top: editorTop, width: colWidthPx, minHeight: layout.oldLineCount * leadingPx,
|
left: overlayLeftPx, top: editorTop, width: overlayWidthPx, height: overlayHeightPx,
|
||||||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`,
|
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`, fontFamily: measureFamily,
|
||||||
caretColor: fallbackVisible ? '#2563eb' : 'transparent',
|
fontWeight: extractedFontWeight,
|
||||||
color: fallbackVisible ? undefined : 'transparent',
|
caretColor: 'transparent',
|
||||||
background: fallbackVisible ? '#ffffff' : 'transparent',
|
color: 'transparent',
|
||||||
whiteSpace: 'normal', overflowWrap: 'break-word',
|
// WebKit may ignore `color: transparent` on contentEditable without this.
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'normal', overflowWrap: 'break-word', overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ interface RedactionLayerProps {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onRedactionSelected: (bounds: Rect) => void;
|
onRedactionSelected: (bounds: Rect) => void;
|
||||||
|
pendingRedactions?: { id: string, bounds: Rect }[];
|
||||||
|
onRemoveRedaction?: (id: string) => void;
|
||||||
|
mode?: 'area' | 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
onRedactionSelected,
|
onRedactionSelected,
|
||||||
|
pendingRedactions = [],
|
||||||
|
onRemoveRedaction,
|
||||||
|
mode = 'area',
|
||||||
}) => {
|
}) => {
|
||||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||||
@@ -62,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
cursor: 'crosshair',
|
cursor: mode === 'area' ? 'crosshair' : 'default',
|
||||||
zIndex: 25,
|
zIndex: 25,
|
||||||
|
pointerEvents: mode === 'area' ? 'auto' : 'none',
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseMove={handleMouseMove}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
|
|||||||
glyphs: Glyph[];
|
glyphs: Glyph[];
|
||||||
mode?: 'select' | 'highlight';
|
mode?: 'select' | 'highlight';
|
||||||
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
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';
|
const SEL_START_EVT = 'pdf-selection-start';
|
||||||
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
glyphs,
|
glyphs,
|
||||||
mode = 'select',
|
mode = 'select',
|
||||||
onTextSelected,
|
onTextSelected,
|
||||||
|
onSelectionChange,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||||
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
setBox(null);
|
setBox(null);
|
||||||
drag.current = null;
|
drag.current = null;
|
||||||
boxStart.current = null;
|
boxStart.current = null;
|
||||||
}, []);
|
onSelectionChange?.(null);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOther = (e: Event) => {
|
const onOther = (e: Event) => {
|
||||||
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|||||||
const cur = selRef.current;
|
const cur = selRef.current;
|
||||||
if (!cur || cur.start === cur.end) {
|
if (!cur || cur.start === cur.end) {
|
||||||
setSel(null);
|
setSel(null);
|
||||||
|
onSelectionChange?.(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'highlight') {
|
|
||||||
const text = model.textOfRange(cur);
|
const text = model.textOfRange(cur);
|
||||||
const u = model.unionRect(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 }));
|
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') {
|
||||||
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
||||||
setSel(null);
|
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 React, { useEffect, useState, useRef } from 'react';
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
import { gatewayService } from '../lib/gatewayService';
|
||||||
import type { TextObjectResponse } from '../lib/gatewayService';
|
import type { TextObjectResponse } from '../lib/gatewayService';
|
||||||
import { toast } from '../lib/toast';
|
|
||||||
|
|
||||||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
|||||||
if (newText === obj.text) return;
|
if (newText === obj.text) return;
|
||||||
try {
|
try {
|
||||||
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
|
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
|
||||||
toast('Text updated successfully', 'success');
|
|
||||||
if (res.newDocumentId && onDocumentChanged) {
|
if (res.newDocumentId && onDocumentChanged) {
|
||||||
onDocumentChanged(res.newDocumentId);
|
onDocumentChanged(res.newDocumentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
|||||||
onEditSuccess();
|
onEditSuccess();
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(`Failed to update text: ${e.message}`, 'error');
|
console.error('Failed to update text:', e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface ReflowFragment {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
color: string;
|
color: string;
|
||||||
advances?: number[];
|
advances?: number[];
|
||||||
|
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||||
|
advanceSeedText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommitFrame {
|
export interface CommitFrame {
|
||||||
@@ -227,21 +229,7 @@ interface TextEditLayerProps {
|
|||||||
onCommitPreview?: (frame: CommitFrame) => void;
|
onCommitPreview?: (frame: CommitFrame) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let measureCanvas: HTMLCanvasElement | null = null;
|
|
||||||
function caretIndexFromX(text: string, cssFont: string, x: number): number {
|
|
||||||
if (x <= 0) return 0;
|
|
||||||
if (!measureCanvas) measureCanvas = document.createElement('canvas');
|
|
||||||
const ctx = measureCanvas.getContext('2d');
|
|
||||||
if (!ctx) return text.length;
|
|
||||||
ctx.font = cssFont;
|
|
||||||
let acc = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const w = ctx.measureText(text[i]).width;
|
|
||||||
if (acc + w / 2 >= x) return i;
|
|
||||||
acc += w;
|
|
||||||
}
|
|
||||||
return text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackFamily(fontName: string): string {
|
function fallbackFamily(fontName: string): string {
|
||||||
const n = (fontName || '').toLowerCase();
|
const n = (fontName || '').toLowerCase();
|
||||||
@@ -271,7 +259,7 @@ function flattenRuns(model: any): EditableRun[] {
|
|||||||
text: r.text,
|
text: r.text,
|
||||||
x: r.x, y: r.y, w: r.w, h: r.h,
|
x: r.x, y: r.y, w: r.w, h: r.h,
|
||||||
baselineY,
|
baselineY,
|
||||||
fontSize: r.font_size ?? r.h,
|
fontSize: Math.max(r.font_size ?? 0, r.h ?? 0),
|
||||||
objectIndices,
|
objectIndices,
|
||||||
internalFontId: r.internal_font_id ?? '',
|
internalFontId: r.internal_font_id ?? '',
|
||||||
fontName: r.font_name ?? '',
|
fontName: r.font_name ?? '',
|
||||||
@@ -293,11 +281,7 @@ function median(xs: number[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function displayFontSize(r: EditableRun): number {
|
function displayFontSize(r: EditableRun): number {
|
||||||
const t = r.text || '';
|
return Math.max(r.fontSize ?? 0, r.h ?? 0);
|
||||||
const hasDescender = /[gjpqy(),;\[\]{}₀-₉]/.test(t);
|
|
||||||
const hasAscender = /[bdfhklt]/.test(t);
|
|
||||||
const frac = hasDescender ? 0.92 : hasAscender ? 0.75 : 0.70;
|
|
||||||
return Math.max(r.fontSize, r.h / frac);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
|
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
|
||||||
@@ -379,7 +363,6 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
const [fontFamily, setFontFamily] = useState('inherit');
|
const [fontFamily, setFontFamily] = useState('inherit');
|
||||||
const committedRef = useRef(false);
|
const committedRef = useRef(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const caretIdxRef = useRef<number | null>(null);
|
|
||||||
const modelRef = useRef<any>(null);
|
const modelRef = useRef<any>(null);
|
||||||
const [paraEdit, setParaEdit] = useState<{
|
const [paraEdit, setParaEdit] = useState<{
|
||||||
para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number;
|
para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number;
|
||||||
@@ -474,44 +457,39 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
setParaEdit({ ...edit, anchorPageIndex: pageIndex });
|
setParaEdit({ ...edit, anchorPageIndex: pageIndex });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
|
const openEditor = (i: number, _clickX: number, clientX?: number, clientY?: number) => {
|
||||||
const run = runs[i];
|
const run = runs[i];
|
||||||
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
||||||
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph && !isTableParagraph(para, modelRef.current)) {
|
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph && !isTableParagraph(para, modelRef.current)) {
|
||||||
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
|
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
|
||||||
|
setCaretClick(click);
|
||||||
if (para.lines.length > 1) {
|
if (para.lines.length > 1) {
|
||||||
if (isFlowingParagraph(para)) {
|
if (isFlowingParagraph(para)) {
|
||||||
setCaretClick(click);
|
|
||||||
openParaEdit({ para });
|
openParaEdit({ para });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = buildBulletItem(para, run.lineIndex);
|
const item = buildBulletItem(para, run.lineIndex);
|
||||||
if (item) {
|
if (item) {
|
||||||
setCaretClick(click);
|
|
||||||
openParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
|
openParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
const editableRuns = (para.lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length;
|
|
||||||
if (editableRuns > 1) {
|
|
||||||
setCaretClick(click);
|
|
||||||
const al = headingAlign(para.lines[0], modelRef.current);
|
const al = headingAlign(para.lines[0], modelRef.current);
|
||||||
|
const pr = pageContentRight(modelRef.current);
|
||||||
if (al === 'center' || al === 'right') {
|
if (al === 'center' || al === 'right') {
|
||||||
openParaEdit({
|
openParaEdit({
|
||||||
para, align: al,
|
para, align: al,
|
||||||
columnLeft: pageContentLeft(modelRef.current),
|
columnLeft: pageContentLeft(modelRef.current),
|
||||||
columnRight: pageContentRight(modelRef.current),
|
columnRight: pr,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
openParaEdit({ para, columnRight: pageContentRight(modelRef.current) });
|
const paraRight = Math.max(...para.lines.map((l: any) => l.x + l.w));
|
||||||
|
openParaEdit({ para, columnRight: Math.max(pr, paraRight) });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
committedRef.current = false;
|
committedRef.current = false;
|
||||||
const fb = fallbackFamily(run.fontName);
|
const fb = fallbackFamily(run.fontName);
|
||||||
caretIdxRef.current = caretIndexFromX(run.text, `${displayFontSize(run) * zoom}px ${fb}`, clickX);
|
|
||||||
setEditing(i);
|
setEditing(i);
|
||||||
setValue(run.text);
|
setValue(run.text);
|
||||||
setFontFamily(fb);
|
setFontFamily(fb);
|
||||||
@@ -577,16 +555,40 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
const dpi = Math.round(72 * zoom);
|
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||||
|
const dpi = Math.round(96 * zoom * dpr);
|
||||||
const { topScreen, heightScreen } = cellBand(r);
|
const { topScreen, heightScreen } = cellBand(r);
|
||||||
const res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
|
const res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
|
||||||
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
|
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
|
||||||
const cv = previewCanvasRef.current;
|
const cv = previewCanvasRef.current;
|
||||||
if (!cv) return;
|
if (!cv) return;
|
||||||
if (cv.width !== res.width) cv.width = res.width;
|
const displayW = width;
|
||||||
if (cv.height !== res.height) cv.height = res.height;
|
const displayH = heightScreen;
|
||||||
|
const targetW = Math.round(displayW * dpr);
|
||||||
|
const targetH = Math.round(displayH * dpr);
|
||||||
|
if (cv.width !== targetW) cv.width = targetW;
|
||||||
|
if (cv.height !== targetH) cv.height = targetH;
|
||||||
|
cv.style.width = `${displayW}px`;
|
||||||
|
cv.style.height = `${displayH}px`;
|
||||||
|
|
||||||
const ctx = cv.getContext('2d');
|
const ctx = cv.getContext('2d');
|
||||||
if (ctx) { const img = ctx.createImageData(res.width, res.height); img.data.set(res.rgba); ctx.putImageData(img, 0, 0); }
|
if (ctx) {
|
||||||
|
const offscreen = document.createElement('canvas');
|
||||||
|
offscreen.width = res.width;
|
||||||
|
offscreen.height = res.height;
|
||||||
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
if (offCtx) {
|
||||||
|
const imgData = offCtx.createImageData(res.width, res.height);
|
||||||
|
imgData.data.set(res.rgba);
|
||||||
|
offCtx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
setCellPreviewReady(true);
|
setCellPreviewReady(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -702,11 +704,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
const idx = caretIdxRef.current;
|
const end = e.currentTarget.value.length;
|
||||||
if (idx != null) {
|
e.currentTarget.setSelectionRange(end, end);
|
||||||
e.currentTarget.setSelectionRange(idx, idx);
|
|
||||||
caretIdxRef.current = null;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -718,8 +717,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
left: box.left,
|
left: box.left,
|
||||||
top: inputTop,
|
top: inputTop,
|
||||||
width: Math.max(box.width + 120, 60),
|
width: box.width,
|
||||||
height: fpx,
|
height: box.height,
|
||||||
lineHeight: `${fpx}px`,
|
lineHeight: `${fpx}px`,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize: `${fpx}px`,
|
fontSize: `${fpx}px`,
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['pdf-dev.maskantech.in'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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=8765
|
||||||
|
|
||||||
|
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 8765
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8765"]
|
||||||
+4
-4
@@ -59,7 +59,7 @@ Run the service:
|
|||||||
|
|
||||||
Using the startup script (Windows):
|
Using the startup script (Windows):
|
||||||
```powershell
|
```powershell
|
||||||
# Default port (8000)
|
# Default port (8765)
|
||||||
powershell -File scripts/start_gateway.ps1
|
powershell -File scripts/start_gateway.ps1
|
||||||
|
|
||||||
# Custom port (e.g. 8080)
|
# Custom port (e.g. 8080)
|
||||||
@@ -68,7 +68,7 @@ powershell -File scripts/start_gateway.ps1 -Port 8080
|
|||||||
|
|
||||||
Or run directly via uvicorn (cross-platform):
|
Or run directly via uvicorn (cross-platform):
|
||||||
```sh
|
```sh
|
||||||
# Default port (8000)
|
# Default port (8765)
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload
|
||||||
|
|
||||||
# Custom port (e.g. 8080)
|
# Custom port (e.g. 8080)
|
||||||
@@ -100,8 +100,8 @@ Additionally, the gateway startup scripts and frontend configuration support:
|
|||||||
|
|
||||||
| Var | Default | Meaning |
|
| Var | Default | Meaning |
|
||||||
|--------------------|-------------------------|--------------------------------------------------|
|
|--------------------|-------------------------|--------------------------------------------------|
|
||||||
| `PORT` | `8000` | Gateway listening port (used by `start_gateway.ps1`). |
|
| `PORT` | `8765` | Gateway listening port (used by `start_gateway.ps1`). |
|
||||||
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8000` | URL of the gateway API (used by the frontend). |
|
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8765` | URL of the gateway API (used by the frontend). |
|
||||||
|
|
||||||
A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored
|
A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored
|
||||||
via the repo-wide `.venv/` and Python rules — add `.env` to your local
|
via the repo-wide `.venv/` and Python rules — add `.env` to your local
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app import __version__
|
from app import __version__
|
||||||
from app.routers import documents, edits, health, info, render
|
from app.routers import documents, edits, health, info, internal, render
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -29,6 +29,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(render.compat_router)
|
app.include_router(render.compat_router)
|
||||||
app.include_router(edits.router)
|
app.include_router(edits.router)
|
||||||
app.include_router(edits.compat_router)
|
app.include_router(edits.compat_router)
|
||||||
|
app.include_router(internal.router)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
|
|||||||
@@ -111,7 +111,9 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
|||||||
content=a.content,
|
content=a.content,
|
||||||
timestamp=getattr(a, "timestamp", None),
|
timestamp=getattr(a, "timestamp", None),
|
||||||
pageIndex=a.page_index,
|
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", [])],
|
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),
|
fieldName=getattr(a, "field_name", None),
|
||||||
fieldValue=getattr(a, "field_value", None),
|
fieldValue=getattr(a, "field_value", None),
|
||||||
fieldType=getattr(a, "field_type", None),
|
fieldType=getattr(a, "field_type", None),
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import re
|
||||||
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.schemas.document import (
|
from app.schemas.document import (
|
||||||
DocumentInfoResponse,
|
DocumentInfoResponse,
|
||||||
@@ -10,6 +14,83 @@ from app.services.store import document_store
|
|||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteImportRequest(BaseModel):
|
||||||
|
stream_url: str
|
||||||
|
upload_url: str
|
||||||
|
auth_token: str | None = None
|
||||||
|
resource_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-remote", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def import_remote_document(req: RemoteImportRequest) -> DocumentInfoResponse:
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Engine bridge (bindings/python) not yet available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_parsed = urlparse(req.stream_url)
|
||||||
|
upload_parsed = urlparse(req.upload_url)
|
||||||
|
if stream_parsed.scheme not in ("http", "https") or upload_parsed.scheme not in ("http", "https"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid URL scheme. Only http/https are allowed.")
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if req.auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {req.auth_token}"
|
||||||
|
|
||||||
|
MAX_SIZE = 50 * 1024 * 1024 # 50 MB limit
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||||
|
try:
|
||||||
|
async with client.stream("GET", req.stream_url, headers=headers) as res:
|
||||||
|
if res.status_code != 200:
|
||||||
|
await res.aread()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=res.status_code,
|
||||||
|
detail=f"Failed to stream remote document: {res.text[:200]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
content_length = res.headers.get("Content-Length")
|
||||||
|
if content_length and int(content_length) > MAX_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
|
||||||
|
|
||||||
|
bytes_data_arr = bytearray()
|
||||||
|
async for chunk in res.aiter_bytes():
|
||||||
|
bytes_data_arr.extend(chunk)
|
||||||
|
if len(bytes_data_arr) > MAX_SIZE:
|
||||||
|
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
|
||||||
|
|
||||||
|
bytes_data = bytes(bytes_data_arr)
|
||||||
|
except Exception as err:
|
||||||
|
if isinstance(err, HTTPException):
|
||||||
|
raise err
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to connect to remote stream_url: {err!s}",
|
||||||
|
)
|
||||||
|
|
||||||
|
cd = res.headers.get("Content-Disposition", "")
|
||||||
|
match = re.search(r'filename="?([^";]+)"?', cd)
|
||||||
|
filename = match.group(1) if match else f"remote_document_{req.resource_id or 'file'}.pdf"
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdfengine = engine.require()
|
||||||
|
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, "")
|
||||||
|
info = document_store.add_document(filename, bytes_data, doc)
|
||||||
|
info["remote_context"] = {
|
||||||
|
"stream_url": req.stream_url,
|
||||||
|
"upload_url": req.upload_url,
|
||||||
|
"auth_token": req.auth_token,
|
||||||
|
"resource_id": req.resource_id,
|
||||||
|
}
|
||||||
|
return make_document_response(info)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to load remote PDF into engine: {e!s}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_document_response(d: dict) -> DocumentInfoResponse:
|
def make_document_response(d: dict) -> DocumentInfoResponse:
|
||||||
pages_list = []
|
pages_list = []
|
||||||
if "doc_instance" in d:
|
if "doc_instance" in d:
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
|
|
||||||
router = APIRouter(tags=["documents"])
|
router = APIRouter(tags=["documents"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportRemoteRequest(BaseModel):
|
||||||
|
fresh_token: str | None = None
|
||||||
|
|
||||||
@router.get("/{document_id}/export")
|
@router.get("/{document_id}/export")
|
||||||
def export_document(document_id: str):
|
def export_document(document_id: str):
|
||||||
@@ -41,3 +50,83 @@ def export_document(document_id: str):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{document_id}/export-remote")
|
||||||
|
async def export_remote_document(document_id: str, body: ExportRemoteRequest | None = None):
|
||||||
|
if not engine.is_available():
|
||||||
|
raise HTTPException(status_code=501, detail="Engine bridge not available.")
|
||||||
|
|
||||||
|
d = document_store.get_document(document_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|
||||||
|
perms = d.get("permissions") or {}
|
||||||
|
if perms.get("canCopy", True) is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Exporting is not permitted by this document's restrictions (canCopy).",
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = d.get("remote_context")
|
||||||
|
if not ctx or not ctx.get("upload_url"):
|
||||||
|
print(f"[export-remote] FAILING with 400: ctx={ctx}", flush=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Document does not have a registered remote upload context.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = d["doc_instance"]
|
||||||
|
bytes_data = doc.save_full_for_export()
|
||||||
|
filename = d["filename"]
|
||||||
|
if not filename.endswith(".pdf"):
|
||||||
|
filename += ".pdf"
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
# Prefer fresh_token sent at save-time over the potentially-stale stored token
|
||||||
|
token_val = (body.fresh_token if body and body.fresh_token else None) or ctx.get("auth_token")
|
||||||
|
if token_val:
|
||||||
|
headers["Authorization"] = f"Bearer {token_val}"
|
||||||
|
csrf_token = str(uuid.uuid4())
|
||||||
|
headers["Cookie"] = f"csrf_token={csrf_token}"
|
||||||
|
headers["X-CSRF-Token"] = csrf_token
|
||||||
|
|
||||||
|
# Resolve folder_id from remote context or default to 0
|
||||||
|
data = {"folder_id": str(ctx.get("folder_id", "0"))}
|
||||||
|
if ctx.get("resource_id"):
|
||||||
|
data["file_id"] = str(ctx["resource_id"])
|
||||||
|
|
||||||
|
upload_url = ctx["upload_url"]
|
||||||
|
print(f"[export-remote] POST {upload_url} | file_id={data.get('file_id')} | filename={filename} | size={len(bytes_data)}", flush=True)
|
||||||
|
|
||||||
|
files = {"upload": (filename, bytes_data, "application/pdf")}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
||||||
|
res = await client.post(upload_url, headers=headers, data=data, files=files)
|
||||||
|
|
||||||
|
print(f"[export-remote] Response status={res.status_code} body={res.text[:500]}", flush=True)
|
||||||
|
|
||||||
|
if res.status_code not in (200, 201):
|
||||||
|
logger.error("[export-remote] upstream %s: %s", res.status_code, res.text[:500])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Remote upload failed (upstream status {res.status_code}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_response = res.json() if res.content else {}
|
||||||
|
except ValueError:
|
||||||
|
remote_response = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Successfully exported updated document to remote host!",
|
||||||
|
"remoteResponse": remote_response,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
raise e
|
||||||
|
logger.error(f"[export-remote] Unexpected error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Remote export failed.")
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ def replace_text_object(document_id: str, page_index: int, object_index: int, re
|
|||||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||||
permissions=doc_info.get("permissions"),
|
permissions=doc_info.get("permissions"),
|
||||||
)
|
)
|
||||||
|
if "remote_context" in doc_info:
|
||||||
|
new_info["remote_context"] = doc_info["remote_context"]
|
||||||
return {"success": True, "newDocumentId": new_info["id"]}
|
return {"success": True, "newDocumentId": new_info["id"]}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
|
|||||||
fontFamily: str
|
fontFamily: str
|
||||||
color: 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):
|
class RedactionData(BaseModel):
|
||||||
x: float
|
x: float
|
||||||
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
|
|||||||
pageIndex: int = Field(..., ge=0)
|
pageIndex: int = Field(..., ge=0)
|
||||||
data: TextOverlayData
|
data: TextOverlayData
|
||||||
|
|
||||||
|
class StampOperation(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: Literal["stamp"]
|
||||||
|
pageIndex: int = Field(..., ge=0)
|
||||||
|
data: StampData
|
||||||
|
|
||||||
|
|
||||||
class RedactionOperation(BaseModel):
|
class RedactionOperation(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -230,6 +249,7 @@ class ReflowRun(BaseModel):
|
|||||||
fontSize: float
|
fontSize: float
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
advances: list[float] | None = None
|
advances: list[float] | None = None
|
||||||
|
advanceSeedText: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ReflowParagraphData(BaseModel):
|
class ReflowParagraphData(BaseModel):
|
||||||
@@ -283,8 +303,23 @@ class SquigglyOperation(BaseModel):
|
|||||||
pageIndex: int = Field(..., ge=0)
|
pageIndex: int = Field(..., ge=0)
|
||||||
data: DecorationData
|
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[
|
EditOperation = Annotated[
|
||||||
TextOverlayOperation
|
TextOverlayOperation
|
||||||
|
| StampOperation
|
||||||
| RedactionOperation
|
| RedactionOperation
|
||||||
| ImageOverlayOperation
|
| ImageOverlayOperation
|
||||||
| HighlightOperation
|
| HighlightOperation
|
||||||
@@ -301,7 +336,9 @@ EditOperation = Annotated[
|
|||||||
| ReflowParagraphOperation
|
| ReflowParagraphOperation
|
||||||
| UnderlineOperation
|
| UnderlineOperation
|
||||||
| StrikeoutOperation
|
| StrikeoutOperation
|
||||||
| SquigglyOperation,
|
| SquigglyOperation
|
||||||
|
| SignatureOperation
|
||||||
|
,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -313,8 +350,8 @@ class EditsRequest(BaseModel):
|
|||||||
_OP_PERMISSION = {
|
_OP_PERMISSION = {
|
||||||
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
|
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
|
||||||
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
|
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
|
||||||
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
|
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
|
||||||
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
"image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
||||||
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
|
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
|
||||||
"update_field": "canFillForms",
|
"update_field": "canFillForms",
|
||||||
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
|
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
|
||||||
@@ -361,6 +398,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
if "," in img_data_str:
|
if "," in img_data_str:
|
||||||
img_data_str = img_data_str.split(",", 1)[1]
|
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)
|
raw_bytes = base64.b64decode(img_data_str)
|
||||||
img = Image.open(io.BytesIO(raw_bytes))
|
img = Image.open(io.BytesIO(raw_bytes))
|
||||||
img_rgba = img.convert("RGBA")
|
img_rgba = img.convert("RGBA")
|
||||||
@@ -371,6 +409,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
bgra_bytes = img_bgra.tobytes()
|
bgra_bytes = img_bgra.tobytes()
|
||||||
|
|
||||||
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
||||||
|
temp_path = temp_path.replace("\\", "/")
|
||||||
created_temp_files.append(temp_path)
|
created_temp_files.append(temp_path)
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "wb") as tmp:
|
with os.fdopen(fd, "wb") as tmp:
|
||||||
@@ -388,7 +427,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
|
|
||||||
edits_json = json.dumps(req_dict)
|
edits_json = json.dumps(req_dict)
|
||||||
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
|
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"}
|
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", []))
|
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
|
||||||
@@ -400,8 +439,13 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
|||||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||||
permissions=doc_info.get("permissions"),
|
permissions=doc_info.get("permissions"),
|
||||||
)
|
)
|
||||||
|
if "remote_context" in doc_info:
|
||||||
|
new_info["remote_context"] = doc_info["remote_context"]
|
||||||
|
|
||||||
return {"success": True, "newDocumentId": new_info["id"]}
|
from app.services.render_cache import tile_cache
|
||||||
|
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
|
||||||
|
|
||||||
|
return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
finally:
|
finally:
|
||||||
@@ -419,6 +463,155 @@ def apply_edits(document_id: str, request: EditsRequest):
|
|||||||
return apply_edits_impl(document_id, request)
|
return apply_edits_impl(document_id, request)
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewEditsBody(EditsRequest):
|
||||||
|
"""Same ops as apply_edits, plus preview render parameters. Not persisted."""
|
||||||
|
pageIndex: int = Field(0, ge=0)
|
||||||
|
dpi: int = Field(144, ge=36, le=600)
|
||||||
|
yTopPt: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview")
|
||||||
|
def preview_edits(document_id: str, request: PreviewEditsBody):
|
||||||
|
"""Apply edits on a throwaway copy and return a page-region PNG.
|
||||||
|
|
||||||
|
Live typing must preview through this path (same engine as save), not the
|
||||||
|
stale browser WASM — otherwise font/width jump on keystroke then snap back on save.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
perms = doc_info.get("permissions") or {}
|
||||||
|
for op in request.operations:
|
||||||
|
required = _OP_PERMISSION.get(op.type)
|
||||||
|
if required and perms.get(required, True) is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Operation '{op.type}' is not permitted ({required}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdfengine = engine.require()
|
||||||
|
req_dict = request.model_dump(exclude_none=True)
|
||||||
|
# Strip preview-only fields before apply_edits JSON
|
||||||
|
page_index = int(req_dict.pop("pageIndex", 0))
|
||||||
|
dpi = int(req_dict.pop("dpi", 144))
|
||||||
|
y_top = float(req_dict.pop("yTopPt", 0.0))
|
||||||
|
edits_json = json.dumps(req_dict)
|
||||||
|
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
|
||||||
|
doc_copy.apply_edits(edits_json)
|
||||||
|
page = doc_copy.get_page(page_index)
|
||||||
|
# Binding returns (width, height, rgba_bytes) — not an Image-like object.
|
||||||
|
w, h, raw = page.render_region_raw(dpi, y_top, 0.0)
|
||||||
|
raw = bytes(raw)
|
||||||
|
w, h = int(w), int(h)
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
png_buf = io.BytesIO()
|
||||||
|
Image.frombytes("RGBA", (w, h), raw).save(png_buf, format="PNG")
|
||||||
|
b64 = base64.b64encode(png_buf.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
layout_obj = None
|
||||||
|
layout_source = "extract"
|
||||||
|
try:
|
||||||
|
# Prefer engine's reflow layout (matches WASM STAGE_5) when binding exposes it.
|
||||||
|
if hasattr(doc_copy, "last_reflow_layout"):
|
||||||
|
raw_lay = doc_copy.last_reflow_layout()
|
||||||
|
if raw_lay:
|
||||||
|
layout_obj = json.loads(raw_lay)
|
||||||
|
layout_source = "reflow"
|
||||||
|
except Exception:
|
||||||
|
layout_obj = None
|
||||||
|
layout_source = "extract"
|
||||||
|
|
||||||
|
if not layout_obj:
|
||||||
|
# Fallback: extract page model and keep only lines in the edited paragraph band.
|
||||||
|
reflow_op = next((op for op in request.operations if op.type == "reflow_paragraph"), None)
|
||||||
|
col_l = float(reflow_op.data.columnLeft) if reflow_op else None
|
||||||
|
col_r = float(reflow_op.data.columnRight) if reflow_op else None
|
||||||
|
base0 = float(reflow_op.data.firstBaselineY) if reflow_op else None
|
||||||
|
leading = float(reflow_op.data.leading) if reflow_op else 14.0
|
||||||
|
old_n = int(reflow_op.data.oldLineCount) if reflow_op else 1
|
||||||
|
y_lo = (base0 - leading * 0.75) if base0 is not None else None
|
||||||
|
y_hi = (base0 + leading * max(old_n + 48, 64)) if base0 is not None else None
|
||||||
|
|
||||||
|
layout_lines = []
|
||||||
|
try:
|
||||||
|
model = page.extract_document_model()
|
||||||
|
for para in model.paragraphs:
|
||||||
|
for ln in para.lines:
|
||||||
|
text = "".join(r.text or "" for r in ln.runs)
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
# Flatten glyphs across runs, then advances from origin deltas
|
||||||
|
# (per-run last-glyph bbox_w under-advances and lags the caret).
|
||||||
|
glyphs = []
|
||||||
|
for r in ln.runs:
|
||||||
|
glyphs.extend(list(r.glyphs))
|
||||||
|
if not glyphs:
|
||||||
|
continue
|
||||||
|
adv: list[float] = []
|
||||||
|
for i, g in enumerate(glyphs):
|
||||||
|
if i + 1 < len(glyphs):
|
||||||
|
adv.append(float(glyphs[i + 1].origin_x - g.origin_x))
|
||||||
|
else:
|
||||||
|
bw = float(g.bbox_w) if g.bbox_w > 0 else 0.0
|
||||||
|
adv.append(bw if bw > 0 else float(max((r.font_size or 0) for r in ln.runs) or 12) * 0.5)
|
||||||
|
x0 = float(glyphs[0].origin_x)
|
||||||
|
by = float(ln.baseline_y)
|
||||||
|
if col_l is not None and x0 < col_l - 24:
|
||||||
|
continue
|
||||||
|
if col_r is not None and x0 > col_r + 8:
|
||||||
|
continue
|
||||||
|
if y_lo is not None and by < y_lo:
|
||||||
|
continue
|
||||||
|
if y_hi is not None and by > y_hi:
|
||||||
|
continue
|
||||||
|
layout_lines.append({
|
||||||
|
"baselineY": by,
|
||||||
|
"x0": x0,
|
||||||
|
"fontSize": float(max((r.font_size or 0) for r in ln.runs) or 12),
|
||||||
|
"text": text,
|
||||||
|
"adv": adv,
|
||||||
|
"pageIndex": page_index,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
layout_lines = []
|
||||||
|
|
||||||
|
layout_lines.sort(key=lambda L: -L["baselineY"])
|
||||||
|
layout_obj = {
|
||||||
|
"columnLeft": layout_lines[0]["x0"] if layout_lines else (col_l or 0),
|
||||||
|
"anchorPage": page_index,
|
||||||
|
"lines": layout_lines,
|
||||||
|
}
|
||||||
|
layout_source = "extract"
|
||||||
|
|
||||||
|
if isinstance(layout_obj, dict):
|
||||||
|
layout_obj["layoutSource"] = layout_source
|
||||||
|
|
||||||
|
return {
|
||||||
|
"width": w,
|
||||||
|
"height": h,
|
||||||
|
"yTopPt": y_top,
|
||||||
|
"pageIndex": page_index,
|
||||||
|
"pngBase64": b64,
|
||||||
|
"layout": layout_obj,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
@compat_router.post("/edits/{document_id}")
|
@compat_router.post("/edits/{document_id}")
|
||||||
def apply_edits_compat(document_id: str, request: EditsRequest):
|
def apply_edits_compat(document_id: str, request: EditsRequest):
|
||||||
return apply_edits_impl(document_id, request)
|
return apply_edits_impl(document_id, request)
|
||||||
|
|||||||
@@ -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,106 @@
|
|||||||
|
import hashlib
|
||||||
from typing import Annotated
|
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.schemas.font import FontInfoResponse
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.font import font_info_to_response
|
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
|
from app.services.store import document_store
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||||
compat_router = APIRouter(tags=["render"])
|
compat_router = APIRouter(tags=["render"])
|
||||||
|
|
||||||
|
def _etag(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}/render")
|
@router.get("/{page_index}/render")
|
||||||
def render_page(
|
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:
|
||||||
|
print(f"[RENDER] document_id={document_id} page_index={page_index} zoom={zoom} dpi={dpi}")
|
||||||
|
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=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)
|
||||||
|
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:
|
) -> Response:
|
||||||
if not engine.is_available():
|
if not engine.is_available():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -28,8 +115,52 @@ def render_page(
|
|||||||
try:
|
try:
|
||||||
doc = doc_info["doc_instance"]
|
doc = doc_info["doc_instance"]
|
||||||
page = doc.get_page(page_index)
|
page = doc.get_page(page_index)
|
||||||
img = page.render(dpi)
|
|
||||||
return Response(content=img.data, media_type="image/png")
|
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:
|
except IndexError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds"
|
||||||
@@ -72,10 +203,12 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
|||||||
|
|
||||||
@compat_router.get("/render/{document_id}")
|
@compat_router.get("/render/{document_id}")
|
||||||
def render_page_compat(
|
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, dpi: int | None = None
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
if dpi is None:
|
||||||
dpi = int(96 * zoom)
|
dpi = int(96 * zoom)
|
||||||
return render_page(document_id, page, dpi)
|
print(f"[RENDER_COMPAT] document_id={document_id} page={page} zoom={zoom} dpi={dpi}")
|
||||||
|
return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{page_index}")
|
@router.get("/{page_index}")
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ class AnnotationResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
timestamp: str | None = None
|
timestamp: str | None = None
|
||||||
pageIndex: int
|
pageIndex: int
|
||||||
|
thickness: float | None = None
|
||||||
paths: list[list[dict[str, float]]] = []
|
paths: list[list[dict[str, float]]] = []
|
||||||
|
quadPoints: list[list[dict[str, float]]] = []
|
||||||
|
|
||||||
fieldName: str | None = None
|
fieldName: str | None = None
|
||||||
fieldValue: 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 threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -57,6 +58,7 @@ class DocumentStore:
|
|||||||
info = {
|
info = {
|
||||||
"id": doc_id,
|
"id": doc_id,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
|
"doc_hash": hashlib.sha256(bytes_data).hexdigest(),
|
||||||
"sizeBytes": len(bytes_data),
|
"sizeBytes": len(bytes_data),
|
||||||
"totalPages": doc_instance.page_count,
|
"totalPages": doc_instance.page_count,
|
||||||
"pageWidth": page_width,
|
"pageWidth": page_width,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"pydantic-settings==2.7.1",
|
"pydantic-settings==2.7.1",
|
||||||
"python-multipart==0.0.19",
|
"python-multipart==0.0.19",
|
||||||
"pillow==10.4.0",
|
"pillow==10.4.0",
|
||||||
|
"httpx==0.28.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import urllib.request
|
|||||||
|
|
||||||
sys.path.insert(0, "gateway")
|
sys.path.insert(0, "gateway")
|
||||||
|
|
||||||
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||||
|
|
||||||
|
|
||||||
def http_get(url):
|
def http_get(url):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||||
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
|
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
|
||||||
|
|
||||||
TARGET_PDFS = [
|
TARGET_PDFS = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
================================================================================
|
================================================================================
|
||||||
FONT EXTRACTION API VALIDATION
|
FONT EXTRACTION API VALIDATION
|
||||||
Server: http://localhost:8000
|
Server: http://localhost:8765
|
||||||
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
|
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
|
||||||
Timestamp: 2026-06-02 12:36:47
|
Timestamp: 2026-06-02 12:36:47
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -10,7 +10,7 @@ if ($Port -le 0) {
|
|||||||
if ($envPort) {
|
if ($envPort) {
|
||||||
$Port = $envPort
|
$Port = $envPort
|
||||||
} else {
|
} else {
|
||||||
$Port = 8000
|
$Port = 8765
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%¡³Å×
|
||||||
|
1 0 obj
|
||||||
|
<</Pages 2 0 R /Type/Catalog>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<</BM/Normal/CA 1/ca 1>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<</Filter/FlateDecode/Length 237>>stream
|
||||||
|
xœÅ“=kÃ0EwýŠ;6Cä+É–-0†|Ø…@! ¡Sj')mMœ!¿¤…:¨*Â[îô÷<ÞI×Yßã3{(\@,@<‹¤ÙÔ
|
||||||
|
û3’å¼YNÖ”er|ºëú¡};wã®víørè_ÚQUa:Ÿ‰Ó÷ª¯åSõ‘”ÑÒºÌ9‡\þI³i”†ï@øÛ2O«Gøj�•¨fâùÚþ�_DäSR§¤ðm�¯®þ5©ü,¢ZIM2½Íý�“&-nãÞ™_ع‰Æ.¬´¹%M¨ºŽ‡§$�sÁöñÌç¡oË~Z_‰w‘UAÆ
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
[ 8 0 R ]
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000017 00000 n
|
||||||
|
0000000066 00000 n
|
||||||
|
0000000122 00000 n
|
||||||
|
6 4
|
||||||
|
0000000269 00000 n
|
||||||
|
0000000312 00000 n
|
||||||
|
0000000403 00000 n
|
||||||
|
0000000712 00000 n
|
||||||
|
trailer
|
||||||
|
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||||
|
startxref
|
||||||
|
740
|
||||||
|
%%EOF
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%¡³Å×
|
||||||
|
1 0 obj
|
||||||
|
<</Pages 2 0 R /Type/Catalog>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<</BM/Normal/CA 1/ca 1>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<</Filter/FlateDecode/Length 169>>stream
|
||||||
|
xœŽK‚@E÷ó+î²æ|ãc
|
||||||
|
Dð5�¨ÌBˆV¦fT¢.üûQA-‚Vq7guîÇcÅ/[fp¤à83S• ¡�`f±Ê‚"€ç™ÝqÑôc}›ºÊhú±ª�ùÔ_ê¥ï#Œ#6|Toy¨AO&‰•»œ¤àÐW˜ªTÐ
8ôˆ½çØ®#×ÒöÐ)�œ%»ˆý¯BŠ_ï–t\!-×ù*ÈÙ,·J
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
[ 8 0 R ]
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000017 00000 n
|
||||||
|
0000000066 00000 n
|
||||||
|
0000000122 00000 n
|
||||||
|
6 4
|
||||||
|
0000000269 00000 n
|
||||||
|
0000000312 00000 n
|
||||||
|
0000000403 00000 n
|
||||||
|
0000000644 00000 n
|
||||||
|
trailer
|
||||||
|
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||||
|
startxref
|
||||||
|
672
|
||||||
|
%%EOF
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%¡³Å×
|
||||||
|
1 0 obj
|
||||||
|
<</Pages 2 0 R /Type/Catalog>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<</Filter/FlateDecode/Length 832>>stream
|
||||||
|
xœÅšMkTQ†÷ó+ÎR¦ù:9 ”B?fAP™EA\ˆ_(¨Xþ}¹Ó:-Âäº0séfV÷áMrNòžôÇ
|
||||||
|
Ûô÷êi»ýqó©QûÕ°=kؾ¬N6×kjŸ~¶“W›ç¯ÎÛééÉç÷�>~¿ùðíççwOÞ
|
||||||
|
Žw6äñÙY»¸º\ý¸ÿÎþËÛF»ßÔÈ",¢Ùà¶ýÚN6×jDmû±aÛÞ´×§¼>{Ó¶ÏÚzÛ^®ÖÏ/Wÿ�-àN ÙFyÖ3r•æÑAX2rT‘ Ìðà"°u誈)«à8—"x'PïœÁ«ò1¯Ü.«àÄ1b,QpÀ¦‘^jFUpÒ@Ìrn½Î1úLΫ®6&è"ˆé½ZUíÄ =ÓvR¥
X9"
û¦
|
||||||
|
Ž@l3Õ^$š)ö¢¨‚¨/p¿¸/sÂ�€†ä•6´†=ˆz~§›±Ó`Ýå,ê< v;rûfA³”]u²˜¸cølr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3�GÏ7¹
ÌÙUsqξ¨bh,sûéüøÅf´ŸÎë¬A×½=|Ä«àê 숩ò*k 1EÝ›÷Fø°ò*k Îùµ^•s`wvðø9ß92ž9ç^åÈtèLØ‹FU"‚Ñe¡œc“%ytfDY ØB@±ç¯jhÙavÑ[òWtôf¤‚‹ÎÃdA£
Äc»¢>€çØçUlb OØeNTÌ)g½å²t9»Ê‘‰€ÄŒî¢¶sà’绪1Ë�3IØEí‹iÜ9“òMt·ŽLt
©Œ}·ŽLTWùл¥`¢¹jCŒ##W-æœUò*»ª[Â�¼ÊªfòÉ÷OV(Ó]ÔÁÈôV·f«ü@wp¢ééz�Öû?�ÀËܯAwœÆÓ^õÖ#¾3#I«þÉBø�Y áìàÓ>p‰JÌ0n÷�‰ò*8)�ëL©W½jâ4§ÎÁ«šÙý6�W½2ݯa�ßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚0ò?¦¦ÝtÕ>]§z©¡k}Ñ—«ßVn$
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<</BM/Normal/CA 1/ca 1>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 5
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000017 00000 n
|
||||||
|
0000000066 00000 n
|
||||||
|
0000000122 00000 n
|
||||||
|
0000000269 00000 n
|
||||||
|
6 2
|
||||||
|
0000001173 00000 n
|
||||||
|
0000001216 00000 n
|
||||||
|
trailer
|
||||||
|
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||||
|
startxref
|
||||||
|
1307
|
||||||
|
%%EOF
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%¡³Å×
|
||||||
|
1 0 obj
|
||||||
|
<</Pages 2 0 R /Type/Catalog>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<</Filter/FlateDecode/Length 417>>stream
|
||||||
|
xœµ”;k\A…ûû+T&EfGéhËÂ>îÛÜÂ`R¿p ^ùûacHRÄCŠÓ¨ÒùF::OS¦Ó»|O/Åñ�˜¾S¦3ÊôyZµ«™éá™Vç‡v¾½ÜÒz½z¼}sÿíx÷õùñæÝ§’ý^Þn6´;ì§§ß}~uÞ-Ä?k&©H�p*hùB«vÕ˜˜i¹§LË‘®×^<» 0ÃeÞ|¤åŒæ….¦ùÃ~ú_ɳw8``ì=1hMÌEK�!0h5™¤§Î€+Ì3Ú0 1PÐ[‡7̘Gƒ
)B¬ÔîD‰+'Ï9jôÄÇ-€5y°s÷ïÆŒÝ„È©¨ÃºÉ ÇÉ�c¼w®¨°AQ5‰d
òœ_ÿüHƒ©2‹õ
>.Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬v�h£lÀ"É‚.€¸ ¹�JäД9Wéz‘Ý(£"Ù¥#n;„ÈWÖW*´i3U³Sî5tÖ?.¦Ïê}=
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<</BM/Normal/CA 1/ca 1>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 5
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000017 00000 n
|
||||||
|
0000000066 00000 n
|
||||||
|
0000000122 00000 n
|
||||||
|
0000000269 00000 n
|
||||||
|
6 2
|
||||||
|
0000000758 00000 n
|
||||||
|
0000000801 00000 n
|
||||||
|
trailer
|
||||||
|
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||||
|
startxref
|
||||||
|
892
|
||||||
|
%%EOF
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user