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:
azeem
2026-08-06 06:20:05 +00:00
154 changed files with 14325 additions and 1221 deletions
+30
View File
@@ -0,0 +1,30 @@
**/.git
**/.github
**/.venv
**/node_modules
**/dist
**/build
**/out
**/__pycache__
**/*.pyc
**/*.pyo
**/*.pyd
**/*.log
**/.DS_Store
**/Thumbs.db
**/.vscode
**/.idea
**/coverage
**/tmp
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/CMakeUserPresets.json
**/compile_commands.json
**/vcpkg
**/third_party/pdfium/depot_tools
**/third_party/pdfium/checkout
**/third_party/pdfium/install
**/third_party/skia/depot_tools
**/third_party/skia/checkout
**/third_party/skia/install
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -73,7 +73,9 @@ find_package(freetype CONFIG REQUIRED)
find_package(harfbuzz CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(qpdf CONFIG REQUIRED)
if(PDFENGINE_WITH_QPDF)
find_package(qpdf CONFIG REQUIRED)
endif()
if(WIN32 AND DEFINED VCPKG_TARGET_TRIPLET)
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/lib")
link_directories("${CMAKE_BINARY_DIR}/vcpkg_installed/${VCPKG_TARGET_TRIPLET}/debug/lib")
+9 -2
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+1 -1
View File
@@ -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"
+20 -1
View File
@@ -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

+41
View File
@@ -0,0 +1,41 @@
services:
gateway:
build:
context: .
dockerfile: gateway/Dockerfile
network: host
image: pdf-engine-gateway:dev
container_name: pdf-engine-gateway
environment:
PDFENGINE_ENVIRONMENT: dev
PDFENGINE_ENGINE_AVAILABLE: "true"
PORT: 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
View File
@@ -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()
+5 -5
View File
@@ -32,34 +32,34 @@ struct SetTransformCommand : public Command {
struct FillRectCommand : public Command {
float x, y, width, height;
FillRectCommand(float x, float y, float w, float h) : x(x), y(y), width(w), height(h) {}
FillRectCommand(float _x, float _y, float w, float h) : x(_x), y(_y), width(w), height(h) {}
void accept(CommandVisitor& visitor) const override;
};
struct DrawTextCommand : public Command {
std::string text;
float x, y;
DrawTextCommand(std::string text, float x, float y) : text(std::move(text)), x(x), y(y) {}
DrawTextCommand(std::string _text, float _x, float _y) : text(std::move(_text)), x(_x), y(_y) {}
void accept(CommandVisitor& visitor) const override;
};
struct FillPathCommand : public Command {
Path path;
FillRule rule;
explicit FillPathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {}
explicit FillPathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {}
void accept(CommandVisitor& visitor) const override;
};
struct StrokePathCommand : public Command {
Path path;
explicit StrokePathCommand(Path path) : path(std::move(path)) {}
explicit StrokePathCommand(Path _path) : path(std::move(_path)) {}
void accept(CommandVisitor& visitor) const override;
};
struct FillStrokePathCommand : public Command {
Path path;
FillRule rule;
explicit FillStrokePathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {}
explicit FillStrokePathCommand(Path _path, FillRule _rule = FillRule::NonZero) : path(std::move(_path)), rule(_rule) {}
void accept(CommandVisitor& visitor) const override;
};
+93
View File
@@ -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
+2 -2
View File
@@ -11,8 +11,8 @@ struct Matrix {
float e = 0.0f, f = 0.0f;
Matrix() = default;
Matrix(float a, float b, float c, float d, float e, float f)
: a(a), b(b), c(c), d(d), e(e), f(f) {}
Matrix(float _a, float _b, float _c, float _d, float _e, float _f)
: a(_a), b(_b), c(_c), d(_d), e(_e), f(_f) {}
[[nodiscard]] Matrix multiply(const Matrix& other) const noexcept;
+27 -2
View File
@@ -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
+6 -3
View File
@@ -48,7 +48,9 @@ public:
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
std::expected<PageImage, EngineError> renderRegionRaw(int dpi, double yTopPt, double heightPt) const override;
std::expected<PageImage, EngineError> renderTile(int dpi, double xPt, double yPt, double wPt, double hPt) const override;
std::expected<std::string, EngineError> extractText() const override;
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
std::expected<PageModel, EngineError> extractDocumentModel() const override;
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
@@ -100,7 +102,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);
+33 -3
View File
@@ -8,7 +8,7 @@
namespace pdfengine::parser {
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
std::expected<std::vector<InvalidatedRegion>, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
@@ -17,7 +17,9 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
lastReflowLayout_.clear();
std::vector<int> editedPages;
auto markEdited = [&editedPages](int p) {
std::vector<InvalidatedRegion> invalidatedRegions;
auto markEdited = [&](int p) {
if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end())
editedPages.push_back(p);
};
@@ -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);
+11 -4
View File
@@ -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
}
}
+241 -45
View File
@@ -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
}
}
+180
View File
@@ -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
+46
View File
@@ -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
+77 -3
View File
@@ -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
+71 -2
View File
@@ -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
}
+13
View File
@@ -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);
+135
View File
@@ -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
+5
View File
@@ -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()
+129
View File
@@ -1,5 +1,8 @@
#include <gtest/gtest.h>
#include <pdfengine/display_list.hpp>
#include <pdfengine/path_interpreter.hpp>
#include <pdfengine/content_object.hpp>
#include "../src/parser/content_stream_parser.hpp"
#include <string>
#include <vector>
@@ -20,6 +23,38 @@ public:
void visit(const DrawImageCommand&) override { calls.push_back("DrawImage"); }
};
class PathVerifyVisitor : public CommandVisitor {
public:
struct PathInfo {
std::string type;
size_t segmentCount = 0;
FillRule rule = FillRule::NonZero;
};
std::vector<PathInfo> paths;
std::vector<std::string> calls;
void visit(const SaveStateCommand&) override { calls.push_back("SaveState"); }
void visit(const RestoreStateCommand&) override { calls.push_back("RestoreState"); }
void visit(const SetTransformCommand& cmd) override {
calls.push_back("SetTransform(" + std::to_string(cmd.matrix.a) + "," + std::to_string(cmd.matrix.d) + ")");
}
void visit(const FillRectCommand&) override {}
void visit(const DrawTextCommand&) override {}
void visit(const FillPathCommand& cmd) override {
calls.push_back("FillPath");
paths.push_back({"Fill", cmd.path.segments().size(), cmd.rule});
}
void visit(const StrokePathCommand& cmd) override {
calls.push_back("StrokePath");
paths.push_back({"Stroke", cmd.path.segments().size(), FillRule::NonZero});
}
void visit(const FillStrokePathCommand& cmd) override {
calls.push_back("FillStrokePath");
paths.push_back({"FillStroke", cmd.path.segments().size(), cmd.rule});
}
void visit(const DrawImageCommand&) override {}
};
TEST(DisplayListTest, RecordAndReplay) {
DisplayList list;
@@ -53,3 +88,97 @@ TEST(DisplayListTest, Clear) {
list.clear();
EXPECT_EQ(list.size(), 0);
}
TEST(DisplayListTest, PathObjectInterpreterStroke) {
Path path;
path.moveTo(10.0f, 20.0f);
path.lineTo(30.0f, 40.0f);
PathObject pathObj;
pathObj.path = path;
pathObj.paintOp = PathPaintOp::Stroke;
pathObj.transform = Matrix(1.5f, 0.0f, 0.0f, 1.5f, 5.0f, 5.0f);
DisplayList list;
PathObjectInterpreter::interpret(pathObj, list);
PathVerifyVisitor visitor;
list.replay(visitor);
ASSERT_EQ(visitor.calls.size(), 4);
EXPECT_EQ(visitor.calls[0], "SaveState");
EXPECT_EQ(visitor.calls[1], "SetTransform(1.500000,1.500000)");
EXPECT_EQ(visitor.calls[2], "StrokePath");
EXPECT_EQ(visitor.calls[3], "RestoreState");
ASSERT_EQ(visitor.paths.size(), 1);
EXPECT_EQ(visitor.paths[0].type, "Stroke");
EXPECT_EQ(visitor.paths[0].segmentCount, 2);
}
TEST(DisplayListTest, PathObjectInterpreterFillEvenOdd) {
Path path;
path.addRect(0.0f, 0.0f, 10.0f, 10.0f);
PathObject pathObj;
pathObj.path = path;
pathObj.paintOp = PathPaintOp::Fill;
pathObj.fillRule = FillRule::EvenOdd;
pathObj.transform = Matrix(1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f);
DisplayList list;
PathObjectInterpreter::interpret(pathObj, list);
PathVerifyVisitor visitor;
list.replay(visitor);
ASSERT_EQ(visitor.calls.size(), 4);
EXPECT_EQ(visitor.calls[2], "FillPath");
ASSERT_EQ(visitor.paths.size(), 1);
EXPECT_EQ(visitor.paths[0].type, "Fill");
EXPECT_EQ(visitor.paths[0].segmentCount, 5); // moveTo + 3 lineTo + close
EXPECT_EQ(visitor.paths[0].rule, FillRule::EvenOdd);
}
TEST(DisplayListTest, ContentStreamParserPaths) {
DisplayList list;
ContentStreamParser parser;
// Parse stroke path (m, l, S)
parser.parse("10 20 m 30 40 l S", list);
// Parse fill path (re, f)
parser.parse("5 6 7 8 re f", list);
// Parse fill and stroke (m, c, B)
parser.parse("1 2 m 3 4 5 6 7 8 c B", list);
PathVerifyVisitor visitor;
list.replay(visitor);
// S -> StrokePath
// f -> FillPath
// B -> FillPath, StrokePath
ASSERT_EQ(visitor.calls.size(), 4);
EXPECT_EQ(visitor.calls[0], "StrokePath");
EXPECT_EQ(visitor.calls[1], "FillPath");
EXPECT_EQ(visitor.calls[2], "FillPath");
EXPECT_EQ(visitor.calls[3], "StrokePath");
ASSERT_EQ(visitor.paths.size(), 4);
// 1st: 10 20 m 30 40 l S -> MoveTo, LineTo
EXPECT_EQ(visitor.paths[0].type, "Stroke");
EXPECT_EQ(visitor.paths[0].segmentCount, 2);
// 2nd: 5 6 7 8 re f -> MoveTo, 3xLineTo, Close
EXPECT_EQ(visitor.paths[1].type, "Fill");
EXPECT_EQ(visitor.paths[1].segmentCount, 5);
// 3rd & 4th: 1 2 m 3 4 5 6 7 8 c B -> MoveTo, CubicBezierTo
EXPECT_EQ(visitor.paths[2].type, "Fill");
EXPECT_EQ(visitor.paths[2].segmentCount, 2);
EXPECT_EQ(visitor.paths[3].type, "Stroke");
EXPECT_EQ(visitor.paths[3].segmentCount, 2);
}
+57
View File
@@ -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
+4 -1
View File
@@ -262,7 +262,8 @@ std::string decodedStream(QPDFObjectHandle contents) {
}
std::vector<std::unique_ptr<ContentObject>> buildPageObjects(QPDFObjectHandle page) {
Lexer lexer(decodedStream(page.getKey("/Contents")));
std::string decodedContent = decodedStream(page.getKey("/Contents"));
Lexer lexer(decodedContent);
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
auto operations = parser.parse();
@@ -436,6 +437,8 @@ TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 2u);
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.vite/
.git/
.gitignore
*.log
.DS_Store
+1
View File
@@ -0,0 +1 @@
VITE_GATEWAY_URL=http://127.0.0.1:8765
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine AS base
ENV NODE_ENV=development
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS development
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
FROM base AS build
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+115 -11
View File
@@ -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>
+154 -96
View File
@@ -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>
);
};
+17 -35
View File
@@ -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>
);
};
+14 -14
View File
@@ -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>
+75 -13
View File
@@ -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>
);
+46 -44
View File
@@ -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
+5
View File
@@ -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" /></>);
+15 -15
View File
@@ -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
View File
@@ -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); } }
+15 -3
View File
@@ -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);
+77 -2
View File
@@ -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}`);
+10 -1
View File
@@ -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 || '{}';
}
+34
View File
@@ -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 };
}
+8 -5
View File
@@ -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;
-36
View File
@@ -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);
};
+19 -19
View File
@@ -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">
+12 -12
View File
@@ -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
View File
@@ -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>
);
});
},
);
+699 -75
View File
@@ -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',
}}
/>
</>
+44 -45
View File
@@ -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`,
+3
View File
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
allowedHosts: ['pdf-dev.maskantech.in'],
},
})
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.pyo
*.pyd
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
*.log
.DS_Store
.git/
.gitignore
+119
View File
@@ -0,0 +1,119 @@
# Stage 1: Builder
FROM python:3.11-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
VCPKG_FORCE_SYSTEM_BINARIES=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
pkg-config \
git \
fonts-liberation \
libjpeg-dev \
zlib1g-dev \
libpng-dev \
curl \
zip \
unzip \
tar \
autoconf \
autoconf-archive \
automake \
libtool \
lld \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/ld.lld /usr/bin/ld
WORKDIR /build
# Install vcpkg
RUN git clone https://github.com/microsoft/vcpkg.git /opt/vcpkg \
&& /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics
ENV VCPKG_ROOT=/opt/vcpkg
# Cache vcpkg dependencies in a separate layer
COPY vcpkg.json ./
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/opt/vcpkg/downloads \
/opt/vcpkg/vcpkg install --triplet x64-linux
# Install depot_tools globally with caching
RUN --mount=type=cache,target=/opt/depot_tools \
if [ ! -d /opt/depot_tools/.git ]; then \
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git /opt/depot_tools; \
fi
ENV PATH="/opt/depot_tools:${PATH}"
# Build PDFium for Linux (heavily cached, keeping the huge source tree out of the image layer)
COPY third_party/pdfium/ ./third_party/pdfium/
RUN --mount=type=cache,target=/build/third_party/pdfium/checkout \
./third_party/pdfium/build_pdfium.sh
# Copy everything needed for the engine and bindings build
COPY CMakeLists.txt CMakePresets.json ./
COPY cmake/ ./cmake/
COPY engine/ ./engine/
COPY bindings/ ./bindings/
COPY gateway/ ./gateway/
COPY corpus/ ./corpus/
# Configure CMake with tests enabled
RUN --mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/opt/vcpkg/downloads \
cmake --preset linux-release \
-DPDFENGINE_BUILD_TESTS=ON \
-DPDFENGINE_WITH_PDFIUM=ON \
-DPDFENGINE_WITH_SKIA=OFF \
-DPDFENGINE_WITH_QPDF=ON \
-DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake
# Build all targets including tests and pdfengine_py
RUN cmake --build out/build/linux-release
# Stage 2: Tester
FROM builder AS tester
RUN ctest --test-dir out/build/linux-release --output-on-failure
# Stage 3: Runtime
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=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
View File
@@ -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
+11
View File
@@ -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
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.routers import documents, edits, health, info, render
from app.routers import documents, edits, health, info, internal, render
def create_app() -> FastAPI:
@@ -29,6 +29,7 @@ def create_app() -> FastAPI:
app.include_router(render.compat_router)
app.include_router(edits.router)
app.include_router(edits.compat_router)
app.include_router(internal.router)
@app.get("/")
def read_root():
+81
View File
@@ -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:
+89
View File
@@ -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
+157 -2
View File
@@ -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)
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.services.render_cache import tile_cache
router = APIRouter(prefix="/internal", tags=["internal"])
@router.get("/cache/stats")
def cache_stats():
stats = tile_cache.stats()
return {
"hits": stats.hits,
"misses": stats.misses,
"evictions": stats.evictions,
"current_entries": stats.current_entries,
"current_bytes": stats.current_bytes,
"max_bytes": stats.max_bytes,
"hit_rate": stats.hit_rate,
"avg_lookup_ns": stats.avg_lookup_ns,
"avg_render_time_ns": stats.avg_render_time_ns,
}
+139 -6
View File
@@ -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}")
+222
View File
@@ -0,0 +1,222 @@
import threading
import time
from dataclasses import dataclass
from enum import Enum
from typing import Any
from app.config import get_settings
RENDERER_VERSION: int = 1
class RenderMode(str, Enum):
# Current
NORMAL = "normal"
PRINT = "print"
GRAYSCALE = "grayscale"
# Future (reserved)
ANNOTATION_ONLY = "annotation_only"
EDIT_PREVIEW = "edit_preview"
SELECTION_OVERLAY = "selection_overlay"
PROOF = "proof"
HIGH_QUALITY = "high_quality"
DRAFT = "draft"
@dataclass(frozen=True)
class TileCacheKey:
doc_hash: str
renderer_version: int
page: int
dpi: int
zoom: float
rotation: int
render_mode: str
tile_x: float
tile_y: float
tile_w: float
tile_h: float
@dataclass
class CacheStats:
hits: int = 0
misses: int = 0
evictions: int = 0
current_entries: int = 0
current_bytes: int = 0
max_bytes: int = 0
hit_rate: float = 0.0
avg_lookup_ns: float = 0.0
avg_render_time_ns: float = 0.0
class _Node:
__slots__ = ["key", "value", "size", "prev", "next"]
def __init__(self, key: TileCacheKey | None, value: bytes | None, size: int):
self.key = key
self.value = value
self.size = size
self.prev: _Node | None = None
self.next: _Node | None = None
class TileCache:
def __init__(self):
settings = get_settings()
self.enabled = settings.render_cache_enabled
self.max_entries = settings.render_cache_max_entries
self.max_bytes = settings.render_cache_max_bytes
self.max_entry_bytes = settings.render_cache_max_entry_bytes
self._lock = threading.Lock()
self._cache: dict[TileCacheKey, _Node] = {}
self._head = _Node(None, None, 0)
self._tail = _Node(None, None, 0)
self._head.next = self._tail
self._tail.prev = self._head
self._current_bytes = 0
# Stats
self._hits = 0
self._misses = 0
self._evictions = 0
self._total_lookup_ns = 0
self._total_render_time_ns = 0
self._render_time_count = 0
def _remove(self, node: _Node):
p = node.prev
n = node.next
if p and n:
p.next = n
n.prev = p
def _add_to_front(self, node: _Node):
first = self._head.next
if first:
self._head.next = node
node.prev = self._head
node.next = first
first.prev = node
def _evict(self):
last = self._tail.prev
if last and last != self._head:
self._remove(last)
if last.key:
del self._cache[last.key]
self._current_bytes -= last.size
self._evictions += 1
def get(self, key: TileCacheKey) -> bytes | None:
if not self.enabled:
return None
start_time = time.perf_counter_ns()
with self._lock:
node = self._cache.get(key)
if node:
self._hits += 1
self._remove(node)
self._add_to_front(node)
res = node.value
else:
self._misses += 1
res = None
lookup_time = time.perf_counter_ns() - start_time
self._total_lookup_ns += lookup_time
return res
def put(self, key: TileCacheKey, data: bytes) -> None:
if not self.enabled:
return
size = len(data)
if size > self.max_entry_bytes:
# Too large to cache
return
with self._lock:
if key in self._cache:
node = self._cache[key]
self._current_bytes -= node.size
node.value = data
node.size = size
self._current_bytes += size
self._remove(node)
self._add_to_front(node)
else:
new_node = _Node(key, data, size)
self._cache[key] = new_node
self._add_to_front(new_node)
self._current_bytes += size
# Evict if over limits
while len(self._cache) > self.max_entries or (self.max_bytes > 0 and self._current_bytes > self.max_bytes):
self._evict()
def invalidate_doc(self, doc_hash: str) -> None:
with self._lock:
keys_to_remove = [k for k in self._cache.keys() if k.doc_hash == doc_hash]
for k in keys_to_remove:
node = self._cache[k]
self._remove(node)
self._current_bytes -= node.size
del self._cache[k]
def invalidate_renderer_version(self, old_version: int) -> None:
with self._lock:
keys_to_remove = [k for k in self._cache.keys() if k.renderer_version == old_version]
for k in keys_to_remove:
node = self._cache[k]
self._remove(node)
self._current_bytes -= node.size
del self._cache[k]
def clear(self) -> None:
with self._lock:
self._cache.clear()
self._head.next = self._tail
self._tail.prev = self._head
self._current_bytes = 0
def stats(self) -> CacheStats:
with self._lock:
hit_rate = 0.0
total_reqs = self._hits + self._misses
if total_reqs > 0:
hit_rate = self._hits / total_reqs
avg_lookup = 0.0
if total_reqs > 0:
avg_lookup = self._total_lookup_ns / total_reqs
avg_render = 0.0
if self._render_time_count > 0:
avg_render = self._total_render_time_ns / self._render_time_count
return CacheStats(
hits=self._hits,
misses=self._misses,
evictions=self._evictions,
current_entries=len(self._cache),
current_bytes=self._current_bytes,
max_bytes=self.max_bytes,
hit_rate=hit_rate,
avg_lookup_ns=avg_lookup,
avg_render_time_ns=avg_render,
)
def record_render_time(self, elapsed_ns: int):
with self._lock:
self._total_render_time_ns += elapsed_ns
self._render_time_count += 1
tile_cache = TileCache()
+2
View File
@@ -1,3 +1,4 @@
import hashlib
import threading
import uuid
from datetime import UTC, datetime
@@ -57,6 +58,7 @@ class DocumentStore:
info = {
"id": doc_id,
"filename": filename,
"doc_hash": hashlib.sha256(bytes_data).hexdigest(),
"sizeBytes": len(bytes_data),
"totalPages": doc_instance.page_count,
"pageWidth": page_width,
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1
View File
@@ -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]
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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 -1
View File
@@ -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

+1 -1
View File
@@ -10,7 +10,7 @@ if ($Port -le 0) {
if ($envPort) {
$Port = $envPort
} else {
$Port = 8000
$Port = 8765
}
}
+42
View File
@@ -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•¨ùÚþ_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.
+40
View File
@@ -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²˜¸lr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3GÏ7¹ ÌÙU­sqξ¨b h,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à’绪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§ÎÁ«šÙý6W½2ݯaßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚?¦¦Ý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
+37
View File
@@ -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&©Hp*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 òœ_ÿü©2‹õ >. Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬vh£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.
+68
View File
@@ -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()
+254
View File
@@ -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()
+112
View File
@@ -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()
+193
View File
@@ -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()
+407
View File
@@ -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()
+138
View File
@@ -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()
+677
View File
@@ -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()
+130
View File
@@ -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