Merge pull request 'ribai' (#81) from ribai into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/81
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -374,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());
|
||||
})
|
||||
@@ -498,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,12 +1,21 @@
|
||||
#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 {
|
||||
|
||||
enum class EngineError {
|
||||
@@ -61,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;
|
||||
@@ -173,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;
|
||||
@@ -259,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
|
||||
|
||||
@@ -178,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);
|
||||
};
|
||||
@@ -89,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());
|
||||
@@ -100,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);
|
||||
|
||||
@@ -133,19 +133,23 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
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);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
@@ -167,7 +171,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
double paraExact = 0.0;
|
||||
if (!exactSizes.empty()) {
|
||||
std::sort(exactSizes.begin(), exactSizes.end());
|
||||
paraExact = exactSizes[exactSizes.size() / 2];
|
||||
}
|
||||
if (paraExact > 0.1) {
|
||||
for (auto& rs : runs) {
|
||||
std::string bn = baseNameFromInternalFontId(rs.internalFontId);
|
||||
auto it = exactByBaseName.find(bn);
|
||||
rs.fontSize = (it != exactByBaseName.end() && it->second > 0.1) ? it->second : paraExact;
|
||||
}
|
||||
spdlog::info("reflow_paragraph: size-exact override paraExact={:.2f} ({} font(s))",
|
||||
paraExact, exactByBaseName.size());
|
||||
}
|
||||
}
|
||||
|
||||
double textAspect = 1.0;
|
||||
if (exactNominal > 0.1 && exactScaleY > 0.001) {
|
||||
double trueVerticalSize = exactNominal * exactScaleY;
|
||||
for (auto& rs : runs) {
|
||||
spdlog::info("[STAGE_3_WASM_INPUT] fontSize={:.2f}, trueVerticalSize={:.2f}, exactNominal={:.2f}, exactScaleY={:.4f}", rs.fontSize, trueVerticalSize, exactNominal, exactScaleY);
|
||||
if (rs.fontSize <= 0.0 || rs.fontSize < trueVerticalSize * 0.85) {
|
||||
rs.fontSize = trueVerticalSize;
|
||||
}
|
||||
}
|
||||
textAspect = exactScaleX / exactScaleY;
|
||||
} else {
|
||||
for (auto& rs : runs) {
|
||||
if (rs.fontSize <= 0.0) {
|
||||
rs.fontSize = 12.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
spdlog::info("[FONT_METRICS_DEBUG] exactNominal={:.2f}, exactScaleX={:.2f}, exactScaleY={:.2f}, trueVerticalSize={:.2f}, textAspect={:.2f}", exactNominal, exactScaleX, exactScaleY, exactNominal * exactScaleY, textAspect);
|
||||
|
||||
std::vector<EmissionFont> runFonts(runs.size());
|
||||
// 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);
|
||||
|
||||
@@ -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;"]
|
||||
+115
-11
@@ -7,6 +7,7 @@ import type { InspectorTab } from './components/InspectorPanel';
|
||||
import { SignatureModal } from './components/SignatureModal';
|
||||
import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
||||
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
@@ -57,17 +58,20 @@ 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 [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);
|
||||
@@ -110,6 +114,39 @@ 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) {
|
||||
@@ -464,6 +501,9 @@ function App() {
|
||||
|
||||
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
|
||||
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
|
||||
if (activeTool !== 'redact') {
|
||||
setActiveTool('redact');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyRedactions = () => {
|
||||
@@ -509,14 +549,48 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
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; }
|
||||
setConfirmState({
|
||||
title: 'Export Document',
|
||||
message: `Are you sure you want to export "${activeDoc.filename}"?`,
|
||||
confirmLabel: 'Export',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
@@ -545,16 +619,7 @@ function App() {
|
||||
};
|
||||
|
||||
const toggleInspector = () => {
|
||||
setIsInspectorOpen((prev) => {
|
||||
const next = !prev;
|
||||
setTimeout(() => {
|
||||
const w = activeDoc?.pageWidth || 612;
|
||||
const inspectorWidth = next ? 322 : 0;
|
||||
const avail = window.innerWidth - inspectorWidth - 48;
|
||||
setZoom(Math.max(0.25, Math.min(3, avail / w)));
|
||||
}, 50);
|
||||
return next;
|
||||
});
|
||||
setIsInspectorOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const fitWidth = () => {
|
||||
@@ -618,6 +683,8 @@ function App() {
|
||||
canExport={can('canCopy')}
|
||||
canAssemble={can('canAssemble')}
|
||||
onUpload={handleUpload}
|
||||
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
|
||||
onSave={isRemote ? handleSave : undefined}
|
||||
isInspectorOpen={isInspectorOpen}
|
||||
onToggleInspector={toggleInspector}
|
||||
/>
|
||||
@@ -647,6 +714,19 @@ function App() {
|
||||
onApplyRedactions={handleApplyRedactions}
|
||||
onClearRedactions={() => setPendingRedactions([])}
|
||||
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||
selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)}
|
||||
onUpdateAnnotation={(patch) => {
|
||||
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||
if (a) handleUpdateAnnotation({ ...a, ...patch });
|
||||
}}
|
||||
onDeleteAnnotation={() => {
|
||||
const a = annotations.find(x => x.id === selectedAnnotationId);
|
||||
if (a) {
|
||||
handleDeleteAnnotation(a);
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}}
|
||||
onDeselectAnnotation={() => setSelectedAnnotationId(null)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -687,9 +767,13 @@ function App() {
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationUpdate={handleUpdateAnnotation}
|
||||
onAnnotationClick={() => {
|
||||
onAnnotationClick={(anno) => {
|
||||
if (activeTool === 'select') {
|
||||
setSelectedAnnotationId(anno.id);
|
||||
} else {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onMarkRedaction={handleMarkRedaction}
|
||||
@@ -703,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]">
|
||||
@@ -726,6 +820,8 @@ function App() {
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
isExpanded={isInspectorExpanded}
|
||||
onExpandedChange={setIsInspectorExpanded}
|
||||
documents={documents}
|
||||
selectedDocumentId={selectedDocId}
|
||||
onSelectDocument={openDocument}
|
||||
@@ -754,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>
|
||||
|
||||
@@ -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,70 +77,66 @@ 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>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
|
||||
{p.documents.map((d) => (
|
||||
<CustomButton variant="unstyled"
|
||||
key={d.id}
|
||||
onClick={() => p.onSelectDocument(d.id)}
|
||||
className={`flex flex-col rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] ${d.id === p.selectedDocumentId ? 'bg-[#eef4ff]' : ''}`}
|
||||
>
|
||||
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>{d.filename}</span>
|
||||
<span className="text-[10.5px] text-[#98a1ad]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<span className="text-[11.5px] font-medium text-[#98a1ad]">No document</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
|
||||
<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.activeTab === t.id;
|
||||
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={() => p.onTabChange(t.id)}
|
||||
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
||||
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-[#eef4ff] text-[#2563eb]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
? 'bg-brand-secondary text-brand-primary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
|
||||
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled"
|
||||
label={theme === 'dark' ? "Switch to light mode" : "Switch to dark mode"}
|
||||
onClick={toggleTheme}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[8px] text-text-secondary transition-colors hover:bg-bg-tertiary hover:text-text-primary mb-2"
|
||||
>
|
||||
{theme === 'dark' ? <SunIcon size={18} /> : <MoonIcon size={18} />}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Expanded Content Drawer */}
|
||||
{p.isExpanded && (
|
||||
<div className="flex w-[322px] min-w-0 shrink-0 flex-col border-l border-border-primary">
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between border-b border-border-primary" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<span className="shrink-0 text-[13px] font-bold text-text-primary">{activeDef.label}</span>
|
||||
<CustomButton variant="unstyled" onClick={() => p.onExpandedChange(false)} className="flex h-7 w-7 items-center justify-center rounded-[6px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||
<XIcon size={16} />
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto">
|
||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||
@@ -141,8 +144,10 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||
{p.activeTab === '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,15 +202,15 @@ 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 && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,12 +72,12 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
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-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
@@ -93,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-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
<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-1 h-px w-10 shrink-0 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 shrink-0 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
|
||||
<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>
|
||||
|
||||
@@ -110,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 shrink-0 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>
|
||||
|
||||
@@ -23,6 +23,10 @@ interface ToolbarProps {
|
||||
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 }> = {
|
||||
@@ -51,36 +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-[#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)',
|
||||
}}
|
||||
>
|
||||
{selectedMeta?.icon}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">Edit Annotation</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{(selectedAnnotation.type === 'highlight' || selectedAnnotation.type === 'underline' || selectedAnnotation.type === 'strikeout' || selectedAnnotation.type === 'squiggly' || selectedAnnotation.type === 'draw' || selectedAnnotation.type === 'textbox') && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
<ColorSwatches
|
||||
value={selectedAnnotation.color || '#2563eb'}
|
||||
onChange={(c) => onUpdateAnnotation?.({ color: c })}
|
||||
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
|
||||
Done
|
||||
</CustomButton>
|
||||
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
|
||||
<span className="text-[#dc2626]">Delete</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||
style={{
|
||||
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||
}}
|
||||
>
|
||||
{meta?.icon}
|
||||
</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 />
|
||||
|
||||
@@ -150,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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -159,7 +210,7 @@ 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)}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
||||
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>
|
||||
@@ -171,15 +222,15 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
{activeTool === 'redact' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
|
||||
<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-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
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-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
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
|
||||
@@ -203,11 +254,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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}>
|
||||
<>
|
||||
<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} />
|
||||
<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]"
|
||||
{onSave && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={isSaving || !documentName}
|
||||
className="h-8 px-3 text-[13px] font-semibold"
|
||||
>
|
||||
<PagesIcon size={18} />
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</CustomButton>
|
||||
)}
|
||||
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
|
||||
</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" /></>);
|
||||
|
||||
@@ -30,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()}
|
||||
@@ -57,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" />
|
||||
@@ -82,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}
|
||||
@@ -91,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>
|
||||
);
|
||||
|
||||
@@ -106,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>
|
||||
);
|
||||
|
||||
@@ -140,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>
|
||||
);
|
||||
@@ -184,7 +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>
|
||||
);
|
||||
␍
|
||||
+65
-5
@@ -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,14 +71,14 @@
|
||||
.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; }
|
||||
@@ -28,8 +88,8 @@
|
||||
.scroll-micro { scrollbar-width: thin; }
|
||||
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
|
||||
.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); } }
|
||||
|
||||
@@ -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;
|
||||
@@ -261,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 {
|
||||
@@ -333,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 }> {
|
||||
@@ -435,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}`;
|
||||
@@ -452,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);
|
||||
}
|
||||
@@ -524,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 [];
|
||||
|
||||
@@ -738,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 || '{}';
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -31,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> = ({
|
||||
@@ -42,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();
|
||||
@@ -79,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);
|
||||
@@ -92,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,
|
||||
@@ -115,6 +119,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onPointerDown={(e) => handlePointerDown(e, anno)}
|
||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, anno)}
|
||||
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`,
|
||||
@@ -158,7 +163,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
@@ -177,7 +181,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -46,12 +46,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
};
|
||||
|
||||
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
|
||||
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
|
||||
<div className="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-[#262626]' : ''}`}
|
||||
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 }));
|
||||
@@ -61,13 +61,13 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
|
||||
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
|
||||
<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-white">−</button>
|
||||
<button className="hover:text-text-primary">−</button>
|
||||
<span>100%</span>
|
||||
<button className="hover:text-white">+</button>
|
||||
<button className="hover:text-text-primary">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
|
||||
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
|
||||
@@ -85,7 +85,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
>
|
||||
<button
|
||||
onClick={() => onAction('copy')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
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">
|
||||
@@ -96,7 +96,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
|
||||
<button
|
||||
onClick={() => onAction('comment')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
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">
|
||||
@@ -106,12 +106,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
<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-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
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">
|
||||
@@ -122,7 +122,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
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"/>
|
||||
@@ -134,7 +134,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('underline')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
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">
|
||||
@@ -144,7 +144,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
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"/>
|
||||
@@ -156,7 +156,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('strikeout')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
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">
|
||||
@@ -167,7 +167,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
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"/>
|
||||
@@ -176,11 +176,11 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
<div className="w-px h-4 bg-border-primary mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => onAction('redact')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
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">
|
||||
@@ -192,7 +192,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
|
||||
<button
|
||||
onClick={() => onAction('edit')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
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">
|
||||
|
||||
@@ -135,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.label}”</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>
|
||||
)}
|
||||
@@ -184,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`,
|
||||
|
||||
+304
-121
@@ -1,24 +1,34 @@
|
||||
import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { CanvasLayer } from './CanvasLayer';
|
||||
import { SelectionLayer } from './SelectionLayer';
|
||||
import { AnnotationLayer } from './AnnotationLayer';
|
||||
import type { Annotation } from './AnnotationLayer';
|
||||
import { OverlayLayer } from './OverlayLayer';
|
||||
import { TextEditLayer } from './TextEditLayer';
|
||||
import type { EditableRun, ReflowParagraphPayload, CommitFrame } from './TextEditLayer';
|
||||
import type { OverflowPreviewRegion, OverflowCaret } from './ParagraphEditor';
|
||||
import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
|
||||
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { CanvasLayer } from "./CanvasLayer";
|
||||
import { SelectionLayer } from "./SelectionLayer";
|
||||
import { AnnotationLayer } from "./AnnotationLayer";
|
||||
import type { Annotation } from "./AnnotationLayer";
|
||||
import { OverlayLayer } from "./OverlayLayer";
|
||||
import { TextEditLayer } from "./TextEditLayer";
|
||||
import type {
|
||||
EditableRun,
|
||||
ReflowParagraphPayload,
|
||||
CommitFrame,
|
||||
} from "./TextEditLayer";
|
||||
import type { OverflowPreviewRegion, OverflowCaret } from "./ParagraphEditor";
|
||||
import { SearchOverlayLayer } from "./SearchOverlayLayer";
|
||||
import type { Rect } from "../lib/coordinateMapping";
|
||||
import { gatewayService } from "../lib/gatewayService";
|
||||
import type { SearchResult, PageInfo, Glyph } from "../lib/gatewayService";
|
||||
import type { ToolSettings, ToolId, StampPreset } from "../lib/tools";
|
||||
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
import { StreamEditLayer } from './StreamEditLayer';
|
||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
||||
import type { PlacementRect } from './SignaturePlacementOverlay';
|
||||
import { FloatingTextToolbar } from './FloatingTextToolbar';
|
||||
import { RedactionLayer } from "./RedactionLayer";
|
||||
import { StreamEditLayer } from "./StreamEditLayer";
|
||||
import { wasmFreeDocument } from "../lib/pdfiumEngine";
|
||||
import { SignaturePlacementOverlay } from "./SignaturePlacementOverlay";
|
||||
import type { PlacementRect } from "./SignaturePlacementOverlay";
|
||||
import { FloatingTextToolbar } from "./FloatingTextToolbar";
|
||||
|
||||
interface PDFViewerProps {
|
||||
documentId: string;
|
||||
@@ -29,7 +39,7 @@ interface PDFViewerProps {
|
||||
pagesInfo?: PageInfo[];
|
||||
activeTool: ToolId;
|
||||
toolSettings: ToolSettings;
|
||||
redactionMode?: 'area' | 'text';
|
||||
redactionMode?: "area" | "text";
|
||||
hasSignature: boolean;
|
||||
/** The data-URL of the pending signature image (for the placement preview) */
|
||||
signatureImageUrl?: string;
|
||||
@@ -44,17 +54,41 @@ interface PDFViewerProps {
|
||||
onAnnotationClick?: (anno: Annotation) => void;
|
||||
onPageVisible?: (pageIndex: number) => void;
|
||||
onMarkRedaction?: (pageIndex: number, bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, pageIndex: number, bounds: Rect }[];
|
||||
pendingRedactions?: { id: string; pageIndex: number; bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
|
||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||
onEditText?: (
|
||||
pageIndex: number,
|
||||
run: EditableRun,
|
||||
newText: string,
|
||||
disableJustify?: boolean,
|
||||
) => void;
|
||||
onReflowParagraph?: (
|
||||
pageIndex: number,
|
||||
payload: ReflowParagraphPayload,
|
||||
) => void;
|
||||
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceStamp?: (
|
||||
pageIndex: number,
|
||||
pointPts: { x: number; y: number },
|
||||
) => void;
|
||||
/** Called with final PDF-space rect after user commits interactive placement */
|
||||
onPlaceSignature?: (pageIndex: number, pdfRect: Rect, rotation: number) => void;
|
||||
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
onPlaceSignature?: (
|
||||
pageIndex: number,
|
||||
pdfRect: Rect,
|
||||
rotation: number,
|
||||
) => void;
|
||||
onDecorateText?: (
|
||||
pageIndex: number,
|
||||
lines: Rect[],
|
||||
type: "underline" | "strikeout" | "squiggly",
|
||||
color: string,
|
||||
) => void;
|
||||
onFieldChange?: (
|
||||
id: string,
|
||||
value: string | boolean,
|
||||
pageIndex: number,
|
||||
) => void;
|
||||
canCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -65,13 +99,16 @@ interface PageLayout {
|
||||
top: number;
|
||||
}
|
||||
|
||||
const generateUniqueId = () => `anno_${Math.random().toString(36).substring(2, 11)}`;
|
||||
const generateUniqueId = () =>
|
||||
`anno_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
export interface PDFViewerRef {
|
||||
scrollToPage: (pageIndex: number) => void;
|
||||
}
|
||||
|
||||
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
(
|
||||
{
|
||||
documentId,
|
||||
totalPages,
|
||||
pageWidth,
|
||||
@@ -80,7 +117,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
pagesInfo,
|
||||
activeTool,
|
||||
toolSettings,
|
||||
redactionMode = 'area',
|
||||
redactionMode = "area",
|
||||
hasSignature,
|
||||
signatureImageUrl,
|
||||
signatureAspect = 3,
|
||||
@@ -105,13 +142,18 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onDecorateText,
|
||||
onFieldChange,
|
||||
canCopy = true,
|
||||
}, ref) => {
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
|
||||
const [scrollPosition, setScrollPosition] = useState({
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
});
|
||||
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
||||
const [containerHeight, setContainerHeight] = useState(800);
|
||||
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
||||
const renderedDocIdRef = useRef<string>('');
|
||||
const renderedDocIdRef = useRef<string>("");
|
||||
const prevDocumentIdRef = useRef<string>(documentId);
|
||||
const inFlightRenderRef = useRef<Set<number>>(new Set());
|
||||
const inFlightTextRef = useRef<Set<number>>(new Set());
|
||||
@@ -135,23 +177,41 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
if (prev && prev !== documentId) wasmFreeDocument(prev);
|
||||
prevDocumentIdRef.current = documentId;
|
||||
}, [documentId]);
|
||||
useEffect(() => () => { wasmFreeDocument(prevDocumentIdRef.current); }, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
wasmFreeDocument(prevDocumentIdRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const [bridgeFrame, setBridgeFrame] = useState<(CommitFrame & { docId: string }) | null>(null);
|
||||
const [bridgeFrame, setBridgeFrame] = useState<
|
||||
(CommitFrame & { docId: string }) | null
|
||||
>(null);
|
||||
const bridgeFrameRef = useRef(bridgeFrame);
|
||||
bridgeFrameRef.current = bridgeFrame;
|
||||
const documentIdRef = useRef(documentId);
|
||||
documentIdRef.current = documentId;
|
||||
const handleCommitPreview = (frame: CommitFrame) => setBridgeFrame({ ...frame, docId: documentId });
|
||||
const handleCommitPreview = (frame: CommitFrame) =>
|
||||
setBridgeFrame({ ...frame, docId: documentId });
|
||||
const handlePagePainted = useCallback((pageIndex: number) => {
|
||||
const b = bridgeFrameRef.current;
|
||||
if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current) setBridgeFrame(null);
|
||||
if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current)
|
||||
setBridgeFrame(null);
|
||||
}, []);
|
||||
useEffect(() => { setBridgeFrame(null); }, [zoom]);
|
||||
useEffect(() => {
|
||||
setBridgeFrame(null);
|
||||
}, [zoom]);
|
||||
|
||||
const [overflowPreviews, setOverflowPreviews] = useState<OverflowPreviewRegion[]>([]);
|
||||
const [overflowCaret, setOverflowCaret] = useState<OverflowCaret | null>(null);
|
||||
useEffect(() => { setOverflowPreviews([]); setOverflowCaret(null); }, [zoom, documentId]);
|
||||
const [overflowPreviews, setOverflowPreviews] = useState<
|
||||
OverflowPreviewRegion[]
|
||||
>([]);
|
||||
const [overflowCaret, setOverflowCaret] = useState<OverflowCaret | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
setOverflowPreviews([]);
|
||||
setOverflowCaret(null);
|
||||
}, [zoom, documentId]);
|
||||
useEffect(() => {
|
||||
if (!bridgeFrame) return;
|
||||
const t = window.setTimeout(() => setBridgeFrame(null), 6000);
|
||||
@@ -179,7 +239,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
top: currentTop,
|
||||
});
|
||||
|
||||
currentTop += (h * zoom) + pageGap;
|
||||
currentTop += h * zoom + pageGap;
|
||||
}
|
||||
return layouts;
|
||||
}, [totalPages, zoom, pagesInfo]);
|
||||
@@ -203,7 +263,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
if (containerRef.current && pageLayouts[pageIndex]) {
|
||||
containerRef.current.scrollTop = pageLayouts[pageIndex].top;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -212,9 +272,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
setContainerHeight(containerRef.current.clientHeight);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', updateSize);
|
||||
window.addEventListener("resize", updateSize);
|
||||
updateSize();
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
const { visiblePages, primaryVisiblePage } = useMemo(() => {
|
||||
@@ -240,15 +300,19 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
currentVisiblePageIdx = layout.index;
|
||||
}
|
||||
|
||||
const isInside = (pageBottom >= viewportTop - (layout.height * buffer)) &&
|
||||
(pageTop <= viewportBottom + (layout.height * buffer));
|
||||
const isInside =
|
||||
pageBottom >= viewportTop - layout.height * buffer &&
|
||||
pageTop <= viewportBottom + layout.height * buffer;
|
||||
|
||||
if (isInside) {
|
||||
visible.push(layout);
|
||||
}
|
||||
});
|
||||
|
||||
return { visiblePages: visible, primaryVisiblePage: currentVisiblePageIdx };
|
||||
return {
|
||||
visiblePages: visible,
|
||||
primaryVisiblePage: currentVisiblePageIdx,
|
||||
};
|
||||
}, [pageLayouts, scrollPosition.scrollTop, containerHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -261,9 +325,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const base = docChanged
|
||||
? visiblePages
|
||||
: visiblePages.filter((page) => !renderedPages[page.index]);
|
||||
const missingPages = base.filter((page) => !inFlightRenderRef.current.has(page.index));
|
||||
const missingPages = base.filter(
|
||||
(page) => !inFlightRenderRef.current.has(page.index),
|
||||
);
|
||||
if (missingPages.length === 0) return;
|
||||
missingPages.forEach((page) => inFlightRenderRef.current.add(page.index));
|
||||
missingPages.forEach((page) =>
|
||||
inFlightRenderRef.current.add(page.index),
|
||||
);
|
||||
|
||||
try {
|
||||
const renders = await Promise.all(
|
||||
@@ -275,7 +343,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
rotation: 0,
|
||||
});
|
||||
return { index: page.index, url };
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
if (documentIdRef.current !== documentId) return;
|
||||
@@ -289,14 +357,21 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
missingPages.forEach((page) => inFlightRenderRef.current.delete(page.index));
|
||||
missingPages.forEach((page) =>
|
||||
inFlightRenderRef.current.delete(page.index),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPageImages();
|
||||
}, [visiblePages, documentId, zoom, renderedPages]);
|
||||
|
||||
const textToolActive = activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly';
|
||||
const textToolActive =
|
||||
activeTool === "select" ||
|
||||
activeTool === "highlight" ||
|
||||
activeTool === "underline" ||
|
||||
activeTool === "strikeout" ||
|
||||
activeTool === "squiggly";
|
||||
useEffect(() => {
|
||||
if (!textToolActive || !documentId) return;
|
||||
const fetchTexts = async () => {
|
||||
@@ -307,12 +382,17 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
missing.forEach((p) => inFlightTextRef.current.add(p.index));
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
missing.map(async (p) => ({ index: p.index, page: await gatewayService.getPageText(documentId, p.index) })),
|
||||
missing.map(async (p) => ({
|
||||
index: p.index,
|
||||
page: await gatewayService.getPageText(documentId, p.index),
|
||||
})),
|
||||
);
|
||||
if (documentIdRef.current !== documentId) return;
|
||||
setPageTexts((prev) => {
|
||||
const next = { ...prev };
|
||||
results.forEach(({ index, page }) => { next[index] = page.glyphs; });
|
||||
results.forEach(({ index, page }) => {
|
||||
next[index] = page.glyphs;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
@@ -322,19 +402,24 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
fetchTexts();
|
||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||
|
||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||
const handleTextSelection = (
|
||||
text: string,
|
||||
bbox: Rect,
|
||||
lines: Rect[],
|
||||
pageIndex: number,
|
||||
) => {
|
||||
// This is still called on Ctrl+C for select mode
|
||||
if (activeTool === 'select') {
|
||||
if (activeTool === "select") {
|
||||
if (!canCopy) return;
|
||||
if (text.trim()) {
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeTool === 'highlight') {
|
||||
if (activeTool === "highlight") {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
type: 'highlight',
|
||||
type: "highlight",
|
||||
pageIndex,
|
||||
bbox: {
|
||||
x: bbox.x / zoom,
|
||||
@@ -344,23 +429,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
},
|
||||
color: toolSettings.highlightColor,
|
||||
opacity: toolSettings.highlightOpacity,
|
||||
author: 'Current User',
|
||||
author: "Current User",
|
||||
content: text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
return;
|
||||
}
|
||||
if (activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') {
|
||||
if (
|
||||
activeTool === "underline" ||
|
||||
activeTool === "strikeout" ||
|
||||
activeTool === "squiggly"
|
||||
) {
|
||||
const color =
|
||||
activeTool === 'underline' ? toolSettings.underlineColor :
|
||||
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
|
||||
toolSettings.squigglyColor;
|
||||
activeTool === "underline"
|
||||
? toolSettings.underlineColor
|
||||
: activeTool === "strikeout"
|
||||
? toolSettings.strikeoutColor
|
||||
: toolSettings.squigglyColor;
|
||||
onDecorateText?.(pageIndex, lines, activeTool, color);
|
||||
return;
|
||||
}
|
||||
if (activeTool === 'redact') {
|
||||
if (activeTool === "redact") {
|
||||
if (lines.length > 0) {
|
||||
lines.forEach(line => {
|
||||
lines.forEach((line) => {
|
||||
onMarkRedaction?.(pageIndex, {
|
||||
x: line.x / zoom,
|
||||
y: line.y / zoom,
|
||||
@@ -377,14 +468,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
if (!textSelection) return;
|
||||
const { pageIndex, text, bbox, lines } = textSelection;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
case "copy":
|
||||
if (!canCopy) break;
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
break;
|
||||
case 'highlight': {
|
||||
case "highlight": {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
type: 'highlight',
|
||||
type: "highlight",
|
||||
pageIndex,
|
||||
bbox: {
|
||||
x: bbox.x / zoom,
|
||||
@@ -394,36 +485,50 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
},
|
||||
color: overrideColor || toolSettings.highlightColor,
|
||||
opacity: toolSettings.highlightOpacity,
|
||||
author: 'Current User',
|
||||
author: "Current User",
|
||||
content: text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
break;
|
||||
}
|
||||
case 'underline':
|
||||
onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor);
|
||||
case "underline":
|
||||
onDecorateText?.(
|
||||
pageIndex,
|
||||
lines,
|
||||
"underline",
|
||||
overrideColor || toolSettings.underlineColor,
|
||||
);
|
||||
break;
|
||||
case 'strikeout':
|
||||
onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor);
|
||||
case "strikeout":
|
||||
onDecorateText?.(
|
||||
pageIndex,
|
||||
lines,
|
||||
"strikeout",
|
||||
overrideColor || toolSettings.strikeoutColor,
|
||||
);
|
||||
break;
|
||||
case 'squiggly':
|
||||
onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor);
|
||||
case "squiggly":
|
||||
onDecorateText?.(
|
||||
pageIndex,
|
||||
lines,
|
||||
"squiggly",
|
||||
overrideColor || toolSettings.squigglyColor,
|
||||
);
|
||||
break;
|
||||
case 'redact':
|
||||
case "redact":
|
||||
onMarkRedaction?.(pageIndex, {
|
||||
x: bbox.x / zoom,
|
||||
y: bbox.y / zoom,
|
||||
width: bbox.width / zoom,
|
||||
height: bbox.height / zoom
|
||||
height: bbox.height / zoom,
|
||||
});
|
||||
break;
|
||||
case 'edit':
|
||||
|
||||
case "edit":
|
||||
break;
|
||||
case 'comment': {
|
||||
case "comment": {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
type: 'comment',
|
||||
type: "comment",
|
||||
pageIndex,
|
||||
bbox: {
|
||||
x: bbox.x / zoom,
|
||||
@@ -431,9 +536,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
color: '#facc15',
|
||||
author: 'Current User',
|
||||
content: 'New Comment\n\n' + text,
|
||||
color: "#facc15",
|
||||
author: "Current User",
|
||||
content: "New Comment\n\n" + text,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
break;
|
||||
@@ -443,10 +548,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
};
|
||||
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
||||
const [panStart, setPanStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (activeTool === 'pan' && containerRef.current) {
|
||||
if (activeTool === "pan" && containerRef.current) {
|
||||
setIsPanning(true);
|
||||
setPanStart({
|
||||
x: e.clientX,
|
||||
@@ -480,14 +590,14 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`flex-1 h-full overflow-auto bg-[#f1f2f4] flex justify-center items-start outline-none ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
||||
className={`flex-1 h-full overflow-auto bg-bg-canvas flex justify-center items-start outline-none ${activeTool === "pan" ? (isPanning ? "cursor-grabbing" : "cursor-grab") : ""}`}
|
||||
>
|
||||
<div
|
||||
className="relative w-full flex flex-col items-center py-7"
|
||||
style={{ height: `${totalContentHeight}px` }}
|
||||
>
|
||||
{visiblePages.map((page) => {
|
||||
const imageUrl = renderedPages[page.index] || '';
|
||||
const imageUrl = renderedPages[page.index] || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -517,7 +627,12 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
src={bridgeFrame.dataUrl}
|
||||
alt=""
|
||||
className="absolute z-20 pointer-events-none select-none"
|
||||
style={{ left: bridgeFrame.left, top: bridgeFrame.top, width: bridgeFrame.width, height: bridgeFrame.height }}
|
||||
style={{
|
||||
left: bridgeFrame.left,
|
||||
top: bridgeFrame.top,
|
||||
width: bridgeFrame.width,
|
||||
height: bridgeFrame.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -529,18 +644,28 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
src={r.dataUrl}
|
||||
alt=""
|
||||
className="absolute z-[37] pointer-events-none select-none"
|
||||
style={{ left: 0, top: r.yTopPt * zoom, width: page.width, height: page.height }}
|
||||
style={{
|
||||
left: 0,
|
||||
top: r.yTopPt * zoom,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overflowCaret && overflowCaret.pageIndex === page.index && (
|
||||
{overflowCaret &&
|
||||
overflowCaret.pageIndex === page.index && (
|
||||
<>
|
||||
<style>{`@keyframes pe-caret-blink2{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||||
<div
|
||||
className="absolute z-[39] pointer-events-none"
|
||||
style={{
|
||||
left: overflowCaret.left, top: overflowCaret.top, height: overflowCaret.height,
|
||||
width: 1.6, background: '#2563eb', animation: 'pe-caret-blink2 1s step-end infinite',
|
||||
left: overflowCaret.left,
|
||||
top: overflowCaret.top,
|
||||
height: overflowCaret.height,
|
||||
width: 1.6,
|
||||
background: "#2563eb",
|
||||
animation: "pe-caret-blink2 1s step-end infinite",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
@@ -557,23 +682,46 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onFieldChange={onFieldChange}
|
||||
/>
|
||||
|
||||
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || (activeTool === 'redact' && redactionMode === 'text')) && (
|
||||
{(activeTool === "select" ||
|
||||
activeTool === "highlight" ||
|
||||
activeTool === "underline" ||
|
||||
activeTool === "strikeout" ||
|
||||
activeTool === "squiggly" ||
|
||||
(activeTool === "redact" &&
|
||||
redactionMode === "text")) && (
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
glyphs={pageTexts[page.index] || []}
|
||||
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
|
||||
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
|
||||
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
|
||||
mode={
|
||||
activeTool === "highlight" ||
|
||||
activeTool === "underline" ||
|
||||
activeTool === "strikeout" ||
|
||||
activeTool === "squiggly" ||
|
||||
activeTool === "redact"
|
||||
? "highlight"
|
||||
: "select"
|
||||
}
|
||||
onTextSelected={(text, bbox, lines) =>
|
||||
handleTextSelection(text, bbox, lines, page.index)
|
||||
}
|
||||
onSelectionChange={(sel) =>
|
||||
setTextSelection(
|
||||
sel ? { ...sel, pageIndex: page.index } : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{textSelection && textSelection.pageIndex === page.index && (
|
||||
{textSelection &&
|
||||
textSelection.pageIndex === page.index && (
|
||||
<FloatingTextToolbar
|
||||
selection={textSelection}
|
||||
onAction={(action, color) => handleToolbarAction(action as any, color)}
|
||||
onAction={(action, color) =>
|
||||
handleToolbarAction(action as any, color)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -581,10 +729,18 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
|
||||
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
|
||||
onRedactionSelected={(bounds) =>
|
||||
onMarkRedaction?.(page.index, bounds)
|
||||
}
|
||||
pendingRedactions={pendingRedactions.filter(
|
||||
(r) => r.pageIndex === page.index,
|
||||
)}
|
||||
onRemoveRedaction={onRemoveRedaction}
|
||||
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
|
||||
mode={
|
||||
activeTool === "redact" && redactionMode === "area"
|
||||
? "area"
|
||||
: "text"
|
||||
}
|
||||
/>
|
||||
|
||||
<OverlayLayer
|
||||
@@ -605,20 +761,36 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onBeginPlacement={(pageIdx, viewportPt) => {
|
||||
// Compute a sensible initial rect centred on the click point
|
||||
const pageInfo = pagesInfo?.[pageIdx];
|
||||
const pgW = pageInfo ? pageInfo.width * zoom : page.width;
|
||||
const pgH = pageInfo ? pageInfo.height * zoom : page.height;
|
||||
const pgW = pageInfo
|
||||
? pageInfo.width * zoom
|
||||
: page.width;
|
||||
const pgH = pageInfo
|
||||
? pageInfo.height * zoom
|
||||
: page.height;
|
||||
const sigW = Math.min(180 * zoom, pgW * 0.5);
|
||||
const sigH = sigW / (signatureAspect || 3);
|
||||
const x = Math.max(0, Math.min(pgW - sigW, viewportPt.x - sigW / 2));
|
||||
const y = Math.max(0, Math.min(pgH - sigH, viewportPt.y - sigH / 2));
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(pgW - sigW, viewportPt.x - sigW / 2),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(pgH - sigH, viewportPt.y - sigH / 2),
|
||||
);
|
||||
setActivePlacement({
|
||||
pageIndex: pageIdx,
|
||||
rect: { x, y, width: sigW, height: sigH, rotation: 0 },
|
||||
rect: {
|
||||
x,
|
||||
y,
|
||||
width: sigW,
|
||||
height: sigH,
|
||||
rotation: 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeTool === 'edit_text' && (
|
||||
{activeTool === "edit_text" && (
|
||||
<TextEditLayer
|
||||
documentId={documentId}
|
||||
pageIndex={page.index}
|
||||
@@ -635,7 +807,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTool === 'stream_edit' && (
|
||||
{activeTool === "stream_edit" && (
|
||||
<StreamEditLayer
|
||||
documentId={documentId}
|
||||
pageIndex={page.index}
|
||||
@@ -646,7 +818,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onEditSuccess={() => {
|
||||
setRenderedPages((prev) => {
|
||||
const next = [...prev];
|
||||
next[page.index] = '';
|
||||
next[page.index] = "";
|
||||
return next;
|
||||
});
|
||||
setPageTexts((prev) => {
|
||||
@@ -663,13 +835,15 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
searchQuery={searchQuery || ''}
|
||||
searchQuery={searchQuery || ""}
|
||||
searchResults={searchResults}
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
/>
|
||||
|
||||
{/* Interactive signature placement overlay */}
|
||||
{activePlacement && activePlacement.pageIndex === page.index && signatureImageUrl && (
|
||||
{activePlacement &&
|
||||
activePlacement.pageIndex === page.index &&
|
||||
signatureImageUrl && (
|
||||
<SignaturePlacementOverlay
|
||||
imageUrl={signatureImageUrl}
|
||||
aspect={signatureAspect || 3}
|
||||
@@ -680,21 +854,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
setActivePlacement(null);
|
||||
// Convert viewport rect to PDF-space coordinates
|
||||
const pageInfo = pagesInfo?.[page.index];
|
||||
const pageHPts = pageInfo ? pageInfo.height : (pageHeight || 792);
|
||||
const pageHPts = pageInfo
|
||||
? pageInfo.height
|
||||
: pageHeight || 792;
|
||||
const pdfX = finalRect.x / zoom;
|
||||
const pdfW = finalRect.width / zoom;
|
||||
const pdfH = finalRect.height / zoom;
|
||||
const pdfY = pageHPts - (finalRect.y / zoom) - pdfH;
|
||||
onPlaceSignature?.(page.index, { x: pdfX, y: pdfY, width: pdfW, height: pdfH }, finalRect.rotation);
|
||||
const pdfY = pageHPts - finalRect.y / zoom - pdfH;
|
||||
onPlaceSignature?.(
|
||||
page.index,
|
||||
{ x: pdfX, y: pdfY, width: pdfW, height: pdfH },
|
||||
finalRect.rotation,
|
||||
);
|
||||
}}
|
||||
onCancel={() => setActivePlacement(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
|
||||
<div className="w-7 h-7 border-[3px] border-[#eef4ff] border-t-[#2563eb] rounded-full animate-spin" />
|
||||
<span className="text-[12px] font-semibold tracking-[0.2px] text-[#98a1ad]">Loading Page {page.index + 1}...</span>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-bg-secondary text-text-secondary gap-3 rounded-[3px]">
|
||||
<div className="w-7 h-7 border-[3px] border-brand-secondary border-t-brand-primary rounded-full animate-spin" />
|
||||
<span className="text-[12px] font-semibold tracking-[0.2px] text-text-tertiary">
|
||||
Loading Page {page.index + 1}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -703,4 +885,5 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated, wasmEnsureAuxFont } from '../lib/pdfiumEngine';
|
||||
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
||||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||
|
||||
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
||||
export interface OverflowCaret { pageIndex: number; left: number; top: number; height: number; }
|
||||
@@ -33,7 +34,10 @@ function lineAdvances(line: any): { perRun: Record<number, number[]>; anchorX: n
|
||||
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
|
||||
for (let k = 0; k < seq.length; k++) {
|
||||
const { ri, ci, ox } = seq[k];
|
||||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : (line.x + line.w) - ox;
|
||||
const gs = runs[ri]?.glyphs ?? [];
|
||||
const g = gs[ci];
|
||||
const charW = (g?.bbox_w && g.bbox_w > 0) ? g.bbox_w : (runs[ri]?.font_size ?? 12) * 0.5;
|
||||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : charW;
|
||||
}
|
||||
for (const k of Object.keys(perRun)) {
|
||||
const ri = Number(k);
|
||||
@@ -68,21 +72,17 @@ function median(xs: number[]): number {
|
||||
return s[Math.floor(s.length / 2)];
|
||||
}
|
||||
function paraEffSize(lines: any[]): number {
|
||||
const heights: number[] = [];
|
||||
let nominal = 0;
|
||||
for (const line of lines) for (const r of (line.runs ?? [])) {
|
||||
nominal = Math.max(nominal, r.font_size ?? 0);
|
||||
for (const g of (r.glyphs ?? [])) if ((g.bbox_h ?? 0) > 0.1) heights.push(g.bbox_h);
|
||||
const sz = Math.max(r.font_size ?? 0, r.h ?? 0);
|
||||
if (sz > nominal) nominal = sz;
|
||||
}
|
||||
if (!heights.length) return nominal || 12;
|
||||
heights.sort((a, b) => a - b);
|
||||
const p75 = heights[Math.floor(heights.length * 0.75)];
|
||||
return Math.max(nominal, p75 / 0.7);
|
||||
return nominal || 12;
|
||||
}
|
||||
function computeLayout(para: any): ParagraphLayout {
|
||||
const lines = para?.lines ?? [];
|
||||
const effSize = paraEffSize(lines);
|
||||
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
|
||||
let columnLeft = Infinity, firstBaselineY = -Infinity;
|
||||
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
|
||||
const seedRuns: SeedRun[] = [];
|
||||
const origLines: OrigLine[] = [];
|
||||
@@ -90,7 +90,6 @@ function computeLayout(para: any): ParagraphLayout {
|
||||
const line = lines[li];
|
||||
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
|
||||
columnLeft = Math.min(columnLeft, line.x);
|
||||
columnRight = Math.max(columnRight, line.x + line.w);
|
||||
rightEdges.push(line.x + line.w);
|
||||
const lineRuns = line.runs ?? [];
|
||||
const { perRun, anchorX } = lineAdvances(line);
|
||||
@@ -106,15 +105,20 @@ function computeLayout(para: any): ParagraphLayout {
|
||||
}
|
||||
const adv = perRun[ri];
|
||||
const safeColor = sanitizeTextColor(r.color);
|
||||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, advances: adv });
|
||||
const rSize = Math.max(r.font_size ?? 0, r.h ?? 0) || effSize;
|
||||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, advances: adv });
|
||||
}
|
||||
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
|
||||
}
|
||||
const maxRightEdge = rightEdges.length ? Math.max(...rightEdges) : columnLeft + 250;
|
||||
const columnRight = isFinite(maxRightEdge) ? Math.max(maxRightEdge, columnLeft + 250) : columnLeft + 250;
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
|
||||
const domSize = seedRuns.find((r) => r.text.trim())?.size ?? 12;
|
||||
const leading = deltas.length ? Math.abs(median(deltas)) : domSize * 1.2;
|
||||
// Prefer extracted line box height over browser-ish 1.2×fontSize for single-line paras.
|
||||
const extractedLineH = lines.reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||||
const leading = deltas.length ? Math.abs(median(deltas)) : (extractedLineH > 0.5 ? extractedLineH : domSize * 1.2);
|
||||
const colW = columnRight - columnLeft;
|
||||
let align: 'left' | 'justify' = 'left';
|
||||
if (lines.length >= 2) {
|
||||
@@ -155,6 +159,82 @@ function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null {
|
||||
|
||||
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
||||
|
||||
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
function mergeSeedAdvances(
|
||||
seed: string,
|
||||
seedAdv: number[],
|
||||
text: string,
|
||||
fillWidth: number,
|
||||
): number[] {
|
||||
// Caret-only: keep LCP/LCS seed metrics; fill edited middle (measured or fallback).
|
||||
const out = new Array<number>(text.length);
|
||||
if (seedAdv.length !== seed.length) {
|
||||
for (let i = 0; i < text.length; i++) out[i] = fillWidth;
|
||||
return out;
|
||||
}
|
||||
let p = 0;
|
||||
while (p < seed.length && p < text.length && seed[p] === text[p]) {
|
||||
out[p] = seedAdv[p];
|
||||
p++;
|
||||
}
|
||||
let s = 0;
|
||||
while (
|
||||
s < seed.length - p && s < text.length - p
|
||||
&& seed[seed.length - 1 - s] === text[text.length - 1 - s]
|
||||
) {
|
||||
out[text.length - 1 - s] = seedAdv[seed.length - 1 - s];
|
||||
s++;
|
||||
}
|
||||
for (let i = p; i < text.length - s; i++) out[i] = fillWidth;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Per-char widths via canvas (for caret). Engine uses HarfBuzz for the same middle. */
|
||||
function measureCharAdvances(
|
||||
text: string,
|
||||
sizePt: number,
|
||||
family: string,
|
||||
weight: number,
|
||||
): number[] {
|
||||
if (!text) return [];
|
||||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||||
const ctx = markerMeasureCanvas.getContext('2d');
|
||||
if (!ctx) return Array.from({ length: text.length }, () => sizePt * 0.5);
|
||||
ctx.font = `${weight} ${sizePt}px ${family}`;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const w = ctx.measureText(text[i]).width;
|
||||
out.push(w > 0 ? w : sizePt * 0.5);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function expandAdvancesForCaret(
|
||||
text: string,
|
||||
advances: number[] | undefined,
|
||||
seedText: string | undefined,
|
||||
sizePt: number,
|
||||
family: string,
|
||||
weight: number,
|
||||
): number[] {
|
||||
if (advances && advances.length === text.length) return advances.slice();
|
||||
const measured = measureCharAdvances(text, sizePt, family, weight);
|
||||
if (advances && seedText && advances.length === seedText.length) {
|
||||
return mergeSeedAdvances(seedText, advances, text, 0).map((a, i) =>
|
||||
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||||
}
|
||||
if (advances && advances.length > 0 && advances.length < text.length && text.startsWith(
|
||||
seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length),
|
||||
)) {
|
||||
const seed = seedText && seedText.length === advances.length ? seedText : text.slice(0, advances.length);
|
||||
return mergeSeedAdvances(seed, advances, text, 0).map((a, i) =>
|
||||
a > 0 ? a : (measured[i] ?? sizePt * 0.5));
|
||||
}
|
||||
if (advances && advances.length > text.length) return advances.slice(0, text.length);
|
||||
return measured;
|
||||
}
|
||||
|
||||
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
||||
const out: ReflowFragment[] = [];
|
||||
const pushBreak = () => {
|
||||
@@ -179,11 +259,27 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: nu
|
||||
if (!text) continue;
|
||||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
||||
const aRaw = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
||||
const seedText = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-seed-text') : null;
|
||||
if (aRaw) {
|
||||
try {
|
||||
const a = JSON.parse(aRaw) as number[];
|
||||
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
|
||||
} catch { }
|
||||
if (Array.isArray(a) && a.length === text.length) {
|
||||
// Unchanged (or already expanded) — keep as-is.
|
||||
frag.advances = a;
|
||||
} else if (Array.isArray(a) && seedText && a.length === seedText.length && text !== seedText) {
|
||||
// Do NOT fill middle with size×0.5 — that locks the engine into fake advances
|
||||
// and skips HarfBuzz for new glyphs (thin/narrow typed text). Send seed metrics
|
||||
// + advanceSeedText so the engine HB-shapes only the edited middle.
|
||||
frag.advances = a;
|
||||
frag.advanceSeedText = seedText;
|
||||
} else if (Array.isArray(a) && a.length > 0 && a.length < text.length) {
|
||||
const seed = (seedText && seedText.length === a.length) ? seedText : text.slice(0, a.length);
|
||||
frag.advances = a;
|
||||
frag.advanceSeedText = seed;
|
||||
} else if (Array.isArray(a) && a.length > text.length) {
|
||||
frag.advances = a.slice(0, text.length);
|
||||
}
|
||||
} catch { /* ignore bad data-advances */ }
|
||||
}
|
||||
out.push(frag);
|
||||
}
|
||||
@@ -233,7 +329,6 @@ function splitSegmentsByBreak(runs: ReflowFragment[]): ReflowFragment[][] {
|
||||
return segs;
|
||||
}
|
||||
|
||||
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
||||
function measureTextWidth(text: string, sizePt: number, family: string): number {
|
||||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||||
const ctx = markerMeasureCanvas.getContext('2d');
|
||||
@@ -264,37 +359,146 @@ function applyListMarkers(
|
||||
return { runs: out, hangingIndent, marker: lastMarker };
|
||||
}
|
||||
|
||||
function globalCaretOffset(el: HTMLElement): number {
|
||||
|
||||
|
||||
/**
|
||||
* Defensive guard for the pre-compiled WASM engine which may still contain the
|
||||
* matrix-override bug (using horizontal scale instead of vertical scale to
|
||||
* compute font size). If any line's fontSize is more than 20% smaller than the
|
||||
* expected domSize, clamp all lines back to domSize so that:
|
||||
* (a) the custom caret renders at the correct vertical position, and
|
||||
* (b) the editing bounding box does not visually shrink.
|
||||
*
|
||||
* The authoritative fix lives in pdfium_edit_reflow.cpp (matrix-override block
|
||||
* removed). This guard will become a no-op once a rebuilt WASM is deployed.
|
||||
*/
|
||||
function sanitizeEngineLayout(
|
||||
lay: import('../lib/pdfiumEngine').ReflowLayout | null,
|
||||
expectedFontSize: number,
|
||||
): import('../lib/pdfiumEngine').ReflowLayout | null {
|
||||
if (!lay || !lay.lines) return lay;
|
||||
for (const line of lay.lines) {
|
||||
console.log('[STAGE_6_SANITIZE_LAYOUT]', { lineFontSize: line.fontSize, expectedFontSize });
|
||||
if (line.fontSize && expectedFontSize > 0 && line.fontSize < expectedFontSize * 0.8) {
|
||||
line.fontSize = expectedFontSize;
|
||||
}
|
||||
}
|
||||
return lay;
|
||||
}
|
||||
|
||||
function globalCaretOffset(el: HTMLElement, caretRefVal?: number): number {
|
||||
if (typeof caretRefVal === 'number') return caretRefVal;
|
||||
return (el.textContent ?? '').length;
|
||||
}
|
||||
|
||||
/** Live caret index inside a contentEditable — matches setGlobalCaretOffset / textContent indexing. */
|
||||
function getDomCaretOffset(el: HTMLElement): number | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return 0;
|
||||
const pre = document.createRange();
|
||||
pre.selectNodeContents(el);
|
||||
pre.setEnd(range.startContainer, range.startOffset);
|
||||
return pre.toString().length;
|
||||
if (!el.contains(range.startContainer)) return null;
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let acc = 0;
|
||||
let node = walker.nextNode() as Text | null;
|
||||
while (node) {
|
||||
if (node === range.startContainer) return acc + range.startOffset;
|
||||
acc += node.textContent?.length ?? 0;
|
||||
node = walker.nextNode() as Text | null;
|
||||
}
|
||||
// Caret after a trailing <br> / at end of empty editor
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Caret metrics from typed runs (seed advances + measured middle). */
|
||||
function caretLayoutFromRuns(
|
||||
runs: ReflowFragment[],
|
||||
opts: {
|
||||
columnLeft: number;
|
||||
columnRight: number;
|
||||
firstBaselineY: number;
|
||||
leading: number;
|
||||
pageIndex: number;
|
||||
fontSize: number;
|
||||
/** Ink origin of first line (often slightly left of columnLeft). */
|
||||
anchorX0?: number;
|
||||
measureFamily?: string;
|
||||
fontWeight?: number;
|
||||
},
|
||||
): ReflowLayout {
|
||||
const lines: ReflowLayout['lines'] = [];
|
||||
const lineStartX = opts.anchorX0 ?? opts.columnLeft;
|
||||
let text = '';
|
||||
let adv: number[] = [];
|
||||
let x = lineStartX;
|
||||
let x0 = lineStartX;
|
||||
let baselineY = opts.firstBaselineY;
|
||||
const fontSize = opts.fontSize;
|
||||
const family = opts.measureFamily ?? 'Arial, sans-serif';
|
||||
const weight = opts.fontWeight ?? 400;
|
||||
const flush = () => {
|
||||
lines.push({
|
||||
baselineY,
|
||||
x0,
|
||||
fontSize,
|
||||
text,
|
||||
adv: adv.slice(),
|
||||
pageIndex: opts.pageIndex,
|
||||
});
|
||||
text = '';
|
||||
adv = [];
|
||||
x = opts.columnLeft;
|
||||
x0 = opts.columnLeft;
|
||||
baselineY -= opts.leading;
|
||||
};
|
||||
for (const r of runs) {
|
||||
if (r.text === '\n') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
const size = r.fontSize || fontSize;
|
||||
const runAdv = expandAdvancesForCaret(
|
||||
r.text, r.advances, r.advanceSeedText, size, family, weight,
|
||||
);
|
||||
for (let i = 0; i < r.text.length; i++) {
|
||||
const a = runAdv[i] ?? size * 0.5;
|
||||
if (text.length > 0 && x + a > opts.columnRight + 0.01) flush();
|
||||
if (text.length === 0) {
|
||||
x0 = lines.length === 0 ? lineStartX : opts.columnLeft;
|
||||
x = x0;
|
||||
}
|
||||
text += r.text[i];
|
||||
adv.push(a);
|
||||
x += a;
|
||||
}
|
||||
}
|
||||
if (text.length > 0 || lines.length === 0) flush();
|
||||
return { columnLeft: opts.columnLeft, anchorPage: opts.pageIndex, lines };
|
||||
}
|
||||
|
||||
function layoutTextMatchesEditor(lay: ReflowLayout | null, editorText: string): boolean {
|
||||
if (!lay?.lines?.length) return false;
|
||||
if (!lay.lines.every((l) => Array.isArray(l.adv) && l.adv.length === l.text.length)) return false;
|
||||
const norm = (s: string) => s.replace(/\s+/g, '');
|
||||
return norm(lay.lines.map((l) => l.text).join('')) === norm(editorText);
|
||||
}
|
||||
|
||||
function setGlobalCaretOffset(el: HTMLElement, target: number): void {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let acc = 0;
|
||||
let node = walker.nextNode() as Text | null;
|
||||
const sel = window.getSelection();
|
||||
while (node) {
|
||||
const len = node.textContent?.length ?? 0;
|
||||
if (acc + len >= target) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, Math.max(0, Math.min(target - acc, len)));
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges(); sel?.addRange(range);
|
||||
return;
|
||||
}
|
||||
acc += len;
|
||||
node = walker.nextNode() as Text | null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el); range.collapse(false);
|
||||
sel?.removeAllRanges(); sel?.addRange(range);
|
||||
}
|
||||
|
||||
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||||
@@ -308,9 +512,39 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** Caret layout from extracted PDF lines — no WASM reflow. Used on edit-entry so click is a visual no-op. */
|
||||
function layoutFromOrigLines(
|
||||
origLines: OrigLine[],
|
||||
columnLeft: number,
|
||||
pageIndex: number,
|
||||
fallbackSize: number,
|
||||
): ReflowLayout {
|
||||
return {
|
||||
columnLeft,
|
||||
anchorPage: pageIndex,
|
||||
lines: origLines.map((l) => {
|
||||
const text = l.frags.map((f) => f.text).join('');
|
||||
const adv = l.frags.flatMap((f) => {
|
||||
if (f.advances && f.advances.length === f.text.length) return f.advances;
|
||||
const n = f.text.length;
|
||||
const approx = (f.size || fallbackSize) * 0.5;
|
||||
return Array.from({ length: n }, () => approx);
|
||||
});
|
||||
return {
|
||||
baselineY: l.baselineY,
|
||||
x0: l.x,
|
||||
fontSize: l.frags.find((f) => f.size > 1)?.size ?? fallbackSize,
|
||||
text,
|
||||
adv: adv.length === text.length ? adv : Array.from({ length: text.length }, () => fallbackSize * 0.5),
|
||||
pageIndex,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||||
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
}) => {
|
||||
const layout = useMemo(() => computeLayout(para), [para]);
|
||||
const leading = leadingOverride ?? layout.leading;
|
||||
@@ -352,16 +586,73 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const domSize = domRun?.size ?? layout.seedRuns[0]?.size ?? 12;
|
||||
const domColor = domRun?.color ?? '#000000';
|
||||
const domFontName = domRun?.fontName ?? '';
|
||||
|
||||
// Extracted glyph/line metrics for overlay geometry (Fix 3 / Fix 4).
|
||||
const paraBox = useMemo(() => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
let ascent = 0, descent = 0;
|
||||
const bl = layout.firstBaselineY;
|
||||
for (const ln of (para?.lines ?? []) as any[]) {
|
||||
for (const r of (ln.runs ?? [])) {
|
||||
for (const g of (r.glyphs ?? [])) {
|
||||
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||||
minX = Math.min(minX, x0); minY = Math.min(minY, y0);
|
||||
maxX = Math.max(maxX, x1); maxY = Math.max(maxY, y1);
|
||||
const gbl = g.origin_y ?? ln.baseline_y ?? bl;
|
||||
ascent = Math.max(ascent, y1 - gbl);
|
||||
descent = Math.max(descent, gbl - y0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const lineH = (para?.lines ?? []).reduce((m: number, ln: any) => Math.max(m, ln?.h ?? 0), 0);
|
||||
if (!isFinite(minX)) {
|
||||
return { x: layout.columnLeft, y: bl - domSize * 0.8, w: Math.max(0, columnRight - layout.columnLeft),
|
||||
h: lineH || domSize * 1.2, ascent: domSize * 0.8, descent: domSize * 0.2, lineHeight: lineH || domSize * 1.2 };
|
||||
}
|
||||
return {
|
||||
x: minX, y: minY, w: maxX - minX, h: maxY - minY,
|
||||
ascent: ascent || domSize * 0.8, descent, lineHeight: lineH > 0.5 ? lineH : (maxY - minY),
|
||||
};
|
||||
}, [para, layout.columnLeft, layout.firstBaselineY, columnRight, domSize]);
|
||||
|
||||
// Fix 3: line-height from extracted metrics (not size×1.2 / browser normal).
|
||||
const lineHeightPt = leadingOverride ?? paraBox.lineHeight;
|
||||
const fontPx = domSize * zoom;
|
||||
const leadingPx = leading * zoom;
|
||||
const leadingPx = lineHeightPt * zoom;
|
||||
// Fix 4: overlay geometry matches extracted paragraph bbox (not page column).
|
||||
const overlayLeftPx = paraBox.x * zoom;
|
||||
const overlayWidthPx = Math.max(paraBox.w, 1) * zoom;
|
||||
const overlayHeightPx = Math.max(paraBox.h, lineHeightPt) * zoom;
|
||||
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
||||
const editorTop = firstBaselineScreen - paraBox.ascent * zoom;
|
||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||||
// Keep column metrics for reflow payload / toolbar (editing column can be wider than ink bbox).
|
||||
const colLeftPx = columnLeft * zoom;
|
||||
const colWidthPx = (columnRight - columnLeft) * zoom;
|
||||
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
||||
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
|
||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||||
|
||||
const measureFamily = /times|serif/i.test(domFontName) ? 'Times New Roman, serif'
|
||||
: /courier|mono/i.test(domFontName) ? 'Courier New, monospace' : 'Arial, sans-serif';
|
||||
// Fix 1: use extracted PDF font name (loaded via @font-face), not a generic Arial/Times/Courier map.
|
||||
const extractedFamily = (domFontName || '').replace(/^[A-Z]{6}\+/, '').trim() || 'sans-serif';
|
||||
const fallbackFamily = /times|serif/i.test(extractedFamily) ? 'Times New Roman, serif'
|
||||
: /courier|mono/i.test(extractedFamily) ? 'Courier New, monospace' : 'Arial, sans-serif';
|
||||
const [measureFamily, setMeasureFamily] = useState(
|
||||
extractedFamily !== 'sans-serif' ? `'${extractedFamily}', ${fallbackFamily}` : fallbackFamily,
|
||||
);
|
||||
|
||||
// Fix 2: preserve PDF bold/italic weight (BoldMT etc. previously fell through as 400).
|
||||
const extractedFontWeight: number = /bold|black|heavy|semibold|demibold/i.test(extractedFamily)
|
||||
|| /_B(?:old)?(?:_|$)/i.test(dominantFid)
|
||||
? 700
|
||||
: 400;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!dominantFid || !documentId) return;
|
||||
loadPdfFont(documentId, dominantFid, extractedFamily, extractedFontWeight).then((family) => {
|
||||
if (cancelled || !family) return;
|
||||
setMeasureFamily(`'${family}', ${fallbackFamily}`);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [documentId, dominantFid, extractedFamily, fallbackFamily, extractedFontWeight]);
|
||||
|
||||
const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => {
|
||||
const listActive = listKind !== null;
|
||||
@@ -375,17 +666,20 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
||||
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
||||
}
|
||||
const linePositionData = (!listActive && layout.origLines && layout.origLines.length) ? {
|
||||
lineX: layout.origLines.map((l) => l.x),
|
||||
lineBaselineY: layout.origLines.map((l) => l.baselineY),
|
||||
} : {};
|
||||
const linesData = (!listActive && origLines && origLines.length) ? {
|
||||
lines: origLines.map((l) => l.frags.map((f) => ({
|
||||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||||
...(f.advances ? { advances: f.advances } : {}),
|
||||
}))),
|
||||
lineX: origLines.map((l) => l.x),
|
||||
lineBaselineY: origLines.map((l) => l.baselineY),
|
||||
} : {};
|
||||
return {
|
||||
objectIndices: layout.objectIndices,
|
||||
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||||
...linePositionData,
|
||||
...linesData,
|
||||
columnLeft: effColumnLeft, columnRight,
|
||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||
@@ -403,6 +697,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }],
|
||||
});
|
||||
|
||||
const caretIndexRef = useRef<number | null>(null);
|
||||
|
||||
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
|
||||
if (!lay.lines.length) return null;
|
||||
const starts = lineStarts(lay, fullText);
|
||||
@@ -448,7 +744,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const positionCaret = () => {
|
||||
const el = editRef.current, lay = engineLayoutRef.current;
|
||||
if (!el || !lay) return;
|
||||
const box = caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? '');
|
||||
const fullText = el.textContent ?? '';
|
||||
// Always follow the live contentEditable caret — caretIndexRef alone stays stuck at last click.
|
||||
const live = getDomCaretOffset(el);
|
||||
if (typeof live === 'number') caretIndexRef.current = live;
|
||||
const idx = typeof caretIndexRef.current === 'number' ? caretIndexRef.current : fullText.length;
|
||||
const box = caretBoxFor(idx, lay, fullText);
|
||||
if (box && box.pageIndex !== pageIndex) {
|
||||
setCaretBox(null);
|
||||
onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height });
|
||||
@@ -468,36 +769,159 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const renderPreview = async () => {
|
||||
const el = editRef.current;
|
||||
if (!el) return;
|
||||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||
const dpi = Math.round(72 * zoom);
|
||||
const origLines = editedRef.current ? undefined : layout.origLines;
|
||||
const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines);
|
||||
|
||||
// Edit-entry (click before typing): do NOT run identity reflow.
|
||||
// Reflow rebuilds text objects (often runPerChar) and looks different from the PDF
|
||||
// even when the string is unchanged. Keep the page bitmap visible; caret uses extracted metrics.
|
||||
if (!editedRef.current) {
|
||||
const origLay = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
engineLayoutRef.current = origLay;
|
||||
if (!initialCaretApplied.current) {
|
||||
initialCaretApplied.current = true;
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
}
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing preview: use gateway (same engine as save). Browser WASM is stale and
|
||||
// changes font/width/height on keystroke; save then snaps back to the native look.
|
||||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||
const dpi = Math.round(96 * zoom * dpr);
|
||||
const runs = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||
const data = buildReflowData(runs);
|
||||
const yTopPt = bandTop / zoom;
|
||||
const { regions, layout: lay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||||
if (regions.length === 0) { return; }
|
||||
engineLayoutRef.current = lay;
|
||||
const operations = [{
|
||||
id: 'preview',
|
||||
type: 'reflow_paragraph' as const,
|
||||
pageIndex,
|
||||
data,
|
||||
}];
|
||||
|
||||
console.log('[STAGE_1_EDITABLE_RUN]', { dominantFid, domSize, fontPx, domFontName, edited: true });
|
||||
console.log('[STAGE_2_RENDER_PREVIEW_PAYLOAD]', { operations });
|
||||
|
||||
const preview = await gatewayService.previewEdits({
|
||||
documentId,
|
||||
operations,
|
||||
pageIndex,
|
||||
dpi,
|
||||
yTopPt,
|
||||
});
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let rawLay: ReflowLayout | null = null;
|
||||
let drawSource: HTMLCanvasElement | HTMLImageElement | null = null;
|
||||
let usedGateway = false;
|
||||
|
||||
if (preview?.pngBase64 && preview.width > 0 && preview.height > 0) {
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('preview png decode failed'));
|
||||
img.src = `data:image/png;base64,${preview.pngBase64}`;
|
||||
});
|
||||
width = preview.width;
|
||||
height = preview.height;
|
||||
rawLay = preview.layout;
|
||||
drawSource = img;
|
||||
usedGateway = true;
|
||||
onOverflowPreview?.([]);
|
||||
} catch {
|
||||
usedGateway = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!usedGateway) {
|
||||
// Fallback only if gateway preview is unavailable.
|
||||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||
if (!wasmHasDocument(documentId)) {
|
||||
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||||
if (bytes) await wasmLoadDocument(documentId, bytes);
|
||||
}
|
||||
if (!wasmHasDocument(documentId)) return;
|
||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||
const opJson = buildOpJson(runs);
|
||||
const { regions, layout: wasmLay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||||
if (regions.length === 0) return;
|
||||
rawLay = wasmLay;
|
||||
const r0 = regions[0];
|
||||
const { rgba, width, height } = r0;
|
||||
if (!rgba || width <= 0 || height <= 0) { return; }
|
||||
const cv = previewCanvasRef.current;
|
||||
if (cv) {
|
||||
if (cv.width !== width) cv.width = width;
|
||||
if (cv.height !== height) cv.height = height;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (ctx) {
|
||||
const img = ctx.createImageData(width, height);
|
||||
img.data.set(rgba);
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
}
|
||||
width = r0.width;
|
||||
height = r0.height;
|
||||
if (!r0.rgba || width <= 0 || height <= 0) return;
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
if (!offCtx) return;
|
||||
const imgData = offCtx.createImageData(width, height);
|
||||
imgData.data.set(r0.rgba);
|
||||
offCtx.putImageData(imgData, 0, 0);
|
||||
drawSource = offscreen;
|
||||
onOverflowPreview?.(regions.slice(1).map((rg) => ({
|
||||
pageIndex: rg.pageIndex, yTopPt: rg.yTopPt, dataUrl: rgbaToDataUrl(rg.rgba, rg.width, rg.height),
|
||||
})));
|
||||
}
|
||||
|
||||
console.log('[STAGE_7_PREVIEW_DRAW]', {
|
||||
fontSize: domSize,
|
||||
fontPx,
|
||||
via: usedGateway ? 'gateway' : 'wasm-fallback',
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
// Gateway PNG is authoritative for glyphs; caret needs per-glyph advances.
|
||||
// Only trust STAGE_5 (layoutSource=reflow). Extract layouts use bbox widths and lag the caret.
|
||||
const anchorX0 = layout.origLines[0]?.x ?? columnLeft;
|
||||
const caretOpts = {
|
||||
columnLeft,
|
||||
columnRight,
|
||||
firstBaselineY: layout.firstBaselineY,
|
||||
leading,
|
||||
pageIndex,
|
||||
fontSize: domSize,
|
||||
anchorX0,
|
||||
measureFamily,
|
||||
fontWeight: extractedFontWeight,
|
||||
};
|
||||
const engineLay = rawLay as (ReflowLayout & { layoutSource?: string }) | null;
|
||||
const caretLay = usedGateway
|
||||
? (engineLay?.layoutSource === 'reflow' && layoutTextMatchesEditor(engineLay, el.textContent ?? '')
|
||||
? engineLay
|
||||
: caretLayoutFromRuns(runs, caretOpts))
|
||||
: rawLay;
|
||||
const lay = sanitizeEngineLayout(caretLay, domSize);
|
||||
engineLayoutRef.current = lay;
|
||||
const cv = previewCanvasRef.current;
|
||||
if (cv && drawSource) {
|
||||
const displayW = pageWidthPx;
|
||||
const displayH = Math.max(0, pageHeightPx - bandTop);
|
||||
const targetW = Math.round(displayW * dpr);
|
||||
const targetH = Math.round(displayH * dpr);
|
||||
if (cv.width !== targetW) cv.width = targetW;
|
||||
if (cv.height !== targetH) cv.height = targetH;
|
||||
cv.style.width = `${displayW}px`;
|
||||
cv.style.height = `${displayH}px`;
|
||||
|
||||
const ctx = cv.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.drawImage(drawSource, 0, 0, displayW, displayH);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (!hasPreview) setHasPreview(true);
|
||||
if (!initialCaretApplied.current) {
|
||||
initialCaretApplied.current = true;
|
||||
if (caretClick && lay) setGlobalCaretOffset(el, globalFromPoint(caretClick.x, caretClick.y, lay, el.textContent ?? ''));
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
}
|
||||
positionCaret();
|
||||
};
|
||||
@@ -518,13 +942,13 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Edit-entry caret is sync (no reflow). Preload WASM in background for first keystroke.
|
||||
renderPreview();
|
||||
(async () => {
|
||||
if (!wasmHasDocument(documentId)) {
|
||||
const bytes = await gatewayService.getDocumentRaw(documentId);
|
||||
if (bytes) await wasmLoadDocument(documentId, bytes);
|
||||
if (bytes && !cancelled) await wasmLoadDocument(documentId, bytes);
|
||||
}
|
||||
if (cancelled) return;
|
||||
renderPreview();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -539,33 +963,209 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const el = editRef.current;
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
for (const r of layout.seedRuns) {
|
||||
// Preserve PDF hard line breaks (not a single CSS-wrapped flow).
|
||||
const lineFrags = layout.origLines.length
|
||||
? layout.origLines.map((l) => l.frags.map((f) => ({
|
||||
text: f.text, fid: f.fid, size: f.size, color: f.color, fontName: domFontName, advances: f.advances,
|
||||
})))
|
||||
: [layout.seedRuns];
|
||||
lineFrags.forEach((frags, li) => {
|
||||
if (li > 0) el.appendChild(document.createElement('br'));
|
||||
for (const r of frags) {
|
||||
if (!r.text) continue;
|
||||
const effSize = !r.text.trim() || r.size <= 1 ? domSize : r.size;
|
||||
const span = document.createElement('span');
|
||||
span.setAttribute('data-fid', r.fid || dominantFid);
|
||||
span.setAttribute('data-size', String(effSize));
|
||||
span.setAttribute('data-color', r.color);
|
||||
span.setAttribute('data-fontname', r.fontName);
|
||||
if (r.advances && r.advances.length === r.text.length) span.setAttribute('data-advances', JSON.stringify(r.advances));
|
||||
span.setAttribute('data-fontname', ('fontName' in r ? (r as { fontName?: string }).fontName : domFontName) || '');
|
||||
if (r.advances && r.advances.length === r.text.length) {
|
||||
span.setAttribute('data-advances', JSON.stringify(r.advances));
|
||||
span.setAttribute('data-seed-text', r.text);
|
||||
}
|
||||
span.style.fontSize = `${effSize * zoom}px`;
|
||||
span.style.color = 'transparent';
|
||||
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||||
span.textContent = r.text;
|
||||
el.appendChild(span);
|
||||
}
|
||||
});
|
||||
el.focus();
|
||||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||
// Caret layout after DOM seed is ready (documentId effect may have run first on an empty editor).
|
||||
if (!editedRef.current) {
|
||||
engineLayoutRef.current = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
initialCaretApplied.current = true;
|
||||
setGlobalCaretOffset(el, (el.textContent ?? '').length);
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
}
|
||||
|
||||
// --- Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing) ---
|
||||
try {
|
||||
const lines = (para?.lines ?? []) as any[];
|
||||
let pdfMinX = Infinity, pdfMinY = Infinity, pdfMaxX = -Infinity, pdfMaxY = -Infinity;
|
||||
let pdfBaseline = layout.firstBaselineY;
|
||||
let pdfAscent = 0, pdfDescent = 0;
|
||||
let pdfFontName = domFontName, pdfFontSize = domSize;
|
||||
for (const ln of lines) {
|
||||
for (const r of (ln.runs ?? [])) {
|
||||
pdfFontName = r.font_name || pdfFontName;
|
||||
pdfFontSize = Math.max(pdfFontSize, r.font_size ?? 0, r.h ?? 0);
|
||||
for (const g of (r.glyphs ?? [])) {
|
||||
const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h;
|
||||
pdfMinX = Math.min(pdfMinX, x0); pdfMinY = Math.min(pdfMinY, y0);
|
||||
pdfMaxX = Math.max(pdfMaxX, x1); pdfMaxY = Math.max(pdfMaxY, y1);
|
||||
const bl = g.origin_y ?? ln.baseline_y ?? pdfBaseline;
|
||||
pdfAscent = Math.max(pdfAscent, y1 - bl);
|
||||
pdfDescent = Math.max(pdfDescent, bl - y0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pdfW = isFinite(pdfMaxX) ? pdfMaxX - pdfMinX : 0;
|
||||
const pdfH = isFinite(pdfMaxY) ? pdfMaxY - pdfMinY : 0;
|
||||
const pdfLineH = lines[0]?.h ?? pdfH;
|
||||
const expectedBold = /bold|black|heavy/i.test(pdfFontName) || /Bold/i.test(dominantFid);
|
||||
|
||||
// Wait one frame so layout/scroll metrics settle after focus + children.
|
||||
requestAnimationFrame(() => {
|
||||
const cs = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pdf = {
|
||||
bbox: { x: pdfMinX, y: pdfMinY, w: pdfW, h: pdfH },
|
||||
lineHeightPt: pdfLineH,
|
||||
ascentPt: pdfAscent,
|
||||
descentPt: pdfDescent,
|
||||
widthPt: pdfW,
|
||||
heightPt: pdfH,
|
||||
fontName: pdfFontName,
|
||||
fontSizePt: pdfFontSize,
|
||||
baselineY: pdfBaseline,
|
||||
expectedFontWeight: expectedBold ? 700 : 400,
|
||||
};
|
||||
const overlayApplied = {
|
||||
fontFamily: measureFamily,
|
||||
fontSizePx: fontPx,
|
||||
fontSizePt: domSize,
|
||||
fontWeight: extractedFontWeight,
|
||||
lineHeightPx: leadingPx,
|
||||
lineHeightPt: lineHeightPt,
|
||||
letterSpacing: '(not set)',
|
||||
widthPx: overlayWidthPx,
|
||||
widthPt: paraBox.w,
|
||||
heightPx: overlayHeightPx,
|
||||
heightPt: paraBox.h,
|
||||
topPx: editorTop,
|
||||
leftPx: overlayLeftPx,
|
||||
ascentHeuristicPt: paraBox.ascent,
|
||||
transform: 'none',
|
||||
};
|
||||
const overlayMeasured = {
|
||||
fontFamily: cs.fontFamily,
|
||||
fontSize: cs.fontSize,
|
||||
fontWeight: cs.fontWeight,
|
||||
lineHeight: cs.lineHeight,
|
||||
letterSpacing: cs.letterSpacing,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
transform: cs.transform,
|
||||
scrollWidth: el.scrollWidth,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
boundingRect: { width: rect.width, height: rect.height, top: rect.top, left: rect.left },
|
||||
};
|
||||
|
||||
const checks: { property: string; pdf: string | number; overlay: string | number; match: boolean }[] = [];
|
||||
const pdfFamilyKey = (pdf.fontName || '').replace(/^[A-Z]{6}\+/, '');
|
||||
const familyApplied = measureFamily;
|
||||
const familyMatches =
|
||||
!!pdfFamilyKey
|
||||
&& (familyApplied.includes(pdfFamilyKey)
|
||||
|| familyApplied.replace(/['"]/g, '').split(',')[0].trim() === pdfFamilyKey);
|
||||
checks.push({
|
||||
property: 'font-family',
|
||||
pdf: pdf.fontName,
|
||||
overlay: familyApplied,
|
||||
match: familyMatches,
|
||||
});
|
||||
checks.push({
|
||||
property: 'font-weight',
|
||||
pdf: pdf.expectedFontWeight,
|
||||
overlay: parseInt(cs.fontWeight, 10) || cs.fontWeight,
|
||||
match: !expectedBold || (parseInt(cs.fontWeight, 10) || 0) >= 600,
|
||||
});
|
||||
checks.push({
|
||||
property: 'font-size (pt)',
|
||||
pdf: pdf.fontSizePt,
|
||||
overlay: overlayApplied.fontSizePt,
|
||||
match: Math.abs(pdf.fontSizePt - overlayApplied.fontSizePt) < 0.05,
|
||||
});
|
||||
checks.push({
|
||||
property: 'line-height / leading (pt)',
|
||||
// Multi-line CSS uses baseline gap (leadingOverride); ink line.h is not the CSS line-height.
|
||||
pdf: leadingOverride ?? pdf.lineHeightPt,
|
||||
overlay: overlayApplied.lineHeightPt,
|
||||
match: Math.abs((leadingOverride ?? pdf.lineHeightPt) - overlayApplied.lineHeightPt) < 0.5,
|
||||
});
|
||||
checks.push({
|
||||
property: 'ascent used for overlay top (pt)',
|
||||
pdf: pdf.ascentPt,
|
||||
overlay: overlayApplied.ascentHeuristicPt,
|
||||
match: Math.abs(pdf.ascentPt - overlayApplied.ascentHeuristicPt) < 0.5,
|
||||
});
|
||||
checks.push({
|
||||
property: 'height (pt)',
|
||||
pdf: pdf.heightPt,
|
||||
overlay: overlayApplied.heightPt,
|
||||
match: Math.abs(pdf.heightPt - overlayApplied.heightPt) < 0.5,
|
||||
});
|
||||
checks.push({
|
||||
property: 'width (pt)',
|
||||
pdf: pdf.widthPt,
|
||||
overlay: overlayApplied.widthPt,
|
||||
match: Math.abs(pdf.widthPt - overlayApplied.widthPt) < 1.0,
|
||||
});
|
||||
checks.push({
|
||||
property: 'letter-spacing',
|
||||
pdf: '0 / normal',
|
||||
overlay: cs.letterSpacing,
|
||||
match: cs.letterSpacing === 'normal' || cs.letterSpacing === '0px',
|
||||
});
|
||||
checks.push({
|
||||
property: 'transform',
|
||||
pdf: 'none',
|
||||
overlay: cs.transform,
|
||||
match: !cs.transform || cs.transform === 'none',
|
||||
});
|
||||
|
||||
const first = checks.find((c) => !c.match) ?? null;
|
||||
console.log('[EDIT_ENTRY_OVERLAY_COMPARE]', {
|
||||
pdf,
|
||||
overlayApplied,
|
||||
overlayMeasured,
|
||||
checks,
|
||||
firstPropertyThatChanges: first,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[EDIT_ENTRY_OVERLAY_COMPARE] failed', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fallbackVisible = wasmFailed && !hasPreview;
|
||||
// Never paint HTML over the PDF on edit-entry. Fallback HTML was a common
|
||||
// source of font/width/leading jumps when WASM was slow or unavailable.
|
||||
const fallbackVisible = false;
|
||||
useEffect(() => {
|
||||
const el = editRef.current;
|
||||
if (!el) return;
|
||||
el.querySelectorAll<HTMLElement>('span[data-fid]').forEach((span) => {
|
||||
span.style.color = fallbackVisible ? (span.getAttribute('data-color') ?? '#000000') : 'transparent';
|
||||
span.style.color = 'transparent';
|
||||
(span.style as CSSStyleDeclaration & { webkitTextFillColor?: string }).webkitTextFillColor = 'transparent';
|
||||
});
|
||||
}, [fallbackVisible]);
|
||||
|
||||
// Diagnostics only — does not drive a visible HTML fallback.
|
||||
useEffect(() => {
|
||||
if (hasPreview) return;
|
||||
const t = window.setTimeout(() => setWasmFailed(true), 4000);
|
||||
@@ -573,11 +1173,30 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
}, [hasPreview]);
|
||||
|
||||
const composingRef = useRef(false);
|
||||
const ignoreInputUntilRef = useRef(Date.now() + 150);
|
||||
|
||||
const onInput = () => {
|
||||
// Ignore spurious input events from contentEditable population / focus.
|
||||
if (Date.now() < ignoreInputUntilRef.current) return;
|
||||
editedRef.current = true;
|
||||
if (!edited) setEdited(true);
|
||||
if (composingRef.current) return;
|
||||
// Optimistic caret layout so the cursor tracks keystrokes while gateway preview is in flight.
|
||||
const elNow = editRef.current;
|
||||
if (elNow) {
|
||||
const liveRuns = extractFlatRuns(elNow, dominantFid, domSize, domColor);
|
||||
engineLayoutRef.current = caretLayoutFromRuns(liveRuns, {
|
||||
columnLeft,
|
||||
columnRight,
|
||||
firstBaselineY: layout.firstBaselineY,
|
||||
leading,
|
||||
pageIndex,
|
||||
fontSize: domSize,
|
||||
anchorX0: layout.origLines[0]?.x ?? columnLeft,
|
||||
measureFamily,
|
||||
fontWeight: extractedFontWeight,
|
||||
});
|
||||
}
|
||||
positionCaret();
|
||||
scheduleRender();
|
||||
};
|
||||
@@ -593,7 +1212,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const onClickEditor = (e: React.MouseEvent) => {
|
||||
const lay = engineLayoutRef.current, el = editRef.current;
|
||||
if (!lay || !el || fallbackVisible) return;
|
||||
setGlobalCaretOffset(el, globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? ''));
|
||||
const targetIdx = globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? '');
|
||||
caretIndexRef.current = targetIdx;
|
||||
setGlobalCaretOffset(el, targetIdx);
|
||||
positionCaret();
|
||||
};
|
||||
|
||||
@@ -656,7 +1277,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
{fallbackVisible && (
|
||||
<div
|
||||
className="absolute z-[36] bg-white"
|
||||
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: layout.oldLineCount * leadingPx + 8 }}
|
||||
style={{ left: overlayLeftPx - 2, top: editorTop - 2, width: overlayWidthPx + 4, height: overlayHeightPx + 4 }}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
@@ -735,12 +1356,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||||
className="absolute z-[38] outline-none"
|
||||
style={{
|
||||
left: colLeftPx, top: editorTop, width: colWidthPx, minHeight: layout.oldLineCount * leadingPx,
|
||||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`,
|
||||
caretColor: fallbackVisible ? '#2563eb' : 'transparent',
|
||||
color: fallbackVisible ? undefined : 'transparent',
|
||||
background: fallbackVisible ? '#ffffff' : 'transparent',
|
||||
whiteSpace: 'normal', overflowWrap: 'break-word',
|
||||
left: overlayLeftPx, top: editorTop, width: overlayWidthPx, height: overlayHeightPx,
|
||||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`, fontFamily: measureFamily,
|
||||
fontWeight: extractedFontWeight,
|
||||
caretColor: 'transparent',
|
||||
color: 'transparent',
|
||||
// WebKit may ignore `color: transparent` on contentEditable without this.
|
||||
WebkitTextFillColor: 'transparent',
|
||||
background: 'transparent',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'normal', overflowWrap: 'break-word', overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
const pr = pageContentRight(modelRef.current);
|
||||
if (al === 'center' || al === 'right') {
|
||||
openParaEdit({
|
||||
para, align: al,
|
||||
columnLeft: pageContentLeft(modelRef.current),
|
||||
columnRight: pageContentRight(modelRef.current),
|
||||
columnRight: pr,
|
||||
});
|
||||
} else {
|
||||
openParaEdit({ para, columnRight: pageContentRight(modelRef.current) });
|
||||
const paraRight = Math.max(...para.lines.map((l: any) => l.x + l.w));
|
||||
openParaEdit({ para, columnRight: Math.max(pr, paraRight) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -27,6 +27,17 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
render_cache_enabled: bool = Field(default=True)
|
||||
render_cache_max_entries: int = Field(default=256)
|
||||
render_cache_max_bytes: int = Field(
|
||||
default=536_870_912,
|
||||
description="Max total bytes for the tile render cache. Set to 0 to disable byte cap."
|
||||
)
|
||||
render_cache_max_entry_bytes: int = Field(
|
||||
default=8_388_608,
|
||||
description="Max bytes for a single cache entry. Oversized tiles are rendered but not cached."
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
+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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -249,6 +249,7 @@ class ReflowRun(BaseModel):
|
||||
fontSize: float
|
||||
color: str = "#000000"
|
||||
advances: list[float] | None = None
|
||||
advanceSeedText: str | None = None
|
||||
|
||||
|
||||
class ReflowParagraphData(BaseModel):
|
||||
@@ -426,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", []))
|
||||
@@ -438,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"]
|
||||
|
||||
return {"success": True, "newDocumentId": new_info["id"]}
|
||||
from app.services.render_cache import tile_cache
|
||||
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
|
||||
|
||||
return {"success": True, "newDocumentId": new_info["id"], "invalidatedRegions": invalidated_regions}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
finally:
|
||||
@@ -457,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:
|
||||
if dpi is None:
|
||||
dpi = int(96 * zoom)
|
||||
return render_page(document_id, page, dpi)
|
||||
print(f"[RENDER_COMPAT] document_id={document_id} page={page} zoom={zoom} dpi={dpi}")
|
||||
return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL)
|
||||
|
||||
|
||||
@router.get("/{page_index}")
|
||||
|
||||
@@ -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.
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
"""Locate the real failing PDF containing 'Professional Experiences' / Arial-BoldMT."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
sys.path.insert(0, r"c:\Users\Maskan\Desktop\pdf_editor\pdf\gateway")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
CANDS = [
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq (1).pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)verfication.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\DemoPdf.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\DemoPdf-1-3.pdf"),
|
||||
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\801c2dcf-afa2-462a-a71d-4b2e2599808f.pdf"),
|
||||
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\fc716e7e-aa82-4bb3-825a-51140db03c1b.pdf"),
|
||||
]
|
||||
|
||||
raw = Path(r"C:\Users\Maskan\Documents\pdf_html\backend\storage\raw")
|
||||
if raw.exists():
|
||||
CANDS.extend(sorted(raw.glob("*.pdf")))
|
||||
for d in raw.iterdir():
|
||||
if d.is_dir():
|
||||
CANDS.extend(sorted(d.glob("*.pdf")))
|
||||
|
||||
|
||||
def main():
|
||||
hits = []
|
||||
for p in CANDS:
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(p), "")
|
||||
except Exception as e:
|
||||
print(f"ERR load {p}: {e}")
|
||||
continue
|
||||
for pi in range(min(doc.page_count, 5)):
|
||||
page = doc.get_page(pi)
|
||||
try:
|
||||
model = page.extract_document_model()
|
||||
except Exception as e:
|
||||
print(f"ERR model {p} p{pi}: {e}")
|
||||
continue
|
||||
for para in model.paragraphs:
|
||||
text = " ".join("".join(r.text for r in ln.runs) for ln in para.lines)
|
||||
if "Professional Experience" in text:
|
||||
fonts = sorted({
|
||||
(r.font_name, r.internal_font_id, r.is_embedded, r.type, r.font_size)
|
||||
for ln in para.lines for r in ln.runs
|
||||
})
|
||||
hits.append((str(p), pi, text[:100], fonts))
|
||||
print("FOUND", p)
|
||||
print(" page", pi)
|
||||
print(" text", repr(text[:120]))
|
||||
print(" fonts", fonts)
|
||||
for f in doc.get_fonts(pi, pi):
|
||||
if "Arial-BoldMT_TrueType_32" in (f.internal_font_id or "") or (
|
||||
"Arial-BoldMT" in (f.font_name or "") and f.is_embedded
|
||||
):
|
||||
print("FONT", p, "page", pi, f.font_name, f.internal_font_id, "emb", f.is_embedded)
|
||||
print("hits", len(hits))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Edit-entry identity check for the voice-search bullet item (real resume).
|
||||
|
||||
Compares:
|
||||
1) Overlay CSS construction (mirrors ParagraphEditor Fixes 1-4 + buildBulletItem)
|
||||
2) Identity reflow region vs original region (pixel / glyph)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "bullet_edit_entry_report.json"
|
||||
TARGET = "voice search"
|
||||
|
||||
|
||||
def is_bullet(t: str) -> bool:
|
||||
t = (t or "").strip()
|
||||
return t in {"•", "●", "○", "◆", "■", "-", "–", "—", "*"} or (len(t) <= 3 and t[:1].isdigit() and t.endswith("."))
|
||||
|
||||
|
||||
def build_bullet_item(para, run_line_index: int):
|
||||
lines = para.lines
|
||||
col_left = min(l.x for l in lines)
|
||||
col_right = max(l.x + l.w for l in lines)
|
||||
|
||||
def lead_font(l):
|
||||
for r in l.runs:
|
||||
if (r.text or "").strip() and not is_bullet(r.text):
|
||||
return r.internal_font_id or ""
|
||||
return ""
|
||||
|
||||
hang = min((l.x for l in lines if l.x > col_left + 1), default=col_left + 8)
|
||||
flush_thresh = col_left + (hang - col_left) * 0.5
|
||||
|
||||
def flush_left(l):
|
||||
return l.x <= flush_thresh
|
||||
|
||||
def is_start(idx):
|
||||
l = lines[idx]
|
||||
if is_bullet((l.runs[0].text if l.runs else "") or ""):
|
||||
return True
|
||||
if idx == 0:
|
||||
return True
|
||||
return flush_left(l) and lead_font(l) and lead_font(l) != lead_font(lines[idx - 1])
|
||||
|
||||
start = run_line_index
|
||||
while start > 0 and not is_start(start):
|
||||
start -= 1
|
||||
end = run_line_index + 1
|
||||
while end < len(lines) and not is_start(end):
|
||||
end += 1
|
||||
item_lines = lines[start:end]
|
||||
deltas = []
|
||||
for i in range(start, end - 1):
|
||||
if hasattr(lines[i], "baseline_y") and hasattr(lines[i + 1], "baseline_y"):
|
||||
deltas.append(abs(lines[i].baseline_y - lines[i + 1].baseline_y))
|
||||
leading = sorted(deltas)[len(deltas) // 2] if deltas else 12.0
|
||||
|
||||
first_runs = list(item_lines[0].runs)
|
||||
sub_lines = item_lines
|
||||
if first_runs and is_bullet(first_runs[0].text):
|
||||
ti = 1
|
||||
while ti < len(first_runs) and not (first_runs[ti].text or "").strip():
|
||||
ti += 1
|
||||
text_runs = first_runs[ti:]
|
||||
text_indent = text_runs[0].x
|
||||
|
||||
class LW:
|
||||
pass
|
||||
|
||||
new_lines = []
|
||||
for idx, l in enumerate(item_lines):
|
||||
w = LW()
|
||||
if idx == 0:
|
||||
w.runs = text_runs
|
||||
w.x = text_indent
|
||||
w.w = (l.x + l.w) - text_indent
|
||||
else:
|
||||
w.runs = l.runs
|
||||
w.x = l.x
|
||||
w.w = l.w
|
||||
w.y = l.y
|
||||
w.h = l.h
|
||||
w.baseline_y = l.baseline_y
|
||||
new_lines.append(w)
|
||||
sub_lines = new_lines
|
||||
|
||||
class SP:
|
||||
pass
|
||||
|
||||
sp = SP()
|
||||
sp.lines = sub_lines
|
||||
return sp, col_left, leading, col_right, start, end
|
||||
|
||||
|
||||
def glyph_union(lines):
|
||||
minx = miny = 1e18
|
||||
maxx = maxy = -1e18
|
||||
ascent = descent = 0.0
|
||||
for ln in lines:
|
||||
for r in ln.runs:
|
||||
for g in r.glyphs:
|
||||
minx = min(minx, g.bbox_x)
|
||||
miny = min(miny, g.bbox_y)
|
||||
maxx = max(maxx, g.bbox_x + g.bbox_w)
|
||||
maxy = max(maxy, g.bbox_y + g.bbox_h)
|
||||
gbl = g.origin_y if g.origin_y else ln.baseline_y
|
||||
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
|
||||
descent = max(descent, gbl - g.bbox_y)
|
||||
return {
|
||||
"x": minx,
|
||||
"y": miny,
|
||||
"w": maxx - minx,
|
||||
"h": maxy - miny,
|
||||
"ascent": ascent,
|
||||
"descent": descent,
|
||||
"line_h": max(l.h for l in lines),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
para = None
|
||||
pi = -1
|
||||
for i, p in enumerate(model.paragraphs):
|
||||
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET.lower() in text.lower():
|
||||
para, pi = p, i
|
||||
break
|
||||
assert para is not None
|
||||
|
||||
# line index of voice-search bullet start
|
||||
run_line = 0
|
||||
for li, ln in enumerate(para.lines):
|
||||
t = "".join(r.text or "" for r in ln.runs)
|
||||
if "voice search" in t.lower() or (li and "Speech API" in "".join(r.text or "" for r in para.lines[li - 1].runs)):
|
||||
# find bullet start
|
||||
pass
|
||||
for li, ln in enumerate(para.lines):
|
||||
t = "".join(r.text or "" for r in ln.runs)
|
||||
if "Implemented a voice" in t:
|
||||
run_line = li
|
||||
break
|
||||
|
||||
sub, push_left, leading, col_right, start, end = build_bullet_item(para, run_line)
|
||||
box = glyph_union(sub.lines)
|
||||
layout = compute_layout(sub)
|
||||
|
||||
# dominant run
|
||||
dom = None
|
||||
for r in layout["seedRuns"]:
|
||||
if (r.get("text") or "").strip() and r.get("fid"):
|
||||
dom = r
|
||||
break
|
||||
font_name = (dom or {}).get("fontName") or ""
|
||||
extracted = re.sub(r"^[A-Z]{6}\+", "", font_name).strip() or "sans-serif"
|
||||
weight = 700 if re.search(r"bold|black|heavy", extracted, re.I) else 400
|
||||
line_height_pt = leading # leadingOverride ?? paraBox.lineHeight — override wins
|
||||
overlay = {
|
||||
"font_family": extracted,
|
||||
"font_weight": weight,
|
||||
"font_size": (dom or {}).get("size"),
|
||||
"line_height_pt": line_height_pt,
|
||||
"width_pt": box["w"],
|
||||
"height_pt": max(box["h"], line_height_pt),
|
||||
"left_pt": box["x"],
|
||||
"ascent": box["ascent"],
|
||||
"pdf_line_h": box["line_h"],
|
||||
"leading_override": leading,
|
||||
"column_left": push_left,
|
||||
"column_right": col_right,
|
||||
"seed_text": "".join(r["text"] for r in layout["seedRuns"]),
|
||||
"seed_has_bullet": any(is_bullet(r["text"]) for r in layout["seedRuns"]),
|
||||
"n_lines": len(sub.lines),
|
||||
"line_range": [start, end],
|
||||
}
|
||||
|
||||
checks = []
|
||||
checks.append(("font-family", font_name, extracted, extracted.lower() in (font_name or "").lower().replace("bcdjee+", "") or "arialmt" in extracted.lower()))
|
||||
checks.append(("font-weight", weight, weight, True))
|
||||
checks.append(("font-size", overlay["font_size"], overlay["font_size"], True))
|
||||
# Multi-line: CSS line-height should be baseline delta (leading), NOT ink line.h
|
||||
checks.append(("line-height(leading)", leading, line_height_pt, abs(leading - line_height_pt) < 0.01))
|
||||
checks.append(("width(ink)", box["w"], overlay["width_pt"], abs(box["w"] - overlay["width_pt"]) < 0.01))
|
||||
checks.append(("height(ink)", box["h"], overlay["height_pt"], abs(max(box["h"], line_height_pt) - overlay["height_pt"]) < 0.01))
|
||||
|
||||
# Identity reflow
|
||||
fid = (dom or {}).get("fid") or ""
|
||||
flat = extract_flat_runs(layout["seedRuns"], fid, (dom or {}).get("size") or 10, "#000000")
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "x")
|
||||
data["columnRight"] = col_right
|
||||
data["pushColumnLeft"] = push_left
|
||||
data["leading"] = leading
|
||||
data["columnLeft"] = layout["columnLeft"]
|
||||
op = {"version": "1.0", "operations": [{"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
||||
|
||||
# Original region crop
|
||||
y_top = box["y"] - 2
|
||||
h = box["h"] + 4
|
||||
dpi = 144
|
||||
orig_img = page.render_region_raw(dpi, y_top, h)
|
||||
|
||||
r = doc.apply_edits(json.dumps(op))
|
||||
page2 = doc.get_page(0)
|
||||
prev_img = page2.render_region_raw(dpi, y_top, h)
|
||||
|
||||
def sha(img):
|
||||
import hashlib
|
||||
return hashlib.sha256(bytes(img.data)).hexdigest()[:16] if img else None
|
||||
|
||||
pixel_match = False
|
||||
if orig_img and prev_img and orig_img.width == prev_img.width and orig_img.height == prev_img.height:
|
||||
a = bytes(orig_img.data)
|
||||
b = bytes(prev_img.data)
|
||||
pixel_match = a == b
|
||||
diff = sum(1 for i in range(0, len(a), 4) if a[i : i + 3] != b[i : i + 3])
|
||||
else:
|
||||
diff = -1
|
||||
|
||||
report = {
|
||||
"para_index": pi,
|
||||
"overlay": overlay,
|
||||
"checks": [{"property": a, "pdf": b, "overlay": c, "match": d} for a, b, c, d in checks],
|
||||
"identity_reflow": {
|
||||
"apply_ok": bool(r),
|
||||
"orig_sha": sha(orig_img),
|
||||
"prev_sha": sha(prev_img),
|
||||
"pixel_identical": pixel_match,
|
||||
"diff_pixels": diff,
|
||||
"region": {"y_top": y_top, "h": h, "dpi": dpi},
|
||||
},
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
print("OVERLAY", "ALL MATCH" if all(c[3] for c in checks) else "DIFFS")
|
||||
print("IDENTITY PIXELS", "MATCH" if pixel_match else f"DIFF ({diff})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Compare forensic snapshots and print diff tables."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def load(p):
|
||||
return json.loads(Path(p).read_text(encoding="utf-8-sig"))
|
||||
|
||||
def diff_table(a, b, keys):
|
||||
rows = []
|
||||
for k in keys:
|
||||
va, vb = a.get(k), b.get(k)
|
||||
changed = va != vb
|
||||
rows.append((k, va, vb, "CHANGED" if changed else "same"))
|
||||
return rows
|
||||
|
||||
def print_table(title, rows):
|
||||
print(f"\n=== {title} ===")
|
||||
print(f"{'Property':<22} {'Before':<40} {'After':<40} {'Status'}")
|
||||
for k, va, vb, st in rows:
|
||||
sa = json.dumps(va) if not isinstance(va, str) else va
|
||||
sb = json.dumps(vb) if not isinstance(vb, str) else vb
|
||||
if len(sa) > 38: sa = sa[:35] + "..."
|
||||
if len(sb) > 38: sb = sb[:35] + "..."
|
||||
print(f"{k:<22} {sa:<40} {sb:<40} {st}")
|
||||
|
||||
def compare_advances(orig, new):
|
||||
oa, na = orig or [], new or []
|
||||
diffs = []
|
||||
for i in range(max(len(oa), len(na))):
|
||||
ov = oa[i] if i < len(oa) else None
|
||||
nv = na[i] if i < len(na) else None
|
||||
if ov != nv:
|
||||
diffs.append((i, ov, nv))
|
||||
return diffs
|
||||
|
||||
def main():
|
||||
extract_path = Path(sys.argv[1] if len(sys.argv) > 1 else "forensic_extract_out.json")
|
||||
wasm_path = Path(sys.argv[2] if len(sys.argv) > 2 else "forensic_wasm_out.json")
|
||||
ex = load(extract_path)
|
||||
wasm = load(wasm_path)
|
||||
|
||||
keys = [
|
||||
"text", "fontId", "fontName", "fontSize", "color",
|
||||
"glyphCount", "advanceCount", "paragraphWidth", "baseline", "lineHeight",
|
||||
"origLinesProvided",
|
||||
]
|
||||
|
||||
orig = ex["original"]
|
||||
entry_payload = ex["editEntry"]
|
||||
one_payload = ex["editOne"]
|
||||
|
||||
# WASM layout snapshots
|
||||
from forensic_extract import snapshot_from_wasm_layout, para_signature # type: ignore
|
||||
seed_fn = orig.get("fontName", "")
|
||||
entry_wasm = snapshot_from_wasm_layout("ENTRY_WASM", wasm["editEntryWasm"]["layout"], seed_fn)
|
||||
one_wasm = snapshot_from_wasm_layout("ONE_WASM", wasm["editOneWasm"]["layout"], seed_fn)
|
||||
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (payload)", diff_table(orig, entry_payload, keys))
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (WASM layout)", diff_table(orig, entry_wasm, keys))
|
||||
|
||||
adv_diff_entry = compare_advances(orig.get("advances"), entry_wasm.get("advances"))
|
||||
if adv_diff_entry:
|
||||
print(f"\nAdvance diffs ORIGINAL vs ENTRY_WASM: {len(adv_diff_entry)} positions")
|
||||
for i, ov, nv in adv_diff_entry[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
else:
|
||||
print("\nAdvance arrays IDENTICAL: ORIGINAL vs ENTRY_WASM")
|
||||
|
||||
print_table("EDIT_ENTRY vs AFTER_ONE_CHAR (payload)", diff_table(entry_payload, one_payload, keys))
|
||||
print_table("ENTRY_WASM vs ONE_WASM", diff_table(entry_wasm, one_wasm, keys))
|
||||
|
||||
adv_diff_one = compare_advances(entry_wasm.get("advances"), one_wasm.get("advances"))
|
||||
if adv_diff_one:
|
||||
print(f"\nAdvance diffs ENTRY_WASM vs ONE_WASM: {len(adv_diff_one)} positions")
|
||||
for i, ov, nv in adv_diff_one[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
|
||||
# Line widths
|
||||
print("\n=== LINE WIDTHS ===")
|
||||
print(f"Original lineWidths: {orig.get('lineWidths')}")
|
||||
print(f"Entry WASM lineWidths: {entry_wasm.get('lineWidths')}")
|
||||
print(f"One WASM lineWidths: {one_wasm.get('lineWidths')}")
|
||||
|
||||
# Signatures chain
|
||||
sigs = {
|
||||
"original": para_signature(orig),
|
||||
"entry_payload": para_signature(entry_payload),
|
||||
"entry_wasm": para_signature(entry_wasm),
|
||||
"one_payload": para_signature(one_payload),
|
||||
"one_wasm": para_signature(one_wasm),
|
||||
}
|
||||
print("\n=== SIGNATURE CHAIN ===")
|
||||
prev = None
|
||||
for name, sig in sigs.items():
|
||||
changed = prev and sig != prev
|
||||
print(f" {name}: {sig}" + (" <-- FIRST CHANGE from " + prev_name if changed else ""))
|
||||
if changed and prev:
|
||||
pass
|
||||
prev_name = name
|
||||
prev = sig
|
||||
|
||||
# First mutation
|
||||
chain = list(sigs.items())
|
||||
base = chain[0][1]
|
||||
for i in range(1, len(chain)):
|
||||
if chain[i][1] != base:
|
||||
print(f"\nFIRST SIGNATURE CHANGE: {chain[i][0]} (vs original)")
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing).
|
||||
|
||||
Mirrors ParagraphEditor computeLayout + style construction for the real resume
|
||||
heading \"Professional Experience\" and ranks the first property that diverges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "edit_entry_overlay_report.json"
|
||||
TARGET = "Professional Experience"
|
||||
ZOOM = 1.0 # property ratios are zoom-invariant in pt space
|
||||
|
||||
|
||||
def measure_family(font_name: str) -> str:
|
||||
if re.search(r"times|serif", font_name, re.I):
|
||||
return "Times New Roman, serif"
|
||||
if re.search(r"courier|mono", font_name, re.I):
|
||||
return "Courier New, monospace"
|
||||
return "Arial, sans-serif"
|
||||
|
||||
|
||||
def page_content_right(model) -> float:
|
||||
right = float("-inf")
|
||||
for p in model.paragraphs:
|
||||
for ln in p.lines:
|
||||
if any((r.text or "").strip() for r in ln.runs):
|
||||
right = max(right, ln.x + ln.w)
|
||||
return right if right != float("-inf") else 0.0
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
height_pts = page.height
|
||||
|
||||
para = None
|
||||
for p in model.paragraphs:
|
||||
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in text:
|
||||
para = p
|
||||
break
|
||||
if para is None:
|
||||
raise SystemExit("paragraph not found")
|
||||
|
||||
# --- Extracted PDF metrics ---
|
||||
line = para.lines[0]
|
||||
run = line.runs[0]
|
||||
glyphs = list(run.glyphs)
|
||||
min_x = min(g.bbox_x for g in glyphs)
|
||||
min_y = min(g.bbox_y for g in glyphs)
|
||||
max_x = max(g.bbox_x + g.bbox_w for g in glyphs)
|
||||
max_y = max(g.bbox_y + g.bbox_h for g in glyphs)
|
||||
baseline = glyphs[0].origin_y
|
||||
ascent = max_y - baseline
|
||||
descent = baseline - min_y
|
||||
pdf = {
|
||||
"text": "".join(r.text or "" for ln in para.lines for r in ln.runs),
|
||||
"bbox": {"x": min_x, "y": min_y, "w": max_x - min_x, "h": max_y - min_y},
|
||||
"line_x": line.x,
|
||||
"line_y": line.y,
|
||||
"line_w": line.w,
|
||||
"line_h": line.h,
|
||||
"baseline_y": line.baseline_y,
|
||||
"ascent_pt": ascent,
|
||||
"descent_pt": descent,
|
||||
"width_pt": max_x - min_x,
|
||||
"height_pt": max_y - min_y,
|
||||
"font_name": run.font_name,
|
||||
"font_size_pt": run.font_size,
|
||||
"run_h": run.h,
|
||||
"internal_font_id": run.internal_font_id,
|
||||
"expected_font_weight": 700 if re.search(r"bold|black|heavy", run.font_name or "", re.I) else 400,
|
||||
}
|
||||
|
||||
# --- Overlay construction (mirrors ParagraphEditor + TextEditLayer openEditor) ---
|
||||
dom_size = max(run.font_size or 0, run.h or 0) or 12
|
||||
column_left = line.x
|
||||
para_right = line.x + line.w
|
||||
pr = page_content_right(model)
|
||||
column_right = max(pr, para_right) # TextEditLayer left-align path
|
||||
# single-line leading default in computeLayout:
|
||||
leading = dom_size * 1.2
|
||||
first_baseline_y = line.baseline_y
|
||||
font_name = run.font_name or ""
|
||||
family = measure_family(font_name)
|
||||
font_px = dom_size * ZOOM
|
||||
leading_px = leading * ZOOM
|
||||
col_left_px = column_left * ZOOM
|
||||
col_width_px = (column_right - column_left) * ZOOM
|
||||
col_height_px = 1 * leading_px # oldLineCount=1
|
||||
first_baseline_screen = (height_pts - first_baseline_y) * ZOOM
|
||||
editor_top = first_baseline_screen - font_px * 0.8
|
||||
ascent_heuristic = dom_size * 0.8
|
||||
|
||||
overlay = {
|
||||
"font_family": family,
|
||||
"font_size_pt": dom_size,
|
||||
"font_size_px": font_px,
|
||||
"font_weight": "(not set; browser default 400)",
|
||||
"line_height_pt": leading,
|
||||
"line_height_px": leading_px,
|
||||
"letter_spacing": "(not set; normal)",
|
||||
"width_pt": column_right - column_left,
|
||||
"width_px": col_width_px,
|
||||
"height_pt": leading, # oldLineCount * leading
|
||||
"height_px": col_height_px,
|
||||
"left_px": col_left_px,
|
||||
"top_px": editor_top,
|
||||
"ascent_heuristic_pt": ascent_heuristic,
|
||||
"transform": "none",
|
||||
"column_left_pt": column_left,
|
||||
"column_right_pt": column_right,
|
||||
"page_content_right_pt": pr,
|
||||
"style_source": {
|
||||
"measureFamily": "Arial, sans-serif because name matches neither times|serif nor courier|mono",
|
||||
"leading": "domSize * 1.2 (single-line; no baseline deltas)",
|
||||
"editorTop": "baselineScreen - fontPx * 0.8",
|
||||
"height": "oldLineCount * leadingPx",
|
||||
"width": "columnRightOverride - columnLeft (page content right, not text bbox)",
|
||||
},
|
||||
}
|
||||
|
||||
# Ordered property checks (construction order / visual identity order)
|
||||
checks = []
|
||||
|
||||
def add(prop, pdf_v, ov_v, match):
|
||||
checks.append({"property": prop, "pdf": pdf_v, "overlay": ov_v, "match": match})
|
||||
|
||||
bold = pdf["expected_font_weight"] >= 700
|
||||
add(
|
||||
"font-family",
|
||||
pdf["font_name"],
|
||||
family,
|
||||
(not bold) and ("arial" in (pdf["font_name"] or "").lower()),
|
||||
)
|
||||
add(
|
||||
"font-weight",
|
||||
pdf["expected_font_weight"],
|
||||
400,
|
||||
(not bold) or False, # BoldMT → no font-weight set → mismatch
|
||||
)
|
||||
add("font-size (pt)", pdf["font_size_pt"], overlay["font_size_pt"], abs(pdf["font_size_pt"] - overlay["font_size_pt"]) < 0.05)
|
||||
add("line-height / leading (pt)", pdf["line_h"], overlay["line_height_pt"], abs(pdf["line_h"] - overlay["line_height_pt"]) < 0.5)
|
||||
add("ascent (pt)", pdf["ascent_pt"], overlay["ascent_heuristic_pt"], abs(pdf["ascent_pt"] - overlay["ascent_heuristic_pt"]) < 0.5)
|
||||
add("descent (pt)", pdf["descent_pt"], "(not represented in overlay CSS)", False)
|
||||
add("height (pt)", pdf["height_pt"], overlay["height_pt"], abs(pdf["height_pt"] - overlay["height_pt"]) < 0.5)
|
||||
add("width (pt)", pdf["width_pt"], overlay["width_pt"], abs(pdf["width_pt"] - overlay["width_pt"]) < 1.0)
|
||||
add("letter-spacing", 0, "normal", True)
|
||||
add("transform", "none", "none", True)
|
||||
|
||||
first = next((c for c in checks if not c["match"]), None)
|
||||
|
||||
print("=== PDF paragraph ===")
|
||||
print(json.dumps(pdf, indent=2))
|
||||
print("\n=== Overlay applied (edit-entry) ===")
|
||||
print(json.dumps(overlay, indent=2))
|
||||
print("\n=== Property checks (order) ===")
|
||||
for c in checks:
|
||||
flag = "OK " if c["match"] else "DIFF"
|
||||
print(f" [{flag}] {c['property']}: pdf={c['pdf']!r} overlay={c['overlay']!r}")
|
||||
print("\n=== FIRST PROPERTY THAT CHANGES ON EDIT-ENTRY ===")
|
||||
print(json.dumps(first, indent=2))
|
||||
|
||||
report = {
|
||||
"scope": "edit-entry only (mouse click before typing); no reflow/typing",
|
||||
"pdf": pdf,
|
||||
"overlay": overlay,
|
||||
"checks": checks,
|
||||
"firstPropertyThatChanges": first,
|
||||
"notes": [
|
||||
"measureFamily maps Arial-BoldMT → 'Arial, sans-serif' and never sets font-weight:700",
|
||||
"That is the first identity/style mutation when the contentEditable overlay is created",
|
||||
"Subsequent geometric diffs: leading 11.09→14.4, ascent 8.74→9.6, height 11.09→14.4, width text→page column",
|
||||
],
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Stage 1-4 extraction + frontend layout mirror (ParagraphEditor.tsx)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
|
||||
def line_advances(line) -> tuple[dict[int, list[float]], float]:
|
||||
runs = line.runs
|
||||
seq: list[tuple[int, int, float]] = []
|
||||
aligned: dict[int, bool] = {}
|
||||
for ri, r in enumerate(runs):
|
||||
gs = r.glyphs
|
||||
ok = bool(r.text) and len(gs) == len(r.text)
|
||||
aligned[ri] = ok
|
||||
if ok:
|
||||
for ci, g in enumerate(gs):
|
||||
seq.append((ri, ci, g.origin_x))
|
||||
per_run: dict[int, list[float]] = {}
|
||||
for ri, r in enumerate(runs):
|
||||
if aligned.get(ri):
|
||||
per_run[ri] = [0.0] * len(r.text)
|
||||
anchor_x = seq[0][2] if seq else line.x
|
||||
for k, (ri, ci, ox) in enumerate(seq):
|
||||
gs = runs[ri].glyphs
|
||||
g = gs[ci]
|
||||
char_w = g.bbox_w if g.bbox_w > 0 else (runs[ri].font_size or 12) * 0.5
|
||||
per_run[ri][ci] = (seq[k + 1][2] - ox) if k + 1 < len(seq) else char_w
|
||||
for ri in list(per_run.keys()):
|
||||
if any(a <= 0 for a in per_run[ri]):
|
||||
del per_run[ri]
|
||||
return per_run, anchor_x
|
||||
|
||||
|
||||
def para_eff_size(lines) -> float:
|
||||
nominal = 0.0
|
||||
for line in lines:
|
||||
for r in line.runs:
|
||||
sz = max(r.font_size or 0, r.h or 0)
|
||||
if sz > nominal:
|
||||
nominal = sz
|
||||
return nominal or 12.0
|
||||
|
||||
|
||||
def median(xs: list[float]) -> float:
|
||||
if not xs:
|
||||
return 0.0
|
||||
s = sorted(xs)
|
||||
return s[len(s) // 2]
|
||||
|
||||
|
||||
def compute_layout(para) -> dict:
|
||||
lines = para.lines
|
||||
eff_size = para_eff_size(lines)
|
||||
column_left = float("inf")
|
||||
first_baseline_y = float("-inf")
|
||||
baselines: list[float] = []
|
||||
right_edges: list[float] = []
|
||||
object_indices: list[int] = []
|
||||
seed_runs: list[dict] = []
|
||||
orig_lines: list[dict] = []
|
||||
|
||||
for li, line in enumerate(lines):
|
||||
if line.baseline_y is not None:
|
||||
baselines.append(line.baseline_y)
|
||||
first_baseline_y = max(first_baseline_y, line.baseline_y)
|
||||
column_left = min(column_left, line.x)
|
||||
right_edges.append(line.x + line.w)
|
||||
line_runs = line.runs
|
||||
per_run, anchor_x = line_advances(line)
|
||||
line_frags: list[dict] = []
|
||||
for ri, r in enumerate(line_runs):
|
||||
for oi in (r.object_indices or []):
|
||||
object_indices.append(oi)
|
||||
orig = r.text or ""
|
||||
text = orig
|
||||
if li > 0 and ri == 0 and seed_runs:
|
||||
prev = seed_runs[-1]["text"]
|
||||
if prev and not prev.endswith((" ", "\t")) and not text.startswith((" ", "\t")):
|
||||
text = " " + text
|
||||
adv = per_run.get(ri)
|
||||
fc = getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000"
|
||||
safe_color = fc if fc and fc != "#ffffff00" else "#000000"
|
||||
r_size = max(r.font_size or 0, r.h or 0) or eff_size
|
||||
seed_runs.append({
|
||||
"text": text, "fid": r.internal_font_id or "", "size": r_size,
|
||||
"color": safe_color, "fontName": r.font_name or "", "advances": adv,
|
||||
})
|
||||
if orig:
|
||||
line_frags.append({
|
||||
"text": orig, "fid": r.internal_font_id or "", "size": r_size,
|
||||
"color": safe_color, "advances": adv,
|
||||
})
|
||||
if line_frags:
|
||||
orig_lines.append({"frags": line_frags, "x": anchor_x, "baselineY": line.baseline_y or 0})
|
||||
|
||||
max_right = max(right_edges) if right_edges else column_left + 250
|
||||
column_right = max(max_right, column_left + 250) if column_left != float("inf") else 250
|
||||
deltas = [baselines[i] - baselines[i + 1] for i in range(len(baselines) - 1)]
|
||||
dom_size = next((r["size"] for r in seed_runs if r["text"].strip()), 12.0)
|
||||
leading = abs(median(deltas)) if deltas else dom_size * 1.2
|
||||
col_w = column_right - column_left
|
||||
align = "left"
|
||||
if len(lines) >= 2:
|
||||
reaching = sum(1 for i in range(len(right_edges) - 1) if right_edges[i] >= column_right - col_w * 0.04)
|
||||
if reaching >= (len(lines) - 1) * 0.7:
|
||||
align = "justify"
|
||||
|
||||
return {
|
||||
"columnLeft": column_left, "columnRight": column_right,
|
||||
"firstBaselineY": first_baseline_y, "leading": leading,
|
||||
"oldLineCount": len(lines), "align": align,
|
||||
"objectIndices": object_indices, "seedRuns": seed_runs, "origLines": orig_lines,
|
||||
"domSize": dom_size,
|
||||
}
|
||||
|
||||
|
||||
def extract_flat_runs(seed_runs: list[dict], dominant_fid: str, dom_size: float, dom_color: str) -> list[dict]:
|
||||
"""Mirror extractFlatRuns when DOM matches seedRuns exactly (edit mode entry)."""
|
||||
out: list[dict] = []
|
||||
for r in seed_runs:
|
||||
if not r["text"]:
|
||||
continue
|
||||
frag = {
|
||||
"text": r["text"], "internalFontId": r["fid"] or dominant_fid,
|
||||
"fontSize": r["size"], "color": r["color"],
|
||||
}
|
||||
adv = r.get("advances")
|
||||
if adv and len(adv) == len(r["text"]):
|
||||
frag["advances"] = adv
|
||||
out.append(frag)
|
||||
return out
|
||||
|
||||
|
||||
def build_reflow_data(layout: dict, runs: list[dict], orig_lines: list[dict] | None, para_id: str) -> dict:
|
||||
line_position = {}
|
||||
if layout["origLines"]:
|
||||
line_position = {
|
||||
"lineX": [l["x"] for l in layout["origLines"]],
|
||||
"lineBaselineY": [l["baselineY"] for l in layout["origLines"]],
|
||||
}
|
||||
lines_data = {}
|
||||
if orig_lines:
|
||||
lines_data = {
|
||||
"lines": [
|
||||
[
|
||||
{
|
||||
"text": f["text"], "internalFontId": f["fid"],
|
||||
"fontSize": f["size"], "color": f["color"],
|
||||
**({"advances": f["advances"]} if f.get("advances") else {}),
|
||||
}
|
||||
for f in l["frags"]
|
||||
]
|
||||
for l in orig_lines
|
||||
],
|
||||
}
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip() and r["fid"] == dominant_fid), None)
|
||||
dom_size = (dom_run or layout["seedRuns"][0])["size"] if layout["seedRuns"] else 12.0
|
||||
dom_color = (dom_run or layout["seedRuns"][0])["color"] if layout["seedRuns"] else "#000000"
|
||||
out_runs = runs if runs else [{"text": " ", "internalFontId": dominant_fid, "fontSize": dom_size, "color": "#000000"}]
|
||||
return {
|
||||
"objectIndices": layout["objectIndices"],
|
||||
"runs": out_runs,
|
||||
**line_position,
|
||||
**lines_data,
|
||||
"columnLeft": layout["columnLeft"],
|
||||
"columnRight": layout["columnRight"],
|
||||
"pushColumnLeft": layout["columnLeft"],
|
||||
"firstBaselineY": layout["firstBaselineY"],
|
||||
"leading": layout["leading"],
|
||||
"oldLineCount": layout["oldLineCount"],
|
||||
"align": layout["align"],
|
||||
"paraId": para_id,
|
||||
}
|
||||
|
||||
|
||||
def paragraph_snapshot_from_extraction(para, layout: dict) -> dict:
|
||||
"""Original PDF paragraph metrics from extraction + derived advances."""
|
||||
lines = para.lines
|
||||
all_text = "".join(r.text for ln in lines for r in ln.runs)
|
||||
runs_detail = []
|
||||
for ln in lines:
|
||||
per_run, anchor_x = line_advances(ln)
|
||||
for ri, r in enumerate(ln.runs):
|
||||
adv = per_run.get(ri)
|
||||
runs_detail.append({
|
||||
"text": r.text,
|
||||
"fontId": r.internal_font_id,
|
||||
"fontName": r.font_name,
|
||||
"fontSize": max(r.font_size or 0, r.h or 0),
|
||||
"color": getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000",
|
||||
"glyphCount": len(r.glyphs),
|
||||
"advanceCount": len(adv) if adv else 0,
|
||||
"advances": adv,
|
||||
"bbox": [r.x, r.y, r.w, r.h],
|
||||
"lineWidth": sum(adv) if adv else r.w,
|
||||
})
|
||||
line_widths = [ln.w for ln in lines]
|
||||
orig_line_texts = ["".join(r.text for r in ln.runs) for ln in lines]
|
||||
return {
|
||||
"stage": "ORIGINAL_PDF_EXTRACTION",
|
||||
"text": all_text,
|
||||
"fontId": runs_detail[0]["fontId"] if runs_detail else "",
|
||||
"fontName": runs_detail[0]["fontName"] if runs_detail else "",
|
||||
"fontSize": layout["domSize"],
|
||||
"color": runs_detail[0]["color"] if runs_detail else "#000000",
|
||||
"glyphCount": sum(len(r.glyphs) for ln in lines for r in ln.runs),
|
||||
"advanceCount": sum(rd["advanceCount"] for rd in runs_detail),
|
||||
"advances": [a for rd in runs_detail for a in (rd["advances"] or [])],
|
||||
"boundingBox": [para.x, para.y, para.w, para.h],
|
||||
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
|
||||
"lineWidths": line_widths,
|
||||
"baseline": layout["firstBaselineY"],
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": layout["leading"],
|
||||
"origLines": orig_line_texts,
|
||||
"runs": runs_detail,
|
||||
"origLinesProvided": False,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_from_payload(stage: str, layout: dict, runs: list[dict], orig_lines: list[dict] | None) -> dict:
|
||||
all_text = "".join(r["text"] for r in runs)
|
||||
dom = runs[0] if runs else {}
|
||||
adv_flat = []
|
||||
for r in runs:
|
||||
if r.get("advances"):
|
||||
adv_flat.extend(r["advances"])
|
||||
return {
|
||||
"stage": stage,
|
||||
"text": all_text,
|
||||
"fontId": dom.get("internalFontId", ""),
|
||||
"fontName": layout["seedRuns"][0]["fontName"] if layout["seedRuns"] else "",
|
||||
"fontSize": dom.get("fontSize", layout["domSize"]),
|
||||
"color": dom.get("color", "#000000"),
|
||||
"glyphCount": len(all_text),
|
||||
"advanceCount": len(adv_flat),
|
||||
"advances": adv_flat,
|
||||
"boundingBox": None,
|
||||
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
|
||||
"lineWidths": None,
|
||||
"baseline": layout["firstBaselineY"],
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": layout["leading"],
|
||||
"origLines": ["".join(f["text"] for f in l["frags"]) for l in orig_lines] if orig_lines else None,
|
||||
"runs": [
|
||||
{
|
||||
"text": r["text"],
|
||||
"fontId": r.get("internalFontId"),
|
||||
"fontSize": r.get("fontSize"),
|
||||
"color": r.get("color"),
|
||||
"advanceCount": len(r.get("advances") or []),
|
||||
"advances": r.get("advances"),
|
||||
"advanceSource": "frontend_origin_x_derived" if r.get("advances") else "missing",
|
||||
}
|
||||
for r in runs
|
||||
],
|
||||
"origLinesProvided": orig_lines is not None,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_from_wasm_layout(stage: str, layout_json: dict, seed_font_name: str) -> dict:
|
||||
lines = layout_json.get("lines") or []
|
||||
all_text = "".join(ln.get("text", "") for ln in lines)
|
||||
adv_flat = []
|
||||
line_widths = []
|
||||
for ln in lines:
|
||||
adv = ln.get("adv") or []
|
||||
adv_flat.extend(adv)
|
||||
line_widths.append(sum(adv))
|
||||
dom_fs = lines[0].get("fontSize") if lines else None
|
||||
return {
|
||||
"stage": stage,
|
||||
"text": all_text,
|
||||
"fontId": "",
|
||||
"fontName": seed_font_name,
|
||||
"fontSize": dom_fs,
|
||||
"color": None,
|
||||
"glyphCount": len(all_text),
|
||||
"advanceCount": len(adv_flat),
|
||||
"advances": adv_flat,
|
||||
"boundingBox": None,
|
||||
"paragraphWidth": (layout_json.get("columnRight") or 0) - (layout_json.get("columnLeft") or 0),
|
||||
"lineWidths": line_widths,
|
||||
"baseline": lines[0].get("baselineY") if lines else None,
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": None,
|
||||
"origLines": [ln.get("text", "") for ln in lines],
|
||||
"runs": [
|
||||
{
|
||||
"text": ln.get("text"),
|
||||
"lineX0": ln.get("x0"),
|
||||
"fontSize": ln.get("fontSize"),
|
||||
"advanceCount": len(ln.get("adv") or []),
|
||||
"advances": ln.get("adv"),
|
||||
"advanceSum": sum(ln.get("adv") or []),
|
||||
"advanceSource": "wasm_reflow_output",
|
||||
}
|
||||
for ln in lines
|
||||
],
|
||||
"origLinesProvided": None,
|
||||
}
|
||||
|
||||
|
||||
def para_signature(snap: dict) -> str:
|
||||
parts = [
|
||||
snap.get("text") or "",
|
||||
str(snap.get("fontId") or ""),
|
||||
str(snap.get("fontSize") or ""),
|
||||
json.dumps(snap.get("advances") or []),
|
||||
json.dumps(snap.get("lineWidths") or []),
|
||||
str(snap.get("baseline") or ""),
|
||||
json.dumps(snap.get("boundingBox") or []),
|
||||
]
|
||||
return hashlib.sha256("".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
if len(sys.argv) > 1:
|
||||
pdf_path = Path(sys.argv[1])
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
fonts = doc.get_fonts(0, 0)
|
||||
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
dom_size = dom_run["size"]
|
||||
dom_color = dom_run["color"]
|
||||
|
||||
para_id = f"forensic-{hashlib.md5(str(pdf_path).encode()).hexdigest()[:8]}"
|
||||
|
||||
# Stage snapshots
|
||||
original = paragraph_snapshot_from_extraction(para, layout)
|
||||
|
||||
flat_entry = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_size, dom_color)
|
||||
payload_entry = build_reflow_data(layout, flat_entry, layout["origLines"], para_id)
|
||||
edit_entry = snapshot_from_payload("EDIT_MODE_ENTRY_PAYLOAD", layout, flat_entry, layout["origLines"])
|
||||
|
||||
# After one character
|
||||
flat_one = []
|
||||
for i, r in enumerate(flat_entry):
|
||||
if i == 0:
|
||||
flat_one.append({**r, "text": r["text"] + "x"})
|
||||
else:
|
||||
flat_one.append(r)
|
||||
payload_one = build_reflow_data(layout, flat_one, None, para_id)
|
||||
edit_one = snapshot_from_payload("AFTER_ONE_CHAR_PAYLOAD", layout, flat_one, None)
|
||||
|
||||
out = {
|
||||
"pdf": str(pdf_path),
|
||||
"fonts": [
|
||||
{
|
||||
"internalFontId": f.internal_font_id,
|
||||
"fontName": f.font_name,
|
||||
"ascent": f.ascent,
|
||||
"descent": f.descent,
|
||||
"capHeight": getattr(f, "cap_height", None),
|
||||
"unitsPerEm": getattr(f, "units_per_em", None),
|
||||
}
|
||||
for f in fonts
|
||||
],
|
||||
"layout": layout,
|
||||
"original": original,
|
||||
"editEntry": edit_entry,
|
||||
"editOne": edit_one,
|
||||
"payloads": {
|
||||
"editEntry": {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "forensic-entry", "type": "reflow_paragraph", "pageIndex": 0,
|
||||
"data": payload_entry,
|
||||
}],
|
||||
},
|
||||
"editOne": {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "forensic-one", "type": "reflow_paragraph", "pageIndex": 0,
|
||||
"data": payload_one,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"signatures": {
|
||||
"original": para_signature(original),
|
||||
"editEntryPayload": para_signature(edit_entry),
|
||||
"editOnePayload": para_signature(edit_one),
|
||||
},
|
||||
}
|
||||
out_path = Path(__file__).resolve().parent / "forensic_extract_out.json"
|
||||
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
|
||||
print(json.dumps(out, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
"""First-property mutation on first keystroke (typing/reflow only).
|
||||
|
||||
Compares ORIGINAL extracted paragraph vs after typing one char at end.
|
||||
Mirrors ParagraphEditor when editedRef=true:
|
||||
- no lines[] payload (origLines dropped)
|
||||
- lineX / lineBaselineY still sent from layout
|
||||
- advances + advanceSeedText for the edited run
|
||||
|
||||
Prints the first property that diverges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "first_keystroke_mutation.json"
|
||||
TARGET = "Professional Experience"
|
||||
TYPED = TARGET + "x"
|
||||
TOL = 0.05
|
||||
|
||||
|
||||
def para_metrics(para, prefix: str) -> dict:
|
||||
glyphs = []
|
||||
fonts = set()
|
||||
sizes = set()
|
||||
minx = miny = 1e18
|
||||
maxx = maxy = -1e18
|
||||
ascent = descent = 0.0
|
||||
baselines = []
|
||||
line_hs = []
|
||||
for ln in para.lines:
|
||||
baselines.append(ln.baseline_y)
|
||||
line_hs.append(ln.h)
|
||||
for r in ln.runs:
|
||||
fonts.add(r.font_name or "")
|
||||
sizes.add(r.font_size or 0)
|
||||
text = r.text or ""
|
||||
gs = list(r.glyphs)
|
||||
for i, g in enumerate(gs):
|
||||
ch = text[i] if i < len(text) else "?"
|
||||
adv = (gs[i + 1].origin_x - g.origin_x) if i + 1 < len(gs) else (g.bbox_w or (r.font_size or 12) * 0.5)
|
||||
glyphs.append({
|
||||
"char": ch,
|
||||
"origin_x": g.origin_x,
|
||||
"origin_y": g.origin_y,
|
||||
"advance": adv,
|
||||
"bbox_x": g.bbox_x,
|
||||
"bbox_y": g.bbox_y,
|
||||
"bbox_w": g.bbox_w,
|
||||
"bbox_h": g.bbox_h,
|
||||
"font_size": g.font_size or r.font_size,
|
||||
"font_name": g.font_name or r.font_name,
|
||||
"fid": r.internal_font_id,
|
||||
})
|
||||
minx = min(minx, g.bbox_x)
|
||||
miny = min(miny, g.bbox_y)
|
||||
maxx = max(maxx, g.bbox_x + g.bbox_w)
|
||||
maxy = max(maxy, g.bbox_y + g.bbox_h)
|
||||
gbl = g.origin_y if g.origin_y else ln.baseline_y
|
||||
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
|
||||
descent = max(descent, gbl - g.bbox_y)
|
||||
|
||||
joined = "".join(g["char"] for g in glyphs)
|
||||
start = joined.find(prefix[: len(TARGET)])
|
||||
if start < 0:
|
||||
start = 0
|
||||
shared = glyphs[start : start + len(TARGET)]
|
||||
|
||||
leading = None
|
||||
if len(baselines) >= 2:
|
||||
leading = abs(baselines[0] - baselines[1])
|
||||
|
||||
return {
|
||||
"text": joined,
|
||||
"font_family": sorted(fonts),
|
||||
"font_size": max(sizes) if sizes else None,
|
||||
"line_height": max(line_hs) if line_hs else None,
|
||||
"leading": leading,
|
||||
"ascent": ascent,
|
||||
"descent": descent,
|
||||
"paragraph_width": (maxx - minx) if maxx > minx else 0,
|
||||
"paragraph_height": (maxy - miny) if maxy > miny else 0,
|
||||
"n_lines": len(para.lines),
|
||||
"baselines": baselines,
|
||||
"shared_glyphs": shared,
|
||||
"bbox": {"x": minx, "y": miny, "w": maxx - minx, "h": maxy - miny},
|
||||
}
|
||||
|
||||
|
||||
def first_diff(before: dict, after: dict) -> dict | None:
|
||||
checks = []
|
||||
|
||||
def add(name, b, a, ok):
|
||||
checks.append({"property": name, "before": b, "after": a, "match": ok})
|
||||
|
||||
add("font_family", before["font_family"], after["font_family"],
|
||||
before["font_family"] == after["font_family"])
|
||||
add("font_size", before["font_size"], after["font_size"],
|
||||
before["font_size"] is not None and abs((before["font_size"] or 0) - (after["font_size"] or 0)) < TOL)
|
||||
add("line_height", before["line_height"], after["line_height"],
|
||||
before["line_height"] is not None and abs((before["line_height"] or 0) - (after["line_height"] or 0)) < TOL)
|
||||
add("ascent", before["ascent"], after["ascent"], abs(before["ascent"] - after["ascent"]) < TOL)
|
||||
add("descent", before["descent"], after["descent"], abs(before["descent"] - after["descent"]) < TOL)
|
||||
# Width/height: after includes +x so width may grow at the end — compare shared-prefix ink only below.
|
||||
bg, ag = before["shared_glyphs"], after["shared_glyphs"]
|
||||
n = min(len(bg), len(ag), len(TARGET))
|
||||
|
||||
# First glyph-level mutation among UNCHANGED chars
|
||||
glyph_first = None
|
||||
for i in range(n):
|
||||
b, a = bg[i], ag[i]
|
||||
reasons = []
|
||||
if b["char"] != a["char"]:
|
||||
reasons.append("char")
|
||||
if abs(b["advance"] - a["advance"]) > TOL:
|
||||
reasons.append("advance")
|
||||
if abs(b["origin_x"] - a["origin_x"]) > TOL:
|
||||
reasons.append("origin_x")
|
||||
if abs(b["origin_y"] - a["origin_y"]) > TOL:
|
||||
reasons.append("origin_y")
|
||||
if (b.get("font_name") or "") != (a.get("font_name") or ""):
|
||||
reasons.append("font_name")
|
||||
if abs((b.get("font_size") or 0) - (a.get("font_size") or 0)) > TOL:
|
||||
reasons.append("font_size")
|
||||
if reasons:
|
||||
glyph_first = {
|
||||
"index": i,
|
||||
"char": b["char"],
|
||||
"reasons": reasons,
|
||||
"before": b,
|
||||
"after": a,
|
||||
}
|
||||
break
|
||||
|
||||
add("unchanged_glyph_identity", "all match", glyph_first or "all match", glyph_first is None)
|
||||
|
||||
# Prefix span width (should be identical if advances+positions preserved)
|
||||
if n:
|
||||
bw = (bg[n - 1]["origin_x"] + bg[n - 1]["advance"]) - bg[0]["origin_x"]
|
||||
aw = (ag[n - 1]["origin_x"] + ag[n - 1]["advance"]) - ag[0]["origin_x"]
|
||||
add("prefix_width", bw, aw, abs(bw - aw) < TOL)
|
||||
add("prefix_start_x", bg[0]["origin_x"], ag[0]["origin_x"], abs(bg[0]["origin_x"] - ag[0]["origin_x"]) < TOL)
|
||||
|
||||
first = next((c for c in checks if not c["match"]), None)
|
||||
return {"checks": checks, "first_property_that_changes": first, "first_unchanged_glyph_mutation": glyph_first}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
para = None
|
||||
for p in model.paragraphs:
|
||||
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in t:
|
||||
para = p
|
||||
break
|
||||
assert para is not None
|
||||
|
||||
before = para_metrics(para, TARGET)
|
||||
layout = compute_layout(para)
|
||||
flat = extract_flat_runs(layout["seedRuns"], layout["seedRuns"][0]["fid"], layout["seedRuns"][0]["size"], "#000")
|
||||
|
||||
seed_adv = None
|
||||
for r in flat:
|
||||
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
|
||||
seed_adv = list(r["advances"])
|
||||
break
|
||||
|
||||
typed = []
|
||||
for r in flat:
|
||||
nr = dict(r)
|
||||
if nr.get("text") == TARGET:
|
||||
nr["text"] = TYPED
|
||||
if seed_adv is not None:
|
||||
nr["advances"] = seed_adv
|
||||
nr["advanceSeedText"] = TARGET
|
||||
else:
|
||||
nr.pop("advances", None)
|
||||
typed.append(nr)
|
||||
|
||||
data = build_reflow_data(layout, typed, None, "key-after")
|
||||
data.pop("lines", None) # editedRef drops lines
|
||||
# keep lineX/lineBaselineY as frontend does
|
||||
|
||||
print("PAYLOAD keys:", sorted(data.keys()))
|
||||
print("run:", [(r.get("text"), len(r.get("advances") or []), r.get("advanceSeedText")) for r in typed])
|
||||
|
||||
op = {"version": "1.0", "operations": [{"id": "t", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
para_a = None
|
||||
model_a = doc.get_page(0).extract_document_model()
|
||||
for p in model_a.paragraphs:
|
||||
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in t or TYPED in t or "Professional" in t:
|
||||
para_a = p
|
||||
break
|
||||
assert para_a is not None
|
||||
after = para_metrics(para_a, TYPED)
|
||||
|
||||
result = first_diff(before, after)
|
||||
report = {
|
||||
"target": TARGET,
|
||||
"typed": TYPED,
|
||||
"seed_adv_len": len(seed_adv or []),
|
||||
"before": {k: v for k, v in before.items() if k != "shared_glyphs"},
|
||||
"after": {k: v for k, v in after.items() if k != "shared_glyphs"},
|
||||
"before_shared_adv_sample": [g["advance"] for g in before["shared_glyphs"][:8]],
|
||||
"after_shared_adv_sample": [g["advance"] for g in after["shared_glyphs"][:8]],
|
||||
**result,
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
first = result["first_property_that_changes"]
|
||||
print("\n=== FIRST PROPERTY THAT CHANGES ===")
|
||||
print(json.dumps(first, indent=2))
|
||||
print("\n=== FIRST UNCHANGED GLYPH MUTATION ===")
|
||||
print(json.dumps(result["first_unchanged_glyph_mutation"], indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Step 2 experiment: force runPerChar=false for UNCHANGED paragraph.
|
||||
|
||||
Requires rebuilt pdfengine with forensicForceWholeRun support.
|
||||
Compares:
|
||||
A) default entry (runPerChar as decided by diverges)
|
||||
B) forensicForceWholeRun=true (runPerChar forced 0)
|
||||
C) original PDF
|
||||
|
||||
Emits object dumps + content streams + optional region bitmap SHA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
# Prefer freshly rebuilt engine when present (must be AFTER gateway insert so it wins).
|
||||
_winlocal = Path(r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
if _winlocal.exists():
|
||||
sys.path.insert(0, str(_winlocal))
|
||||
import pdfengine # type: ignore
|
||||
print(f"[engine] {pdfengine.__file__}", flush=True)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
|
||||
def inflate_content(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(pdf_bytes: bytes) -> list[str]:
|
||||
found = []
|
||||
for m in re.finditer(rb"<<[^>]*?/Type\s*/Font[^>]*?>>", pdf_bytes, re.DOTALL):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
# also compact form
|
||||
if not found:
|
||||
for m in re.finditer(rb"<</BaseFont/[^>]+>>", pdf_bytes):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
return found
|
||||
|
||||
|
||||
def region_sha(doc, page_index=0, dpi=144, y_top=200, height=80) -> str:
|
||||
page = doc.get_page(page_index)
|
||||
w, h, raw = page.render_region_raw(dpi, y_top, height)
|
||||
return hashlib.sha256(raw).hexdigest()[:16], w, h
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
|
||||
doc0 = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc0.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-force-whole")
|
||||
|
||||
variants = {
|
||||
"A_default_entry": {**data},
|
||||
"B_force_whole_run": {**data, "forensicForceWholeRun": True},
|
||||
}
|
||||
|
||||
orig_bytes = pdf_path.read_bytes()
|
||||
print("ORIGINAL fonts:", font_dicts(orig_bytes))
|
||||
print("ORIGINAL streams:")
|
||||
for s in inflate_content(orig_bytes):
|
||||
print(s)
|
||||
try:
|
||||
sha, w, h = region_sha(doc0, y_top=120, height=40)
|
||||
print(f"ORIGINAL region sha={sha} {w}x{h}")
|
||||
except Exception as e:
|
||||
print("ORIGINAL region render failed:", e)
|
||||
|
||||
results = {}
|
||||
for name, d in variants.items():
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": name, "type": "reflow_paragraph", "pageIndex": 0, "data": d,
|
||||
}]}
|
||||
print(f"\n===== {name} =====")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
out = doc.save_full()
|
||||
path = out_dir / f"_forensic_{name}.pdf"
|
||||
path.write_bytes(out)
|
||||
print("fonts:", font_dicts(out))
|
||||
streams = inflate_content(out)
|
||||
for s in streams:
|
||||
# only show streams that mention Goodbye / FXF2 / 100 Tm
|
||||
if "100 Tm" in s or "Goodbye" in s or "476F6F64" in s:
|
||||
print("--- content ---")
|
||||
print(s[:2000])
|
||||
# count TJ
|
||||
tj_count = sum(s.count(" TJ") for s in streams)
|
||||
print(f"TJ operator count across streams: {tj_count}")
|
||||
try:
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(out, "")
|
||||
sha, w, h = region_sha(d2, y_top=120, height=40)
|
||||
print(f"region sha={sha} {w}x{h}")
|
||||
results[name] = {"sha": sha, "tj": tj_count, "fonts": font_dicts(out)}
|
||||
except Exception as e:
|
||||
print("region render failed:", e)
|
||||
results[name] = {"error": str(e), "tj": tj_count}
|
||||
|
||||
print("\n===== VERDICT =====")
|
||||
if "A_default_entry" in results and "B_force_whole_run" in results:
|
||||
a, b = results["A_default_entry"], results["B_force_whole_run"]
|
||||
print(f"A (default runPerChar) TJ={a.get('tj')} sha={a.get('sha')}")
|
||||
print(f"B (force whole run) TJ={b.get('tj')} sha={b.get('sha')}")
|
||||
if a.get("sha") and b.get("sha"):
|
||||
if a["sha"] == b["sha"]:
|
||||
print("RESULT: A and B IDENTICAL bitmaps → runPerChar does NOT change pixels")
|
||||
else:
|
||||
print("RESULT: A and B DIFFER → runPerChar DOES change pixels")
|
||||
if a.get("tj") == 1 and b.get("tj") and b["tj"] < a.get("tj", 99):
|
||||
print("NOTE: B emitted fewer TJ (whole-run path active)")
|
||||
if a.get("tj", 0) > 5 and b.get("tj", 0) <= 3:
|
||||
print("CONFIRMED: forensicForceWholeRun reduced emission to whole-run")
|
||||
elif a.get("tj") == b.get("tj"):
|
||||
print("WARNING: TJ counts equal — rebuild may not include forensicForceWholeRun yet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,677 @@
|
||||
"""Runtime glyph-position compare + U+0000 coverage audit (no engine code changes).
|
||||
|
||||
Uses real resume PDF only. Produces per-glyph table for \"Professional Experience\".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real"
|
||||
FID = "Arial-BoldMT_TrueType_32"
|
||||
TARGET = "Professional Experience"
|
||||
TOL = 0.05 # PDF points — first position differ threshold
|
||||
|
||||
|
||||
# --- TJ / content-stream helpers -------------------------------------------------
|
||||
|
||||
def decompress_streams(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
raw = m.group(1)
|
||||
try:
|
||||
out.append(zlib.decompress(raw).decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
try:
|
||||
out.append(raw.decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def parse_tj_kerning(tj_body: str) -> list[dict]:
|
||||
"""Parse a TJ array body into char runs + kerning (thousandths of em).
|
||||
|
||||
Example: [(Pr)-6(o)7(fe)-6(s)...]
|
||||
Returns list of {chars, kern_before} where kern_before is the TJ number
|
||||
preceding that string fragment (0 for the first).
|
||||
"""
|
||||
# Strip outer brackets if present
|
||||
body = tj_body.strip()
|
||||
if body.startswith("["):
|
||||
body = body[1:]
|
||||
if body.endswith("]"):
|
||||
body = body[:-1]
|
||||
items: list[dict] = []
|
||||
pos = 0
|
||||
pending_kern = 0.0
|
||||
while pos < len(body):
|
||||
while pos < len(body) and body[pos].isspace():
|
||||
pos += 1
|
||||
if pos >= len(body):
|
||||
break
|
||||
if body[pos] == "(":
|
||||
# literal string with escape handling
|
||||
pos += 1
|
||||
chars = []
|
||||
while pos < len(body) and body[pos] != ")":
|
||||
if body[pos] == "\\" and pos + 1 < len(body):
|
||||
chars.append(body[pos + 1])
|
||||
pos += 2
|
||||
else:
|
||||
chars.append(body[pos])
|
||||
pos += 1
|
||||
pos += 1 # )
|
||||
s = "".join(chars)
|
||||
items.append({"chars": s, "kern_before": pending_kern})
|
||||
pending_kern = 0.0
|
||||
elif body[pos] == "<":
|
||||
end = body.find(">", pos)
|
||||
hexpart = body[pos + 1 : end]
|
||||
s = bytes.fromhex(hexpart).decode("latin-1", "replace")
|
||||
items.append({"chars": s, "kern_before": pending_kern})
|
||||
pending_kern = 0.0
|
||||
pos = end + 1
|
||||
else:
|
||||
# number (kerning)
|
||||
m = re.match(r"[+-]?\d+(?:\.\d+)?", body[pos:])
|
||||
if not m:
|
||||
pos += 1
|
||||
continue
|
||||
pending_kern = float(m.group(0))
|
||||
pos += len(m.group(0))
|
||||
return items
|
||||
|
||||
|
||||
def expand_tj_to_glyphs(tj_items: list[dict], font_size: float, tm: list[float]) -> list[dict]:
|
||||
"""Expand TJ fragments to per-char records with cumulative X from Tm + kerning.
|
||||
|
||||
Note: absolute X needs glyph advances; here we only attach kerning adjustment
|
||||
(PDF units = kern/1000 * fontSize) and the text matrix at the run start.
|
||||
Per-char X from stream alone is incomplete without widths — pair with extraction.
|
||||
"""
|
||||
rows = []
|
||||
for frag in tj_items:
|
||||
kern_pdf = (frag["kern_before"] / 1000.0) * font_size
|
||||
for i, ch in enumerate(frag["chars"]):
|
||||
rows.append({
|
||||
"char": ch,
|
||||
"kern_before_thousandths": frag["kern_before"] if i == 0 else 0.0,
|
||||
"kern_adj_pdf": kern_pdf if i == 0 else 0.0,
|
||||
"text_matrix": tm[:],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def find_original_heading_tj(pdf_bytes: bytes) -> tuple[list[float], float, list[dict]]:
|
||||
"""Locate Professional Experience TJ near y=597.45."""
|
||||
for s in decompress_streams(pdf_bytes):
|
||||
if "597.45" not in s or "Professional" not in s and "Pr)-6(o)" not in s:
|
||||
# still check for the known TJ pattern
|
||||
if "597.45" not in s:
|
||||
continue
|
||||
lines = s.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if "597.45" in ln and "Tm" in ln:
|
||||
# look forward for Tf + TJ
|
||||
tm = [float(x) for x in re.findall(r"[+-]?\d+(?:\.\d+)?", ln)[:6]]
|
||||
font_size = 12.0
|
||||
tj_body = None
|
||||
for j in range(i, min(i + 10, len(lines))):
|
||||
if " Tf" in lines[j]:
|
||||
nums = re.findall(r"[+-]?\d+(?:\.\d+)?", lines[j])
|
||||
if nums:
|
||||
font_size = float(nums[-1])
|
||||
if "TJ" in lines[j] and "[" in lines[j]:
|
||||
tj_body = lines[j]
|
||||
# may be `... ] TJ` on same line
|
||||
m = re.search(r"\[(.*)\]\s*TJ", lines[j])
|
||||
if m:
|
||||
tj_body = m.group(1)
|
||||
break
|
||||
if tj_body is None:
|
||||
continue
|
||||
# Prefer the heading that contains Pr
|
||||
if "Pr" not in tj_body and "Professional" not in tj_body:
|
||||
continue
|
||||
items = parse_tj_kerning(tj_body)
|
||||
return tm, font_size, expand_tj_to_glyphs(items, font_size, tm)
|
||||
raise RuntimeError("original TJ for Professional Experience not found")
|
||||
|
||||
|
||||
def find_preview_heading_glyphs(pdf_bytes: bytes) -> list[dict]:
|
||||
"""Parse per-char Tm + TJ hex CIDs for FXF3 at y≈597.45."""
|
||||
rows = []
|
||||
for s in decompress_streams(pdf_bytes):
|
||||
if "FXF3" not in s or "597.45" not in s:
|
||||
continue
|
||||
# Match blocks: Tm ... Tf ... [<HHHH>] TJ
|
||||
for m in re.finditer(
|
||||
r"1 0 0 1 ([0-9.+\-]+) ([0-9.+\-]+) Tm\s+/FXF3 ([0-9.]+) Tf.*?\[<([0-9A-Fa-f]+)>\]\s*TJ",
|
||||
s,
|
||||
re.DOTALL,
|
||||
):
|
||||
x, y, size, cid_hex = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||
rows.append({
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"font_size": float(size),
|
||||
"cid_or_gid": int(cid_hex, 16),
|
||||
"cid_hex": cid_hex.upper(),
|
||||
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(x), float(y)],
|
||||
"kern_adj_pdf": 0.0, # Identity-H per-char emit has no TJ kerning numbers
|
||||
})
|
||||
# Sort by x ascending (stream is reverse insert order)
|
||||
rows.sort(key=lambda r: r["x"])
|
||||
return rows
|
||||
|
||||
|
||||
# --- Extraction helpers ---------------------------------------------------------
|
||||
|
||||
def collect_para_glyphs(para) -> list[dict]:
|
||||
rows = []
|
||||
for ln in para.lines:
|
||||
for r in ln.runs:
|
||||
text = r.text or ""
|
||||
glyphs = list(r.glyphs)
|
||||
for i, g in enumerate(glyphs):
|
||||
ch = g.text if getattr(g, "text", None) is not None else (text[i] if i < len(text) else "?")
|
||||
# advance = delta to next origin, else bbox_w
|
||||
if i + 1 < len(glyphs):
|
||||
adv = glyphs[i + 1].origin_x - g.origin_x
|
||||
else:
|
||||
adv = g.bbox_w if g.bbox_w > 0 else (r.font_size or 12) * 0.5
|
||||
row = {
|
||||
"char": ch,
|
||||
"unicode": ord(ch) if len(ch) == 1 else None,
|
||||
"origin_x": g.origin_x,
|
||||
"origin_y": g.origin_y,
|
||||
"advance": adv,
|
||||
"bbox_w": g.bbox_w,
|
||||
"bbox_h": g.bbox_h,
|
||||
"font_size": g.font_size,
|
||||
"font_name": g.font_name,
|
||||
"fid": r.internal_font_id,
|
||||
}
|
||||
for attr in ("glyph_id", "gid", "charcode", "unicode_value", "font_glyph_id"):
|
||||
if hasattr(g, attr):
|
||||
row["glyph_id"] = getattr(g, attr)
|
||||
break
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def find_para(doc):
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
for idx, para in enumerate(model.paragraphs):
|
||||
text = "".join(r.text or "" for ln in para.lines for r in ln.runs)
|
||||
if TARGET in text or TARGET.replace(" ", "") in text.replace(" ", ""):
|
||||
# Prefer exact heading paragraph
|
||||
flat = "".join(r.text or "" for ln in para.lines for r in ln.runs)
|
||||
if TARGET in flat or flat.strip().startswith("Professional"):
|
||||
return idx, para, flat
|
||||
raise RuntimeError("paragraph not found")
|
||||
|
||||
|
||||
# --- U+0000 coverage audit (mirrors engine, no C++ edits) -----------------------
|
||||
|
||||
def utf8_to_utf16le_mirror(s: str) -> list[int]:
|
||||
"""Mirror pdfium_internal.cpp utf8_to_utf16le including trailing NUL."""
|
||||
utf16: list[int] = []
|
||||
data = s.encode("utf-8")
|
||||
i = 0
|
||||
while i < len(data):
|
||||
c = data[i]
|
||||
if c < 0x80:
|
||||
cp, extra = c, 0
|
||||
elif (c & 0xE0) == 0xC0:
|
||||
cp, extra = c & 0x1F, 1
|
||||
elif (c & 0xF0) == 0xE0:
|
||||
cp, extra = c & 0x0F, 2
|
||||
elif (c & 0xF8) == 0xF0:
|
||||
cp, extra = c & 0x07, 3
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
if i + extra >= len(data):
|
||||
break
|
||||
invalid = False
|
||||
for j in range(1, extra + 1):
|
||||
nxt = data[i + j]
|
||||
if (nxt & 0xC0) != 0x80:
|
||||
invalid = True
|
||||
break
|
||||
cp = (cp << 6) | (nxt & 0x3F)
|
||||
if invalid:
|
||||
i += 1
|
||||
continue
|
||||
i += 1 + extra
|
||||
if cp < 0x10000:
|
||||
utf16.append(cp)
|
||||
else:
|
||||
cp -= 0x10000
|
||||
utf16.append((cp >> 10) + 0xD800)
|
||||
utf16.append((cp & 0x3FF) + 0xDC00)
|
||||
utf16.append(0) # <-- engine always appends NUL terminator
|
||||
return utf16
|
||||
|
||||
|
||||
def to_codepoints_mirror(s: str) -> list[int]:
|
||||
"""Mirror toCodepoints lambda in pdfium_edit_reflow.cpp."""
|
||||
u16 = utf8_to_utf16le_mirror(s)
|
||||
cps: list[int] = []
|
||||
i = 0
|
||||
while i < len(u16):
|
||||
cp = u16[i]
|
||||
if 0xD800 <= cp <= 0xDBFF and i + 1 < len(u16):
|
||||
low = u16[i + 1]
|
||||
if 0xDC00 <= low <= 0xDFFF:
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
cps.append(cp)
|
||||
return cps
|
||||
|
||||
|
||||
def unicode_cmap(font_bytes: bytes) -> dict[int, int]:
|
||||
"""Prefer MS/Unicode cmap (what FreeType typically uses for FT_Get_Char_Index)."""
|
||||
from fontTools.ttLib import TTFont # type: ignore
|
||||
import io
|
||||
tt = TTFont(io.BytesIO(font_bytes))
|
||||
glyph_order = tt.getGlyphOrder()
|
||||
name_to_gid = {n: i for i, n in enumerate(glyph_order)}
|
||||
|
||||
def to_map(table) -> dict[int, int]:
|
||||
out = {}
|
||||
for cp, name in table.cmap.items():
|
||||
out[cp] = name_to_gid.get(name, 0) if isinstance(name, str) else int(name)
|
||||
return out
|
||||
|
||||
preferred: list[dict[int, int]] = []
|
||||
fallback: list[dict[int, int]] = []
|
||||
for t in tt["cmap"].tables:
|
||||
mapping = to_map(t)
|
||||
if t.platformID == 3 and t.platEncID in (1, 10):
|
||||
preferred.append(mapping)
|
||||
elif t.platformID == 0:
|
||||
preferred.append(mapping)
|
||||
else:
|
||||
fallback.append(mapping)
|
||||
merged: dict[int, int] = {}
|
||||
for m in (preferred or fallback):
|
||||
merged.update(m)
|
||||
return merged
|
||||
|
||||
|
||||
def cmap_has_glyph(font_bytes: bytes, codepoint: int) -> tuple[bool, int]:
|
||||
"""Return (has_glyph_like_engine, gid). Engine: FT_Get_Char_Index != 0."""
|
||||
try:
|
||||
cmap = unicode_cmap(font_bytes)
|
||||
gid = int(cmap.get(codepoint, 0) or 0)
|
||||
return (gid != 0), gid
|
||||
except Exception:
|
||||
return False, -1
|
||||
|
||||
|
||||
def parse_emit_log(log_text: str) -> list[dict]:
|
||||
"""Parse [EMIT_FONT] lines for Professional Experience heading."""
|
||||
rows = []
|
||||
for m in re.finditer(
|
||||
r"\[EMIT_FONT\] text='([^']*)'.*?atX=([0-9.+\-]+)\s+baselineY=([0-9.+\-]+).*?runPerChar=(\d+)",
|
||||
log_text,
|
||||
):
|
||||
rows.append({
|
||||
"char": m.group(1),
|
||||
"origin_x": float(m.group(2)),
|
||||
"origin_y": float(m.group(3)),
|
||||
"runPerChar": int(m.group(4)),
|
||||
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(m.group(2)), float(m.group(3))],
|
||||
"emitted": True,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def align_emit_to_target(target: str, emit_rows: list[dict], advances: list[float], baseline_y: float) -> list[dict]:
|
||||
"""Align EMIT rows to TARGET; spaces skipped by runPerChar get emitted=False with inferred X."""
|
||||
out: list[dict] = []
|
||||
ei = 0
|
||||
x_cursor = None
|
||||
for i, ch in enumerate(target):
|
||||
adv = advances[i] if i < len(advances) else 0.0
|
||||
if ch == " ":
|
||||
# runPerChar path: if (seg.text[c] != ' ') emitObj(...) — space NOT emitted
|
||||
if x_cursor is None and out:
|
||||
x_cursor = out[-1]["origin_x"] + out[-1]["advance"]
|
||||
elif x_cursor is None:
|
||||
x_cursor = 0.0
|
||||
out.append({
|
||||
"char": " ",
|
||||
"origin_x": x_cursor,
|
||||
"origin_y": baseline_y,
|
||||
"advance": adv,
|
||||
"emitted": False,
|
||||
"glyph_id": None,
|
||||
"kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0,
|
||||
"text_matrix": None,
|
||||
"note": "space skipped by runPerChar emit (x advanced only)",
|
||||
})
|
||||
x_cursor = x_cursor + adv
|
||||
continue
|
||||
if ei >= len(emit_rows):
|
||||
out.append({
|
||||
"char": ch, "origin_x": None, "origin_y": None, "advance": adv,
|
||||
"emitted": False, "glyph_id": None, "kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0, "text_matrix": None,
|
||||
"note": "missing emit",
|
||||
})
|
||||
continue
|
||||
er = emit_rows[ei]
|
||||
ei += 1
|
||||
# If emit char doesn't match (shouldn't), still take position
|
||||
row = {
|
||||
"char": ch,
|
||||
"origin_x": er["origin_x"],
|
||||
"origin_y": er["origin_y"],
|
||||
"advance": adv,
|
||||
"emitted": True,
|
||||
"glyph_id": None,
|
||||
"kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0,
|
||||
"text_matrix": er["text_matrix"],
|
||||
"note": "" if er["char"] == ch else f"emit_char_mismatch emit={er['char']!r}",
|
||||
}
|
||||
out.append(row)
|
||||
x_cursor = er["origin_x"] + adv
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
import io
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
orig_bytes = PDF.read_bytes()
|
||||
|
||||
print("=== LOAD ORIGINAL ===")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
pidx, para, flat = find_para(doc)
|
||||
print(f"paragraph idx={pidx} text={flat!r}")
|
||||
|
||||
orig_glyphs = collect_para_glyphs(para)
|
||||
joined = "".join(g["char"] for g in orig_glyphs)
|
||||
start = joined.find(TARGET)
|
||||
if start < 0:
|
||||
target_glyphs = [g for g in orig_glyphs if g.get("fid") == FID]
|
||||
else:
|
||||
target_glyphs = orig_glyphs[start : start + len(TARGET)]
|
||||
|
||||
print(f"orig glyph count for target={len(target_glyphs)} chars={''.join(g['char'] for g in target_glyphs)!r}")
|
||||
|
||||
tm, fsize, tj_rows = find_original_heading_tj(orig_bytes)
|
||||
print(f"original Tm={tm} fontSize={fsize} TJ expanded chars={len(tj_rows)}")
|
||||
|
||||
for i, g in enumerate(target_glyphs):
|
||||
if i < len(tj_rows):
|
||||
g["kern_before_thousandths"] = tj_rows[i]["kern_before_thousandths"]
|
||||
g["kern_adj_pdf"] = tj_rows[i]["kern_adj_pdf"]
|
||||
g["text_matrix"] = tj_rows[i]["text_matrix"]
|
||||
else:
|
||||
g["kern_before_thousandths"] = 0.0
|
||||
g["kern_adj_pdf"] = 0.0
|
||||
g["text_matrix"] = tm
|
||||
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), FID)
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["fid"] == dominant_fid and r["text"].strip()), layout["seedRuns"][0])
|
||||
runs = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_run["size"], dom_run["color"])
|
||||
data = build_reflow_data(layout, runs, layout["origLines"], f"pos-{pidx}")
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "pos", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
|
||||
print("=== APPLY REFLOW (edit-entry, unchanged text) ===")
|
||||
# Capture spdlog on stderr/stdout if redirected; also read prior pattern from engine
|
||||
log_buf = io.StringIO()
|
||||
doc2 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
# spdlog writes to stderr typically via our capture of the process — here we rely on
|
||||
# re-parsing from a file we tee. Capture by running and also reading STAGE_5 from
|
||||
# a side channel: apply then parse preview stream + reconstruct from known EMIT positions.
|
||||
doc2.apply_edits(json.dumps(op))
|
||||
prev_bytes = doc2.save_full()
|
||||
(OUT / "preview_pos.pdf").write_bytes(prev_bytes)
|
||||
|
||||
# Client advances from seed (same numbers reflow uses when lengths match)
|
||||
client_adv = None
|
||||
for r in layout["seedRuns"]:
|
||||
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
|
||||
client_adv = list(r["advances"])
|
||||
break
|
||||
if client_adv is None:
|
||||
client_adv = [g["advance"] for g in target_glyphs]
|
||||
|
||||
# Preview positions: from content stream FXF3 (non-space) + inferred space
|
||||
stream_prev = find_preview_heading_glyphs(prev_bytes)
|
||||
print(f"preview stream FXF3 glyphs={len(stream_prev)} (spaces intentionally not emitted in runPerChar)")
|
||||
|
||||
# Build synthetic emit rows from stream (sorted by x) — chars from TARGET without spaces
|
||||
non_space = [ch for ch in TARGET if ch != " "]
|
||||
emit_rows = []
|
||||
for i, ch in enumerate(non_space):
|
||||
if i < len(stream_prev):
|
||||
sp = stream_prev[i]
|
||||
emit_rows.append({
|
||||
"char": ch,
|
||||
"origin_x": sp["x"],
|
||||
"origin_y": sp["y"],
|
||||
"runPerChar": 1,
|
||||
"text_matrix": sp["text_matrix"],
|
||||
"emitted": True,
|
||||
"glyph_id": sp["cid_or_gid"],
|
||||
})
|
||||
# Also try to enrich from glyph_pos_log if present from prior tee
|
||||
log_path = OUT / "glyph_pos_log.txt"
|
||||
if log_path.exists():
|
||||
parsed = parse_emit_log(log_path.read_text(encoding="utf-8", errors="replace"))
|
||||
if len(parsed) >= len(non_space):
|
||||
# Prefer live EMIT atX from log (more authoritative for this run if same)
|
||||
emit_rows = []
|
||||
for i, ch in enumerate(non_space):
|
||||
er = parsed[i]
|
||||
emit_rows.append({**er, "char": ch})
|
||||
|
||||
baseline_y = target_glyphs[0]["origin_y"] if target_glyphs else 597.45
|
||||
prev_glyphs = align_emit_to_target(TARGET, emit_rows, client_adv, baseline_y)
|
||||
# Attach GIDs from stream to emitted glyphs
|
||||
si = 0
|
||||
for g in prev_glyphs:
|
||||
if g["emitted"] and si < len(stream_prev):
|
||||
g["glyph_id"] = stream_prev[si]["cid_or_gid"]
|
||||
si += 1
|
||||
|
||||
font_bytes = bytes(doc.get_font_data(FID) or b"")
|
||||
print(f"embedded font bytes={len(font_bytes)}")
|
||||
for g in target_glyphs:
|
||||
if g.get("unicode") is not None and font_bytes:
|
||||
has, gid = cmap_has_glyph(font_bytes, g["unicode"])
|
||||
g["glyph_id"] = gid
|
||||
g["cmap_has"] = has
|
||||
|
||||
print("\n=== PER-GLYPH COMPARISON (orig extraction vs emit atX) ===")
|
||||
print(
|
||||
f"{'#':>2} {'ch':>3} {'gidO':>5} {'gidP':>5} {'emit':>4} "
|
||||
f"{'xO':>10} {'xP':>10} {'dx':>8} "
|
||||
f"{'yO':>10} {'yP':>10} {'dy':>8} "
|
||||
f"{'advO':>8} {'advP':>8} {'dAdv':>8} "
|
||||
f"{'kernO':>8} {'kernP':>8} Tm tx,ty"
|
||||
)
|
||||
first_diff = None
|
||||
rows_out = []
|
||||
n = min(len(target_glyphs), len(prev_glyphs), len(TARGET))
|
||||
for i in range(n):
|
||||
o, p = target_glyphs[i], prev_glyphs[i]
|
||||
xP = p.get("origin_x")
|
||||
yP = p.get("origin_y")
|
||||
if xP is None:
|
||||
dx = dy = float("nan")
|
||||
pos_diff = True
|
||||
else:
|
||||
dx = xP - o["origin_x"]
|
||||
dy = yP - o["origin_y"]
|
||||
pos_diff = (not p.get("emitted")) or abs(dx) > TOL or abs(dy) > TOL
|
||||
dadv = (p["advance"] - o["advance"]) if p.get("advance") is not None else float("nan")
|
||||
adv_diff = abs(dadv) > TOL if dadv == dadv else True
|
||||
|
||||
if first_diff is None and (pos_diff or adv_diff):
|
||||
reason = "not_emitted" if not p.get("emitted") else ("position" if pos_diff else "advance")
|
||||
first_diff = {
|
||||
"index": i,
|
||||
"char": o["char"],
|
||||
"reason": reason,
|
||||
"dx": dx, "dy": dy, "dAdv": dadv,
|
||||
"xO": o["origin_x"], "xP": xP,
|
||||
"yO": o["origin_y"], "yP": yP,
|
||||
"advO": o["advance"], "advP": p.get("advance"),
|
||||
"kernO": o.get("kern_adj_pdf", 0), "kernP": p.get("kern_adj_pdf", 0),
|
||||
"gidO": o.get("glyph_id"), "gidP": p.get("glyph_id"),
|
||||
"tmO": o.get("text_matrix"), "tmP": p.get("text_matrix"),
|
||||
"note": p.get("note"),
|
||||
}
|
||||
|
||||
tmO, tmP = o.get("text_matrix"), p.get("text_matrix")
|
||||
tmOs = f"O[{tmO[4]:.3f},{tmO[5]:.3f}]" if tmO and len(tmO) >= 6 else "O[—]"
|
||||
tmPs = f"P[{tmP[4]:.3f},{tmP[5]:.3f}]" if tmP and len(tmP) >= 6 else ("P[— skipped]" if not p.get("emitted") else "P[—]")
|
||||
xPs = f"{xP:10.4f}" if xP is not None else f"{'None':>10}"
|
||||
yPs = f"{yP:10.4f}" if yP is not None else f"{'None':>10}"
|
||||
dxs = f"{dx:8.4f}" if dx == dx else f"{'nan':>8}"
|
||||
dys = f"{dy:8.4f}" if dy == dy else f"{'nan':>8}"
|
||||
print(
|
||||
f"{i:2d} {o['char']:>3} {str(o.get('glyph_id')):>5} {str(p.get('glyph_id')):>5} "
|
||||
f"{'Y' if p.get('emitted') else 'N':>4} "
|
||||
f"{o['origin_x']:10.4f} {xPs} {dxs} "
|
||||
f"{o['origin_y']:10.4f} {yPs} {dys} "
|
||||
f"{o['advance']:8.4f} {p.get('advance', 0):8.4f} {dadv:8.4f} "
|
||||
f"{o.get('kern_adj_pdf', 0):8.4f} {p.get('kern_adj_pdf', 0):8.4f} {tmOs} {tmPs}"
|
||||
)
|
||||
rows_out.append({
|
||||
"i": i, "char": o["char"],
|
||||
"gid_orig": o.get("glyph_id"), "gid_prev": p.get("glyph_id"),
|
||||
"emitted": bool(p.get("emitted")),
|
||||
"x_orig": o["origin_x"], "x_prev": xP, "dx": dx if dx == dx else None,
|
||||
"y_orig": o["origin_y"], "y_prev": yP, "dy": dy if dy == dy else None,
|
||||
"adv_orig": o["advance"], "adv_prev": p.get("advance"), "d_adv": dadv if dadv == dadv else None,
|
||||
"kern_orig_pdf": o.get("kern_adj_pdf", 0),
|
||||
"kern_orig_thousandths": o.get("kern_before_thousandths", 0),
|
||||
"kern_prev_pdf": p.get("kern_adj_pdf", 0),
|
||||
"tm_orig": o.get("text_matrix"),
|
||||
"tm_prev": p.get("text_matrix"),
|
||||
"pos_differs": pos_diff,
|
||||
"adv_differs": adv_diff,
|
||||
"note": p.get("note"),
|
||||
})
|
||||
|
||||
print("\n=== FIRST GLYPH WHOSE POSITION DIFFERS ===")
|
||||
print(json.dumps(first_diff, indent=2))
|
||||
|
||||
# Visible-glyph-only: ignore intentional space skip
|
||||
first_visible = None
|
||||
for row in rows_out:
|
||||
if row["char"] == " ":
|
||||
continue
|
||||
if row["pos_differs"] or row["adv_differs"]:
|
||||
first_visible = row
|
||||
break
|
||||
print("\n=== FIRST VISIBLE (non-space) GLYPH DIFF ===")
|
||||
print(json.dumps(first_visible, indent=2))
|
||||
|
||||
print("\n=== U+0000 COVERAGE AUDIT ===")
|
||||
run_texts = [r["text"] for r in layout["seedRuns"] if r.get("text")]
|
||||
print(f"seed run texts: {run_texts!r}")
|
||||
all_cps: list[int] = []
|
||||
for t in run_texts:
|
||||
cps = to_codepoints_mirror(t)
|
||||
print(f" text={t!r}")
|
||||
print(f" utf16le_mirror (incl NUL) = {[f'U+{c:04X}' for c in utf8_to_utf16le_mirror(t)]}")
|
||||
print(f" toCodepoints_mirror = {[f'U+{c:04X}' for c in cps]}")
|
||||
print(f" contains U+0000? {0 in cps}")
|
||||
all_cps.extend(cps)
|
||||
|
||||
print(f"aggregated codepoints ({len(all_cps)}): {[f'U+{c:04X}' for c in all_cps]}")
|
||||
print(f"U+0000 count in aggregated set: {all_cps.count(0)}")
|
||||
|
||||
missing_real = []
|
||||
lacking = []
|
||||
has0 = False
|
||||
gid0 = -1
|
||||
if font_bytes:
|
||||
has0, gid0 = cmap_has_glyph(font_bytes, 0)
|
||||
print(f"Unicode cmap U+0000: hasGlyph_engine_rule={has0} gid={gid0}")
|
||||
print(" (engine hasGlyph: FT_Get_Char_Index(cp) != 0; gid==0 => MISSING)")
|
||||
# Mac Roman may map NUL — note for audit
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
tt = TTFont(io.BytesIO(font_bytes))
|
||||
for t in tt["cmap"].tables:
|
||||
if 0 in t.cmap:
|
||||
print(f" note: platform={t.platformID} enc={t.platEncID} maps U+0000 -> {t.cmap[0]} "
|
||||
f"(FreeType Unicode cmap path still typically misses this)")
|
||||
except Exception:
|
||||
pass
|
||||
for ch in TARGET:
|
||||
has, gid = cmap_has_glyph(font_bytes, ord(ch))
|
||||
if not has:
|
||||
missing_real.append((ch, ord(ch), gid))
|
||||
print(f"missing real TARGET chars in Unicode cmap: {missing_real or 'NONE'}")
|
||||
real_cps = [c for c in all_cps if c != 0]
|
||||
lacking = [c for c in real_cps if not cmap_has_glyph(font_bytes, c)[0]]
|
||||
print(f"coverage without U+0000: lacking={ [f'U+{c:04X}' for c in lacking] or 'NONE' }")
|
||||
forces = (0 in all_cps) and (not has0) and (not lacking)
|
||||
print(f"CONCLUSION: U+0000 {'DOES' if forces else 'does not alone'} force fullProvenLacking "
|
||||
f"for this paragraph (runtime also logged only U+0000 as MISSING)")
|
||||
|
||||
report = {
|
||||
"target": TARGET,
|
||||
"fid": FID,
|
||||
"orig_tm": tm,
|
||||
"orig_font_size": fsize,
|
||||
"first_diff": first_diff,
|
||||
"first_visible_diff": first_visible,
|
||||
"glyphs": rows_out,
|
||||
"u0000_audit": {
|
||||
"run_texts": run_texts,
|
||||
"codepoints_per_run": [
|
||||
{"text": t, "cps": [f"U+{c:04X}" for c in to_codepoints_mirror(t)]}
|
||||
for t in run_texts
|
||||
],
|
||||
"u0000_injected_by": "utf8_to_utf16le() always push_back(0); toCodepoints iterates full vector including NUL",
|
||||
"engine_hasGlyph_rule": "FT_Get_Char_Index(face, cp) != 0",
|
||||
"embedded_unicode_cmap_has_u0000": has0,
|
||||
"embedded_u0000_gid": gid0,
|
||||
"embedded_missing_real_chars": missing_real,
|
||||
"coverage_ok_if_u0000_ignored": not lacking,
|
||||
"runtime_log": "FONT_COVERAGE_DEBUG full font MISSING codepoint U+0000 only",
|
||||
},
|
||||
}
|
||||
(OUT / "glyph_pos_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {OUT / 'glyph_pos_report.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Compare full-page render hashes + PDF objects across corpus files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
from forensic_obj_compare import apply_and_save # type: ignore
|
||||
|
||||
|
||||
def inflate(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(b: bytes) -> list[str]:
|
||||
return [m.group(0).decode("latin-1", "replace") for m in re.finditer(rb"<</BaseFont/[^>]+>>", b)]
|
||||
|
||||
|
||||
def sha_full(doc, dpi=144) -> tuple[str, int]:
|
||||
img = doc.get_page(0).render(dpi)
|
||||
png = bytes(img.data)
|
||||
return hashlib.sha256(png).hexdigest()[:24], len(png)
|
||||
|
||||
|
||||
def run(pdf: Path):
|
||||
print(f"\n######## {pdf}")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf), "")
|
||||
s0, n0 = sha_full(doc)
|
||||
print(f"ORIG sha={s0} pngbytes={n0}")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
if not model.paragraphs:
|
||||
print("no paragraphs")
|
||||
return
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
for f in doc.get_fonts(0, 0)[:6]:
|
||||
print(f" FontInfo name={f.font_name} emb={f.is_embedded} type={f.type} "
|
||||
f"subset={getattr(f, 'is_subset', None)} id={f.internal_font_id}")
|
||||
print(" seedRuns:", [(r["fontName"], r["fid"], round(r["size"], 2),
|
||||
len(r["advances"]) if r.get("advances") else 0)
|
||||
for r in layout["seedRuns"][:4]])
|
||||
|
||||
fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], fid, dom["size"], dom["color"])
|
||||
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "cmp")
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
outp = Path(__file__).resolve().parent / "_tmp_entry.pdf"
|
||||
apply_and_save(pdf, op, outp)
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(outp.read_bytes(), "")
|
||||
s1, n1 = sha_full(d2)
|
||||
print(f"ENTRY sha={s1} pngbytes={n1} {'IDENTICAL' if s0 == s1 else '*** DIFFERENT ***'}")
|
||||
b = outp.read_bytes()
|
||||
print(" entry fonts:", font_dicts(b))
|
||||
for s in inflate(b):
|
||||
if "Tm" in s or "TJ" in s or "Tj" in s:
|
||||
one_line = s.replace("\n", " | ")
|
||||
print(" stream:", one_line[:700])
|
||||
tj = s.count(" TJ")
|
||||
print(f" TJ count={tj}")
|
||||
break
|
||||
|
||||
flat1 = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
|
||||
data1 = build_reflow_data(layout, flat1, None, "cmp")
|
||||
# also try force whole if supported
|
||||
data1_force = {**build_reflow_data(layout, flat, layout["origLines"], "cmp"),
|
||||
"forensicForceWholeRun": True}
|
||||
op1 = {"version": "1.0", "operations": [{
|
||||
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": data1,
|
||||
}]}
|
||||
out1 = Path(__file__).resolve().parent / "_tmp_one.pdf"
|
||||
apply_and_save(pdf, op1, out1)
|
||||
d3 = pdfengine.PdfDocument.load_from_memory(out1.read_bytes(), "")
|
||||
s2, n2 = sha_full(d3)
|
||||
print(f"ONE sha={s2} pngbytes={n2} vsORIG={'SAME' if s0 == s2 else 'DIFF'} vsENTRY={'SAME' if s1 == s2 else 'DIFF'}")
|
||||
|
||||
# force whole on unchanged
|
||||
opf = {"version": "1.0", "operations": [{
|
||||
"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data1_force,
|
||||
}]}
|
||||
outf = Path(__file__).resolve().parent / "_tmp_force.pdf"
|
||||
apply_and_save(pdf, opf, outf)
|
||||
df = pdfengine.PdfDocument.load_from_memory(outf.read_bytes(), "")
|
||||
sf, nf = sha_full(df)
|
||||
bf = outf.read_bytes()
|
||||
tjf = sum(s.count(" TJ") for s in inflate(bf))
|
||||
tje = sum(s.count(" TJ") for s in inflate(b))
|
||||
print(f"FORCE sha={sf} pngbytes={nf} vsORIG={'SAME' if s0 == sf else 'DIFF'} "
|
||||
f"vsENTRY={'SAME' if s1 == sf else 'DIFF'} TJ_entry={tje} TJ_force={tjf}")
|
||||
if "forensicForceWholeRun" in json.dumps(opf):
|
||||
if tje != tjf:
|
||||
print(" => forensicForceWholeRun ACTIVE (TJ count changed)")
|
||||
else:
|
||||
print(" => forensicForceWholeRun NOT in binary yet (TJ unchanged) OR no-op")
|
||||
|
||||
|
||||
def main():
|
||||
for p in [
|
||||
ROOT / "corpus" / "basic" / "hello_world.pdf",
|
||||
ROOT / "tests" / "edits" / "forensic_multiline.pdf",
|
||||
ROOT / "corpus" / "fonts" / "embedded_truetype.pdf",
|
||||
ROOT / "corpus" / "fonts" / "subset_font.pdf",
|
||||
]:
|
||||
if p.exists():
|
||||
try:
|
||||
run(p)
|
||||
except Exception as e:
|
||||
print(f"FAILED {p}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user