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(spdlog CONFIG REQUIRED)
|
||||
find_package(nlohmann_json CONFIG REQUIRED)
|
||||
find_package(qpdf CONFIG REQUIRED)
|
||||
if(PDFENGINE_WITH_QPDF)
|
||||
find_package(qpdf CONFIG REQUIRED)
|
||||
endif()
|
||||
if(WIN32 AND DEFINED VCPKG_TARGET_TRIPLET)
|
||||
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/lib")
|
||||
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/debug/lib")
|
||||
|
||||
@@ -6,14 +6,21 @@
|
||||
"name": "win-local",
|
||||
"displayName": "Windows • Debug (local — build dir outside OneDrive/spaces)",
|
||||
"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",
|
||||
"displayName": "Windows • RelWithDebInfo + PDFium (static CRT — build dir outside OneDrive/spaces)",
|
||||
"inherits": "windows-release",
|
||||
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local-pdfium",
|
||||
"cacheVariables": { "PDFENGINE_WITH_PDFIUM": "ON" }
|
||||
"cacheVariables": {
|
||||
"PDFENGINE_WITH_PDFIUM": "ON",
|
||||
"PDFENGINE_WITH_QPDF": "ON"
|
||||
}
|
||||
}
|
||||
],
|
||||
"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
|
||||
# target collision with the static C++ library pdfengine, but we set the
|
||||
# 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
|
||||
OUTPUT_NAME "pdfengine"
|
||||
|
||||
@@ -341,12 +341,27 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
||||
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
|
||||
.def_readonly("thickness", &pdfengine::PdfPage::AnnotationInfo::thickness)
|
||||
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
|
||||
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
|
||||
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
|
||||
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
|
||||
.def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags)
|
||||
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions);
|
||||
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions)
|
||||
.def_property_readonly("quad_points", [](const pdfengine::PdfPage::AnnotationInfo& self) {
|
||||
py::list out;
|
||||
for (const auto& quad : self.quadPoints) {
|
||||
py::list quad_list;
|
||||
for (const auto& pt : quad) {
|
||||
py::dict d;
|
||||
d["x"] = pt.x;
|
||||
d["y"] = pt.y;
|
||||
quad_list.append(d);
|
||||
}
|
||||
out.append(quad_list);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||
@@ -359,6 +374,11 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
return py::make_tuple(img.width, img.height,
|
||||
py::bytes(reinterpret_cast<const char*>(img.data.data()), img.data.size()));
|
||||
}, py::arg("dpi"), py::arg("y_top_pt"), py::arg("height_pt") = 0.0)
|
||||
.def("render_tile", [](const pdfengine::PdfPage& self, int dpi, double xPt, double yPt, double wPt, double hPt) {
|
||||
auto img = get_or_throw(self.renderTile(dpi, xPt, yPt, wPt, hPt));
|
||||
return py::make_tuple(img.width, img.height,
|
||||
py::bytes(reinterpret_cast<const char*>(img.data.data()), img.data.size()));
|
||||
}, py::arg("dpi"), py::arg("xPt"), py::arg("yPt"), py::arg("wPt"), py::arg("hPt"))
|
||||
.def("extract_document_model", [](const pdfengine::PdfPage& self) {
|
||||
return get_or_throw(self.extractDocumentModel());
|
||||
})
|
||||
@@ -483,8 +503,22 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
|
||||
}, py::arg("internal_font_id"))
|
||||
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
|
||||
get_or_throw(self.applyEdits(editsJson));
|
||||
auto regions = get_or_throw(self.applyEdits(editsJson));
|
||||
py::list py_regions;
|
||||
for (const auto& r : regions) {
|
||||
py::dict d;
|
||||
d["pageIndex"] = r.pageIndex;
|
||||
d["x"] = r.x;
|
||||
d["y"] = r.y;
|
||||
d["width"] = r.width;
|
||||
d["height"] = r.height;
|
||||
py_regions.append(d);
|
||||
}
|
||||
return py_regions;
|
||||
}, py::arg("edits_json"))
|
||||
.def("last_reflow_layout", [](const pdfengine::PdfDocument& self) {
|
||||
return self.lastReflowLayout();
|
||||
})
|
||||
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
|
||||
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
|
||||
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)
|
||||
|
||||
add_library(pdfengine STATIC
|
||||
add_library(pdfengine OBJECT
|
||||
src/core/engine_info.cpp
|
||||
src/core/graphics_state.cpp
|
||||
src/core/display_list.cpp
|
||||
src/core/path_interpreter.cpp
|
||||
src/core/skia_renderer.cpp
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/pdfium_internal.cpp
|
||||
src/parser/pdfium_reflow.cpp
|
||||
src/parser/pdfium_page.cpp
|
||||
src/parser/pdfium_page_model.cpp
|
||||
src/parser/pdfium_fonts.cpp
|
||||
src/parser/pdfium_edit.cpp
|
||||
src/parser/pdfium_edit_replace.cpp
|
||||
src/parser/pdfium_edit_reflow.cpp
|
||||
src/parser/pdfium_edit_annotations.cpp
|
||||
src/parser/pdfium_edit_pages.cpp
|
||||
src/parser/pdfium_edit_images.cpp
|
||||
src/parser/content_stream_parser.cpp
|
||||
src/parser/decoration_builder.cpp
|
||||
src/text/selection.cpp
|
||||
src/text/text_layout_engine.cpp
|
||||
src/fonts/face/font_face.cpp
|
||||
src/fonts/face/free_type_manager.cpp
|
||||
src/fonts/loader/font_resolver.cpp
|
||||
src/fonts/pdf_fonts/font_loader.cpp
|
||||
src/fonts/shaping/hb_shaper.cpp
|
||||
src/fonts/cache/glyph_bitmap.cpp
|
||||
src/fonts/cache/glyph_cache.cpp
|
||||
@@ -46,8 +33,33 @@ add_library(pdfengine STATIC
|
||||
src/fonts/pdf_fonts/encoding/encoding.cpp
|
||||
src/fonts/pdf_fonts/encoding/tounicode_parser.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)
|
||||
set_target_properties(pdfengine PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
target_include_directories(pdfengine
|
||||
PUBLIC
|
||||
@@ -70,7 +82,10 @@ target_link_libraries(pdfengine
|
||||
|
||||
if(PDFENGINE_WITH_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()
|
||||
|
||||
if(EMSCRIPTEN)
|
||||
@@ -93,18 +108,6 @@ if(PDFENGINE_WITH_QPDF)
|
||||
if(NOT TARGET jpeg)
|
||||
add_library(jpeg ALIAS JPEG::JPEG)
|
||||
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_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_QPDF)
|
||||
endif()
|
||||
|
||||
@@ -32,34 +32,34 @@ struct SetTransformCommand : public Command {
|
||||
|
||||
struct FillRectCommand : public Command {
|
||||
float x, y, width, height;
|
||||
FillRectCommand(float x, float y, float w, float h) : x(x), y(y), width(w), height(h) {}
|
||||
FillRectCommand(float _x, float _y, float w, float h) : x(_x), y(_y), width(w), height(h) {}
|
||||
void accept(CommandVisitor& visitor) const override;
|
||||
};
|
||||
|
||||
struct DrawTextCommand : public Command {
|
||||
std::string text;
|
||||
float x, y;
|
||||
DrawTextCommand(std::string text, float x, float y) : text(std::move(text)), x(x), y(y) {}
|
||||
DrawTextCommand(std::string _text, float _x, float _y) : text(std::move(_text)), x(_x), y(_y) {}
|
||||
void accept(CommandVisitor& visitor) const override;
|
||||
};
|
||||
|
||||
struct FillPathCommand : public Command {
|
||||
Path path;
|
||||
FillRule rule;
|
||||
explicit FillPathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {}
|
||||
explicit FillPathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {}
|
||||
void accept(CommandVisitor& visitor) const override;
|
||||
};
|
||||
|
||||
struct StrokePathCommand : public Command {
|
||||
Path path;
|
||||
explicit StrokePathCommand(Path path) : path(std::move(path)) {}
|
||||
explicit StrokePathCommand(Path _path) : path(std::move(_path)) {}
|
||||
void accept(CommandVisitor& visitor) const override;
|
||||
};
|
||||
|
||||
struct FillStrokePathCommand : public Command {
|
||||
Path path;
|
||||
FillRule rule;
|
||||
explicit FillStrokePathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {}
|
||||
explicit FillStrokePathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {}
|
||||
void accept(CommandVisitor& visitor) const override;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Matrix() = default;
|
||||
Matrix(float a, float b, float c, float d, float e, float f)
|
||||
: a(a), b(b), c(c), d(d), e(e), f(f) {}
|
||||
Matrix(float _a, float _b, float _c, float _d, float _e, float _f)
|
||||
: a(_a), b(_b), c(_c), d(_d), e(_e), f(_f) {}
|
||||
|
||||
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#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 {
|
||||
|
||||
@@ -60,6 +70,14 @@ struct DevicePoint {
|
||||
int y;
|
||||
};
|
||||
|
||||
struct InvalidatedRegion {
|
||||
int pageIndex;
|
||||
double x;
|
||||
double y;
|
||||
double width;
|
||||
double height;
|
||||
};
|
||||
|
||||
struct GlyphBounds {
|
||||
std::string text;
|
||||
double x;
|
||||
@@ -172,6 +190,12 @@ public:
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
[[nodiscard]] virtual std::expected<PageImage, EngineError>
|
||||
renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const {
|
||||
(void)dpi; (void)xPt; (void)yPt; (void)wPt; (void)hPt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::string, EngineError> extractText() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
||||
@@ -197,7 +221,9 @@ public:
|
||||
std::string content;
|
||||
std::string timestamp;
|
||||
int pageIndex = 0;
|
||||
double thickness = 0.0;
|
||||
std::vector<std::vector<Point2D>> paths;
|
||||
std::vector<std::array<Point2D, 4>> quadPoints;
|
||||
|
||||
std::string fieldName;
|
||||
std::string fieldValue;
|
||||
@@ -256,8 +282,10 @@ public:
|
||||
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
|
||||
getResolvedFont(const FontInfo& fontInfo) = 0;
|
||||
|
||||
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
|
||||
virtual std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) = 0;
|
||||
|
||||
|
||||
[[nodiscard]] virtual std::string validateLayout(int pageIndex, const std::string& jsonStr) { (void)pageIndex; (void)jsonStr; return "{}"; }
|
||||
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
|
||||
|
||||
[[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> renderRegionRaw(int dpi, double yTopPt, double heightPt) const override;
|
||||
std::expected<PageImage, EngineError> renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const override;
|
||||
std::expected<std::string, EngineError> extractText() const override;
|
||||
|
||||
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
||||
std::expected<PageModel, EngineError> extractDocumentModel() const override;
|
||||
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
|
||||
@@ -100,7 +102,8 @@ public:
|
||||
|
||||
void invalidateCaches();
|
||||
|
||||
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
||||
std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) override;
|
||||
std::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> saveFull() 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,
|
||||
const std::vector<int>& srcObjects = {},
|
||||
FPDF_FONT reuseFont = nullptr);
|
||||
#endif
|
||||
|
||||
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
|
||||
mutable std::mutex reconstructedFontsMutex_;
|
||||
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;
|
||||
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);
|
||||
#endif
|
||||
|
||||
@@ -164,6 +168,7 @@ private:
|
||||
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_stamp(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
|
||||
@@ -177,7 +182,6 @@ private:
|
||||
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_updateAnnotation(const nlohmann::json& op, int pageIndex);
|
||||
#endif
|
||||
};
|
||||
|
||||
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
||||
std::expected<std::vector<InvalidatedRegion>, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!doc_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
@@ -17,7 +17,9 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
lastReflowLayout_.clear();
|
||||
|
||||
std::vector<int> editedPages;
|
||||
auto markEdited = [&editedPages](int p) {
|
||||
std::vector<InvalidatedRegion> invalidatedRegions;
|
||||
|
||||
auto markEdited = [&](int p) {
|
||||
if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end())
|
||||
editedPages.push_back(p);
|
||||
};
|
||||
@@ -42,7 +44,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
}
|
||||
|
||||
static const std::set<std::string> kContentOps = {
|
||||
"replace_text", "reflow_paragraph", "text_overlay", "add_text",
|
||||
"replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp",
|
||||
"underline", "strikeout", "squiggly", "redaction",
|
||||
"image_overlay", "highlight", "free_text", "comment", "freehand"};
|
||||
if (kContentOps.count(type)) markEdited(pageIndex);
|
||||
@@ -54,6 +56,8 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
r = applyOp_reflow(op, pageIndex);
|
||||
} else if (type == "text_overlay" || type == "add_text") {
|
||||
r = applyOp_textOverlay(op, pageIndex);
|
||||
} else if (type == "stamp") {
|
||||
r = applyOp_stamp(op, pageIndex);
|
||||
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
||||
r = applyOp_decoration(op, pageIndex);
|
||||
} else if (type == "redaction") {
|
||||
@@ -87,6 +91,34 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
if (!r) {
|
||||
return std::unexpected(r.error());
|
||||
}
|
||||
|
||||
// Calculate invalidated region
|
||||
bool regionFound = false;
|
||||
if (op.contains("data") && op["data"].is_object()) {
|
||||
const auto& data = op["data"];
|
||||
if (data.contains("x") && data.contains("y") && data.contains("width") && data.contains("height")) {
|
||||
invalidatedRegions.push_back({
|
||||
pageIndex,
|
||||
data.value("x", 0.0),
|
||||
data.value("y", 0.0),
|
||||
data.value("width", 0.0),
|
||||
data.value("height", 0.0)
|
||||
});
|
||||
regionFound = true;
|
||||
}
|
||||
}
|
||||
if (!regionFound) {
|
||||
auto pageOpt = getPage(pageIndex);
|
||||
if (pageOpt) {
|
||||
invalidatedRegions.push_back({
|
||||
pageIndex,
|
||||
0.0,
|
||||
0.0,
|
||||
(*pageOpt)->width(),
|
||||
(*pageOpt)->height()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (const nlohmann::json::parse_error& e) {
|
||||
spdlog::error("JSON parse error in applyEdits: {}", e.what());
|
||||
@@ -98,7 +130,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
|
||||
rebalanceEditedPages(editedPages);
|
||||
invalidateCaches();
|
||||
return {};
|
||||
return invalidatedRegions;
|
||||
#else
|
||||
(void)editsJson;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_stamp(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("stamp operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
std::string text = data.value("text", "");
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
double width = data.value("width", 100.0);
|
||||
double height = data.value("height", 30.0);
|
||||
double fontSize = data.value("fontSize", 18.0);
|
||||
std::string textColor = data.value("textColor", "#000000");
|
||||
std::string bgColor = data.value("backgroundColor", "#ffffff");
|
||||
std::string borderColor = data.value("borderColor", "#000000");
|
||||
bool includeDate = data.value("includeDate", false);
|
||||
|
||||
if (includeDate) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto in_time_t = std::chrono::system_clock::to_time_t(now);
|
||||
std::stringstream ss;
|
||||
ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M");
|
||||
text += "\n" + ss.str();
|
||||
}
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for stamp", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
// Background rect
|
||||
FPDF_PAGEOBJECT bgRect = FPDFPageObj_CreateNewRect(
|
||||
static_cast<float>(x), static_cast<float>(y),
|
||||
static_cast<float>(width), static_cast<float>(height)
|
||||
);
|
||||
unsigned int r=0, g=0, b=0;
|
||||
parseHexColor(bgColor, r, g, b);
|
||||
FPDFPageObj_SetFillColor(bgRect, r, g, b, 255);
|
||||
FPDFPath_SetDrawMode(bgRect, FPDF_FILLMODE_WINDING, 0);
|
||||
FPDFPage_InsertObject(page, bgRect);
|
||||
|
||||
// Border rect
|
||||
FPDF_PAGEOBJECT borderRect = FPDFPageObj_CreateNewRect(
|
||||
static_cast<float>(x), static_cast<float>(y),
|
||||
static_cast<float>(width), static_cast<float>(height)
|
||||
);
|
||||
parseHexColor(borderColor, r, g, b);
|
||||
FPDFPageObj_SetStrokeColor(borderRect, r, g, b, 255);
|
||||
FPDFPageObj_SetStrokeWidth(borderRect, 2.5f);
|
||||
FPDFPath_SetDrawMode(borderRect, 0, 1);
|
||||
FPDFPage_InsertObject(page, borderRect);
|
||||
|
||||
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, "Helvetica-Bold");
|
||||
|
||||
std::vector<std::string> lines;
|
||||
std::stringstream textStream(text);
|
||||
std::string line;
|
||||
while(std::getline(textStream, line, '\n')) {
|
||||
lines.push_back(line);
|
||||
}
|
||||
|
||||
float startY = static_cast<float>(y + height - fontSize * 1.1);
|
||||
parseHexColor(textColor, r, g, b);
|
||||
|
||||
for (size_t i = 0; i < lines.size(); i++) {
|
||||
float currentFontSize = static_cast<float>(i == 0 ? fontSize : fontSize * 0.5);
|
||||
FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, currentFontSize);
|
||||
FPDFPageObj_SetFillColor(textObj, r, g, b, 255);
|
||||
auto utf16 = utf8_to_utf16le(lines[i]);
|
||||
FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
|
||||
float left=0, bottom=0, right=0, top=0;
|
||||
FPDFPageObj_GetBounds(textObj, &left, &bottom, &right, &top);
|
||||
float textWidth = right - left;
|
||||
|
||||
float textX = static_cast<float>(x + width / 2.0 - textWidth / 2.0);
|
||||
float textY = startY - (i * fontSize * 0.7f);
|
||||
|
||||
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, textX, textY);
|
||||
FPDFPage_InsertObject(page, textObj);
|
||||
}
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after stamp");
|
||||
}
|
||||
FPDF_ClosePage(page);
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::string type = op.value("type", "");
|
||||
@@ -129,6 +227,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohma
|
||||
int subtype = FPDF_ANNOT_UNDERLINE;
|
||||
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
|
||||
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
|
||||
else if (type == "highlight") subtype = FPDF_ANNOT_HIGHLIGHT;
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
|
||||
if (!annot) {
|
||||
@@ -383,6 +482,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_freehand(const nlohmann
|
||||
float thickness = static_cast<float>(data.value("thickness", 2.0));
|
||||
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
|
||||
|
||||
std::string tStr = std::to_string(thickness);
|
||||
auto tUtf16 = utf8_to_utf16le(tStr);
|
||||
FPDFAnnot_SetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
|
||||
|
||||
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
|
||||
bool anyPoints = false;
|
||||
|
||||
@@ -549,6 +652,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
|
||||
if (data.contains("thickness")) {
|
||||
float thickness = static_cast<float>(data["thickness"].get<double>());
|
||||
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
|
||||
|
||||
std::string tStr = std::to_string(thickness);
|
||||
auto tUtf16 = utf8_to_utf16le(tStr);
|
||||
FPDFAnnot_SetStringValue(targetAnnot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
|
||||
}
|
||||
|
||||
if (data.contains("text")) {
|
||||
|
||||
@@ -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) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
(void)op;
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
@@ -176,4 +177,4 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#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 {
|
||||
|
||||
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"];
|
||||
|
||||
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;
|
||||
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
||||
r = 0; g = 0; b = 0;
|
||||
@@ -30,11 +42,12 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
RunStyle rs;
|
||||
rs.text = rj.value("text", "");
|
||||
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);
|
||||
if (rj.contains("advances") && rj["advances"].is_array()) {
|
||||
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
|
||||
}
|
||||
rs.advanceSeedText = rj.value("advanceSeedText", "");
|
||||
runs.push_back(std::move(rs));
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
{
|
||||
std::unordered_map<std::string, double> exactByBaseName;
|
||||
std::vector<double> exactSizes;
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
double exactNominal = 0.0;
|
||||
double exactScaleX = 1.0;
|
||||
double exactScaleY = 1.0;
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
float nom = 0;
|
||||
if (FPDFTextObj_GetFontSize(o, &nom) && nom > 0.1) {
|
||||
FS_MATRIX mtx;
|
||||
if (!FPDFPageObj_GetMatrix(o, &mtx)) continue;
|
||||
float nominal = 0.0f;
|
||||
if (!FPDFTextObj_GetFontSize(o, &nominal)) continue;
|
||||
double scale = std::sqrt(static_cast<double>(mtx.a) * mtx.a +
|
||||
static_cast<double>(mtx.b) * mtx.b);
|
||||
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;
|
||||
if (FPDFPageObj_GetMatrix(o, &mtx)) {
|
||||
exactScaleX = std::sqrt(static_cast<double>(mtx.a) * mtx.a + static_cast<double>(mtx.b) * mtx.b);
|
||||
exactScaleY = std::sqrt(static_cast<double>(mtx.c) * mtx.c + static_cast<double>(mtx.d) * mtx.d);
|
||||
exactNominal = nom;
|
||||
break;
|
||||
}
|
||||
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());
|
||||
// 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 u16 = utf8_to_utf16le(s);
|
||||
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; }
|
||||
else i += 1;
|
||||
} else i += 1;
|
||||
if (cp == 0) continue;
|
||||
cps.push_back(cp);
|
||||
}
|
||||
return cps;
|
||||
@@ -241,7 +254,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
if (text.empty()) return out;
|
||||
auto& rf = runFonts[runIdx];
|
||||
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()
|
||||
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
||||
if (face) {
|
||||
@@ -259,18 +272,88 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
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<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) {
|
||||
if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) {
|
||||
runCharAdv[ri] = runs[ri].advances;
|
||||
auto natural = perCharAdvances(ri, runs[ri].text);
|
||||
const auto& rs = runs[ri];
|
||||
bool usedClient = false;
|
||||
|
||||
// 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();
|
||||
for (size_t c = 0; !diverges && c < natural.size(); ++c)
|
||||
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 {
|
||||
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 {
|
||||
@@ -336,6 +419,8 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
lineCont.push_back(firstLineOfSeg ? 0 : 1);
|
||||
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) {
|
||||
size_t wi = allWords[k];
|
||||
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 gap = (cur.empty() || prevWord == kHardBreak) ? 0.0 : words[prevWord].spaceAfter;
|
||||
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);
|
||||
cur.clear(); firstLineOfSeg = false;
|
||||
cur.push_back(wi); curW = words[wi].width;
|
||||
@@ -421,20 +508,71 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
lineText.push_back(' ');
|
||||
adv.push_back(gap);
|
||||
}
|
||||
double segX = x;
|
||||
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;
|
||||
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) {
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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]) {
|
||||
double gx = segX;
|
||||
@@ -475,6 +613,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
}
|
||||
lastReflowLayout_ = nlohmann::json{
|
||||
{"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump();
|
||||
spdlog::info("[STAGE_5_SERIALIZED_JSON] {}", lastReflowLayout_);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after reflow_paragraph");
|
||||
@@ -500,4 +639,61 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
#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 {
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
fonts::pdf_fonts::ReconstructedFont rf;
|
||||
if (!sfnt.empty()) {
|
||||
rf.ok = true;
|
||||
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
reconstructedFonts_[internalFontId] = std::move(rf);
|
||||
#else
|
||||
(void)internalFontId; (void)sfnt;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError>
|
||||
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
||||
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) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
return getReconstructedEmbeddedFont(internalFontId);
|
||||
#else
|
||||
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
|
||||
auto it = reconstructedFonts_.find(internalFontId);
|
||||
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
(void)internalFontId;
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
const fonts::pdf_fonts::ReconstructedFont*
|
||||
@@ -103,12 +115,23 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||
if (out.resolved) {
|
||||
for (uint32_t cp : codepoints) {
|
||||
// U+0000 is a string terminator, never glyph content.
|
||||
if (cp == 0) continue;
|
||||
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 {
|
||||
fontSupportsAll = false;
|
||||
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' no resolved face - forcing system fallback", internalFontId);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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 &&
|
||||
classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
@@ -211,7 +257,24 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
|
||||
if (!out.font) {
|
||||
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");
|
||||
// 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) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
@@ -220,8 +283,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
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;
|
||||
}
|
||||
#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 int stride = FPDFBitmap_GetStride(bitmap);
|
||||
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) {
|
||||
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;
|
||||
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
|
||||
for (int x = 0; x < w; ++x) {
|
||||
@@ -147,9 +216,9 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
||||
}
|
||||
}
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
return PageImage{w, regionH, std::move(rgba)};
|
||||
return PageImage{w, h, std::move(rgba)};
|
||||
#else
|
||||
(void)dpi; (void)yTopPt; (void)heightPt;
|
||||
(void)dpi; (void)xPt; (void)yPt; (void)wPt; (void)hPt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
if (it != objToIndex.end()) {
|
||||
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);
|
||||
@@ -600,6 +613,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
||||
}
|
||||
|
||||
if (subtype == FPDF_ANNOT_INK) {
|
||||
float h_radius = 0, v_radius = 0, border_width = 1.0f;
|
||||
info.thickness = 2.0; // default
|
||||
|
||||
unsigned long ctLen = FPDFAnnot_GetStringValue(annot, "CustomThickness", nullptr, 0);
|
||||
if (ctLen > 2) {
|
||||
std::vector<char16_t> ctBuf(ctLen / 2);
|
||||
FPDFAnnot_GetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WCHAR*>(ctBuf.data()), ctLen);
|
||||
std::string ctStr = utf16le_to_utf8(ctBuf.data(), ctBuf.size());
|
||||
try {
|
||||
info.thickness = std::stod(ctStr);
|
||||
} catch (...) {}
|
||||
} else if (FPDFAnnot_GetBorder(annot, &h_radius, &v_radius, &border_width)) {
|
||||
info.thickness = border_width;
|
||||
}
|
||||
|
||||
const double pageH = height();
|
||||
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
||||
for (unsigned long s = 0; s < strokeCount; ++s) {
|
||||
@@ -614,6 +642,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
||||
}
|
||||
if (!stroke.empty()) info.paths.push_back(std::move(stroke));
|
||||
}
|
||||
} else if (subtype == FPDF_ANNOT_HIGHLIGHT || subtype == FPDF_ANNOT_STRIKEOUT || subtype == FPDF_ANNOT_UNDERLINE || subtype == FPDF_ANNOT_SQUIGGLY) {
|
||||
const double pageH = height();
|
||||
size_t quadCount = FPDFAnnot_CountAttachmentPoints(annot);
|
||||
for (size_t q = 0; q < quadCount; ++q) {
|
||||
FS_QUADPOINTSF quad;
|
||||
if (FPDFAnnot_GetAttachmentPoints(annot, q, &quad)) {
|
||||
std::array<Point2D, 4> pts = {{
|
||||
{static_cast<double>(quad.x1), pageH - static_cast<double>(quad.y1)},
|
||||
{static_cast<double>(quad.x2), pageH - static_cast<double>(quad.y2)},
|
||||
{static_cast<double>(quad.x3), pageH - static_cast<double>(quad.y3)},
|
||||
{static_cast<double>(quad.x4), pageH - static_cast<double>(quad.y4)}
|
||||
}};
|
||||
info.quadPoints.push_back(pts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(info);
|
||||
|
||||
@@ -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
|
||||
page_render_test.cpp
|
||||
document_edit_test.cpp
|
||||
edit_session_test.cpp
|
||||
font_diagnostics_test.cpp
|
||||
text_encoding_test.cpp
|
||||
skia_renderer_test.cpp
|
||||
@@ -38,6 +39,10 @@ if(PDFENGINE_WITH_SKIA)
|
||||
target_link_libraries(pdfengine_smoke PRIVATE skia::skia)
|
||||
endif()
|
||||
|
||||
if(PDFENGINE_WITH_PDFIUM)
|
||||
target_link_libraries(pdfengine_smoke PRIVATE pdfium::pdfium)
|
||||
endif()
|
||||
|
||||
if(PDFENGINE_WITH_QPDF)
|
||||
target_link_libraries(pdfengine_smoke PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
||||
endif()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <pdfengine/display_list.hpp>
|
||||
#include <pdfengine/path_interpreter.hpp>
|
||||
#include <pdfengine/content_object.hpp>
|
||||
#include "../src/parser/content_stream_parser.hpp"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -20,6 +23,38 @@ public:
|
||||
void visit(const DrawImageCommand&) override { calls.push_back("DrawImage"); }
|
||||
};
|
||||
|
||||
class PathVerifyVisitor : public CommandVisitor {
|
||||
public:
|
||||
struct PathInfo {
|
||||
std::string type;
|
||||
size_t segmentCount = 0;
|
||||
FillRule rule = FillRule::NonZero;
|
||||
};
|
||||
std::vector<PathInfo> paths;
|
||||
std::vector<std::string> calls;
|
||||
|
||||
void visit(const SaveStateCommand&) override { calls.push_back("SaveState"); }
|
||||
void visit(const RestoreStateCommand&) override { calls.push_back("RestoreState"); }
|
||||
void visit(const SetTransformCommand& cmd) override {
|
||||
calls.push_back("SetTransform(" + std::to_string(cmd.matrix.a) + "," + std::to_string(cmd.matrix.d) + ")");
|
||||
}
|
||||
void visit(const FillRectCommand&) override {}
|
||||
void visit(const DrawTextCommand&) override {}
|
||||
void visit(const FillPathCommand& cmd) override {
|
||||
calls.push_back("FillPath");
|
||||
paths.push_back({"Fill", cmd.path.segments().size(), cmd.rule});
|
||||
}
|
||||
void visit(const StrokePathCommand& cmd) override {
|
||||
calls.push_back("StrokePath");
|
||||
paths.push_back({"Stroke", cmd.path.segments().size(), FillRule::NonZero});
|
||||
}
|
||||
void visit(const FillStrokePathCommand& cmd) override {
|
||||
calls.push_back("FillStrokePath");
|
||||
paths.push_back({"FillStroke", cmd.path.segments().size(), cmd.rule});
|
||||
}
|
||||
void visit(const DrawImageCommand&) override {}
|
||||
};
|
||||
|
||||
TEST(DisplayListTest, RecordAndReplay) {
|
||||
DisplayList list;
|
||||
|
||||
@@ -53,3 +88,97 @@ TEST(DisplayListTest, Clear) {
|
||||
list.clear();
|
||||
EXPECT_EQ(list.size(), 0);
|
||||
}
|
||||
|
||||
TEST(DisplayListTest, PathObjectInterpreterStroke) {
|
||||
Path path;
|
||||
path.moveTo(10.0f, 20.0f);
|
||||
path.lineTo(30.0f, 40.0f);
|
||||
|
||||
PathObject pathObj;
|
||||
pathObj.path = path;
|
||||
pathObj.paintOp = PathPaintOp::Stroke;
|
||||
pathObj.transform = Matrix(1.5f, 0.0f, 0.0f, 1.5f, 5.0f, 5.0f);
|
||||
|
||||
DisplayList list;
|
||||
PathObjectInterpreter::interpret(pathObj, list);
|
||||
|
||||
PathVerifyVisitor visitor;
|
||||
list.replay(visitor);
|
||||
|
||||
ASSERT_EQ(visitor.calls.size(), 4);
|
||||
EXPECT_EQ(visitor.calls[0], "SaveState");
|
||||
EXPECT_EQ(visitor.calls[1], "SetTransform(1.500000,1.500000)");
|
||||
EXPECT_EQ(visitor.calls[2], "StrokePath");
|
||||
EXPECT_EQ(visitor.calls[3], "RestoreState");
|
||||
|
||||
ASSERT_EQ(visitor.paths.size(), 1);
|
||||
EXPECT_EQ(visitor.paths[0].type, "Stroke");
|
||||
EXPECT_EQ(visitor.paths[0].segmentCount, 2);
|
||||
}
|
||||
|
||||
TEST(DisplayListTest, PathObjectInterpreterFillEvenOdd) {
|
||||
Path path;
|
||||
path.addRect(0.0f, 0.0f, 10.0f, 10.0f);
|
||||
|
||||
PathObject pathObj;
|
||||
pathObj.path = path;
|
||||
pathObj.paintOp = PathPaintOp::Fill;
|
||||
pathObj.fillRule = FillRule::EvenOdd;
|
||||
pathObj.transform = Matrix(1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f);
|
||||
|
||||
DisplayList list;
|
||||
PathObjectInterpreter::interpret(pathObj, list);
|
||||
|
||||
PathVerifyVisitor visitor;
|
||||
list.replay(visitor);
|
||||
|
||||
ASSERT_EQ(visitor.calls.size(), 4);
|
||||
EXPECT_EQ(visitor.calls[2], "FillPath");
|
||||
|
||||
ASSERT_EQ(visitor.paths.size(), 1);
|
||||
EXPECT_EQ(visitor.paths[0].type, "Fill");
|
||||
EXPECT_EQ(visitor.paths[0].segmentCount, 5); // moveTo + 3 lineTo + close
|
||||
EXPECT_EQ(visitor.paths[0].rule, FillRule::EvenOdd);
|
||||
}
|
||||
|
||||
TEST(DisplayListTest, ContentStreamParserPaths) {
|
||||
DisplayList list;
|
||||
ContentStreamParser parser;
|
||||
|
||||
// Parse stroke path (m, l, S)
|
||||
parser.parse("10 20 m 30 40 l S", list);
|
||||
|
||||
// Parse fill path (re, f)
|
||||
parser.parse("5 6 7 8 re f", list);
|
||||
|
||||
// Parse fill and stroke (m, c, B)
|
||||
parser.parse("1 2 m 3 4 5 6 7 8 c B", list);
|
||||
|
||||
PathVerifyVisitor visitor;
|
||||
list.replay(visitor);
|
||||
|
||||
// S -> StrokePath
|
||||
// f -> FillPath
|
||||
// B -> FillPath, StrokePath
|
||||
ASSERT_EQ(visitor.calls.size(), 4);
|
||||
EXPECT_EQ(visitor.calls[0], "StrokePath");
|
||||
EXPECT_EQ(visitor.calls[1], "FillPath");
|
||||
EXPECT_EQ(visitor.calls[2], "FillPath");
|
||||
EXPECT_EQ(visitor.calls[3], "StrokePath");
|
||||
|
||||
ASSERT_EQ(visitor.paths.size(), 4);
|
||||
|
||||
// 1st: 10 20 m 30 40 l S -> MoveTo, LineTo
|
||||
EXPECT_EQ(visitor.paths[0].type, "Stroke");
|
||||
EXPECT_EQ(visitor.paths[0].segmentCount, 2);
|
||||
|
||||
// 2nd: 5 6 7 8 re f -> MoveTo, 3xLineTo, Close
|
||||
EXPECT_EQ(visitor.paths[1].type, "Fill");
|
||||
EXPECT_EQ(visitor.paths[1].segmentCount, 5);
|
||||
|
||||
// 3rd & 4th: 1 2 m 3 4 5 6 7 8 c B -> MoveTo, CubicBezierTo
|
||||
EXPECT_EQ(visitor.paths[2].type, "Fill");
|
||||
EXPECT_EQ(visitor.paths[2].segmentCount, 2);
|
||||
EXPECT_EQ(visitor.paths[3].type, "Stroke");
|
||||
EXPECT_EQ(visitor.paths[3].segmentCount, 2);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
Lexer lexer(decodedStream(page.getKey("/Contents")));
|
||||
std::string decodedContent = decodedStream(page.getKey("/Contents"));
|
||||
Lexer lexer(decodedContent);
|
||||
auto tokens = lexer.tokenize();
|
||||
ContentParser parser(tokens);
|
||||
auto operations = parser.parse();
|
||||
@@ -436,6 +437,8 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
|
||||
|
||||
QPDF qpdf;
|
||||
auto objects = buildFirstPageObjects(qpdf, pdf);
|
||||
|
||||
|
||||
auto images = imageObjects(objects);
|
||||
ASSERT_EQ(images.size(), 2u);
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.git/
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
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;"]
|
||||
+285
-68
@@ -5,8 +5,10 @@ import { Toolbar } from './components/Toolbar';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import type { InspectorTab } from './components/InspectorPanel';
|
||||
import { SignatureModal } from './components/SignatureModal';
|
||||
import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { ToastViewport } from './components/ui';
|
||||
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
||||
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
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 { viewportRectToPdf } from './lib/coordinateMapping';
|
||||
import type { Rect } from './lib/coordinateMapping';
|
||||
import { toast } from './lib/toast';
|
||||
|
||||
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
|
||||
import type { ToolId, ToolSettings } from './lib/tools';
|
||||
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
|
||||
|
||||
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
@@ -38,8 +40,7 @@ function App() {
|
||||
|
||||
const permissions = activeDoc?.permissions ?? null;
|
||||
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
||||
const denyToast = (label: string) =>
|
||||
toast(`${label} is not permitted by this document's restrictions`, 'error');
|
||||
const denyToast = (_label: string) => {};
|
||||
const disabledTools = new Set<ToolId>();
|
||||
if (!can('canAnnotate'))
|
||||
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
||||
@@ -57,17 +58,22 @@ function App() {
|
||||
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
|
||||
const [fonts, setFonts] = useState<FontInfo[]>([]);
|
||||
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
|
||||
const [engineReady, setEngineReady] = useState(false);
|
||||
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
|
||||
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
||||
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
|
||||
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
||||
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
|
||||
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
|
||||
const [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 [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -83,15 +89,21 @@ function App() {
|
||||
if (!canUndo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
|
||||
toast('Undo', 'info', 1200);
|
||||
|
||||
};
|
||||
const redo = () => {
|
||||
if (!canRedo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
|
||||
toast('Redo', 'info', 1200);
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hist.stack.length > 0) {
|
||||
localStorage.setItem('pdf_hist', JSON.stringify(hist));
|
||||
}
|
||||
}, [hist]);
|
||||
|
||||
useEffect(() => {
|
||||
gatewayService.getHealth()
|
||||
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
||||
@@ -102,9 +114,58 @@ function App() {
|
||||
(async () => {
|
||||
try {
|
||||
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();
|
||||
setDocuments(docs);
|
||||
if (docs.length > 0) openDocument(docs[0].id);
|
||||
if (docs.length > 0) {
|
||||
// Attempt to load from localStorage, otherwise fallback to the most recent document
|
||||
const savedHist = localStorage.getItem('pdf_hist');
|
||||
if (savedHist) {
|
||||
try {
|
||||
const parsedHist = JSON.parse(savedHist);
|
||||
if (parsedHist.stack && parsedHist.stack.length > 0 && docs.some((d: any) => d.id === parsedHist.stack[parsedHist.index])) {
|
||||
setHist(parsedHist);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse history', e);
|
||||
}
|
||||
}
|
||||
// Default to the most recent document (last in list)
|
||||
openDocument(docs[docs.length - 1].id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load documents', e);
|
||||
} finally {
|
||||
@@ -140,6 +201,7 @@ function App() {
|
||||
content: a.content,
|
||||
timestamp: a.timestamp,
|
||||
pageIndex: a.pageIndex,
|
||||
quadPoints: a.quad_points || a.quadPoints,
|
||||
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
|
||||
fieldName: a.fieldName,
|
||||
fieldValue: a.fieldValue,
|
||||
@@ -213,18 +275,16 @@ function App() {
|
||||
gatewayService.listDocuments().then(setDocuments).catch(() => {});
|
||||
};
|
||||
|
||||
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
|
||||
const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
|
||||
if (!selectedDocId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await gatewayService.applyEdits(selectedDocId, ops);
|
||||
if (result.success) {
|
||||
adoptNewDocument(result.newDocumentId);
|
||||
if (successMsg) toast(successMsg, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Edit failed', e);
|
||||
toast('Edit failed — check the gateway connection', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -267,22 +327,33 @@ function App() {
|
||||
|
||||
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
|
||||
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
|
||||
const quadPoints = lines.map((line) => {
|
||||
const quadPointsBackend = lines.map((line) => {
|
||||
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
|
||||
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly };
|
||||
return { x1: lx, y1: ly, x2: lx + lw, y2: ly, x3: lx, y3: ly + lh, x4: lx + lw, y4: ly + lh };
|
||||
});
|
||||
|
||||
const newAnnos = lines.map(line => ({
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const qPointsFront = lines.map((line) => {
|
||||
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
|
||||
if (lx < minX) minX = lx;
|
||||
if (ly < minY) minY = ly;
|
||||
if (lx + lw > maxX) maxX = lx + lw;
|
||||
if (ly + lh > maxY) maxY = ly + lh;
|
||||
return [{ x: lx, y: ly }, { x: lx + lw, y: ly }, { x: lx, y: ly + lh }, { x: lx + lw, y: ly + lh }];
|
||||
});
|
||||
|
||||
const newAnno = {
|
||||
id: rid('locdec'),
|
||||
type,
|
||||
pageIndex,
|
||||
bbox: { x: line.x / zoom, y: line.y / zoom, width: line.width / zoom, height: line.height / zoom },
|
||||
bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
||||
quadPoints: qPointsFront,
|
||||
color,
|
||||
author: 'Current User',
|
||||
} as Annotation));
|
||||
setAnnotations(prev => [...prev, ...newAnnos]);
|
||||
} as Annotation;
|
||||
setAnnotations(prev => [...prev, newAnno]);
|
||||
|
||||
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]);
|
||||
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints: quadPointsBackend, color, author: 'Current User' } }]);
|
||||
};
|
||||
|
||||
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
|
||||
@@ -320,24 +391,80 @@ function App() {
|
||||
if (!activeStamp) return;
|
||||
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
|
||||
const fontSize = 22;
|
||||
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
|
||||
const height = fontSize * 1.5;
|
||||
const padding = 12; // visual padding
|
||||
const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length
|
||||
const labelWidth = activeStamp.label.length * fontSize * 0.7;
|
||||
const contentWidth = Math.max(labelWidth, dateWidth);
|
||||
const width = Math.max(80, contentWidth) + (padding * 2);
|
||||
const height = fontSize * 1.5 + (padding * 2);
|
||||
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
|
||||
applyOps([{
|
||||
id: rid('stamp'), type: 'text_overlay', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
|
||||
id: rid('stamp'), type: 'stamp', pageIndex,
|
||||
data: {
|
||||
x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height,
|
||||
text: activeStamp.label,
|
||||
textColor: activeStamp.textColor,
|
||||
backgroundColor: activeStamp.backgroundColor,
|
||||
borderColor: activeStamp.borderColor,
|
||||
fontSize,
|
||||
includeDate: true // We can toggle this later, true by default for now
|
||||
},
|
||||
}], `Stamp “${activeStamp.label}” placed`);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
|
||||
const handleRedactPages = (pagesString: string) => {
|
||||
if (!activeDoc) return;
|
||||
const ranges = pagesString.split(',').map(s => s.trim());
|
||||
const pagesToRedact = new Set<number>();
|
||||
for (const r of ranges) {
|
||||
if (r.includes('-')) {
|
||||
const parts = r.split('-');
|
||||
if (parts.length === 2) {
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parseInt(parts[1], 10);
|
||||
if (!isNaN(start) && !isNaN(end)) {
|
||||
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
|
||||
if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const val = parseInt(r, 10);
|
||||
if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) {
|
||||
pagesToRedact.add(val - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = [];
|
||||
pagesToRedact.forEach(pageIndex => {
|
||||
const pInfo = activeDoc.pages?.[pageIndex];
|
||||
if (!pInfo) return;
|
||||
newRedactions.push({
|
||||
id: rid('redmark'),
|
||||
pageIndex,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pInfo.width,
|
||||
height: pInfo.height
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (newRedactions.length > 0) {
|
||||
setPendingRedactions(p => [...p, ...newRedactions]);
|
||||
} else {
|
||||
}
|
||||
setRedactPagesModalOpen(false);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
|
||||
if (!pendingSignature) return;
|
||||
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
|
||||
const width = 160;
|
||||
const height = width / (pendingSignature.aspect || 3);
|
||||
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
|
||||
applyOps([{
|
||||
id: rid('sig'), type: 'image_overlay', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url },
|
||||
data: { x: pdfRect.x, y: pdfRect.y, width: pdfRect.width, height: pdfRect.height, imageData: pendingSignature.url },
|
||||
}], 'Signature placed');
|
||||
setActiveTool('select');
|
||||
};
|
||||
@@ -351,7 +478,7 @@ function App() {
|
||||
const handleDeletePage = (pageIndex: number) => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
|
||||
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
|
||||
if (activeDoc.totalPages <= 1) { return; }
|
||||
setConfirmState({
|
||||
title: 'Delete page?',
|
||||
message: `Page ${pageIndex + 1} will be removed from this document.`,
|
||||
@@ -370,19 +497,34 @@ function App() {
|
||||
setCurrentPage(to);
|
||||
};
|
||||
|
||||
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
|
||||
const [pendingRedactions, setPendingRedactions] = useState<{ id: string, pageIndex: number, bounds: Rect }[]>([]);
|
||||
|
||||
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
|
||||
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
|
||||
if (activeTool !== 'redact') {
|
||||
setActiveTool('redact');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyRedactions = () => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canModify')) { denyToast('Redaction'); return; }
|
||||
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
|
||||
if (pendingRedactions.length === 0) return;
|
||||
|
||||
setConfirmState({
|
||||
title: 'Redact area?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
|
||||
confirmLabel: 'Redact', danger: true,
|
||||
title: 'Apply Redactions?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
|
||||
confirmLabel: 'Apply', danger: true,
|
||||
onConfirm: () => {
|
||||
applyOps([{
|
||||
id: rid('redact'), type: 'redaction', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
|
||||
}], 'Area redacted');
|
||||
const ops = pendingRedactions.map(mark => {
|
||||
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
|
||||
return {
|
||||
id: mark.id, type: 'redaction' as const, pageIndex: mark.pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#000000' },
|
||||
};
|
||||
});
|
||||
applyOps(ops, 'Redactions applied');
|
||||
setPendingRedactions([]);
|
||||
setActiveTool('select');
|
||||
},
|
||||
});
|
||||
@@ -394,37 +536,68 @@ function App() {
|
||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||
setDocuments((prev) => [newDoc, ...prev]);
|
||||
openDocument(newDoc.id);
|
||||
toast(`Opened ${newDoc.filename}`, 'success');
|
||||
|
||||
setPasswordPrompt(null);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordError) {
|
||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||
} else {
|
||||
console.error('Upload failed', e);
|
||||
toast('Upload failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
toast('Exported', 'success');
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
toast('Export failed', 'error');
|
||||
}
|
||||
setConfirmState({
|
||||
title: 'Export Document',
|
||||
message: `Are you sure you want to export "${activeDoc.filename}"?`,
|
||||
confirmLabel: 'Export',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canPrint')) { denyToast('Printing'); return; }
|
||||
try {
|
||||
toast('Preparing print...', 'info');
|
||||
|
||||
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -442,21 +615,11 @@ function App() {
|
||||
document.body.appendChild(iframe);
|
||||
} catch (e) {
|
||||
console.error('Print failed', e);
|
||||
toast('Print failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInspector = () => {
|
||||
setIsInspectorOpen((prev) => {
|
||||
const next = !prev;
|
||||
setTimeout(() => {
|
||||
const w = activeDoc?.pageWidth || 612;
|
||||
const inspectorWidth = next ? 322 : 0;
|
||||
const avail = window.innerWidth - inspectorWidth - 48;
|
||||
setZoom(Math.max(0.25, Math.min(3, avail / w)));
|
||||
}, 50);
|
||||
return next;
|
||||
});
|
||||
setIsInspectorOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const fitWidth = () => {
|
||||
@@ -520,6 +683,8 @@ function App() {
|
||||
canExport={can('canCopy')}
|
||||
canAssemble={can('canAssemble')}
|
||||
onUpload={handleUpload}
|
||||
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
||||
onSave={isRemote ? handleSave : undefined}
|
||||
isInspectorOpen={isInspectorOpen}
|
||||
onToggleInspector={toggleInspector}
|
||||
/>
|
||||
@@ -541,8 +706,27 @@ function App() {
|
||||
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
hasSignature={!!pendingSignature}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
|
||||
activeStamp={activeStamp}
|
||||
onSelectStamp={setActiveStamp}
|
||||
redactionMode={redactionMode}
|
||||
onRedactionModeChange={setRedactionMode}
|
||||
pendingRedactionCount={pendingRedactions.length}
|
||||
onApplyRedactions={handleApplyRedactions}
|
||||
onClearRedactions={() => setPendingRedactions([])}
|
||||
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||
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">
|
||||
@@ -562,8 +746,11 @@ function App() {
|
||||
pagesInfo={activeDoc.pages}
|
||||
activeTool={activeTool}
|
||||
toolSettings={toolSettings}
|
||||
redactionMode={redactionMode}
|
||||
hasSignature={!!pendingSignature}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
signatureImageUrl={pendingSignature?.url}
|
||||
signatureAspect={pendingSignature?.aspect}
|
||||
activeStamp={activeStamp}
|
||||
annotations={annotations}
|
||||
canCopy={can('canCopy')}
|
||||
onFieldChange={(id, value, i) => {
|
||||
@@ -580,12 +767,18 @@ function App() {
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationUpdate={handleUpdateAnnotation}
|
||||
onAnnotationClick={() => {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
onAnnotationClick={(anno) => {
|
||||
if (activeTool === 'select') {
|
||||
setSelectedAnnotationId(anno.id);
|
||||
} else {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
onMarkRedaction={handleMarkRedaction}
|
||||
pendingRedactions={pendingRedactions}
|
||||
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
|
||||
onPlaceText={handlePlaceText}
|
||||
onEditText={handleEditText}
|
||||
onReflowParagraph={handleReflowParagraph}
|
||||
@@ -594,6 +787,16 @@ function App() {
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
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-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
|
||||
@@ -617,6 +820,8 @@ function App() {
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
isExpanded={isInspectorExpanded}
|
||||
onExpandedChange={setIsInspectorExpanded}
|
||||
documents={documents}
|
||||
selectedDocumentId={selectedDocId}
|
||||
onSelectDocument={openDocument}
|
||||
@@ -645,6 +850,14 @@ function App() {
|
||||
metadata={metadata}
|
||||
permissions={permissions ?? undefined}
|
||||
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>
|
||||
@@ -656,10 +869,15 @@ function App() {
|
||||
setPendingSignature({ url, aspect });
|
||||
setSignatureModalOpen(false);
|
||||
setActiveTool('signature');
|
||||
toast('Signature ready — click on the page to place it', 'info');
|
||||
}}
|
||||
/>
|
||||
|
||||
<RedactPagesModal
|
||||
open={redactPagesModalOpen}
|
||||
onClose={() => setRedactPagesModalOpen(false)}
|
||||
onConfirm={handleRedactPages}
|
||||
/>
|
||||
|
||||
<AboutModal
|
||||
open={aboutModalOpen}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
@@ -672,7 +890,6 @@ function App() {
|
||||
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
||||
onClose={() => setPasswordPrompt(null)}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ import React from 'react';
|
||||
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
||||
import type { Annotation } from '../viewer/AnnotationLayer';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
import { EmptyState, Popover } from './ui';
|
||||
import { EmptyState } from './ui';
|
||||
import {
|
||||
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
|
||||
} from './icons';
|
||||
import { useTheme } from '../lib/useTheme';
|
||||
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
activeTab: InspectorTab;
|
||||
onTabChange: (t: InspectorTab) => void;
|
||||
isExpanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
|
||||
documents: DocumentInfo[];
|
||||
selectedDocumentId: string;
|
||||
@@ -48,6 +51,9 @@ interface InspectorPanelProps {
|
||||
metadata: DocumentMetadata | null;
|
||||
permissions?: PDFPermissions;
|
||||
fonts: FontInfo[];
|
||||
|
||||
history?: { stack: string[]; index: number };
|
||||
onRestoreHistory?: (docId: string) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
|
||||
@@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo
|
||||
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
|
||||
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
|
||||
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
|
||||
{ id: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||
];
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
@@ -70,79 +77,77 @@ function formatBytes(bytes?: number) {
|
||||
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
|
||||
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<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>
|
||||
)}
|
||||
<aside className="flex h-full shrink-0 flex-row-reverse bg-bg-primary">
|
||||
{/* Icon Strip (Right Edge) */}
|
||||
<div className="flex w-[48px] shrink-0 flex-col items-center gap-2 border-l border-border-primary bg-bg-secondary py-3">
|
||||
{TABS.map((t) => {
|
||||
const active = p.isExpanded && p.activeTab === t.id;
|
||||
return (
|
||||
<CustomButton variant="unstyled"
|
||||
key={t.id}
|
||||
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
||||
aria-label={t.label}
|
||||
onClick={() => {
|
||||
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
|
||||
? 'bg-brand-secondary text-brand-primary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{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') && (
|
||||
<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') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
|
||||
<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) => {
|
||||
const active = p.activeTab === t.id;
|
||||
return (
|
||||
<CustomButton variant="unstyled"
|
||||
key={t.id}
|
||||
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
||||
aria-label={t.label}
|
||||
onClick={() => p.onTabChange(t.id)}
|
||||
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
}`}
|
||||
>
|
||||
{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') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
)}
|
||||
{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]" />
|
||||
)}
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
{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 === 'search' && <SearchTab {...p} />}
|
||||
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||
{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} />}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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 === '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 === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||
{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 === 'history' && <HistoryTab history={p.history} onRestore={p.onRestoreHistory} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -172,7 +177,7 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
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
|
||||
documentId={p.documentId}
|
||||
@@ -197,18 +202,18 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{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">
|
||||
<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"
|
||||
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
|
||||
<span className="text-[10px] text-[#98a1ad]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
||||
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide bg-brand-secondary text-brand-primary"
|
||||
>{a.type}</span>
|
||||
<span className="text-[10px] text-text-tertiary">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
||||
</div>
|
||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-text-secondary">“{a.content}”</p>}
|
||||
<span className="text-[10px] font-medium text-text-tertiary">{a.author}</span>
|
||||
</CustomButton>
|
||||
|
||||
{onUpdate && (a.type === 'highlight' || a.type === 'ink' || a.type === 'comment') && (
|
||||
{onUpdate && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
|
||||
<div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="color"
|
||||
@@ -235,7 +240,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
title="Delete annotation"
|
||||
aria-label="Delete annotation"
|
||||
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} />
|
||||
</button>
|
||||
@@ -257,11 +262,11 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
||||
key={i}
|
||||
onClick={() => item.pageIndex >= 0 && onNavigate(item.pageIndex)}
|
||||
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` }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[#18212e]" title={item.title}>{item.title || '(untitled)'}</span>
|
||||
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">{item.pageIndex + 1}</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-text-tertiary">{item.pageIndex + 1}</span>}
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
@@ -271,27 +276,40 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
||||
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
return (
|
||||
<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">
|
||||
<span className="text-[13px] font-bold text-[#18212e]">Find</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">Find</span>
|
||||
</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]">
|
||||
<SearchGlyph size={15} className="text-[#98a1ad]" />
|
||||
<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="shrink-0 text-text-tertiary" />
|
||||
<input
|
||||
autoFocus
|
||||
value={p.searchQuery}
|
||||
onChange={(e) => p.onSearchQueryChange(e.target.value)}
|
||||
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 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' : '')}
|
||||
</span>
|
||||
{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)}><ChevronDownIcon size={14} /></CustomButton>
|
||||
</div>
|
||||
@@ -299,12 +317,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
<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-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||
Case sensitive
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
<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-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||
Whole words only
|
||||
</label>
|
||||
</div>
|
||||
@@ -326,7 +344,7 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
<span>
|
||||
{parts.map((part, index) =>
|
||||
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>
|
||||
)
|
||||
@@ -341,12 +359,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
return (
|
||||
<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 ${
|
||||
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)}
|
||||
</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}
|
||||
</span>
|
||||
</CustomButton>
|
||||
@@ -386,19 +404,19 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[#ebedf0] py-2 last:border-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{k}</span>
|
||||
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]">—</span>}</span>
|
||||
<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-text-tertiary">{k}</span>
|
||||
<span className="break-words text-[12.5px] text-text-primary">{v && v.trim() ? v : <span className="text-text-tertiary">—</span>}</span>
|
||||
</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">
|
||||
<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
|
||||
? <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>}
|
||||
{permissions?.ownerUnlocked && <span className="rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">Owner</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-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">Owner</span>}
|
||||
</div>
|
||||
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -406,7 +424,7 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
const allowed = permissions[key] !== false;
|
||||
return (
|
||||
<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]'}>
|
||||
{allowed ? 'Allowed' : 'Restricted'}
|
||||
</span>
|
||||
@@ -415,11 +433,11 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-[#98a1ad]">No usage restrictions.</p>
|
||||
<p className="text-[11px] text-text-tertiary">No usage restrictions.</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -433,10 +451,10 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{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">
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" 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>}
|
||||
<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-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">{f.type}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<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 }) => (
|
||||
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
|
||||
style={{
|
||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
|
||||
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
|
||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : 'var(--bg-tertiary)',
|
||||
color: warn ? '#d97706' : ok ? '#16a34a' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -469,25 +487,65 @@ const FormsTab: React.FC<{ fields: Annotation[]; onNavigate: (a: Annotation) =>
|
||||
}
|
||||
return (
|
||||
<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) => (
|
||||
<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">
|
||||
<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="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="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-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 className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#98a1ad]">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>
|
||||
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">p.{f.pageIndex + 1}</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-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-text-tertiary">p.{f.pageIndex + 1}</span>}
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from './ui';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
interface RedactPagesModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (pagesString: string) => void;
|
||||
}
|
||||
|
||||
export const RedactPagesModal: React.FC<RedactPagesModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
const [pagesString, setPagesString] = useState('');
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Redact Pages">
|
||||
<div className="flex w-[400px] flex-col gap-4 p-5">
|
||||
<p className="text-[13px] text-[#4b5563]">
|
||||
Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction.
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[11.5px] font-semibold text-[#18212e]">Pages</label>
|
||||
<input
|
||||
type="text"
|
||||
className="h-[36px] w-full rounded-[8px] border border-[#d1d5db] bg-white px-3 text-[13.5px] shadow-sm outline-none transition-colors focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb]"
|
||||
placeholder="e.g. 1, 3-5"
|
||||
value={pagesString}
|
||||
onChange={(e) => setPagesString(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-[#ebedf0] pt-4">
|
||||
<CustomButton variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (pagesString.trim()) {
|
||||
onConfirm(pagesString.trim());
|
||||
setPagesString('');
|
||||
}
|
||||
}}
|
||||
disabled={!pagesString.trim()}
|
||||
>
|
||||
Mark for Redaction
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +1,276 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal } from './ui';
|
||||
import { toast } from '../lib/toast';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────── */
|
||||
interface SignatureModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (dataUrl: string, aspect: number) => void;
|
||||
}
|
||||
|
||||
type Mode = 'draw' | 'type' | 'upload';
|
||||
type Mode = 'draw' | 'type' | 'upload' | 'saved';
|
||||
|
||||
interface SavedSig {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
aspect: number;
|
||||
label: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/* ─── Constants ──────────────────────────────────────────── */
|
||||
const STORAGE_KEY = 'pdf_saved_signatures';
|
||||
const CANVAS_W = 960;
|
||||
const CANVAS_H = 320;
|
||||
|
||||
const SIGNATURE_FONTS: { label: string; family: string; css: string }[] = [
|
||||
{ label: 'Script', family: 'Great Vibes', css: '"Great Vibes", cursive' },
|
||||
{ label: 'Elegant', family: 'Pacifico', css: '"Pacifico", cursive' },
|
||||
{ label: 'Classic', family: 'Dancing Script', css: '"Dancing Script", cursive' },
|
||||
{ label: 'Formal', family: 'Pinyon Script', css: '"Pinyon Script", cursive' },
|
||||
{ label: 'Bold', family: 'Satisfy', css: '"Satisfy", cursive' },
|
||||
{ label: 'Handwrite', family: 'Caveat', css: '"Caveat", cursive' },
|
||||
];
|
||||
|
||||
const INK_COLORS = [
|
||||
{ label: 'Black', value: '#0f172a' },
|
||||
{ label: 'Navy', value: '#1e3a8a' },
|
||||
{ label: 'Blue', value: '#2563eb' },
|
||||
{ label: 'Ink', value: '#312e81' },
|
||||
];
|
||||
|
||||
const THICKNESS_OPTIONS = [
|
||||
{ label: 'Thin', value: 1.5 },
|
||||
{ label: 'Medium', value: 2.8 },
|
||||
{ label: 'Thick', value: 4.5 },
|
||||
];
|
||||
|
||||
/* ─── Google Fonts loader ────────────────────────────────── */
|
||||
function useGoogleFonts() {
|
||||
useEffect(() => {
|
||||
const id = 'sig-google-fonts';
|
||||
if (document.getElementById(id)) return;
|
||||
const link = document.createElement('link');
|
||||
link.id = id;
|
||||
link.rel = 'stylesheet';
|
||||
link.href =
|
||||
'https://fonts.googleapis.com/css2?family=Great+Vibes&family=Pacifico&family=Dancing+Script:wght@700&family=Pinyon+Script&family=Satisfy&family=Caveat:wght@700&display=swap';
|
||||
document.head.appendChild(link);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* ─── Saved signatures helpers ───────────────────────────── */
|
||||
function loadSaved(): SavedSig[] {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
function persistSaved(sigs: SavedSig[]) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sigs.slice(0, 8))); } catch {}
|
||||
}
|
||||
|
||||
/* ─── Point smoothing (Catmull-Rom) ─────────────────────── */
|
||||
type Pt = { x: number; y: number; p: number };
|
||||
|
||||
function midPt(a: Pt, b: Pt): Pt {
|
||||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 };
|
||||
}
|
||||
|
||||
function strokePoints(ctx: CanvasRenderingContext2D, pts: Pt[], color: string, baseThickness: number) {
|
||||
if (pts.length < 2) return;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const prev = pts[i - 1];
|
||||
const curr = pts[i];
|
||||
const mid = midPt(prev, curr);
|
||||
const pressure = (prev.p + curr.p) / 2;
|
||||
ctx.lineWidth = baseThickness * (0.6 + pressure * 0.8);
|
||||
|
||||
ctx.beginPath();
|
||||
if (i === 1) {
|
||||
ctx.moveTo(prev.x, prev.y);
|
||||
ctx.lineTo(mid.x, mid.y);
|
||||
} else {
|
||||
const prevMid = midPt(pts[i - 2], prev);
|
||||
ctx.moveTo(prevMid.x, prevMid.y);
|
||||
ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────── */
|
||||
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
|
||||
useGoogleFonts();
|
||||
|
||||
/* tabs */
|
||||
const [mode, setMode] = useState<Mode>('draw');
|
||||
const [typed, setTyped] = useState('');
|
||||
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
|
||||
|
||||
/* draw */
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const strokes = useRef<Pt[][]>([]);
|
||||
const currentStroke = useRef<Pt[]>([]);
|
||||
const drawing = useRef(false);
|
||||
const hasInk = useRef(false);
|
||||
const [inkColor, setInkColor] = useState(INK_COLORS[0].value);
|
||||
const [thickness, setThickness] = useState(THICKNESS_OPTIONS[1].value);
|
||||
|
||||
const resetLocal = () => { setMode('draw'); setTyped(''); setUploaded(null); hasInk.current = false; };
|
||||
const handleClose = () => { resetLocal(); onClose(); };
|
||||
/* type */
|
||||
const [typed, setTyped] = useState('');
|
||||
const [fontIdx, setFontIdx] = useState(0);
|
||||
const [typeColor, setTypeColor] = useState(INK_COLORS[0].value);
|
||||
|
||||
const clearCanvas = () => {
|
||||
/* upload */
|
||||
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
/* saved */
|
||||
const [savedSigs, setSavedSigs] = useState<SavedSig[]>([]);
|
||||
|
||||
/* load saved on open */
|
||||
useEffect(() => { if (open) setSavedSigs(loadSaved()); }, [open]);
|
||||
|
||||
/* redraw canvas after color/thickness change */
|
||||
const redrawAll = useCallback(() => {
|
||||
const c = canvasRef.current;
|
||||
if (!c) return;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
for (const stroke of strokes.current) {
|
||||
strokePoints(ctx, stroke, inkColor, thickness);
|
||||
}
|
||||
}, [inkColor, thickness]);
|
||||
|
||||
useEffect(() => { if (mode === 'draw') redrawAll(); }, [inkColor, thickness, mode, redrawAll]);
|
||||
|
||||
/* ── reset on close ── */
|
||||
const resetLocal = () => {
|
||||
setMode('draw');
|
||||
setTyped('');
|
||||
setUploaded(null);
|
||||
hasInk.current = false;
|
||||
strokes.current = [];
|
||||
currentStroke.current = [];
|
||||
const c = canvasRef.current;
|
||||
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
|
||||
};
|
||||
const handleClose = () => { resetLocal(); onClose(); };
|
||||
|
||||
/* ── canvas clear ── */
|
||||
const clearCanvas = () => {
|
||||
strokes.current = [];
|
||||
currentStroke.current = [];
|
||||
hasInk.current = false;
|
||||
const c = canvasRef.current;
|
||||
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
|
||||
};
|
||||
|
||||
const pos = (e: React.PointerEvent) => {
|
||||
/* ── pointer helpers ── */
|
||||
const canvasPos = (e: React.PointerEvent): Pt => {
|
||||
const c = canvasRef.current!;
|
||||
const r = c.getBoundingClientRect();
|
||||
return { x: (e.clientX - r.left) * (c.width / r.width), y: (e.clientY - r.top) * (c.height / r.height) };
|
||||
return {
|
||||
x: (e.clientX - r.left) * (c.width / r.width),
|
||||
y: (e.clientY - r.top) * (c.height / r.height),
|
||||
p: e.pressure > 0 ? e.pressure : 0.5,
|
||||
};
|
||||
};
|
||||
|
||||
const onDown = (e: React.PointerEvent) => {
|
||||
drawing.current = true;
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
const { x, y } = pos(e);
|
||||
ctx.beginPath(); ctx.moveTo(x, y);
|
||||
const pt = canvasPos(e);
|
||||
currentStroke.current = [pt];
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
if (!drawing.current) return;
|
||||
const pt = canvasPos(e);
|
||||
currentStroke.current.push(pt);
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
const { x, y } = pos(e);
|
||||
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
const stroke = currentStroke.current;
|
||||
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
|
||||
hasInk.current = true;
|
||||
};
|
||||
const onUp = () => { drawing.current = false; };
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'draw') {
|
||||
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; }
|
||||
const c = canvasRef.current!;
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
} else if (mode === 'type') {
|
||||
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 600; c.height = 200;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = '#1b2430';
|
||||
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
|
||||
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
|
||||
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
} else if (mode === 'upload') {
|
||||
if (!uploaded) { toast('Upload an image first', 'error'); return; }
|
||||
onConfirm(uploaded.url, uploaded.aspect);
|
||||
const onUp = () => {
|
||||
if (drawing.current && currentStroke.current.length > 0) {
|
||||
strokes.current.push([...currentStroke.current]);
|
||||
currentStroke.current = [];
|
||||
}
|
||||
drawing.current = false;
|
||||
};
|
||||
|
||||
/* ── build final dataUrl from typed ── */
|
||||
const buildTypedCanvas = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 900; c.height = 240;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = typeColor;
|
||||
ctx.font = `bold 100px ${SIGNATURE_FONTS[fontIdx].css}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(typed.trim() || 'Preview', c.width / 2, c.height / 2);
|
||||
return { dataUrl: c.toDataURL('image/png'), aspect: c.width / c.height };
|
||||
};
|
||||
|
||||
/* ── confirm ── */
|
||||
const handleConfirm = () => {
|
||||
let dataUrl = '';
|
||||
let aspect = 3;
|
||||
|
||||
if (mode === 'draw') {
|
||||
if (!hasInk.current) return;
|
||||
const c = canvasRef.current!;
|
||||
dataUrl = c.toDataURL('image/png');
|
||||
aspect = c.width / c.height;
|
||||
} else if (mode === 'type') {
|
||||
if (!typed.trim()) return;
|
||||
const res = buildTypedCanvas();
|
||||
dataUrl = res.dataUrl; aspect = res.aspect;
|
||||
} else if (mode === 'upload') {
|
||||
if (!uploaded) return;
|
||||
dataUrl = uploaded.url; aspect = uploaded.aspect;
|
||||
} else if (mode === 'saved') {
|
||||
return;
|
||||
}
|
||||
|
||||
saveToStorage(dataUrl, aspect);
|
||||
onConfirm(dataUrl, aspect);
|
||||
resetLocal();
|
||||
};
|
||||
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const saveToStorage = (dataUrl: string, aspect: number) => {
|
||||
const prev = loadSaved();
|
||||
const entry: SavedSig = {
|
||||
id: `sig_${Date.now()}`,
|
||||
dataUrl,
|
||||
aspect,
|
||||
label: mode === 'type' ? (typed.trim() || 'Signature') : 'Signature',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const updated = [entry, ...prev.filter((s) => s.dataUrl !== dataUrl)];
|
||||
persistSaved(updated);
|
||||
};
|
||||
|
||||
const handleUseSaved = (s: SavedSig) => {
|
||||
saveToStorage(s.dataUrl, s.aspect); // bump to top
|
||||
onConfirm(s.dataUrl, s.aspect);
|
||||
resetLocal();
|
||||
};
|
||||
|
||||
const handleDeleteSaved = (id: string) => {
|
||||
const updated = savedSigs.filter((s) => s.id !== id);
|
||||
setSavedSigs(updated);
|
||||
persistSaved(updated);
|
||||
};
|
||||
|
||||
/* ── upload handlers ── */
|
||||
const processFile = (f: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
img.src = url;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) processFile(f);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault(); setDragging(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f && f.type.startsWith('image/')) processFile(f);
|
||||
};
|
||||
|
||||
/* ── tab labels ── */
|
||||
const tabs: { id: Mode; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'draw', label: 'Draw',
|
||||
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
|
||||
},
|
||||
{
|
||||
id: 'type', label: 'Type',
|
||||
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>,
|
||||
},
|
||||
{
|
||||
id: 'upload', label: 'Upload',
|
||||
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>,
|
||||
},
|
||||
{
|
||||
id: 'saved', label: 'Saved',
|
||||
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Baseline guide on canvas ── */
|
||||
useEffect(() => {
|
||||
if (!open || mode !== 'draw') return;
|
||||
const c = canvasRef.current;
|
||||
if (!c || hasInk.current || strokes.current.length > 0) return;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
// baseline guide
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([8, 10]);
|
||||
ctx.strokeStyle = '#c7d2e0';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.moveTo(60, c.height * 0.72);
|
||||
ctx.lineTo(c.width - 60, c.height * 0.72);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}, [open, mode]);
|
||||
|
||||
/* ── Render ── */
|
||||
if (!open) return null;
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Create signature"
|
||||
width={580}
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
|
||||
</>
|
||||
}
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
|
||||
onMouseDown={handleClose}
|
||||
>
|
||||
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
|
||||
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
|
||||
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
|
||||
{m}
|
||||
</CustomButton>
|
||||
))}
|
||||
<div
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
style={{
|
||||
width: 660,
|
||||
maxHeight: '92vh',
|
||||
borderRadius: 16,
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 32px 80px rgba(10,15,30,0.28), 0 0 0 1px rgba(0,0,0,0.07)',
|
||||
animation: 'sigModalIn 0.22s cubic-bezier(0.34,1.4,0.64,1)',
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '18px 24px 16px',
|
||||
borderBottom: '1px solid #edf0f5',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 34, height: 34, borderRadius: 8,
|
||||
background: '#eef4ff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', margin: 0, lineHeight: 1.2 }}>
|
||||
Add Signature
|
||||
</h2>
|
||||
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0, marginTop: 1 }}>
|
||||
Draw, type, or upload your signature
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: 8, border: '1px solid #edf0f5',
|
||||
background: '#f8fafc', cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', color: '#64748b',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f1f5f9'; (e.currentTarget as HTMLButtonElement).style.color = '#0f172a'; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; (e.currentTarget as HTMLButtonElement).style.color = '#64748b'; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tab bar ── */}
|
||||
<div style={{ display: 'flex', gap: 4, padding: '12px 20px 0', background: '#f8fafc', borderBottom: '1px solid #edf0f5' }}>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setMode(t.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 16px', borderRadius: '8px 8px 0 0',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
|
||||
transition: 'all 0.15s',
|
||||
background: mode === t.id ? '#ffffff' : 'transparent',
|
||||
color: mode === t.id ? '#2563eb' : '#64748b',
|
||||
borderBottom: mode === t.id ? '2px solid #2563eb' : '2px solid transparent',
|
||||
boxShadow: mode === t.id ? '0 -2px 12px rgba(37,99,235,0.07), inset 0 0 0 1px rgba(37,99,235,0.08)' : 'none',
|
||||
position: 'relative', bottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.icon}
|
||||
{t.label}
|
||||
{t.id === 'saved' && savedSigs.length > 0 && (
|
||||
<span style={{
|
||||
background: '#2563eb', color: '#fff', borderRadius: 20,
|
||||
fontSize: 9, fontWeight: 700, padding: '1px 5px', lineHeight: 1.6,
|
||||
}}>
|
||||
{savedSigs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div style={{ padding: '20px 24px', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
|
||||
{/* ══ DRAW ══ */}
|
||||
{mode === 'draw' && (
|
||||
<div>
|
||||
{/* Controls row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
||||
{/* Ink color */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Ink</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
title={c.label}
|
||||
onClick={() => setInkColor(c.value)}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
background: c.value, border: 'none', cursor: 'pointer',
|
||||
outline: inkColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
|
||||
outlineOffset: 3, transition: 'all 0.12s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
|
||||
|
||||
{/* Thickness */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Thickness</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{THICKNESS_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
title={o.label}
|
||||
onClick={() => setThickness(o.value)}
|
||||
style={{
|
||||
width: 32, height: 30, border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
background: thickness === o.value ? '#eff6ff' : '#ffffff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: thickness === o.value ? 'inset 0 0 0 1.5px #2563eb' : 'inset 0 0 0 1px #e2e8f0',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: o.value * 0.9,
|
||||
borderRadius: 99,
|
||||
background: thickness === o.value ? '#2563eb' : '#64748b',
|
||||
transition: 'all 0.12s',
|
||||
}} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear */}
|
||||
<CustomButton variant="outline" size="sm" onClick={clearCanvas}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
||||
Clear
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: '0 0 0 1.5px #e2e8f0, 0 4px 20px rgba(0,0,0,0.05)' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_W}
|
||||
height={CANVAS_H}
|
||||
onPointerDown={onDown}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerCancel={onUp}
|
||||
style={{
|
||||
width: '100%', height: 200,
|
||||
display: 'block', cursor: 'crosshair',
|
||||
background: '#f8fafc',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
{!hasInk.current && strokes.current.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#c7d2e0" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
||||
</svg>
|
||||
<p style={{ fontSize: 12, color: '#b8c5d6', fontWeight: 500, margin: 0 }}>Sign here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, textAlign: 'center' }}>
|
||||
Use mouse, stylus, or finger — pressure sensitivity supported
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ TYPE ══ */}
|
||||
{mode === 'type' && (
|
||||
<div>
|
||||
{/* Name input */}
|
||||
<input
|
||||
autoFocus
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder="Type your full name"
|
||||
maxLength={60}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
padding: '11px 14px', borderRadius: 9,
|
||||
border: '1.5px solid #e2e8f0', background: '#f8fafc',
|
||||
fontSize: 14, fontWeight: 500, color: '#0f172a', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = '#2563eb')}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = '#e2e8f0')}
|
||||
/>
|
||||
|
||||
{/* Font picker */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<p style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
|
||||
Style
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{SIGNATURE_FONTS.map((f, i) => (
|
||||
<button
|
||||
key={f.family}
|
||||
onClick={() => setFontIdx(i)}
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 9,
|
||||
border: fontIdx === i ? '1.5px solid #2563eb' : '1.5px solid #e2e8f0',
|
||||
background: fontIdx === i ? '#eff6ff' : '#f8fafc',
|
||||
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s',
|
||||
display: 'flex', flexDirection: 'column', gap: 3,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: fontIdx === i ? '#2563eb' : '#94a3b8', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
||||
{f.label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: f.css, fontSize: 26, color: typeColor,
|
||||
lineHeight: 1.2, display: 'block', maxWidth: '100%',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{typed.trim() || 'Preview'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ink color */}
|
||||
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Color</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
title={c.label}
|
||||
onClick={() => setTypeColor(c.value)}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
background: c.value, border: 'none', cursor: 'pointer',
|
||||
outline: typeColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
|
||||
outlineOffset: 3, transition: 'all 0.12s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live preview */}
|
||||
<div style={{
|
||||
marginTop: 16, height: 90, borderRadius: 10,
|
||||
background: 'linear-gradient(180deg,#f8faff 0%,#eef2fb 100%)',
|
||||
border: '1.5px dashed #c7d7f5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: SIGNATURE_FONTS[fontIdx].css,
|
||||
fontSize: 54, color: typeColor, lineHeight: 1,
|
||||
maxWidth: '90%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{typed.trim() || 'Your Signature'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ UPLOAD ══ */}
|
||||
{mode === 'upload' && (
|
||||
<div>
|
||||
<label
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
minHeight: 220, borderRadius: 12, cursor: 'pointer',
|
||||
border: `2px dashed ${dragging ? '#2563eb' : uploaded ? '#c7d7f5' : '#cbd5e1'}`,
|
||||
background: dragging ? '#eff6ff' : uploaded ? '#f8faff' : '#f8fafc',
|
||||
transition: 'all 0.18s', gap: 10,
|
||||
}}
|
||||
>
|
||||
{uploaded ? (
|
||||
<>
|
||||
<img
|
||||
src={uploaded.url}
|
||||
alt="signature preview"
|
||||
style={{ maxHeight: 160, maxWidth: '85%', objectFit: 'contain', borderRadius: 6 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: '#2563eb', fontWeight: 600, marginTop: 4 }}>
|
||||
Click to replace
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14,
|
||||
background: 'linear-gradient(135deg,#eff6ff,#e0e7ff)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/>
|
||||
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>
|
||||
Drop image here or click to browse
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: '#94a3b8', margin: '4px 0 0' }}>
|
||||
PNG with transparent background gives the best result
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, color: '#2563eb',
|
||||
padding: '6px 14px', borderRadius: 7, background: '#eff6ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
}}>
|
||||
Choose file
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
|
||||
</label>
|
||||
|
||||
{uploaded && (
|
||||
<button
|
||||
onClick={() => setUploaded(null)}
|
||||
style={{
|
||||
marginTop: 10, width: '100%', padding: '7px 0', borderRadius: 8,
|
||||
border: '1px solid #fee2e2', background: '#fff5f5', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, color: '#dc2626',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
||||
Remove image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ SAVED ══ */}
|
||||
{mode === 'saved' && (
|
||||
<div>
|
||||
{savedSigs.length === 0 ? (
|
||||
<div style={{
|
||||
minHeight: 200, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 14, background: '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>No saved signatures</p>
|
||||
<p style={{ fontSize: 12, color: '#94a3b8', margin: 0 }}>Your signatures will appear here after use</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{savedSigs.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px', borderRadius: 10,
|
||||
border: '1.5px solid #edf0f5', background: '#f8fafc',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#bfdbfe')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#edf0f5')}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div style={{
|
||||
width: 120, height: 52, borderRadius: 8, overflow: 'hidden',
|
||||
background: '#fff', border: '1px solid #e2e8f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<img
|
||||
src={s.dataUrl}
|
||||
alt={s.label}
|
||||
style={{ maxWidth: '95%', maxHeight: '90%', objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', margin: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{s.label}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: '#94a3b8', margin: '2px 0 0' }}>
|
||||
{new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleUseSaved(s)}
|
||||
>
|
||||
Use
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteSaved(s.id)}
|
||||
title="Delete"
|
||||
style={{ padding: '0 8px' }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{mode !== 'saved' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 24px', borderTop: '1px solid #edf0f5',
|
||||
background: '#f8fafc',
|
||||
}}>
|
||||
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0 }}>
|
||||
⚡ Visual signature — placed as image overlay
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Use Signature
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyframe animation */}
|
||||
<style>{`
|
||||
@keyframes sigModalIn {
|
||||
from { opacity: 0; transform: scale(0.94) translateY(12px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
{mode === 'draw' && (
|
||||
<div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={920}
|
||||
height={300}
|
||||
onPointerDown={onDown}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerCancel={onUp}
|
||||
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
|
||||
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'type' && (
|
||||
<div>
|
||||
<input
|
||||
autoFocus
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder="Type your name"
|
||||
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
|
||||
/>
|
||||
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9]">
|
||||
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: '#18212e' }}>
|
||||
{typed || 'Preview'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'upload' && (
|
||||
<div>
|
||||
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9] text-[#5b6573] hover:border-[#2563eb]">
|
||||
{uploaded ? (
|
||||
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[13px] font-semibold">Click to upload an image</span>
|
||||
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only — not a certified e-signature.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,40 +88,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
</CustomButton>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
@@ -133,7 +99,23 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
@@ -9,22 +9,23 @@ import {
|
||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||
} from './icons';
|
||||
|
||||
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
interface ToolDef { id: ToolId; label: string; shortLabel?: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
|
||||
const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{ id: 'select', label: 'Select & copy text', shortcut: 'V', icon: <SelectIcon /> },
|
||||
{ id: 'select', label: 'Select & copy text', shortLabel: 'Select', shortcut: 'V', icon: <SelectIcon /> },
|
||||
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
|
||||
'divider',
|
||||
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
||||
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
||||
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
id: 'edit_text',
|
||||
label: 'Edit text',
|
||||
shortLabel: 'Edit',
|
||||
shortcut: 'E',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -36,6 +37,7 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{
|
||||
id: 'stream_edit',
|
||||
label: 'Raw Text (beta)',
|
||||
shortLabel: 'Raw',
|
||||
shortcut: 'Q',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -66,24 +68,24 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
|
||||
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
|
||||
disabled
|
||||
? 'cursor-not-allowed text-[#c5cad1]'
|
||||
: active
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: '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' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
||||
const pickTool = (id: ToolId) => {
|
||||
if (disabledTools?.has(id)) {
|
||||
toast("This tool is not permitted by this document's restrictions", 'error');
|
||||
return;
|
||||
}
|
||||
onToolChange(id);
|
||||
@@ -91,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[#ebedf0] bg-[#ffffff]" style={{ width: '80px', paddingTop: '14px', paddingBottom: '12px' }}>
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
{TOOLS.map((t, i) =>
|
||||
t === 'divider'
|
||||
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
|
||||
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
|
||||
: <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} />
|
||||
</CustomButton>
|
||||
|
||||
@@ -108,23 +110,23 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
align="left"
|
||||
width={210}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||
<HelpIcon size={19} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<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) => (
|
||||
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
|
||||
<span className="text-[#5b6573]">{t.label}</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||
<span className="text-text-secondary">{t.label}</span>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||
</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">
|
||||
<span className="text-[#5b6573]">Undo / Redo</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
<span className="text-text-secondary">Undo / Redo</span>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId, ToolSettings } from '../lib/tools';
|
||||
import type { ToolId, ToolSettings, StampPreset } from '../lib/tools';
|
||||
import { STAMP_PRESETS } from '../lib/tools';
|
||||
import { ColorSwatches, Slider } from './ui';
|
||||
import {
|
||||
@@ -15,8 +15,18 @@ interface ToolbarProps {
|
||||
onSettingsChange: (patch: Partial<ToolSettings>) => void;
|
||||
onOpenSignature: () => void;
|
||||
hasSignature: boolean;
|
||||
activeStamp: string | null;
|
||||
onSelectStamp: (label: string, color: string) => void;
|
||||
activeStamp?: StampPreset | null;
|
||||
onSelectStamp: (stamp: StampPreset) => void;
|
||||
redactionMode?: 'area' | 'text';
|
||||
onRedactionModeChange?: (mode: 'area' | 'text') => void;
|
||||
pendingRedactionCount?: number;
|
||||
onApplyRedactions?: () => void;
|
||||
onClearRedactions?: () => void;
|
||||
onRedactPages?: () => void;
|
||||
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 }> = {
|
||||
@@ -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' }) => (
|
||||
<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 }) => (
|
||||
<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> = ({
|
||||
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 isRedact = activeTool === 'redact';
|
||||
|
||||
if (selectedAnnotation) {
|
||||
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
{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-[#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' }}
|
||||
>
|
||||
<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 ? '#fdecec' : '#eef4ff',
|
||||
color: isRedact ? '#dc2626' : '#2563eb',
|
||||
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||
}}
|
||||
>
|
||||
{meta?.icon}
|
||||
</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>
|
||||
<Divider />
|
||||
|
||||
@@ -142,7 +201,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
|
||||
</CustomButton>
|
||||
{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">
|
||||
{STAMP_PRESETS.map((s) => (
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
||||
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s)}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-brand-primary' : ''}`}
|
||||
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
|
||||
{s.label}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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 {
|
||||
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
|
||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
|
||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon,
|
||||
} from './icons';
|
||||
|
||||
interface TopBarProps {
|
||||
@@ -26,6 +26,8 @@ interface TopBarProps {
|
||||
onExport: () => void;
|
||||
onPrint: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
onShowVersionHistory?: () => void;
|
||||
onSave?: () => void;
|
||||
isInspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
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> = ({
|
||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
|
||||
isInspectorOpen, onToggleInspector,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onSave,
|
||||
canPrint = true, canExport = true, canAssemble = true,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -51,109 +52,110 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
return (
|
||||
<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' }}
|
||||
>
|
||||
<div className="flex shrink-0 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">
|
||||
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<Popover
|
||||
align="left"
|
||||
width={210}
|
||||
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} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-[13px]">
|
||||
<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={<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>
|
||||
</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 && (
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
|
||||
{documentName}
|
||||
</span>
|
||||
<>
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-text-secondary" title={documentName}>
|
||||
{documentName}
|
||||
</span>
|
||||
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon 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} className="text-text-secondary hover:text-text-primary"><RedoIcon size={18} /></CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-[8px] bg-[#f6f7f9] p-0.5">
|
||||
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></CustomButton>
|
||||
<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))} className="text-text-secondary hover:text-text-primary"><ZoomOutIcon size={17} /></CustomButton>
|
||||
<Popover
|
||||
align="left"
|
||||
width={140}
|
||||
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)}%
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<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) => (
|
||||
<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)}%
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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 && (
|
||||
<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
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, totalPages)}
|
||||
value={currentPage + 1}
|
||||
onChange={(e) => {
|
||||
const p = parseInt(e.target.value, 10);
|
||||
type="text"
|
||||
defaultValue={currentPage + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const p = parseInt(e.currentTarget.value, 10);
|
||||
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 className="flex shrink-0 items-center gap-2">
|
||||
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
||||
{onSave && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={isSaving || !documentName}
|
||||
className="h-8 px-3 text-[13px] font-semibold"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</CustomButton>
|
||||
)}
|
||||
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
|
||||
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName || !canExport}><DownloadIcon size={15} /> Export</CustomButton>
|
||||
<CustomButton variant="icon"
|
||||
label={isInspectorOpen ? "Hide panel" : "Show panel"}
|
||||
size={34}
|
||||
active={isInspectorOpen}
|
||||
onClick={onToggleInspector}
|
||||
className="text-[#5b6573] hover:text-[#18212e]"
|
||||
>
|
||||
<PagesIcon size={18} />
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
onClick={onClick}
|
||||
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}
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
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;
|
||||
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 }) => {
|
||||
if (healthy && engineReady) return null;
|
||||
|
||||
let color = '#98a1ad';
|
||||
let color = 'var(--text-tertiary)';
|
||||
let label = 'Offline · mock mode';
|
||||
let tip = 'Gateway not reachable — running on mock data.';
|
||||
if (healthy === null) {
|
||||
@@ -191,7 +193,7 @@ const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> =
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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
|
||||
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()}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-7 pb-6">
|
||||
@@ -50,13 +50,13 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
|
||||
</svg>
|
||||
</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>
|
||||
<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 className="flex items-center justify-end gap-3 bg-[#f6f7f9] px-7 py-5 border-t border-[#ebedf0]">
|
||||
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-[#5b6573]">
|
||||
<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-text-secondary">
|
||||
Cancel
|
||||
</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 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 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 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 ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
|
||||
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 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 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 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" /></>);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { subscribeToasts, dismissToast } from '../lib/toast';
|
||||
import type { ToastItem } from '../lib/toast';
|
||||
import { XIcon, CheckIcon, InfoIcon } from './icons';
|
||||
import { XIcon } from './icons';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
interface PopoverProps {
|
||||
@@ -32,7 +30,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
|
||||
{open && (
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -59,12 +57,12 @@ export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, p
|
||||
title={c}
|
||||
onClick={() => onChange(c)}
|
||||
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 }}
|
||||
/>
|
||||
))}
|
||||
<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"
|
||||
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" />
|
||||
@@ -84,7 +82,7 @@ interface SliderProps {
|
||||
}
|
||||
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
|
||||
<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
|
||||
type="range"
|
||||
min={min}
|
||||
@@ -93,9 +91,9 @@ export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onCha
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -108,17 +106,17 @@ interface EmptyStateProps {
|
||||
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">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
|
||||
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</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-text-tertiary">{hint}</p>}
|
||||
</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
|
||||
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()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
|
||||
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
|
||||
<div className="flex items-center justify-between border-b border-border-primary px-6 py-4">
|
||||
<h2 className="text-[15px] font-bold text-text-primary">{title}</h2>
|
||||
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
|
||||
</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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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 "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,
|
||||
.custom-scrollbar::-webkit-scrollbar,
|
||||
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
@@ -11,19 +71,26 @@
|
||||
.scroll-thin::-webkit-scrollbar-thumb,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
.viewer-viewport::-webkit-scrollbar-thumb {
|
||||
background: #dadde2;
|
||||
background: var(--border-secondary);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.scroll-thin::-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) */
|
||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
|
||||
/* Micro scrollbar (used by narrow sidebars) */
|
||||
.scroll-micro { scrollbar-width: thin; }
|
||||
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: 9999px; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@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)}`;
|
||||
}
|
||||
|
||||
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);
|
||||
const key = keyOf(documentId, internalFontId);
|
||||
const existing = fontPromises.get(key);
|
||||
@@ -25,9 +32,14 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
|
||||
const p = (async (): Promise<string | null> => {
|
||||
const bytes = await gatewayService.getFontData(documentId, internalFontId);
|
||||
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 {
|
||||
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();
|
||||
document.fonts.add(face);
|
||||
fontFaces.set(key, face);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReflowLayout } from './pdfiumEngine';
|
||||
|
||||
export interface PageInfo {
|
||||
index: number;
|
||||
width: number;
|
||||
@@ -136,6 +138,20 @@ export interface TextOverlayData {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface StampData {
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
fontSize: number;
|
||||
includeDate: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface RedactionData {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -207,6 +223,7 @@ export interface PageReorderData {
|
||||
|
||||
export type EditOperationDataMap = {
|
||||
text_overlay: TextOverlayData;
|
||||
stamp: StampData;
|
||||
redaction: RedactionData;
|
||||
image_overlay: ImageOverlayData;
|
||||
highlight: HighlightData;
|
||||
@@ -246,6 +263,8 @@ export interface ReflowFragment {
|
||||
fontSize: number;
|
||||
color: string;
|
||||
advances?: number[];
|
||||
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||
advanceSeedText?: string;
|
||||
}
|
||||
|
||||
export interface ReflowParagraphData {
|
||||
@@ -318,7 +337,7 @@ class GatewayService {
|
||||
public baseUrl: string;
|
||||
|
||||
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 }> {
|
||||
@@ -420,12 +439,24 @@ class GatewayService {
|
||||
}
|
||||
}
|
||||
|
||||
private renderCache = new Map<string, 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 {
|
||||
const query = new URLSearchParams({
|
||||
page: params.pageIndex.toString(),
|
||||
zoom: params.zoom.toString(),
|
||||
rotation: params.rotation.toString(),
|
||||
dpi: dpi.toString(),
|
||||
}).toString();
|
||||
|
||||
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}`);
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
this.renderCache.set(cacheKey, objectUrl);
|
||||
if (this.renderCache.size > 100) {
|
||||
const firstKey = this.renderCache.keys().next().value;
|
||||
if (firstKey) {
|
||||
const oldUrl = this.renderCache.get(firstKey)!;
|
||||
if (oldUrl.startsWith('blob:')) URL.revokeObjectURL(oldUrl);
|
||||
this.renderCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
return objectUrl;
|
||||
} catch (err) {
|
||||
return this.generateMockPage(params.pageIndex);
|
||||
}
|
||||
@@ -509,6 +552,40 @@ class GatewayService {
|
||||
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[]> {
|
||||
if (!query) return [];
|
||||
|
||||
@@ -723,6 +800,19 @@ class GatewayService {
|
||||
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> {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
|
||||
|
||||
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
const V = '20260630-fontfix3';
|
||||
const V = '20260730-advseed1';
|
||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||
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();
|
||||
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[] = [];
|
||||
for (const arr of byLine.values()) {
|
||||
const x = Math.min(...arr.map((g) => g.x));
|
||||
const right = Math.max(...arr.map((g) => g.right));
|
||||
const nonSpace = arr.filter(g => !/^\s+$/.test(g.text));
|
||||
const measureArr = nonSpace.length > 0 ? nonSpace : arr;
|
||||
const x = Math.min(...measureArr.map((g) => g.x));
|
||||
const right = Math.max(...measureArr.map((g) => g.right));
|
||||
const band = this.lines[arr[0].line];
|
||||
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
|
||||
}
|
||||
|
||||
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
q: 'stream_edit',
|
||||
};
|
||||
|
||||
export const STAMP_PRESETS = [
|
||||
{ label: 'APPROVED', color: '#16a34a' },
|
||||
{ label: 'DRAFT', color: '#6b7280' },
|
||||
{ label: 'CONFIDENTIAL', color: '#dc2626' },
|
||||
{ label: 'REVIEWED', color: '#2563eb' },
|
||||
{ label: 'FINAL', color: '#7c3aed' },
|
||||
{ label: 'VOID', color: '#dc2626' },
|
||||
export interface StampPreset {
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
export const STAMP_PRESETS: StampPreset[] = [
|
||||
{ label: 'APPROVED', textColor: '#16a34a', backgroundColor: '#dcfce7', borderColor: '#16a34a' },
|
||||
{ label: 'DRAFT', textColor: '#6b7280', backgroundColor: '#f3f4f6', borderColor: '#6b7280' },
|
||||
{ label: 'CONFIDENTIAL', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
|
||||
{ label: 'REVIEWED', textColor: '#2563eb', backgroundColor: '#dbeafe', borderColor: '#2563eb' },
|
||||
{ label: 'FINAL', textColor: '#7c3aed', backgroundColor: '#ede9fe', borderColor: '#7c3aed' },
|
||||
{ label: 'VOID', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
timestamp?: string;
|
||||
paths?: { x: number; y: number }[][];
|
||||
quadPoints?: { x: number; y: number }[][];
|
||||
pageIndex?: number;
|
||||
|
||||
fieldName?: string;
|
||||
@@ -30,6 +31,7 @@ interface AnnotationLayerProps {
|
||||
onAnnotationClick?: (annotation: Annotation) => void;
|
||||
onAnnotationUpdate?: (annotation: Annotation) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
isSelectToolActive?: boolean;
|
||||
}
|
||||
|
||||
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
@@ -41,12 +43,13 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onAnnotationClick,
|
||||
onAnnotationUpdate,
|
||||
onFieldChange,
|
||||
isSelectToolActive,
|
||||
}) => {
|
||||
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
|
||||
const [dragStartPos, setDragStartPos] = 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) => {
|
||||
e.stopPropagation();
|
||||
@@ -78,7 +81,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
...anno.bbox,
|
||||
x: anno.bbox.x + dragOffset.x,
|
||||
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 {
|
||||
onAnnotationClick?.(anno);
|
||||
@@ -91,7 +96,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
>
|
||||
{annotations
|
||||
.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) => {
|
||||
const scaledBbox = {
|
||||
x: anno.bbox.x * zoom,
|
||||
@@ -114,19 +119,29 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onPointerDown={(e) => handlePointerDown(e, anno)}
|
||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, anno)}
|
||||
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
|
||||
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={{
|
||||
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
|
||||
width: `${scaledBbox.width}px`,
|
||||
height: `${scaledBbox.height}px`,
|
||||
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
|
||||
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
|
||||
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
|
||||
backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
|
||||
opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
|
||||
mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
{anno.type === 'highlight' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMin * zoom - scaledBbox.y;
|
||||
return <div key={i} className="absolute" style={{ left, top, width: (xMax - xMin) * zoom, height: (yMax - yMin) * zoom, backgroundColor: anno.color || '#ffeb3b', opacity: anno.opacity ?? 0.4, mixBlendMode: 'multiply' }} />
|
||||
})}
|
||||
{anno.type === 'comment' && (
|
||||
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
|
||||
@@ -134,9 +149,26 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{anno.type === 'strikeout' && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
|
||||
{anno.type === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
|
||||
{anno.type === 'squiggly' && (
|
||||
{anno.type === 'strikeout' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
|
||||
{anno.type === 'strikeout' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMin * zoom - scaledBbox.y + ((yMax - yMin) * zoom / 2);
|
||||
return <div key={i} className="absolute h-[1.5px] opacity-[0.85] -translate-y-1/2" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#dc2626' }} />
|
||||
})}
|
||||
{anno.type === 'underline' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
|
||||
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
return <div key={i} className="absolute h-[1.5px] opacity-[0.85]" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#2563eb' }} />
|
||||
})}
|
||||
{anno.type === 'squiggly' && (!anno.quadPoints || anno.quadPoints.length === 0) && (
|
||||
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
|
||||
<defs>
|
||||
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
|
||||
@@ -146,6 +178,23 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
|
||||
</svg>
|
||||
)}
|
||||
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
return (
|
||||
<svg key={i} style={{position: 'absolute', left, top, width: (xMax - xMin) * zoom, height: 4, opacity: 0.85}} xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={`sq-${anno.id}-${i}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
|
||||
<path d="M 0 2 Q 1.5 0 3 2 T 6 2" fill="none" stroke={anno.color || '#16a34a'} strokeWidth="1.2" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id}-${i})`} />
|
||||
</svg>
|
||||
)
|
||||
})}
|
||||
{anno.type === 'signature' && (
|
||||
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -60,43 +60,7 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
ctx.restore();
|
||||
|
||||
// Render vector ops on top
|
||||
if (vectorOps && vectorOps.length > 0) {
|
||||
ctx.save();
|
||||
ctx.translate(0, height);
|
||||
ctx.scale(1, -1);
|
||||
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
||||
ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
for (const op of vectorOps) {
|
||||
if (op.op === 'm' && op.args && op.args.length === 2) {
|
||||
ctx.moveTo(op.args[0], op.args[1]);
|
||||
} else if (op.op === 'l' && op.args && op.args.length === 2) {
|
||||
ctx.lineTo(op.args[0], op.args[1]);
|
||||
} else if (op.op === 'c' && op.args && op.args.length === 6) {
|
||||
ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]);
|
||||
} else if (op.op === 're' && op.args && op.args.length === 4) {
|
||||
ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]);
|
||||
} else if (op.op === 'h') {
|
||||
ctx.closePath();
|
||||
} else if (op.op === 'S' || op.op === 's') {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'f' || op.op === 'F') {
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'B' || op.op === 'b') {
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'n') {
|
||||
ctx.beginPath();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onRenderComplete?.(pageIndex);
|
||||
};
|
||||
|
||||
@@ -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 type { Annotation } from './AnnotationLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import type { StampPreset } from '../lib/tools';
|
||||
|
||||
interface OverlayLayerProps {
|
||||
pageIndex: number;
|
||||
@@ -14,11 +15,12 @@ interface OverlayLayerProps {
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
hasSignature: boolean;
|
||||
activeStamp: string | null;
|
||||
activeStamp: StampPreset | null;
|
||||
onAnnotationAdded?: (anno: Annotation) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
/** Called when user clicks to begin interactive signature placement */
|
||||
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
const TEXTBOX_WIDTH_PTS = 200;
|
||||
@@ -28,7 +30,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
|
||||
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
pageIndex, width, height, activeTool, zoom,
|
||||
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
|
||||
}) => {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
|
||||
@@ -88,7 +90,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
} else if (activeTool === 'stamp' && activeStamp) {
|
||||
onPlaceStamp?.(pageIndex, coords);
|
||||
} else if (activeTool === 'signature' && hasSignature) {
|
||||
onPlaceSignature?.(pageIndex, coords);
|
||||
// Pass viewport coordinates (relative to page) to begin interactive placement
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
onBeginPlacement?.(pageIndex, {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,40 +135,40 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
onClick={handleLayerClick}
|
||||
>
|
||||
{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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<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 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handleCommentSubmit}>
|
||||
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
|
||||
<span className="text-xs font-bold">Add sticky note</span>
|
||||
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
|
||||
<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 text-text-primary">Add sticky note</span>
|
||||
<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>
|
||||
</CustomButton>
|
||||
</div>
|
||||
<textarea
|
||||
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); } }}
|
||||
/>
|
||||
<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>
|
||||
</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 === '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…"
|
||||
style={{
|
||||
left: `${textBox.x * zoom}px`,
|
||||
|
||||
+800
-467
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,18 @@ interface RedactionLayerProps {
|
||||
width: number;
|
||||
height: number;
|
||||
onRedactionSelected: (bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
mode?: 'area' | 'text';
|
||||
}
|
||||
|
||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
width,
|
||||
height,
|
||||
onRedactionSelected,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
mode = 'area',
|
||||
}) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||
@@ -62,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
left: 0,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
cursor: 'crosshair',
|
||||
cursor: mode === 'area' ? 'crosshair' : 'default',
|
||||
zIndex: 25,
|
||||
pointerEvents: mode === 'area' ? 'auto' : 'none',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -86,6 +93,26 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingRedactions.map((redaction) => (
|
||||
<div
|
||||
key={redaction.id}
|
||||
className="group absolute"
|
||||
style={{
|
||||
left: `${redaction.bounds.x}px`,
|
||||
top: `${redaction.bounds.y}px`,
|
||||
width: `${redaction.bounds.width}px`,
|
||||
height: `${redaction.bounds.height}px`,
|
||||
border: '2px solid #ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)',
|
||||
zIndex: 30,
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
|
||||
glyphs: Glyph[];
|
||||
mode?: 'select' | 'highlight';
|
||||
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
||||
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
|
||||
}
|
||||
|
||||
const SEL_START_EVT = 'pdf-selection-start';
|
||||
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
glyphs,
|
||||
mode = 'select',
|
||||
onTextSelected,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
setBox(null);
|
||||
drag.current = null;
|
||||
boxStart.current = null;
|
||||
}, []);
|
||||
onSelectionChange?.(null);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOther = (e: Event) => {
|
||||
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
const cur = selRef.current;
|
||||
if (!cur || cur.start === cur.end) {
|
||||
setSel(null);
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
||||
|
||||
if (mode === 'highlight') {
|
||||
const text = model.textOfRange(cur);
|
||||
const u = model.unionRect(cur);
|
||||
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
||||
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
||||
setSel(null);
|
||||
onSelectionChange?.(null);
|
||||
} else if (mode === 'select') {
|
||||
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* SignaturePlacementOverlay
|
||||
* Adobe-style interactive signature placement: drag to reposition,
|
||||
* corner/edge handles to resize, rotation handle to rotate.
|
||||
* Sits as an absolute child inside the page div.
|
||||
*/
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { CustomButton } from '../components/custom/CustomButton';
|
||||
|
||||
export interface PlacementRect {
|
||||
/** All values in viewport-pixels relative to the page div */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number; // degrees
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imageUrl: string;
|
||||
aspect: number;
|
||||
initialRect: PlacementRect;
|
||||
pageWidth: number; // viewport px
|
||||
pageHeight: number; // viewport px
|
||||
onCommit: (rect: PlacementRect) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'move';
|
||||
|
||||
const MIN_SIZE = 40;
|
||||
|
||||
const HANDLE_CURSORS: Record<Handle, string> = {
|
||||
nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize',
|
||||
e: 'ew-resize', se: 'nwse-resize', s: 'ns-resize',
|
||||
sw: 'nesw-resize', w: 'ew-resize',
|
||||
rotate: 'grab', move: 'move',
|
||||
};
|
||||
|
||||
const HANDLE_POSITIONS: Record<Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }> = {
|
||||
nw: { left: '-5px', top: '-5px' },
|
||||
n: { left: 'calc(50% - 5px)', top: '-5px' },
|
||||
ne: { left: 'calc(100% - 5px)', top: '-5px' },
|
||||
e: { left: 'calc(100% - 5px)', top: 'calc(50% - 5px)' },
|
||||
se: { left: 'calc(100% - 5px)', top: 'calc(100% - 5px)' },
|
||||
s: { left: 'calc(50% - 5px)', top: 'calc(100% - 5px)' },
|
||||
sw: { left: '-5px', top: 'calc(100% - 5px)' },
|
||||
w: { left: '-5px', top: 'calc(50% - 5px)' },
|
||||
};
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<Props> = ({
|
||||
imageUrl, aspect, initialRect, pageWidth, pageHeight, onCommit, onCancel,
|
||||
}) => {
|
||||
const [rect, setRect] = useState<PlacementRect>(initialRect);
|
||||
const rectRef = useRef(rect);
|
||||
rectRef.current = rect;
|
||||
|
||||
const dragRef = useRef<{
|
||||
handle: Handle;
|
||||
startX: number; startY: number;
|
||||
startRect: PlacementRect;
|
||||
centerX: number; centerY: number;
|
||||
} | null>(null);
|
||||
|
||||
/* Clamp rect to page bounds */
|
||||
const clamp = useCallback((r: PlacementRect): PlacementRect => {
|
||||
const w = Math.max(MIN_SIZE, r.width);
|
||||
const h = Math.max(MIN_SIZE, r.height);
|
||||
const x = Math.max(0, Math.min(pageWidth - w, r.x));
|
||||
const y = Math.max(0, Math.min(pageHeight - h, r.y));
|
||||
return { ...r, x, y, width: w, height: h };
|
||||
}, [pageWidth, pageHeight]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent, handle: Handle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const r = rectRef.current;
|
||||
dragRef.current = {
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startRect: { ...r },
|
||||
centerX: r.x + r.width / 2,
|
||||
centerY: r.y + r.height / 2,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const dx = e.clientX - d.startX;
|
||||
const dy = e.clientY - d.startY;
|
||||
const { startRect, handle } = d;
|
||||
|
||||
setRect(() => {
|
||||
let { x, y, width, height, rotation } = startRect;
|
||||
|
||||
if (handle === 'move') {
|
||||
x = startRect.x + dx;
|
||||
y = startRect.y + dy;
|
||||
} else if (handle === 'rotate') {
|
||||
// Angle from center to current mouse (uses dragRef center, not local cx/cy)
|
||||
const angle = Math.atan2(
|
||||
(e.clientY - d.centerY),
|
||||
(e.clientX - d.centerX),
|
||||
) * 180 / Math.PI + 90;
|
||||
rotation = angle;
|
||||
} else {
|
||||
// Resize — maintain aspect ratio on corners if Shift pressed (we do it always for corners)
|
||||
const isCorner = ['nw', 'ne', 'se', 'sw'].includes(handle);
|
||||
switch (handle) {
|
||||
case 'n': y = startRect.y + dy; height = startRect.height - dy; break;
|
||||
case 's': height = startRect.height + dy; break;
|
||||
case 'e': width = startRect.width + dx; break;
|
||||
case 'w': x = startRect.x + dx; width = startRect.width - dx; break;
|
||||
case 'nw': x = startRect.x + dx; y = startRect.y + dy; width = startRect.width - dx; height = startRect.height - dy; break;
|
||||
case 'ne': y = startRect.y + dy; width = startRect.width + dx; height = startRect.height - dy; break;
|
||||
case 'se': width = startRect.width + dx; height = startRect.height + dy; break;
|
||||
case 'sw': x = startRect.x + dx; width = startRect.width - dx; height = startRect.height + dy; break;
|
||||
}
|
||||
if (isCorner && e.shiftKey) {
|
||||
// Lock aspect ratio
|
||||
const newAspect = width / height;
|
||||
if (newAspect > aspect) width = height * aspect;
|
||||
else height = width / aspect;
|
||||
}
|
||||
}
|
||||
|
||||
return clamp({ x, y, width, height, rotation });
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => { dragRef.current = null; };
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
}, [clamp, aspect]);
|
||||
|
||||
/* Keyboard: Escape = cancel, Enter = commit */
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); onCommit(rectRef.current); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel, onCommit]);
|
||||
|
||||
const { x, y, width, height, rotation } = rect;
|
||||
const cx = x + width / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dim everything outside the signature */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 60,
|
||||
cursor: 'default',
|
||||
background: 'rgba(15,23,42,0.18)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onPointerDown={(e) => { e.stopPropagation(); onCancel(); }}
|
||||
/>
|
||||
|
||||
{/* Signature overlay box */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x, top: y, width, height,
|
||||
zIndex: 61,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: `${width / 2}px ${height / 2}px`,
|
||||
boxSizing: 'border-box',
|
||||
outline: '2px solid #2563eb',
|
||||
outlineOffset: '-1px',
|
||||
borderRadius: 3,
|
||||
cursor: 'move',
|
||||
pointerEvents: 'auto',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||||
>
|
||||
{/* Signature image */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="signature"
|
||||
draggable={false}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Corner + edge handles */}
|
||||
{(Object.entries(HANDLE_POSITIONS) as [Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }][]).map(([h, pos]) => (
|
||||
<div
|
||||
key={h}
|
||||
onPointerDown={(e) => onPointerDown(e, h)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: pos.left, top: pos.top,
|
||||
width: 10, height: 10,
|
||||
background: '#ffffff',
|
||||
border: '2px solid #2563eb',
|
||||
borderRadius: 2,
|
||||
cursor: HANDLE_CURSORS[h],
|
||||
zIndex: 2,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.18)',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Rotation handle — sits above the top center */}
|
||||
<div
|
||||
onPointerDown={(e) => onPointerDown(e, 'rotate')}
|
||||
title="Rotate"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 9px)',
|
||||
top: -34,
|
||||
width: 18, height: 18,
|
||||
background: '#2563eb',
|
||||
borderRadius: '50%',
|
||||
cursor: 'grab',
|
||||
zIndex: 3,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(37,99,235,0.4)',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Rotation stem */}
|
||||
<div style={{
|
||||
position: 'absolute', left: '50%', top: '100%',
|
||||
width: 1.5, height: 14,
|
||||
background: '#2563eb',
|
||||
transform: 'translateX(-50%)',
|
||||
}} />
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<path d="M21.5 2v6h-6"/><path d="M21.34 15.57a10 10 0 1 1-.57-8.38"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Size badge */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -22, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,23,42,0.75)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: '2px 7px',
|
||||
borderRadius: 99,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{Math.round(width)} × {Math.round(height)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar — centered below the signature box */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: cx - 130,
|
||||
top: y + height + 48,
|
||||
zIndex: 62,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 12,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
||||
pointerEvents: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Keyboard hint */}
|
||||
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>
|
||||
Drag · Resize · <kbd style={{ background: '#f1f5f9', padding: '1px 4px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 10 }}>Shift</kbd> lock ratio
|
||||
</span>
|
||||
|
||||
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
|
||||
|
||||
{/* Cancel */}
|
||||
<CustomButton variant="outline" size="sm" onClick={onCancel}>
|
||||
Cancel <span style={{ opacity: 0.5, fontSize: 10 }}>Esc</span>
|
||||
</CustomButton>
|
||||
|
||||
{/* Confirm */}
|
||||
<CustomButton variant="primary" size="sm" onClick={() => onCommit(rect)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Apply Signature
|
||||
<span style={{ opacity: 0.6, fontSize: 10 }}>↵</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { TextObjectResponse } from '../lib/gatewayService';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
|
||||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||
|
||||
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
if (newText === obj.text) return;
|
||||
try {
|
||||
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
|
||||
toast('Text updated successfully', 'success');
|
||||
|
||||
if (res.newDocumentId && onDocumentChanged) {
|
||||
onDocumentChanged(res.newDocumentId);
|
||||
} else {
|
||||
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
onEditSuccess();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(`Failed to update text: ${e.message}`, 'error');
|
||||
console.error('Failed to update text:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ReflowFragment {
|
||||
fontSize: number;
|
||||
color: string;
|
||||
advances?: number[];
|
||||
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||
advanceSeedText?: string;
|
||||
}
|
||||
|
||||
export interface CommitFrame {
|
||||
@@ -102,8 +104,8 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any
|
||||
}
|
||||
const leading = itemDeltas.length ? median(itemDeltas)
|
||||
: wrapDeltas.length ? median(wrapDeltas)
|
||||
: allDeltas.length ? median(allDeltas)
|
||||
: (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
|
||||
: allDeltas.length ? median(allDeltas)
|
||||
: (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
|
||||
|
||||
const firstRuns = itemLines[0].runs ?? [];
|
||||
let subLines = itemLines;
|
||||
@@ -227,21 +229,7 @@ interface TextEditLayerProps {
|
||||
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 {
|
||||
const n = (fontName || '').toLowerCase();
|
||||
@@ -271,7 +259,7 @@ function flattenRuns(model: any): EditableRun[] {
|
||||
text: r.text,
|
||||
x: r.x, y: r.y, w: r.w, h: r.h,
|
||||
baselineY,
|
||||
fontSize: r.font_size ?? r.h,
|
||||
fontSize: Math.max(r.font_size ?? 0, r.h ?? 0),
|
||||
objectIndices,
|
||||
internalFontId: r.internal_font_id ?? '',
|
||||
fontName: r.font_name ?? '',
|
||||
@@ -293,11 +281,7 @@ function median(xs: number[]): number {
|
||||
}
|
||||
|
||||
function displayFontSize(r: EditableRun): number {
|
||||
const t = r.text || '';
|
||||
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);
|
||||
return Math.max(r.fontSize ?? 0, r.h ?? 0);
|
||||
}
|
||||
|
||||
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 committedRef = useRef(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const caretIdxRef = useRef<number | null>(null);
|
||||
const modelRef = useRef<any>(null);
|
||||
const [paraEdit, setParaEdit] = useState<{
|
||||
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 });
|
||||
};
|
||||
|
||||
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 para = modelRef.current?.paragraphs?.[run.paraIndex];
|
||||
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;
|
||||
setCaretClick(click);
|
||||
if (para.lines.length > 1) {
|
||||
if (isFlowingParagraph(para)) {
|
||||
setCaretClick(click);
|
||||
openParaEdit({ para });
|
||||
return;
|
||||
}
|
||||
const item = buildBulletItem(para, run.lineIndex);
|
||||
if (item) {
|
||||
setCaretClick(click);
|
||||
openParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
|
||||
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);
|
||||
if (al === 'center' || al === 'right') {
|
||||
openParaEdit({
|
||||
para, align: al,
|
||||
columnLeft: pageContentLeft(modelRef.current),
|
||||
columnRight: pageContentRight(modelRef.current),
|
||||
});
|
||||
} else {
|
||||
openParaEdit({ para, columnRight: pageContentRight(modelRef.current) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const al = headingAlign(para.lines[0], modelRef.current);
|
||||
const pr = pageContentRight(modelRef.current);
|
||||
if (al === 'center' || al === 'right') {
|
||||
openParaEdit({
|
||||
para, align: al,
|
||||
columnLeft: pageContentLeft(modelRef.current),
|
||||
columnRight: pr,
|
||||
});
|
||||
} else {
|
||||
const paraRight = Math.max(...para.lines.map((l: any) => l.x + l.w));
|
||||
openParaEdit({ para, columnRight: Math.max(pr, paraRight) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
committedRef.current = false;
|
||||
const fb = fallbackFamily(run.fontName);
|
||||
caretIdxRef.current = caretIndexFromX(run.text, `${displayFontSize(run) * zoom}px ${fb}`, clickX);
|
||||
setEditing(i);
|
||||
setValue(run.text);
|
||||
setFontFamily(fb);
|
||||
@@ -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 res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
|
||||
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
|
||||
const cv = previewCanvasRef.current;
|
||||
if (!cv) return;
|
||||
if (cv.width !== res.width) cv.width = res.width;
|
||||
if (cv.height !== res.height) cv.height = res.height;
|
||||
const displayW = width;
|
||||
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');
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -702,11 +704,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
const idx = caretIdxRef.current;
|
||||
if (idx != null) {
|
||||
e.currentTarget.setSelectionRange(idx, idx);
|
||||
caretIdxRef.current = null;
|
||||
}
|
||||
const end = e.currentTarget.value.length;
|
||||
e.currentTarget.setSelectionRange(end, end);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -718,8 +717,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
style={{
|
||||
left: box.left,
|
||||
top: inputTop,
|
||||
width: Math.max(box.width + 120, 60),
|
||||
height: fpx,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
lineHeight: `${fpx}px`,
|
||||
fontFamily,
|
||||
fontSize: `${fpx}px`,
|
||||
|
||||
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
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):
|
||||
```powershell
|
||||
# Default port (8000)
|
||||
# Default port (8765)
|
||||
powershell -File scripts/start_gateway.ps1
|
||||
|
||||
# Custom port (e.g. 8080)
|
||||
@@ -68,7 +68,7 @@ powershell -File scripts/start_gateway.ps1 -Port 8080
|
||||
|
||||
Or run directly via uvicorn (cross-platform):
|
||||
```sh
|
||||
# Default port (8000)
|
||||
# Default port (8765)
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# Custom port (e.g. 8080)
|
||||
@@ -100,8 +100,8 @@ Additionally, the gateway startup scripts and frontend configuration support:
|
||||
|
||||
| Var | Default | Meaning |
|
||||
|--------------------|-------------------------|--------------------------------------------------|
|
||||
| `PORT` | `8000` | Gateway listening port (used by `start_gateway.ps1`). |
|
||||
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8000` | URL of the gateway API (used by the frontend). |
|
||||
| `PORT` | `8765` | Gateway listening port (used by `start_gateway.ps1`). |
|
||||
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8765` | URL of the gateway API (used by the frontend). |
|
||||
|
||||
A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored
|
||||
via the repo-wide `.venv/` and Python rules — add `.env` to your local
|
||||
|
||||
@@ -26,6 +26,17 @@ class Settings(BaseSettings):
|
||||
"Phase 0 default is False — routes that need the engine return 501."
|
||||
),
|
||||
)
|
||||
|
||||
render_cache_enabled: bool = Field(default=True)
|
||||
render_cache_max_entries: int = Field(default=256)
|
||||
render_cache_max_bytes: int = Field(
|
||||
default=536_870_912,
|
||||
description="Max total bytes for the tile render cache. Set to 0 to disable byte cap."
|
||||
)
|
||||
render_cache_max_entry_bytes: int = Field(
|
||||
default=8_388_608,
|
||||
description="Max bytes for a single cache entry. Oversized tiles are rendered but not cached."
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import __version__
|
||||
from app.routers import documents, edits, health, info, render
|
||||
from app.routers import documents, edits, health, info, internal, render
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -29,6 +29,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(render.compat_router)
|
||||
app.include_router(edits.router)
|
||||
app.include_router(edits.compat_router)
|
||||
app.include_router(internal.router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
||||
@@ -111,7 +111,9 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
pageIndex=a.page_index,
|
||||
thickness=getattr(a, "thickness", None),
|
||||
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
|
||||
quadPoints=getattr(a, "quad_points", []),
|
||||
fieldName=getattr(a, "field_name", None),
|
||||
fieldValue=getattr(a, "field_value", None),
|
||||
fieldType=getattr(a, "field_type", None),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import re
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.document import (
|
||||
DocumentInfoResponse,
|
||||
@@ -10,6 +14,83 @@ from app.services.store import document_store
|
||||
|
||||
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:
|
||||
pages_list = []
|
||||
if "doc_instance" in d:
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import httpx
|
||||
import logging
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportRemoteRequest(BaseModel):
|
||||
fresh_token: str | None = None
|
||||
|
||||
@router.get("/{document_id}/export")
|
||||
def export_document(document_id: str):
|
||||
@@ -41,3 +50,83 @@ def export_document(document_id: str):
|
||||
)
|
||||
except Exception as 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,
|
||||
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"]}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
|
||||
fontFamily: str
|
||||
color: str
|
||||
|
||||
class StampData(BaseModel):
|
||||
text: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
textColor: str
|
||||
backgroundColor: str
|
||||
borderColor: str
|
||||
fontSize: float = Field(..., gt=0)
|
||||
includeDate: bool = False
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class RedactionData(BaseModel):
|
||||
x: float
|
||||
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: TextOverlayData
|
||||
|
||||
class StampOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["stamp"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: StampData
|
||||
|
||||
|
||||
class RedactionOperation(BaseModel):
|
||||
id: str
|
||||
@@ -230,6 +249,7 @@ class ReflowRun(BaseModel):
|
||||
fontSize: float
|
||||
color: str = "#000000"
|
||||
advances: list[float] | None = None
|
||||
advanceSeedText: str | None = None
|
||||
|
||||
|
||||
class ReflowParagraphData(BaseModel):
|
||||
@@ -283,8 +303,23 @@ class SquigglyOperation(BaseModel):
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: DecorationData
|
||||
|
||||
class SignatureData(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
image_data: str | None = None
|
||||
author: str = "Signer"
|
||||
|
||||
class SignatureOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["signature"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: SignatureData
|
||||
|
||||
EditOperation = Annotated[
|
||||
TextOverlayOperation
|
||||
| StampOperation
|
||||
| RedactionOperation
|
||||
| ImageOverlayOperation
|
||||
| HighlightOperation
|
||||
@@ -301,7 +336,9 @@ EditOperation = Annotated[
|
||||
| ReflowParagraphOperation
|
||||
| UnderlineOperation
|
||||
| StrikeoutOperation
|
||||
| SquigglyOperation,
|
||||
| SquigglyOperation
|
||||
| SignatureOperation
|
||||
,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -313,8 +350,8 @@ class EditsRequest(BaseModel):
|
||||
_OP_PERMISSION = {
|
||||
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
|
||||
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
|
||||
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
|
||||
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
||||
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
|
||||
"image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
||||
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
|
||||
"update_field": "canFillForms",
|
||||
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
|
||||
@@ -361,6 +398,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
if "," in img_data_str:
|
||||
img_data_str = img_data_str.split(",", 1)[1]
|
||||
|
||||
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
|
||||
raw_bytes = base64.b64decode(img_data_str)
|
||||
img = Image.open(io.BytesIO(raw_bytes))
|
||||
img_rgba = img.convert("RGBA")
|
||||
@@ -371,6 +409,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
bgra_bytes = img_bgra.tobytes()
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
||||
temp_path = temp_path.replace("\\", "/")
|
||||
created_temp_files.append(temp_path)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
@@ -388,7 +427,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
|
||||
edits_json = json.dumps(req_dict)
|
||||
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
|
||||
doc_copy.apply_edits(edits_json)
|
||||
invalidated_regions = doc_copy.apply_edits(edits_json)
|
||||
|
||||
full_save_types = {"redaction", "replace_text", "reflow_paragraph"}
|
||||
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
|
||||
@@ -400,8 +439,13 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
if "remote_context" in doc_info:
|
||||
new_info["remote_context"] = doc_info["remote_context"]
|
||||
|
||||
from app.services.render_cache import tile_cache
|
||||
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
|
||||
|
||||
return {"success": True, "newDocumentId": new_info["id"]}
|
||||
return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
finally:
|
||||
@@ -419,6 +463,155 @@ def apply_edits(document_id: str, request: EditsRequest):
|
||||
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}")
|
||||
def apply_edits_compat(document_id: str, request: EditsRequest):
|
||||
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 fastapi import APIRouter, HTTPException, Path, Query, Response, status
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Request, Response, status
|
||||
|
||||
from app.schemas.font import FontInfoResponse
|
||||
from app.services import engine
|
||||
from app.services.font import font_info_to_response
|
||||
from app.services.render_cache import RENDERER_VERSION, RenderMode, TileCacheKey, tile_cache
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
|
||||
compat_router = APIRouter(tags=["render"])
|
||||
|
||||
def _etag(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()[:16]
|
||||
|
||||
|
||||
@router.get("/{page_index}/render")
|
||||
def render_page(
|
||||
document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96
|
||||
request: Request,
|
||||
document_id: str,
|
||||
page_index: Annotated[int, Path(ge=0)],
|
||||
dpi: int = 96,
|
||||
zoom: float = 1.0,
|
||||
rotation: int = 0,
|
||||
render_mode: RenderMode = RenderMode.NORMAL
|
||||
) -> Response:
|
||||
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:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
@@ -28,8 +115,52 @@ def render_page(
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
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:
|
||||
raise HTTPException(
|
||||
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}")
|
||||
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:
|
||||
dpi = int(96 * zoom)
|
||||
return render_page(document_id, page, dpi)
|
||||
if dpi is None:
|
||||
dpi = int(96 * zoom)
|
||||
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}")
|
||||
|
||||
@@ -13,7 +13,9 @@ class AnnotationResponse(BaseModel):
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
thickness: float | None = None
|
||||
paths: list[list[dict[str, float]]] = []
|
||||
quadPoints: list[list[dict[str, float]]] = []
|
||||
|
||||
fieldName: str | None = None
|
||||
fieldValue: str | None = None
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
RENDERER_VERSION: int = 1
|
||||
|
||||
|
||||
class RenderMode(str, Enum):
|
||||
# Current
|
||||
NORMAL = "normal"
|
||||
PRINT = "print"
|
||||
GRAYSCALE = "grayscale"
|
||||
|
||||
# Future (reserved)
|
||||
ANNOTATION_ONLY = "annotation_only"
|
||||
EDIT_PREVIEW = "edit_preview"
|
||||
SELECTION_OVERLAY = "selection_overlay"
|
||||
PROOF = "proof"
|
||||
HIGH_QUALITY = "high_quality"
|
||||
DRAFT = "draft"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileCacheKey:
|
||||
doc_hash: str
|
||||
renderer_version: int
|
||||
page: int
|
||||
dpi: int
|
||||
zoom: float
|
||||
rotation: int
|
||||
render_mode: str
|
||||
tile_x: float
|
||||
tile_y: float
|
||||
tile_w: float
|
||||
tile_h: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheStats:
|
||||
hits: int = 0
|
||||
misses: int = 0
|
||||
evictions: int = 0
|
||||
current_entries: int = 0
|
||||
current_bytes: int = 0
|
||||
max_bytes: int = 0
|
||||
hit_rate: float = 0.0
|
||||
avg_lookup_ns: float = 0.0
|
||||
avg_render_time_ns: float = 0.0
|
||||
|
||||
|
||||
class _Node:
|
||||
__slots__ = ["key", "value", "size", "prev", "next"]
|
||||
|
||||
def __init__(self, key: TileCacheKey | None, value: bytes | None, size: int):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.size = size
|
||||
self.prev: _Node | None = None
|
||||
self.next: _Node | None = None
|
||||
|
||||
|
||||
class TileCache:
|
||||
def __init__(self):
|
||||
settings = get_settings()
|
||||
self.enabled = settings.render_cache_enabled
|
||||
self.max_entries = settings.render_cache_max_entries
|
||||
self.max_bytes = settings.render_cache_max_bytes
|
||||
self.max_entry_bytes = settings.render_cache_max_entry_bytes
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._cache: dict[TileCacheKey, _Node] = {}
|
||||
self._head = _Node(None, None, 0)
|
||||
self._tail = _Node(None, None, 0)
|
||||
self._head.next = self._tail
|
||||
self._tail.prev = self._head
|
||||
|
||||
self._current_bytes = 0
|
||||
|
||||
# Stats
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._evictions = 0
|
||||
self._total_lookup_ns = 0
|
||||
self._total_render_time_ns = 0
|
||||
self._render_time_count = 0
|
||||
|
||||
def _remove(self, node: _Node):
|
||||
p = node.prev
|
||||
n = node.next
|
||||
if p and n:
|
||||
p.next = n
|
||||
n.prev = p
|
||||
|
||||
def _add_to_front(self, node: _Node):
|
||||
first = self._head.next
|
||||
if first:
|
||||
self._head.next = node
|
||||
node.prev = self._head
|
||||
node.next = first
|
||||
first.prev = node
|
||||
|
||||
def _evict(self):
|
||||
last = self._tail.prev
|
||||
if last and last != self._head:
|
||||
self._remove(last)
|
||||
if last.key:
|
||||
del self._cache[last.key]
|
||||
self._current_bytes -= last.size
|
||||
self._evictions += 1
|
||||
|
||||
def get(self, key: TileCacheKey) -> bytes | None:
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
start_time = time.perf_counter_ns()
|
||||
with self._lock:
|
||||
node = self._cache.get(key)
|
||||
if node:
|
||||
self._hits += 1
|
||||
self._remove(node)
|
||||
self._add_to_front(node)
|
||||
res = node.value
|
||||
else:
|
||||
self._misses += 1
|
||||
res = None
|
||||
|
||||
lookup_time = time.perf_counter_ns() - start_time
|
||||
self._total_lookup_ns += lookup_time
|
||||
|
||||
return res
|
||||
|
||||
def put(self, key: TileCacheKey, data: bytes) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
size = len(data)
|
||||
if size > self.max_entry_bytes:
|
||||
# Too large to cache
|
||||
return
|
||||
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
node = self._cache[key]
|
||||
self._current_bytes -= node.size
|
||||
node.value = data
|
||||
node.size = size
|
||||
self._current_bytes += size
|
||||
self._remove(node)
|
||||
self._add_to_front(node)
|
||||
else:
|
||||
new_node = _Node(key, data, size)
|
||||
self._cache[key] = new_node
|
||||
self._add_to_front(new_node)
|
||||
self._current_bytes += size
|
||||
|
||||
# Evict if over limits
|
||||
while len(self._cache) > self.max_entries or (self.max_bytes > 0 and self._current_bytes > self.max_bytes):
|
||||
self._evict()
|
||||
|
||||
def invalidate_doc(self, doc_hash: str) -> None:
|
||||
with self._lock:
|
||||
keys_to_remove = [k for k in self._cache.keys() if k.doc_hash == doc_hash]
|
||||
for k in keys_to_remove:
|
||||
node = self._cache[k]
|
||||
self._remove(node)
|
||||
self._current_bytes -= node.size
|
||||
del self._cache[k]
|
||||
|
||||
def invalidate_renderer_version(self, old_version: int) -> None:
|
||||
with self._lock:
|
||||
keys_to_remove = [k for k in self._cache.keys() if k.renderer_version == old_version]
|
||||
for k in keys_to_remove:
|
||||
node = self._cache[k]
|
||||
self._remove(node)
|
||||
self._current_bytes -= node.size
|
||||
del self._cache[k]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._head.next = self._tail
|
||||
self._tail.prev = self._head
|
||||
self._current_bytes = 0
|
||||
|
||||
def stats(self) -> CacheStats:
|
||||
with self._lock:
|
||||
hit_rate = 0.0
|
||||
total_reqs = self._hits + self._misses
|
||||
if total_reqs > 0:
|
||||
hit_rate = self._hits / total_reqs
|
||||
|
||||
avg_lookup = 0.0
|
||||
if total_reqs > 0:
|
||||
avg_lookup = self._total_lookup_ns / total_reqs
|
||||
|
||||
avg_render = 0.0
|
||||
if self._render_time_count > 0:
|
||||
avg_render = self._total_render_time_ns / self._render_time_count
|
||||
|
||||
return CacheStats(
|
||||
hits=self._hits,
|
||||
misses=self._misses,
|
||||
evictions=self._evictions,
|
||||
current_entries=len(self._cache),
|
||||
current_bytes=self._current_bytes,
|
||||
max_bytes=self.max_bytes,
|
||||
hit_rate=hit_rate,
|
||||
avg_lookup_ns=avg_lookup,
|
||||
avg_render_time_ns=avg_render,
|
||||
)
|
||||
|
||||
def record_render_time(self, elapsed_ns: int):
|
||||
with self._lock:
|
||||
self._total_render_time_ns += elapsed_ns
|
||||
self._render_time_count += 1
|
||||
|
||||
|
||||
tile_cache = TileCache()
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
@@ -57,6 +58,7 @@ class DocumentStore:
|
||||
info = {
|
||||
"id": doc_id,
|
||||
"filename": filename,
|
||||
"doc_hash": hashlib.sha256(bytes_data).hexdigest(),
|
||||
"sizeBytes": len(bytes_data),
|
||||
"totalPages": doc_instance.page_count,
|
||||
"pageWidth": page_width,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"pydantic-settings==2.7.1",
|
||||
"python-multipart==0.0.19",
|
||||
"pillow==10.4.0",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -8,7 +8,7 @@ import urllib.request
|
||||
|
||||
sys.path.insert(0, "gateway")
|
||||
|
||||
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
||||
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||
|
||||
|
||||
def http_get(url):
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
||||
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
|
||||
|
||||
TARGET_PDFS = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
================================================================================
|
||||
FONT EXTRACTION API VALIDATION
|
||||
Server: http://localhost:8000
|
||||
Server: http://localhost:8765
|
||||
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
|
||||
Timestamp: 2026-06-02 12:36:47
|
||||
================================================================================
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -10,7 +10,7 @@ if ($Port -le 0) {
|
||||
if ($envPort) {
|
||||
$Port = $envPort
|
||||
} 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