Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a766d6d0eb | ||
|
|
9134b5e205 | ||
|
|
676fec5c8b | ||
|
|
bcdc1fca14 | ||
|
|
33ecf9be84 | ||
|
|
6a40ec738d | ||
|
|
da872bf4eb | ||
|
|
ca3d124b0d | ||
|
|
dde7829966 | ||
|
|
94b7f28d9c | ||
|
|
00c8839cdc | ||
|
|
79f32024da | ||
|
|
15b756e7a2 | ||
|
|
671697d161 |
@@ -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": [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
---
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
@@ -6,7 +6,7 @@ find_package(pybind11 CONFIG REQUIRED)
|
||||
# Declare the python module target. We name the target pdfengine_py to avoid
|
||||
# target collision with the static C++ library pdfengine, but we set the
|
||||
# OUTPUT_NAME to pdfengine to produce the correct importable module.
|
||||
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp)
|
||||
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp $<TARGET_OBJECTS:pdfengine>)
|
||||
|
||||
set_target_properties(pdfengine_py PROPERTIES
|
||||
OUTPUT_NAME "pdfengine"
|
||||
|
||||
@@ -516,6 +516,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
}
|
||||
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());
|
||||
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -9,13 +9,13 @@ services:
|
||||
environment:
|
||||
PDFENGINE_ENVIRONMENT: dev
|
||||
PDFENGINE_ENGINE_AVAILABLE: "true"
|
||||
PORT: 8000
|
||||
PORT: 8765
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8765:8765"
|
||||
volumes:
|
||||
- ./gateway:/home/app
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" ]
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8765/health').read()" ]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
image: pdf-engine-frontend:dev
|
||||
container_name: pdf-engine-frontend
|
||||
environment:
|
||||
VITE_GATEWAY_URL: http://localhost:8000
|
||||
VITE_GATEWAY_URL: http://localhost:8765
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
|
||||
@@ -14,6 +14,7 @@ add_library(pdfengine OBJECT
|
||||
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
|
||||
@@ -32,6 +33,30 @@ add_library(pdfengine OBJECT
|
||||
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)
|
||||
@@ -56,21 +81,6 @@ target_link_libraries(pdfengine
|
||||
)
|
||||
|
||||
if(PDFENGINE_WITH_PDFIUM)
|
||||
target_sources(pdfengine PRIVATE
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/pdfium_internal.cpp
|
||||
src/parser/pdfium_reflow.cpp
|
||||
src/parser/pdfium_page.cpp
|
||||
src/parser/pdfium_page_model.cpp
|
||||
src/parser/pdfium_fonts.cpp
|
||||
src/parser/pdfium_edit.cpp
|
||||
src/parser/pdfium_edit_replace.cpp
|
||||
src/parser/pdfium_edit_reflow.cpp
|
||||
src/parser/pdfium_edit_annotations.cpp
|
||||
src/parser/pdfium_edit_pages.cpp
|
||||
src/parser/pdfium_edit_images.cpp
|
||||
)
|
||||
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
|
||||
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_PDFIUM)
|
||||
# PDFium statically bundles its own libjpeg, zlib, etc. which conflicts with vcpkg.
|
||||
@@ -98,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()
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -276,6 +285,7 @@ public:
|
||||
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
|
||||
@@ -103,6 +103,7 @@ public:
|
||||
void invalidateCaches();
|
||||
|
||||
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;
|
||||
@@ -151,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
|
||||
|
||||
@@ -180,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);
|
||||
|
||||
@@ -133,19 +133,23 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
(void)op;
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
@@ -167,7 +171,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
#include "fonts/face/free_type_manager.hpp"
|
||||
#include "fonts/face/font_face.hpp"
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
|
||||
@@ -10,7 +12,17 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector<double> advances; };
|
||||
struct RunStyle {
|
||||
std::string text;
|
||||
std::string internalFontId;
|
||||
double fontSize;
|
||||
unsigned int r, g, b;
|
||||
std::vector<double> advances;
|
||||
// When set (and advances.size() == advanceSeedText.size()), advances are
|
||||
// metrics for advanceSeedText; merge onto text via LCP/LCS so typing
|
||||
// preserves kerning on the unchanged prefix/suffix.
|
||||
std::string advanceSeedText;
|
||||
};
|
||||
std::vector<RunStyle> runs;
|
||||
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
||||
r = 0; g = 0; b = 0;
|
||||
@@ -30,11 +42,12 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
RunStyle rs;
|
||||
rs.text = rj.value("text", "");
|
||||
rs.internalFontId = rj.value("internalFontId", "");
|
||||
rs.fontSize = rj.value("fontSize", 12.0);
|
||||
rs.fontSize = rj.value("fontSize", 0.0);
|
||||
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
|
||||
if (rj.contains("advances") && rj["advances"].is_array()) {
|
||||
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
|
||||
}
|
||||
rs.advanceSeedText = rj.value("advanceSeedText", "");
|
||||
runs.push_back(std::move(rs));
|
||||
};
|
||||
std::vector<std::vector<int>> providedLines;
|
||||
@@ -142,47 +155,46 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
|
||||
}
|
||||
|
||||
{
|
||||
std::unordered_map<std::string, double> exactByBaseName;
|
||||
std::vector<double> exactSizes;
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
double exactNominal = 0.0;
|
||||
double exactScaleX = 1.0;
|
||||
double exactScaleY = 1.0;
|
||||
for (int idx : paragraphSet) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
float nom = 0;
|
||||
if (FPDFTextObj_GetFontSize(o, &nom) && nom > 0.1) {
|
||||
FS_MATRIX mtx;
|
||||
if (!FPDFPageObj_GetMatrix(o, &mtx)) continue;
|
||||
float nominal = 0.0f;
|
||||
if (!FPDFTextObj_GetFontSize(o, &nominal)) continue;
|
||||
double scale = std::sqrt(static_cast<double>(mtx.a) * mtx.a +
|
||||
static_cast<double>(mtx.b) * mtx.b);
|
||||
double exact = static_cast<double>(nominal) * scale;
|
||||
if (exact <= 0.1) continue;
|
||||
exactSizes.push_back(exact);
|
||||
FPDF_FONT fo = FPDFTextObj_GetFont(o);
|
||||
if (!fo) continue;
|
||||
size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0);
|
||||
if (nl == 0) continue;
|
||||
std::vector<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
|
||||
std::string bn(nb.data());
|
||||
if (!exactByBaseName.count(bn)) exactByBaseName[bn] = exact;
|
||||
}
|
||||
double paraExact = 0.0;
|
||||
if (!exactSizes.empty()) {
|
||||
std::sort(exactSizes.begin(), exactSizes.end());
|
||||
paraExact = exactSizes[exactSizes.size() / 2];
|
||||
}
|
||||
if (paraExact > 0.1) {
|
||||
for (auto& rs : runs) {
|
||||
std::string bn = baseNameFromInternalFontId(rs.internalFontId);
|
||||
auto it = exactByBaseName.find(bn);
|
||||
rs.fontSize = (it != exactByBaseName.end() && it->second > 0.1) ? it->second : paraExact;
|
||||
if (FPDFPageObj_GetMatrix(o, &mtx)) {
|
||||
exactScaleX = std::sqrt(static_cast<double>(mtx.a) * mtx.a + static_cast<double>(mtx.b) * mtx.b);
|
||||
exactScaleY = std::sqrt(static_cast<double>(mtx.c) * mtx.c + static_cast<double>(mtx.d) * mtx.d);
|
||||
exactNominal = nom;
|
||||
break;
|
||||
}
|
||||
spdlog::info("reflow_paragraph: size-exact override paraExact={:.2f} ({} font(s))",
|
||||
paraExact, exactByBaseName.size());
|
||||
}
|
||||
}
|
||||
|
||||
double textAspect = 1.0;
|
||||
if (exactNominal > 0.1 && exactScaleY > 0.001) {
|
||||
double trueVerticalSize = exactNominal * exactScaleY;
|
||||
for (auto& rs : runs) {
|
||||
spdlog::info("[STAGE_3_WASM_INPUT] fontSize={:.2f}, trueVerticalSize={:.2f}, exactNominal={:.2f}, exactScaleY={:.4f}", rs.fontSize, trueVerticalSize, exactNominal, exactScaleY);
|
||||
if (rs.fontSize <= 0.0 || rs.fontSize < trueVerticalSize * 0.85) {
|
||||
rs.fontSize = trueVerticalSize;
|
||||
}
|
||||
}
|
||||
textAspect = exactScaleX / exactScaleY;
|
||||
} else {
|
||||
for (auto& rs : runs) {
|
||||
if (rs.fontSize <= 0.0) {
|
||||
rs.fontSize = 12.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
spdlog::info("[FONT_METRICS_DEBUG] exactNominal={:.2f}, exactScaleX={:.2f}, exactScaleY={:.2f}, trueVerticalSize={:.2f}, textAspect={:.2f}", exactNominal, exactScaleX, exactScaleY, exactNominal * exactScaleY, textAspect);
|
||||
|
||||
std::vector<EmissionFont> runFonts(runs.size());
|
||||
// utf8_to_utf16le appends a trailing NUL for PDFium wide-string APIs.
|
||||
// That terminator is not text content and must not enter coverage checks.
|
||||
auto toCodepoints = [](const std::string& s) {
|
||||
auto u16 = utf8_to_utf16le(s);
|
||||
std::vector<uint32_t> cps;
|
||||
@@ -193,6 +205,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
|
||||
else i += 1;
|
||||
} else i += 1;
|
||||
if (cp == 0) continue;
|
||||
cps.push_back(cp);
|
||||
}
|
||||
return cps;
|
||||
@@ -241,7 +254,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
if (text.empty()) return out;
|
||||
auto& rf = runFonts[runIdx];
|
||||
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
||||
double scale = size / static_cast<double>(kRefSize);
|
||||
double scale = (size * textAspect) / static_cast<double>(kRefSize);
|
||||
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
||||
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
||||
if (face) {
|
||||
@@ -259,18 +272,88 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
return out;
|
||||
};
|
||||
|
||||
// Forensic flag: force whole-run emission while keeping client advances.
|
||||
// Set data.forensicForceWholeRun=true to isolate whether runPerChar causes the visual change.
|
||||
const bool forensicForceWholeRun = data.value("forensicForceWholeRun", false);
|
||||
std::vector<std::vector<double>> runCharAdv(runs.size());
|
||||
std::vector<char> runPerChar(runs.size(), 0);
|
||||
|
||||
// Merge seed advances onto edited text: keep LCP/LCS metrics, HB-fill the edit middle.
|
||||
// (Logic inlined in the run loop so unchanged glyphs are never passed to HarfBuzz.)
|
||||
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
||||
if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) {
|
||||
runCharAdv[ri] = runs[ri].advances;
|
||||
auto natural = perCharAdvances(ri, runs[ri].text);
|
||||
const auto& rs = runs[ri];
|
||||
bool usedClient = false;
|
||||
|
||||
// Prefer client/seed advances for UNCHANGED glyphs. Only HarfBuzz-shape
|
||||
// characters that are not covered by seed metrics (the edited middle).
|
||||
if (rs.advances.size() == rs.text.size() && !rs.text.empty()) {
|
||||
runCharAdv[ri] = rs.advances;
|
||||
usedClient = true;
|
||||
} else if (!rs.advanceSeedText.empty()
|
||||
&& rs.advances.size() == rs.advanceSeedText.size()
|
||||
&& !rs.text.empty()) {
|
||||
// Shape only the middle gap; prefix/suffix keep seed advances.
|
||||
size_t p = 0;
|
||||
const auto& seed = rs.advanceSeedText;
|
||||
while (p < seed.size() && p < rs.text.size() && seed[p] == rs.text[p]) ++p;
|
||||
size_t s = 0;
|
||||
while (s < seed.size() - p && s < rs.text.size() - p
|
||||
&& seed[seed.size() - 1 - s] == rs.text[rs.text.size() - 1 - s]) ++s;
|
||||
runCharAdv[ri].assign(rs.text.size(), 0.0);
|
||||
for (size_t i = 0; i < p; ++i) runCharAdv[ri][i] = rs.advances[i];
|
||||
for (size_t i = 0; i < s; ++i)
|
||||
runCharAdv[ri][rs.text.size() - 1 - i] = rs.advances[seed.size() - 1 - i];
|
||||
if (p + s < rs.text.size()) {
|
||||
std::string middle = rs.text.substr(p, rs.text.size() - p - s);
|
||||
auto midNat = perCharAdvances(ri, middle);
|
||||
for (size_t i = 0; i < midNat.size(); ++i) runCharAdv[ri][p + i] = midNat[i];
|
||||
}
|
||||
usedClient = true;
|
||||
spdlog::info("[ADVANCE_SEED_MERGE] run={} seedLen={} textLen={} "
|
||||
"prefixKept={} suffixKept={} (unchanged glyphs not reshaped)",
|
||||
ri, seed.size(), rs.text.size(), p, s);
|
||||
} else if (!rs.advances.empty() && !rs.text.empty()
|
||||
&& rs.advances.size() < rs.text.size()) {
|
||||
// Prefix-only advances (append without advanceSeedText).
|
||||
runCharAdv[ri] = perCharAdvances(ri, rs.text);
|
||||
for (size_t c = 0; c < rs.advances.size(); ++c) runCharAdv[ri][c] = rs.advances[c];
|
||||
usedClient = true;
|
||||
spdlog::info("[ADVANCE_PREFIX_KEEP] run={} prefixAdv={} textLen={} "
|
||||
"(unchanged prefix kept; only suffix reshaped)",
|
||||
ri, rs.advances.size(), rs.text.size());
|
||||
} else if (!rs.advances.empty() && !rs.text.empty()
|
||||
&& rs.advances.size() > rs.text.size()) {
|
||||
// Truncate (end-delete without advanceSeedText).
|
||||
runCharAdv[ri].assign(rs.advances.begin(),
|
||||
rs.advances.begin() + static_cast<std::ptrdiff_t>(rs.text.size()));
|
||||
usedClient = true;
|
||||
} else {
|
||||
// FIRST MUTATION SITE when client advances are missing/mismatched:
|
||||
// HarfBuzz recomputes advances for EVERY glyph, including unchanged ones.
|
||||
runCharAdv[ri] = perCharAdvances(ri, rs.text);
|
||||
spdlog::warn("[ADVANCE_RECOMPUTE_ALL] run={} textLen={} advLen={} "
|
||||
"FIRST_MUTATION=perCharAdvances full reshape (no client advances)",
|
||||
ri, rs.text.size(), rs.advances.size());
|
||||
}
|
||||
|
||||
if (usedClient) {
|
||||
// Client advances (esp. PDF TJ kerning) diverge from HarfBuzz naturals.
|
||||
// Emit per-glyph so those advances are applied; do NOT replace them.
|
||||
auto natural = perCharAdvances(ri, rs.text);
|
||||
bool diverges = natural.size() != runCharAdv[ri].size();
|
||||
for (size_t c = 0; !diverges && c < natural.size(); ++c)
|
||||
if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true;
|
||||
runPerChar[ri] = diverges ? 1 : 0;
|
||||
runPerChar[ri] = (forensicForceWholeRun ? 0 : (diverges ? 1 : 0));
|
||||
spdlog::info("[FORENSIC_RUNPERCHAR] run={} textLen={} advLen={} seedLen={} diverges={} forceWhole={} runPerChar={} natural0={:.4f} client0={:.4f} measureFace={}",
|
||||
ri, rs.text.size(), rs.advances.size(), rs.advanceSeedText.size(), diverges,
|
||||
forensicForceWholeRun, (int)runPerChar[ri],
|
||||
natural.empty() ? -1.0 : natural[0],
|
||||
runCharAdv[ri].empty() ? -1.0 : runCharAdv[ri][0],
|
||||
(bool)(runFonts[ri].measureFace));
|
||||
} else {
|
||||
runCharAdv[ri] = perCharAdvances(ri, runs[ri].text);
|
||||
spdlog::info("[FORENSIC_RUNPERCHAR] run={} textLen={} advLen={} -> recomputed advances (no client match) runPerChar=0 forceWhole={}",
|
||||
ri, rs.text.size(), rs.advances.size(), forensicForceWholeRun);
|
||||
}
|
||||
}
|
||||
auto charAdvAt = [&](int ri, size_t off) -> double {
|
||||
@@ -336,6 +419,8 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
lineCont.push_back(firstLineOfSeg ? 0 : 1);
|
||||
lineSegEnd.push_back(segEnd);
|
||||
};
|
||||
spdlog::info("[KEYSTROKE_BACKEND_DEBUG] columnLeft={:.2f}, columnRight={:.2f}, columnWidth={:.2f}, firstBaselineY={:.2f}, oldLineCount={}, hangingIndent={:.2f}",
|
||||
columnLeft, columnRight, columnWidth, firstBaselineY, oldLineCount, hangingIndent);
|
||||
for (size_t k = 0; k < allWords.size(); ++k) {
|
||||
size_t wi = allWords[k];
|
||||
if (wi == kHardBreak) { // forced break: end the current line (may be blank)
|
||||
@@ -346,6 +431,8 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
double effW = columnWidth - (firstLineOfSeg ? 0.0 : hangingIndent);
|
||||
double gap = (cur.empty() || prevWord == kHardBreak) ? 0.0 : words[prevWord].spaceAfter;
|
||||
if (!cur.empty() && curW + gap + words[wi].width > effW) {
|
||||
spdlog::info("[KEYSTROKE_BACKEND_WRAP] Wrapped at word index {} ('{}'): curW={:.2f}, gap={:.2f}, wordW={:.2f}, sum={:.2f} > effW={:.2f} (columnWidth={:.2f})",
|
||||
wi, words[wi].segs.empty() ? "" : words[wi].segs[0].text, curW, gap, words[wi].width, curW + gap + words[wi].width, effW, columnWidth);
|
||||
pushLine(0);
|
||||
cur.clear(); firstLineOfSeg = false;
|
||||
cur.push_back(wi); curW = words[wi].width;
|
||||
@@ -421,20 +508,71 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
lineText.push_back(' ');
|
||||
adv.push_back(gap);
|
||||
}
|
||||
double segX = x;
|
||||
for (auto& seg : words[wi].segs) {
|
||||
if (lineFontSize <= 0.0) lineFontSize = runs[seg.runIdx].fontSize;
|
||||
double segX = x;
|
||||
FPDF_FONT font = runFonts[seg.runIdx].font;
|
||||
double emitFontSize = runs[seg.runIdx].fontSize;
|
||||
double mtxScaleX = textAspect;
|
||||
double mtxScaleY = 1.0;
|
||||
if (exactNominal > 0.1 && exactScaleY > 0.001) {
|
||||
emitFontSize = exactNominal;
|
||||
mtxScaleX = exactScaleX;
|
||||
mtxScaleY = exactScaleY;
|
||||
lineFontSize = exactNominal * exactScaleY;
|
||||
} else {
|
||||
lineFontSize = (std::max)(lineFontSize, runs[seg.runIdx].fontSize);
|
||||
}
|
||||
spdlog::info("[STAGE_4_REFLOW_OUTPUT] lineFontSize={:.2f}, emitFontSize={:.2f}, mtxScaleY={:.4f}", lineFontSize, emitFontSize, mtxScaleY);
|
||||
auto emitObj = [&](const std::string& s, double atX) {
|
||||
if (!font || s.empty()) return;
|
||||
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(runs[seg.runIdx].fontSize));
|
||||
std::string baseFontName;
|
||||
{
|
||||
size_t nl = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
if (nl > 0) {
|
||||
std::vector<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(font, nb.data(), nl) > 0)
|
||||
baseFontName = nb.data();
|
||||
}
|
||||
}
|
||||
const auto& ef = runFonts[seg.runIdx];
|
||||
spdlog::info("[EMIT_FONT] text='{}' baseFont='{}' fontPtr={} measureFacePtr={} "
|
||||
"hasResolved={} fontSize={:.4f} atX={:.4f} baselineY={:.4f} "
|
||||
"mtxScaleX={:.4f} mtxScaleY={:.4f} runPerChar={} internalFontId='{}'",
|
||||
s, baseFontName, (void*)font,
|
||||
(void*)(ef.measureFace ? ef.measureFace.get() : nullptr),
|
||||
(bool)ef.resolved, emitFontSize, atX, baselineY,
|
||||
mtxScaleX, mtxScaleY, (int)runPerChar[seg.runIdx],
|
||||
runs[seg.runIdx].internalFontId);
|
||||
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(emitFontSize));
|
||||
if (!obj) return;
|
||||
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
|
||||
auto u16 = utf8_to_utf16le(s); u16.push_back(0);
|
||||
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
|
||||
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, atX, baselineY);
|
||||
FPDFPageObj_Transform(obj, mtxScaleX, 0.0, 0.0, mtxScaleY, atX, baselineY);
|
||||
repagSetParaId(doc_, obj, paraId);
|
||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||
FS_MATRIX objMtx;
|
||||
FPDFPageObj_GetMatrix(obj, &objMtx);
|
||||
float objFS = 0;
|
||||
FPDFTextObj_GetFontSize(obj, &objFS);
|
||||
float l = 0, b = 0, r = 0, t = 0;
|
||||
FPDFPageObj_GetBounds(obj, &l, &b, &r, &t);
|
||||
FPDF_FONT objFont = FPDFTextObj_GetFont(obj);
|
||||
std::string objBase;
|
||||
if (objFont) {
|
||||
size_t nl = FPDFFont_GetBaseFontName(objFont, nullptr, 0);
|
||||
if (nl > 0) {
|
||||
std::vector<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(objFont, nb.data(), nl) > 0)
|
||||
objBase = nb.data();
|
||||
}
|
||||
}
|
||||
spdlog::info("[EDITED_TEXT_OBJECT_DEBUG] text='{}' objBaseFont='{}' fontSize={:.2f}, "
|
||||
"matrix=[{:.4f}, {:.4f}, {:.4f}, {:.4f}, {:.4f}, {:.4f}], "
|
||||
"bbox=[{:.2f}, {:.2f}, {:.2f}, {:.2f}]",
|
||||
s, objBase, objFS,
|
||||
objMtx.a, objMtx.b, objMtx.c, objMtx.d, objMtx.e, objMtx.f,
|
||||
l, b, r, t);
|
||||
};
|
||||
if (runPerChar[seg.runIdx]) {
|
||||
double gx = segX;
|
||||
@@ -475,6 +613,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
}
|
||||
lastReflowLayout_ = nlohmann::json{
|
||||
{"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump();
|
||||
spdlog::info("[STAGE_5_SERIALIZED_JSON] {}", lastReflowLayout_);
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after reflow_paragraph");
|
||||
@@ -500,4 +639,61 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string PdfiumDocument::validateLayout(int pageIndex, const std::string& jsonStr) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
try {
|
||||
auto data = nlohmann::json::parse(jsonStr);
|
||||
if (!data.is_object()) return "{}";
|
||||
|
||||
std::string text = "";
|
||||
std::string internalFontId = "";
|
||||
double fontSize = 12.0;
|
||||
|
||||
if (data.contains("runs") && data["runs"].is_array() && !data["runs"].empty()) {
|
||||
auto run = data["runs"][0];
|
||||
text = run.value("text", "");
|
||||
internalFontId = run.value("internalFontId", "");
|
||||
fontSize = run.value("fontSize", 12.0);
|
||||
}
|
||||
|
||||
text::LayoutConstraints constraints;
|
||||
constraints.columnLeft = data.value("columnLeft", 0.0);
|
||||
constraints.columnRight = data.value("columnRight", 0.0);
|
||||
constraints.firstBaselineY = data.value("firstBaselineY", 0.0);
|
||||
constraints.leading = data.value("leading", 0.0);
|
||||
|
||||
auto fontDataRes = getFontData(internalFontId);
|
||||
auto face = std::make_shared<fonts::FontFace>();
|
||||
if (!fontDataRes || !face->loadFromMemory(*fontDataRes)) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
text::TextLayoutEngine engine;
|
||||
auto layout = engine.ComputeLayout(text, constraints, *face, fontSize);
|
||||
|
||||
nlohmann::json out;
|
||||
out["lines"] = nlohmann::json::array();
|
||||
for (const auto& l : layout.lines) {
|
||||
out["lines"].push_back({
|
||||
{"rect", {{"x", l.rect.x}, {"y", l.rect.y}, {"width", l.rect.width}, {"height", l.rect.height}}}
|
||||
});
|
||||
}
|
||||
out["glyphs"] = nlohmann::json::array();
|
||||
for (const auto& g : layout.glyphs) {
|
||||
out["glyphs"].push_back({
|
||||
{"x", g.x}, {"y", g.y}, {"width", g.width}, {"advance", g.advance},
|
||||
{"ascent", g.ascent}, {"descent", g.descent}, {"cluster", g.cluster},
|
||||
{"text", g.text}
|
||||
});
|
||||
}
|
||||
return out.dump();
|
||||
} catch (...) {
|
||||
return "{}";
|
||||
}
|
||||
#else
|
||||
(void)pageIndex; (void)jsonStr;
|
||||
return "{}";
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include "parser/pdfium_edit_session.hpp"
|
||||
#include "parser/pdfium_document.hpp"
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <cmath>
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
std::shared_ptr<EditSession> EditSession::StartEditSession(
|
||||
std::shared_ptr<PdfDocument> doc,
|
||||
int pageIndex,
|
||||
const std::string& paraId
|
||||
) {
|
||||
if (!doc) {
|
||||
spdlog::error("StartEditSession: null document");
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_shared<parser::PdfiumEditSession>(doc, pageIndex, paraId);
|
||||
}
|
||||
|
||||
} // namespace pdfengine
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
PdfiumEditSession::PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId)
|
||||
: doc_(doc), pageIndex_(pageIndex), paraId_(paraId) {
|
||||
spdlog::info("Started edit session on page {} for para {}", pageIndex_, paraId_);
|
||||
|
||||
// In a full implementation, we'd extract text, bounds, and fonts from doc_ using paraId_.
|
||||
// For now, populate dummy state for unit testing the architecture.
|
||||
currentText_ = "Mock initial text for paragraph.";
|
||||
constraints_.columnLeft = 100.0;
|
||||
constraints_.columnRight = 400.0;
|
||||
constraints_.firstBaselineY = 700.0;
|
||||
constraints_.leading = 14.0;
|
||||
isDirty_ = true;
|
||||
}
|
||||
|
||||
PdfiumEditSession::~PdfiumEditSession() {}
|
||||
|
||||
void PdfiumEditSession::RebuildLayoutIfNeeded() const {
|
||||
if (!isDirty_) return;
|
||||
|
||||
if (currentFace_) {
|
||||
cachedLayout_ = layoutEngine_.ComputeLayout(
|
||||
currentText_, constraints_, *currentFace_, currentFontSize_
|
||||
);
|
||||
} else {
|
||||
// Mock fallback if face not loaded yet
|
||||
cachedLayout_.lines.clear();
|
||||
cachedLayout_.glyphs.clear();
|
||||
|
||||
GlyphInfo g;
|
||||
g.x = constraints_.columnLeft;
|
||||
g.y = constraints_.firstBaselineY;
|
||||
g.width = 5.0;
|
||||
g.advance = 6.0;
|
||||
g.ascent = 10.0;
|
||||
g.descent = -2.0;
|
||||
g.cluster = 0;
|
||||
|
||||
if (!currentText_.empty()) {
|
||||
LineInfo line;
|
||||
line.id = 0;
|
||||
line.baselineY = constraints_.firstBaselineY;
|
||||
|
||||
for (size_t i = 0; i < currentText_.size(); ++i) {
|
||||
g.cluster = static_cast<uint32_t>(i);
|
||||
g.text = currentText_.substr(i, 1);
|
||||
cachedLayout_.glyphs.push_back(g);
|
||||
g.x += g.advance;
|
||||
}
|
||||
|
||||
line.rect = Rect{constraints_.columnLeft, constraints_.firstBaselineY - g.ascent,
|
||||
g.x - constraints_.columnLeft, g.ascent - g.descent};
|
||||
cachedLayout_.lines.push_back(line);
|
||||
}
|
||||
}
|
||||
|
||||
isDirty_ = false;
|
||||
}
|
||||
|
||||
LayoutView PdfiumEditSession::GetLayoutView() const {
|
||||
RebuildLayoutIfNeeded();
|
||||
return LayoutView{
|
||||
cachedLayout_.lines,
|
||||
cachedLayout_.glyphs,
|
||||
cachedLayout_.caret,
|
||||
cachedLayout_.dirtyRects
|
||||
};
|
||||
}
|
||||
|
||||
int PdfiumEditSession::HitTest(double x, double y) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
|
||||
// Find closest glyph by checking boundary/midpoint logic
|
||||
int closestCluster = 0;
|
||||
double minDistanceSq = 1e9;
|
||||
|
||||
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||
// Find distance to bounding box
|
||||
double dx = 0.0;
|
||||
if (x < glyph.x) dx = glyph.x - x;
|
||||
else if (x > glyph.x + glyph.advance) dx = x - (glyph.x + glyph.advance);
|
||||
|
||||
double gy = glyph.y - glyph.ascent;
|
||||
double gh = glyph.ascent - glyph.descent;
|
||||
double dy = 0.0;
|
||||
if (y < gy) dy = gy - y;
|
||||
else if (y > gy + gh) dy = y - (gy + gh);
|
||||
|
||||
double distSq = dx * dx + dy * dy;
|
||||
if (distSq < minDistanceSq) {
|
||||
minDistanceSq = distSq;
|
||||
// Midpoint logic: if past midpoint, insertion point is after this glyph
|
||||
if (x > glyph.x + glyph.advance / 2.0) {
|
||||
closestCluster = glyph.cluster + 1;
|
||||
} else {
|
||||
closestCluster = glyph.cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closestCluster;
|
||||
}
|
||||
|
||||
Rect PdfiumEditSession::GetCaretRect(int offset) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
|
||||
if (cachedLayout_.glyphs.empty()) {
|
||||
return Rect{constraints_.columnLeft, constraints_.firstBaselineY - 10.0, 1.0, 12.0};
|
||||
}
|
||||
|
||||
// Caret is positioned *between* glyphs at the insertion point `offset`.
|
||||
// If offset matches a cluster, place it at the left edge of that glyph.
|
||||
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||
if (glyph.cluster == static_cast<uint32_t>(offset)) {
|
||||
return Rect{glyph.x, glyph.y - glyph.ascent, 1.0, glyph.ascent - glyph.descent};
|
||||
}
|
||||
}
|
||||
|
||||
// If offset is beyond all glyphs, place it at the right edge of the last glyph.
|
||||
const auto& lastGlyph = cachedLayout_.glyphs.back();
|
||||
return Rect{lastGlyph.x + lastGlyph.advance, lastGlyph.y - lastGlyph.ascent, 1.0, lastGlyph.ascent - lastGlyph.descent};
|
||||
}
|
||||
|
||||
std::vector<Rect> PdfiumEditSession::GetSelectionRects(int startOffset, int endOffset) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
(void)startOffset; (void)endOffset;
|
||||
return {};
|
||||
}
|
||||
|
||||
void PdfiumEditSession::ApplyEdit(const std::string& editOpJson) {
|
||||
// Phase 1.1: Modify state and mark dirty
|
||||
spdlog::info("ApplyEdit called with {}", editOpJson);
|
||||
|
||||
// Very naive edit implementation for testing
|
||||
// e.g. editOpJson = "append:A"
|
||||
if (editOpJson.find("append:") == 0) {
|
||||
currentText_ += editOpJson.substr(7);
|
||||
}
|
||||
|
||||
isDirty_ = true;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> PdfiumEditSession::RenderDirtyRegion(int dpi, const Rect& region) const {
|
||||
(void)dpi; (void)region;
|
||||
return {};
|
||||
}
|
||||
|
||||
bool PdfiumEditSession::CommitEdit() {
|
||||
spdlog::info("CommitEdit called");
|
||||
return true;
|
||||
}
|
||||
|
||||
void PdfiumEditSession::CancelEdit() {
|
||||
spdlog::info("CancelEdit called");
|
||||
}
|
||||
|
||||
} // namespace pdfengine::parser
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
class PdfiumEditSession : public EditSession {
|
||||
public:
|
||||
PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId);
|
||||
~PdfiumEditSession() override;
|
||||
|
||||
LayoutView GetLayoutView() const override;
|
||||
int HitTest(double x, double y) const override;
|
||||
Rect GetCaretRect(int offset) const override;
|
||||
std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const override;
|
||||
|
||||
void ApplyEdit(const std::string& editOpJson) override;
|
||||
|
||||
std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const override;
|
||||
bool CommitEdit() override;
|
||||
void CancelEdit() override;
|
||||
|
||||
private:
|
||||
void RebuildLayoutIfNeeded() const;
|
||||
|
||||
std::shared_ptr<PdfDocument> doc_;
|
||||
int pageIndex_;
|
||||
std::string paraId_;
|
||||
|
||||
// Live editing state
|
||||
std::string currentText_;
|
||||
text::LayoutConstraints constraints_;
|
||||
std::shared_ptr<fonts::FontFace> currentFace_;
|
||||
double currentFontSize_ = 12.0;
|
||||
|
||||
// Layout cache and engine
|
||||
mutable text::TextLayoutEngine layoutEngine_;
|
||||
mutable LayoutResult cachedLayout_;
|
||||
mutable bool isDirty_ = true;
|
||||
};
|
||||
|
||||
} // namespace pdfengine::parser
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
fonts::pdf_fonts::ReconstructedFont rf;
|
||||
if (!sfnt.empty()) {
|
||||
rf.ok = true;
|
||||
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
reconstructedFonts_[internalFontId] = std::move(rf);
|
||||
#else
|
||||
(void)internalFontId; (void)sfnt;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError>
|
||||
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#else
|
||||
(void)internalFontId;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
return getReconstructedEmbeddedFont(internalFontId);
|
||||
#else
|
||||
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
|
||||
auto it = reconstructedFonts_.find(internalFontId);
|
||||
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
(void)internalFontId;
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
const fonts::pdf_fonts::ReconstructedFont*
|
||||
@@ -103,12 +115,23 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
||||
if (out.resolved) {
|
||||
for (uint32_t cp : codepoints) {
|
||||
// U+0000 is a string terminator, never glyph content.
|
||||
if (cp == 0) continue;
|
||||
if (!out.resolved->hasGlyph(cp)) {
|
||||
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
|
||||
if (isSubsetFont) {
|
||||
subsetLacksGlyphs = true;
|
||||
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' subset MISSING codepoint U+{:04X} (char='{}')",
|
||||
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' full font MISSING codepoint U+{:04X} (char='{}')",
|
||||
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fontSupportsAll = false;
|
||||
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' no resolved face - forcing system fallback", internalFontId);
|
||||
}
|
||||
|
||||
std::string cacheKey;
|
||||
@@ -178,7 +201,30 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
}
|
||||
}
|
||||
// CRITICAL FIX: Even though we are emitting with a system font (because the PDF subset
|
||||
// font does not contain the newly typed glyph), we must still use the ORIGINAL embedded
|
||||
// subset font face for HarfBuzz shaping / advance measurement. This preserves the exact
|
||||
// glyph advance widths, ascent, descent, and visual size of all original characters so
|
||||
// the text does not visually shrink or change weight after the first keystroke.
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
|
||||
std::vector<uint8_t> origBytes;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
||||
perObj.has_value() && !perObj->empty()) {
|
||||
origBytes = std::move(*perObj);
|
||||
} else if (auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||
fontDataRes.has_value() && !fontDataRes.value().empty()) {
|
||||
origBytes = std::move(fontDataRes.value());
|
||||
}
|
||||
if (!origBytes.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(origBytes)) {
|
||||
out.measureFace = mf;
|
||||
spdlog::info("[FONT_METRICS_FIX] internalFontId='{}' preserving original embedded face as measureFace despite system emission font - visual size preserved", internalFontId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.font && matchedFontInfo && matchedFontInfo->isEmbedded &&
|
||||
classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
@@ -211,7 +257,24 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
|
||||
if (!out.font) {
|
||||
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
spdlog::info("[FONT_FALLBACK_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} subsetLacksGlyphs={} fontSupportsAll={} cacheKey='{}' fallbackStd='{}'",
|
||||
internalFontId, useEmbedded, useSystem,
|
||||
matchedFontInfo ? matchedFontInfo->isSubset : false,
|
||||
(bool)(!out.resolved ? false : (isSubsetFont ? subsetLacksGlyphs : !fontSupportsAll)),
|
||||
(bool)fontSupportsAll, cacheKey, fontName);
|
||||
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
// Standard fonts have no SFNT bytes → measureFace cannot be created from them.
|
||||
spdlog::info("[MEASUREFACE_NULL] reason=standard_font_fallback internalFontId='{}' "
|
||||
"isEmbedded={} matched={} useEmbedded={} useSystem={} fontName='{}' "
|
||||
"fontPtr={} — no TTF/OTF bytes available for HarfBuzz measureFace",
|
||||
internalFontId,
|
||||
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
|
||||
(bool)matchedFontInfo, useEmbedded, useSystem, fontName, (void*)out.font);
|
||||
} else {
|
||||
spdlog::info("[FONT_LOAD_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} measureFace={} cacheKey='{}'",
|
||||
internalFontId, useEmbedded, useSystem,
|
||||
matchedFontInfo ? matchedFontInfo->isSubset : false,
|
||||
(bool)out.measureFace, cacheKey);
|
||||
}
|
||||
if (out.font) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
@@ -220,8 +283,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
||||
} else if (!out.measureFace) {
|
||||
spdlog::info("[MEASUREFACE_NULL] reason=no_sfnt_bytes_in_buffer internalFontId='{}' cacheKey='{}' "
|
||||
"useEmbedded={} useSystem={} — emit font loaded but measureFace still null",
|
||||
internalFontId, cacheKey, useEmbedded, useSystem);
|
||||
}
|
||||
}
|
||||
spdlog::info("[LOAD_EMISSION_FONT_RESULT] internalFontId='{}' fontPtr={} measureFacePtr={} "
|
||||
"useEmbedded={} useSystem={} isEmbedded={} isSubset={}",
|
||||
internalFontId, (void*)out.font,
|
||||
(void*)(out.measureFace ? out.measureFace.get() : nullptr),
|
||||
useEmbedded, useSystem,
|
||||
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
|
||||
matchedFontInfo ? matchedFontInfo->isSubset : false);
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -136,6 +136,9 @@ 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;
|
||||
@@ -144,8 +147,31 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
||||
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
|
||||
|
||||
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
if (it != objToIndex.end()) {
|
||||
g.pageObjectIndex = it->second;
|
||||
}
|
||||
FS_MATRIX mtx;
|
||||
if (FPDFPageObj_GetMatrix(textObj, &mtx)) {
|
||||
double scaleY1 = std::hypot(static_cast<double>(mtx.c), static_cast<double>(mtx.d));
|
||||
double scaleY2 = std::hypot(static_cast<double>(mtx.a), static_cast<double>(mtx.b));
|
||||
double scaleY = (std::max)(scaleY1, scaleY2);
|
||||
if (scaleY > 0.001) {
|
||||
double trueSize = g.fontSize * scaleY;
|
||||
if (trueSize > 0.1) g.fontSize = trueSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (g.bboxH > g.fontSize * 0.8) {
|
||||
g.fontSize = (std::max)(g.fontSize, g.bboxH);
|
||||
}
|
||||
|
||||
documentGlyphs.push_back(g);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
#include <cmath>
|
||||
|
||||
namespace pdfengine::text {
|
||||
|
||||
LineBreaker::LineBreaker(const LayoutConstraints& constraints)
|
||||
: constraints_(constraints),
|
||||
currentX_(constraints.columnLeft),
|
||||
currentY_(constraints.firstBaselineY) {}
|
||||
|
||||
void LineBreaker::ProcessRun(
|
||||
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
|
||||
const std::string& runText,
|
||||
double fontSize,
|
||||
const fonts::FontFace& face,
|
||||
double scale
|
||||
) {
|
||||
// Basic line breaker mimicking the HarfBuzz scaling loop from pdfium_edit_reflow
|
||||
// In a full implementation, we'd find spaces and wrap. For now, just lay out linearly
|
||||
// and wrap aggressively at columnRight.
|
||||
|
||||
FT_Face ftFace = face.getFace();
|
||||
double upm = ftFace->units_per_EM;
|
||||
double fontAscent = (ftFace->ascender / upm) * 1000.0 * scale;
|
||||
double fontDescent = (ftFace->descender / upm) * 1000.0 * scale;
|
||||
|
||||
for (size_t i = 0; i < shapedGlyphs.size(); ++i) {
|
||||
const auto& glyph = shapedGlyphs[i];
|
||||
|
||||
// Very rudimentary word wrap for demonstration
|
||||
if (currentX_ + glyph.advanceX * scale > constraints_.columnRight && !currentLineGlyphs_.empty()) {
|
||||
CommitLine();
|
||||
}
|
||||
|
||||
GlyphInfo info;
|
||||
info.glyphId = glyph.glyphIndex;
|
||||
info.cluster = glyph.clusterIndex;
|
||||
info.x = currentX_ + glyph.offsetX * scale;
|
||||
info.y = currentY_ + glyph.offsetY * scale;
|
||||
info.advance = glyph.advanceX * scale;
|
||||
info.width = info.advance; // Simplification
|
||||
info.ascent = fontAscent;
|
||||
info.descent = fontDescent;
|
||||
|
||||
// Extract substring for this cluster if possible
|
||||
if (info.cluster < runText.size()) {
|
||||
info.text = runText.substr(info.cluster, 1);
|
||||
}
|
||||
|
||||
currentLineGlyphs_.push_back(info);
|
||||
currentX_ += info.advance;
|
||||
}
|
||||
}
|
||||
|
||||
void LineBreaker::CommitLine() {
|
||||
if (currentLineGlyphs_.empty()) return;
|
||||
|
||||
LineInfo line;
|
||||
line.id = static_cast<int>(finishedLines_.size());
|
||||
line.baselineY = currentY_;
|
||||
|
||||
if (!currentLineGlyphs_.empty()) {
|
||||
line.rect.x = currentLineGlyphs_.front().x;
|
||||
line.rect.y = currentY_ - currentLineGlyphs_.front().ascent;
|
||||
line.rect.width = currentX_ - line.rect.x;
|
||||
line.rect.height = currentLineGlyphs_.front().ascent - currentLineGlyphs_.front().descent;
|
||||
}
|
||||
|
||||
finishedLines_.push_back(line);
|
||||
|
||||
for (auto& g : currentLineGlyphs_) {
|
||||
allGlyphs_.push_back(g);
|
||||
}
|
||||
currentLineGlyphs_.clear();
|
||||
|
||||
// Advance to next line
|
||||
currentX_ = constraints_.columnLeft;
|
||||
currentY_ -= constraints_.leading; // Assuming standard PDF Y-up coordinates
|
||||
}
|
||||
|
||||
void LineBreaker::Finalize(LayoutResult& outLayout) {
|
||||
CommitLine(); // Commit any remaining glyphs
|
||||
|
||||
outLayout.lines = std::move(finishedLines_);
|
||||
outLayout.glyphs = std::move(allGlyphs_);
|
||||
|
||||
// Compute bounding box
|
||||
if (!outLayout.lines.empty()) {
|
||||
double minX = outLayout.lines.front().rect.x;
|
||||
double maxX = minX;
|
||||
double minY = outLayout.lines.front().rect.y;
|
||||
double maxY = minY + outLayout.lines.front().rect.height;
|
||||
|
||||
for (const auto& line : outLayout.lines) {
|
||||
if (line.rect.x < minX) minX = line.rect.x;
|
||||
if (line.rect.x + line.rect.width > maxX) maxX = line.rect.x + line.rect.width;
|
||||
if (line.rect.y < minY) minY = line.rect.y;
|
||||
if (line.rect.y + line.rect.height > maxY) maxY = line.rect.y + line.rect.height;
|
||||
}
|
||||
|
||||
outLayout.bounds.rect.x = minX;
|
||||
outLayout.bounds.rect.y = minY;
|
||||
outLayout.bounds.rect.width = maxX - minX;
|
||||
outLayout.bounds.rect.height = maxY - minY;
|
||||
}
|
||||
}
|
||||
|
||||
TextLayoutEngine::TextLayoutEngine() = default;
|
||||
|
||||
LayoutResult TextLayoutEngine::ComputeLayout(
|
||||
const std::string& text,
|
||||
const LayoutConstraints& constraints,
|
||||
fonts::FontFace& fontFace,
|
||||
double fontSize
|
||||
) {
|
||||
LayoutResult result;
|
||||
|
||||
if (text.empty() || constraints.columnRight <= constraints.columnLeft) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1000 is the standard reference size used in HarfBuzz scaling in this engine
|
||||
constexpr unsigned int kRefSize = 1000;
|
||||
auto shapedGlyphs = shaper_.shapeRun(text, fontFace, kRefSize);
|
||||
|
||||
double scale = fontSize / static_cast<double>(kRefSize);
|
||||
|
||||
LineBreaker breaker(constraints);
|
||||
breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale);
|
||||
breaker.Finalize(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace pdfengine::text
|
||||
@@ -9,6 +9,7 @@ add_executable(pdfengine_smoke
|
||||
document_load_test.cpp
|
||||
page_render_test.cpp
|
||||
document_edit_test.cpp
|
||||
edit_session_test.cpp
|
||||
font_diagnostics_test.cpp
|
||||
text_encoding_test.cpp
|
||||
skia_renderer_test.cpp
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
VITE_GATEWAY_URL=http://127.0.0.1:8765
|
||||
@@ -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; }
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
}
|
||||
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={() => {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
onAnnotationClick={(anno) => {
|
||||
if (activeTool === 'select') {
|
||||
setSelectedAnnotationId(anno.id);
|
||||
} else {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onMarkRedaction={handleMarkRedaction}
|
||||
@@ -703,6 +787,16 @@ function App() {
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
onDecorateText={handleDecorateText}
|
||||
/>
|
||||
) : importError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 text-red-500">
|
||||
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[14px] font-semibold text-[#18212e]">Import Error</p>
|
||||
<p className="text-[13px]">{importError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
|
||||
@@ -726,6 +820,8 @@ function App() {
|
||||
<InspectorPanel
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
isExpanded={isInspectorExpanded}
|
||||
onExpandedChange={setIsInspectorExpanded}
|
||||
documents={documents}
|
||||
selectedDocumentId={selectedDocId}
|
||||
onSelectDocument={openDocument}
|
||||
@@ -754,6 +850,14 @@ function App() {
|
||||
metadata={metadata}
|
||||
permissions={permissions ?? undefined}
|
||||
fonts={fonts}
|
||||
history={hist}
|
||||
onRestoreHistory={(docId) => {
|
||||
const index = hist.stack.findIndex(id => id === docId);
|
||||
if (index !== -1) {
|
||||
setHist(h => ({ ...h, index }));
|
||||
preservePageRef.current = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,20 @@ import React from 'react';
|
||||
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
|
||||
import type { Annotation } from '../viewer/AnnotationLayer';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
import { EmptyState, Popover } from './ui';
|
||||
import { EmptyState } from './ui';
|
||||
import {
|
||||
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
|
||||
} from './icons';
|
||||
import { useTheme } from '../lib/useTheme';
|
||||
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
|
||||
|
||||
interface InspectorPanelProps {
|
||||
activeTab: InspectorTab;
|
||||
onTabChange: (t: InspectorTab) => void;
|
||||
isExpanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
|
||||
documents: DocumentInfo[];
|
||||
selectedDocumentId: string;
|
||||
@@ -48,6 +51,9 @@ interface InspectorPanelProps {
|
||||
metadata: DocumentMetadata | null;
|
||||
permissions?: PDFPermissions;
|
||||
fonts: FontInfo[];
|
||||
|
||||
history?: { stack: string[]; index: number };
|
||||
onRestoreHistory?: (docId: string) => void;
|
||||
}
|
||||
|
||||
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
|
||||
@@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo
|
||||
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
|
||||
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
|
||||
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
|
||||
{ id: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||
];
|
||||
|
||||
function formatBytes(bytes?: number) {
|
||||
@@ -70,79 +77,77 @@ function formatBytes(bytes?: number) {
|
||||
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
|
||||
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<span className="shrink-0 text-[13px] font-bold text-[#18212e]">{activeDef.label}</span>
|
||||
{p.documents.length > 0 ? (
|
||||
<Popover
|
||||
align="right"
|
||||
width={272}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[8px] px-2 text-left transition-colors ${open ? 'bg-[#edeff2]' : 'hover:bg-[#f6f7f9]'}`}>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#5b6573]">{selectedDoc?.filename ?? 'Document'}</span>
|
||||
<ChevronDownIcon size={13} className="shrink-0 text-[#98a1ad]" />
|
||||
</CustomButton>
|
||||
)}
|
||||
<aside className="flex h-full shrink-0 flex-row-reverse bg-bg-primary">
|
||||
{/* Icon Strip (Right Edge) */}
|
||||
<div className="flex w-[48px] shrink-0 flex-col items-center gap-2 border-l border-border-primary bg-bg-secondary py-3">
|
||||
{TABS.map((t) => {
|
||||
const active = p.isExpanded && p.activeTab === t.id;
|
||||
return (
|
||||
<CustomButton variant="unstyled"
|
||||
key={t.id}
|
||||
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
||||
aria-label={t.label}
|
||||
onClick={() => {
|
||||
if (p.isExpanded && p.activeTab === t.id) {
|
||||
p.onExpandedChange(false);
|
||||
} else {
|
||||
p.onTabChange(t.id);
|
||||
p.onExpandedChange(true);
|
||||
}
|
||||
}}
|
||||
className={`relative flex h-9 w-9 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-brand-secondary text-brand-primary'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
|
||||
{p.documents.map((d) => (
|
||||
<CustomButton variant="unstyled"
|
||||
key={d.id}
|
||||
onClick={() => p.onSelectDocument(d.id)}
|
||||
className={`flex flex-col rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] ${d.id === p.selectedDocumentId ? 'bg-[#eef4ff]' : ''}`}
|
||||
>
|
||||
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>{d.filename}</span>
|
||||
<span className="text-[10.5px] text-[#98a1ad]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<span className="text-[11.5px] font-medium text-[#98a1ad]">No document</span>
|
||||
)}
|
||||
</div>
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
|
||||
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-primary" />
|
||||
)}
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
|
||||
{TABS.map((t) => {
|
||||
const active = p.activeTab === t.id;
|
||||
return (
|
||||
<CustomButton variant="unstyled"
|
||||
key={t.id}
|
||||
title={t.stub ? `${t.label} (coming soon)` : t.label}
|
||||
aria-label={t.label}
|
||||
onClick={() => p.onTabChange(t.id)}
|
||||
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
}`}
|
||||
>
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
|
||||
{t.id === 'notes' && p.annotations.some((a) => a.type !== 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
)}
|
||||
{t.id === 'forms' && p.annotations.some((a) => a.type === 'widget') && (
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
)}
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
|
||||
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
|
||||
</div>
|
||||
<div className="flex-1 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled"
|
||||
label={theme === 'dark' ? "Switch to light mode" : "Switch to dark mode"}
|
||||
onClick={toggleTheme}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[8px] text-text-secondary transition-colors hover:bg-bg-tertiary hover:text-text-primary mb-2"
|
||||
>
|
||||
{theme === 'dark' ? <SunIcon size={18} /> : <MoonIcon size={18} />}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content Drawer */}
|
||||
{p.isExpanded && (
|
||||
<div className="flex w-[322px] min-w-0 shrink-0 flex-col border-l border-border-primary">
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between border-b border-border-primary" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<span className="shrink-0 text-[13px] font-bold text-text-primary">{activeDef.label}</span>
|
||||
<CustomButton variant="unstyled" onClick={() => p.onExpandedChange(false)} className="flex h-7 w-7 items-center justify-center rounded-[6px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||
<XIcon size={16} />
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="scroll-micro min-h-0 flex-1 overflow-y-auto">
|
||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
|
||||
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
|
||||
{p.activeTab === 'history' && <HistoryTab history={p.history} onRestore={p.onRestoreHistory} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -172,7 +177,7 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
setDraggedIdx(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggedIdx(null)}
|
||||
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-[#2563eb] [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-[#eef4ff]' : ''}`}
|
||||
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-brand-primary [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-brand-secondary' : ''}`}
|
||||
>
|
||||
<Thumbnail
|
||||
documentId={p.documentId}
|
||||
@@ -197,15 +202,15 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{annotations.map((a) => (
|
||||
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 transition-colors hover:border-[#dadde2]">
|
||||
<div key={a.id} className="group relative flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 transition-colors hover:border-border-secondary">
|
||||
<CustomButton variant="unstyled" onClick={() => onNavigate(a)} className="flex flex-col gap-1.5 text-left">
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide"
|
||||
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
|
||||
<span className="text-[10px] text-[#98a1ad]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
||||
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide bg-brand-secondary text-brand-primary"
|
||||
>{a.type}</span>
|
||||
<span className="text-[10px] text-text-tertiary">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
|
||||
</div>
|
||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-text-secondary">“{a.content}”</p>}
|
||||
<span className="text-[10px] font-medium text-text-tertiary">{a.author}</span>
|
||||
</CustomButton>
|
||||
|
||||
{onUpdate && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
|
||||
@@ -235,7 +240,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
title="Delete annotation"
|
||||
aria-label="Delete annotation"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(a); }}
|
||||
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-[#98a1ad] opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
|
||||
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-[6px] text-text-tertiary opacity-0 transition-opacity hover:bg-[#fdecec] hover:text-[#dc2626] group-hover:opacity-100"
|
||||
>
|
||||
<TrashIcon size={15} />
|
||||
</button>
|
||||
@@ -257,11 +262,11 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
||||
key={i}
|
||||
onClick={() => item.pageIndex >= 0 && onNavigate(item.pageIndex)}
|
||||
disabled={item.pageIndex < 0}
|
||||
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-50"
|
||||
className="flex items-center justify-between gap-2 rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-bg-secondary disabled:opacity-50"
|
||||
style={{ paddingLeft: `${8 + item.level * 14}px` }}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-[#18212e]" title={item.title}>{item.title || '(untitled)'}</span>
|
||||
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">{item.pageIndex + 1}</span>}
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] text-text-primary" title={item.title}>{item.title || '(untitled)'}</span>
|
||||
{item.pageIndex >= 0 && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">{item.pageIndex + 1}</span>}
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
@@ -271,27 +276,40 @@ const OutlineTab: React.FC<{ outline: OutlineItem[]; onNavigate: (pageIndex: num
|
||||
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-[#ebedf0] p-3">
|
||||
<div className="border-b border-border-primary p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[13px] font-bold text-[#18212e]">Find</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">Find</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-2.5 py-1.5 focus-within:border-[#2563eb]">
|
||||
<SearchGlyph size={15} className="text-[#98a1ad]" />
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-border-primary bg-bg-secondary px-2.5 py-1.5 focus-within:border-brand-primary">
|
||||
<SearchGlyph size={15} className="shrink-0 text-text-tertiary" />
|
||||
<input
|
||||
autoFocus
|
||||
value={p.searchQuery}
|
||||
onChange={(e) => p.onSearchQueryChange(e.target.value)}
|
||||
placeholder="Search document…"
|
||||
className="w-full bg-transparent text-[13px] text-[#18212e] outline-none placeholder:text-[#98a1ad]"
|
||||
className="w-full bg-transparent text-[13px] text-text-primary outline-none placeholder:text-text-tertiary"
|
||||
/>
|
||||
{p.searchQuery && (
|
||||
<button
|
||||
onClick={() => p.onSearchQueryChange('')}
|
||||
className="flex shrink-0 items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between px-0.5">
|
||||
<span className="text-[11.5px] font-medium text-[#5b6573]">
|
||||
<span className="text-[11.5px] font-medium text-text-secondary">
|
||||
{p.searchResults.length > 0 ? `Search results: ${p.searchCurrentMatch + 1}/${p.searchResults.length}` : (p.searchQuery ? 'No matches' : '')}
|
||||
</span>
|
||||
{p.searchResults.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-[#5b6573]">
|
||||
<div className="flex items-center gap-1 text-text-secondary">
|
||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch - 1 + p.searchResults.length) % p.searchResults.length)}><ChevronUpIcon size={14} /></CustomButton>
|
||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch + 1) % p.searchResults.length)}><ChevronDownIcon size={14} /></CustomButton>
|
||||
</div>
|
||||
@@ -299,12 +317,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2 px-0.5">
|
||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||
Case sensitive
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
<label className="flex items-center gap-2 text-[12px] text-text-secondary cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-border-secondary text-brand-primary focus:ring-brand-primary" />
|
||||
Whole words only
|
||||
</label>
|
||||
</div>
|
||||
@@ -326,7 +344,7 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
<span>
|
||||
{parts.map((part, index) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-[#eef4ff] text-[#2563eb]'}`}>{part}</mark>
|
||||
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-brand-secondary text-brand-primary'}`}>{part}</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
@@ -341,12 +359,12 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
return (
|
||||
<CustomButton variant="unstyled" key={i} onClick={() => p.onSelectSearchMatch(i)}
|
||||
className={`mb-1 flex w-full flex-col gap-1.5 rounded-[6px] px-2.5 py-2 text-left transition-colors ${
|
||||
isSelected ? 'bg-[#2563eb] text-white' : 'hover:bg-[#f6f7f9] text-[#5b6573]'
|
||||
isSelected ? 'bg-brand-primary text-white' : 'hover:bg-bg-secondary text-text-secondary'
|
||||
}`}>
|
||||
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-[#18212e]'}`}>
|
||||
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-text-primary'}`}>
|
||||
{highlightText(r.text || p.searchQuery, p.searchQuery)}
|
||||
</span>
|
||||
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-[#1d4ed8] text-white' : 'bg-[#edeff2] text-[#98a1ad]'}`}>
|
||||
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-brand-hover text-white' : 'bg-bg-tertiary text-text-tertiary'}`}>
|
||||
Page {r.pageIndex + 1}
|
||||
</span>
|
||||
</CustomButton>
|
||||
@@ -386,19 +404,19 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[#ebedf0] py-2 last:border-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{k}</span>
|
||||
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]">—</span>}</span>
|
||||
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-border-primary py-2 last:border-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{k}</span>
|
||||
<span className="break-words text-[12.5px] text-text-primary">{v && v.trim() ? v : <span className="text-text-tertiary">—</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-3 border-t border-[#ebedf0] pt-3">
|
||||
<div className="mt-3 border-t border-border-primary pt-3">
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">Security</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">Security</span>
|
||||
{permissions?.isEncrypted
|
||||
? <span className="rounded bg-[#fdecec] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#dc2626]">{permissions.encryption}</span>
|
||||
: <span className="rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">Unencrypted</span>}
|
||||
{permissions?.ownerUnlocked && <span className="rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">Owner</span>}
|
||||
: <span className="rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">Unencrypted</span>}
|
||||
{permissions?.ownerUnlocked && <span className="rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">Owner</span>}
|
||||
</div>
|
||||
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -406,7 +424,7 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
const allowed = permissions[key] !== false;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between text-[12px]">
|
||||
<span className="text-[#5b6573]">{label}</span>
|
||||
<span className="text-text-secondary">{label}</span>
|
||||
<span className={allowed ? 'font-semibold text-[#16a34a]' : 'font-semibold text-[#dc2626]'}>
|
||||
{allowed ? 'Allowed' : 'Restricted'}
|
||||
</span>
|
||||
@@ -415,11 +433,11 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?:
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-[#98a1ad]">No usage restrictions.</p>
|
||||
<p className="text-[11px] text-text-tertiary">No usage restrictions.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="pt-3 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
|
||||
<p className="pt-3 text-[10.5px] text-text-tertiary">Document properties are read-only.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -433,10 +451,10 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{unique.map((f, i) => (
|
||||
<div key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5">
|
||||
<div key={i} className="rounded-[8px] border border-border-primary bg-bg-secondary p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
||||
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-text-primary" title={f.fontName}>{f.fontName || 'Unknown'}</span>
|
||||
{f.type && <span className="shrink-0 rounded bg-bg-tertiary px-1.5 py-0.5 text-[9px] font-bold uppercase text-text-tertiary">{f.type}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
|
||||
@@ -454,8 +472,8 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
|
||||
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
|
||||
style={{
|
||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
|
||||
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
|
||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : 'var(--bg-tertiary)',
|
||||
color: warn ? '#d97706' : ok ? '#16a34a' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
@@ -469,25 +487,65 @@ const FormsTab: React.FC<{ fields: Annotation[]; onNavigate: (a: Annotation) =>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<p className="px-1 text-[11px] font-medium text-[#98a1ad]">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
|
||||
<p className="px-1 text-[11px] font-medium text-text-tertiary">{fields.length} field{fields.length > 1 ? 's' : ''}</p>
|
||||
{fields.map((f) => (
|
||||
<CustomButton variant="unstyled" key={f.id} onClick={() => onNavigate(f)}
|
||||
className="flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 text-left transition-colors hover:border-[#dadde2]">
|
||||
className="flex flex-col gap-1.5 rounded-[8px] border border-border-primary bg-bg-secondary p-2.5 text-left transition-colors hover:border-border-secondary">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-[#18212e]" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
|
||||
<span className="shrink-0 rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12.5px] font-semibold text-text-primary" title={f.fieldName}>{f.fieldName || '(unnamed field)'}</span>
|
||||
<span className="shrink-0 rounded bg-brand-secondary px-1.5 py-0.5 text-[9px] font-bold uppercase text-brand-primary">{FIELD_TYPE_LABEL[f.fieldType || ''] || f.fieldType || 'Field'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[#98a1ad]">Value</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-[#18212e]">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-[#98a1ad]">—</span>}</span>
|
||||
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-[#98a1ad]">p.{f.pageIndex + 1}</span>}
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">Value</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-text-primary">{f.fieldValue && f.fieldValue.trim() ? f.fieldValue : <span className="text-text-tertiary">—</span>}</span>
|
||||
{f.pageIndex !== undefined && <span className="shrink-0 text-[10px] tabular-nums text-text-tertiary">p.{f.pageIndex + 1}</span>}
|
||||
</div>
|
||||
{f.fieldOptions && f.fieldOptions.length > 0 && (
|
||||
<span className="truncate text-[10px] text-[#98a1ad]">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
|
||||
<span className="truncate text-[10px] text-text-tertiary">Options: {f.fieldOptions.slice(0, 5).join(', ')}{f.fieldOptions.length > 5 ? '…' : ''}</span>
|
||||
)}
|
||||
</CustomButton>
|
||||
))}
|
||||
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[#98a1ad]">Form fields are view-only — filling is on the Phase 3 roadmap.</p>
|
||||
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-text-tertiary">Form fields are view-only — filling is on the Phase 3 roadmap.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HistoryTab: React.FC<{ history?: { stack: string[]; index: number }; onRestore?: (docId: string) => void }> = ({ history, onRestore }) => {
|
||||
if (!history || history.stack.length <= 1) {
|
||||
return <EmptyState icon={<HistoryIcon size={30} />} title="No version history" hint="Edits made to the document will appear here." />;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
{history.stack.map((docId, idx) => {
|
||||
const isCurrent = idx === history.index;
|
||||
const originalIndex = history.stack.length - 1 - idx;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between rounded-[8px] border p-2.5 transition-colors ${
|
||||
isCurrent
|
||||
? 'border-[#2563eb] bg-[#eff6ff]'
|
||||
: 'border-border-primary bg-bg-secondary hover:border-border-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`text-[12.5px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-text-primary'}`}>
|
||||
Version {originalIndex + 1} {isCurrent && '(Current)'}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-tertiary">Document ID: {docId.split('_')[0]}...</span>
|
||||
</div>
|
||||
{!isCurrent && onRestore && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onRestore(docId)}
|
||||
>
|
||||
Restore
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -88,40 +88,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
{onMoveUp ? (
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveUp();
|
||||
}}
|
||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
||||
title="Move Page Up"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</CustomButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{onMoveDown ? (
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveDown();
|
||||
}}
|
||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
||||
title="Move Page Down"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</CustomButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -133,7 +99,23 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
|
||||
<div className="flex items-center justify-center gap-2 mt-1 px-1">
|
||||
{onMoveUp ? (
|
||||
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveUp(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Left">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</CustomButton>
|
||||
) : <div className="w-4 h-4 shrink-0" />}
|
||||
<span className="text-[11px] font-semibold text-[#5b6573] text-center min-w-[40px]">Page {pageIndex + 1}</span>
|
||||
{onMoveDown ? (
|
||||
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveDown(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Right">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</CustomButton>
|
||||
) : <div className="w-4 h-4 shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,12 +72,12 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
disabled
|
||||
? 'cursor-not-allowed text-[#c5cad1]'
|
||||
: active
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
@@ -93,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
{TOOLS.map((t, i) =>
|
||||
t === 'divider'
|
||||
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
|
||||
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
|
||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
|
||||
)}
|
||||
|
||||
<div className="flex-1 shrink-0 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
|
||||
<InfoIcon size={19} />
|
||||
</CustomButton>
|
||||
|
||||
@@ -110,23 +110,23 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
align="left"
|
||||
width={210}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||
<HelpIcon size={19} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="text-[12px]">
|
||||
<p className="mb-1.5 px-1 font-bold text-[#18212e]">Shortcuts</p>
|
||||
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
|
||||
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
|
||||
<span className="text-[#5b6573]">{t.label}</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||
<span className="text-text-secondary">{t.label}</span>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
||||
<div className="my-1 h-px bg-border-primary" />
|
||||
<div className="flex items-center justify-between px-1 py-0.5">
|
||||
<span className="text-[#5b6573]">Undo / Redo</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
<span className="text-text-secondary">Undo / Redo</span>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -23,6 +23,10 @@ interface ToolbarProps {
|
||||
onApplyRedactions?: () => void;
|
||||
onClearRedactions?: () => void;
|
||||
onRedactPages?: () => void;
|
||||
selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null;
|
||||
onUpdateAnnotation?: (patch: Partial<import('../viewer/AnnotationLayer').Annotation>) => void;
|
||||
onDeleteAnnotation?: () => void;
|
||||
onDeselectAnnotation?: () => void;
|
||||
}
|
||||
|
||||
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
@@ -51,36 +55,83 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
};
|
||||
|
||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{children}</span>
|
||||
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
|
||||
);
|
||||
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{children}</span>
|
||||
);
|
||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
|
||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
redactionMode = 'area', onRedactionModeChange,
|
||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
|
||||
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
||||
}) => {
|
||||
const meta = TOOL_META[activeTool];
|
||||
const isRedact = activeTool === 'redact';
|
||||
|
||||
if (selectedAnnotation) {
|
||||
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
|
||||
return (
|
||||
<div
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||
style={{
|
||||
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||
}}
|
||||
>
|
||||
{selectedMeta?.icon}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">Edit Annotation</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{(selectedAnnotation.type === 'highlight' || selectedAnnotation.type === 'underline' || selectedAnnotation.type === 'strikeout' || selectedAnnotation.type === 'squiggly' || selectedAnnotation.type === 'draw' || selectedAnnotation.type === 'textbox') && (
|
||||
<>
|
||||
<Label>Color</Label>
|
||||
<ColorSwatches
|
||||
value={selectedAnnotation.color || '#2563eb'}
|
||||
onChange={(c) => onUpdateAnnotation?.({ color: c })}
|
||||
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomButton variant="outline" size="sm" onClick={onDeselectAnnotation}>
|
||||
Done
|
||||
</CustomButton>
|
||||
<CustomButton variant="outline" size="sm" onClick={onDeleteAnnotation}>
|
||||
<span className="text-[#dc2626]">Delete</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
|
||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||
style={{
|
||||
background: isRedact ? '#fdecec' : '#eef4ff',
|
||||
color: isRedact ? '#dc2626' : '#2563eb',
|
||||
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
|
||||
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
|
||||
}}
|
||||
>
|
||||
{meta?.icon}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-[#18212e]">{meta?.label}</span>
|
||||
<span className="text-[13px] font-bold text-text-primary">{meta?.label}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
@@ -150,7 +201,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
|
||||
</CustomButton>
|
||||
{hasSignature ? <Hint>Click on the page to place it.</Hint> : <Hint>Draw, type, or upload a signature.</Hint>}
|
||||
<span className="ml-auto shrink-0 rounded-full bg-[#f6f7f9] px-2 py-0.5 text-[10px] font-semibold text-[#98a1ad]">Visual signature — not certified</span>
|
||||
<span className="ml-auto shrink-0 rounded-full bg-bg-tertiary px-2 py-0.5 text-[10px] font-semibold text-text-tertiary">Visual signature — not certified</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -159,7 +210,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
{STAMP_PRESETS.map((s) => (
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s)}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-brand-primary' : ''}`}
|
||||
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
|
||||
{s.label}
|
||||
</CustomButton>
|
||||
@@ -171,15 +222,15 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
{activeTool === 'redact' && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
|
||||
<div className="flex items-center gap-1.5 border-r border-border-primary pr-3">
|
||||
<button
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
|
||||
onClick={() => onRedactionModeChange?.('area')}
|
||||
>
|
||||
Area
|
||||
</button>
|
||||
<button
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
|
||||
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary'}`}
|
||||
onClick={() => onRedactionModeChange?.('text')}
|
||||
>
|
||||
Text
|
||||
@@ -203,11 +254,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTool !== 'redact' && pendingRedactionCount > 0 && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2 border-l border-border-primary pl-3">
|
||||
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
|
||||
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
|
||||
</CustomButton>
|
||||
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
|
||||
Clear
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 py-0.5 text-[10px] font-semibold text-[#18212e]">{children}</kbd>
|
||||
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 py-0.5 text-[10px] font-semibold text-text-primary">{children}</kbd>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useRef } from 'react';
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
|
||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
|
||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon,
|
||||
} from './icons';
|
||||
|
||||
interface TopBarProps {
|
||||
@@ -26,6 +26,8 @@ interface TopBarProps {
|
||||
onExport: () => void;
|
||||
onPrint: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
onShowVersionHistory?: () => void;
|
||||
onSave?: () => void;
|
||||
isInspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
canPrint?: boolean;
|
||||
@@ -38,8 +40,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
export const TopBar: React.FC<TopBarProps> = ({
|
||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
|
||||
isInspectorOpen, onToggleInspector,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onSave,
|
||||
canPrint = true, canExport = true, canAssemble = true,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -51,109 +52,110 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
||||
className="flex shrink-0 items-center justify-between gap-3 border-b border-border-primary bg-bg-primary"
|
||||
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-brand-primary text-white shadow-sm">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">PDF Editor</span>
|
||||
<span className="text-[14px] font-extrabold tracking-tight text-text-primary">PDF Editor</span>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
align="left"
|
||||
width={210}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'}`}>
|
||||
File <ChevronDownIcon size={14} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-[13px]">
|
||||
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName}>Version History</MenuItem>
|
||||
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
|
||||
<UploadIcon size={18} />
|
||||
</CustomButton>
|
||||
|
||||
{documentName && (
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
|
||||
{documentName}
|
||||
</span>
|
||||
<>
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-text-secondary" title={documentName}>
|
||||
{documentName}
|
||||
</span>
|
||||
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo} className="text-text-secondary hover:text-text-primary"><UndoIcon size={18} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo} className="text-text-secondary hover:text-text-primary"><RedoIcon size={18} /></CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 rounded-[8px] bg-[#f6f7f9] p-0.5">
|
||||
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></CustomButton>
|
||||
<div className="flex items-center gap-0.5 rounded-[8px] bg-bg-secondary p-0.5">
|
||||
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomOutIcon size={17} /></CustomButton>
|
||||
<Popover
|
||||
align="left"
|
||||
width={140}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[#edeff2]' : 'text-[#18212e] hover:bg-[#edeff2]'}`}>
|
||||
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-bg-tertiary' : 'text-text-primary hover:bg-bg-tertiary'}`}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col text-[12.5px]">
|
||||
<MenuItem icon={<FitIcon size={14} />} onClick={onFitWidth}>Fit width</MenuItem>
|
||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
||||
{ZOOM_PRESETS.map((z) => (
|
||||
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-[#f6f7f9]" onClick={() => onZoomChange(z)}>
|
||||
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-bg-secondary" onClick={() => onZoomChange(z)}>
|
||||
{Math.round(z * 100)}%
|
||||
</CustomButton>
|
||||
))}
|
||||
<div className="my-1 h-px bg-border-primary" />
|
||||
<CustomButton variant="unstyled" className="flex items-center justify-between rounded-[6px] px-2 py-1 text-left hover:bg-bg-secondary" onClick={onFitWidth}>
|
||||
Fit Width <FitIcon size={12} className="text-text-tertiary" />
|
||||
</CustomButton>
|
||||
</div>
|
||||
</Popover>
|
||||
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))} className="text-text-secondary hover:text-text-primary"><ZoomInIcon size={17} /></CustomButton>
|
||||
</div>
|
||||
|
||||
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble}><RotateIcon size={18} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble} className="text-text-secondary hover:text-text-primary"><RotateIcon size={18} /></CustomButton>
|
||||
|
||||
{documentName && (
|
||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
|
||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-text-secondary">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, totalPages)}
|
||||
value={currentPage + 1}
|
||||
onChange={(e) => {
|
||||
const p = parseInt(e.target.value, 10);
|
||||
type="text"
|
||||
defaultValue={currentPage + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const p = parseInt(e.currentTarget.value, 10);
|
||||
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
|
||||
}}
|
||||
className="h-7 w-9 rounded-[6px] border border-[#dadde2] bg-[#ffffff] text-center tabular-nums outline-none focus:border-[#2563eb]"
|
||||
className="h-7 w-9 rounded-[6px] border border-border-secondary bg-bg-primary text-center tabular-nums outline-none focus:border-brand-primary text-text-primary"
|
||||
/>
|
||||
<span className="text-[#98a1ad]">/ {Math.max(1, totalPages)}</span>
|
||||
<span className="text-text-tertiary">/ {Math.max(1, totalPages)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SaveState isSaving={isSaving} saved={isDirtySaved} />
|
||||
{onSave && (
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
disabled={isSaving || !documentName}
|
||||
className="h-8 px-3 text-[13px] font-semibold"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</CustomButton>
|
||||
)}
|
||||
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
|
||||
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName || !canExport}><DownloadIcon size={15} /> Export</CustomButton>
|
||||
<CustomButton variant="icon"
|
||||
label={isInspectorOpen ? "Hide panel" : "Show panel"}
|
||||
size={34}
|
||||
active={isInspectorOpen}
|
||||
onClick={onToggleInspector}
|
||||
className="text-[#5b6573] hover:text-[#18212e]"
|
||||
>
|
||||
<PagesIcon size={18} />
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
|
||||
@@ -165,14 +167,14 @@ const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-40"
|
||||
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-bg-secondary disabled:opacity-40 text-text-primary"
|
||||
>
|
||||
{icon} {children}
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => {
|
||||
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[#5b6573]"><SpinnerIcon size={14} /> Saving…</span>;
|
||||
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-text-secondary"><SpinnerIcon size={14} /> Saving…</span>;
|
||||
if (!saved) return null;
|
||||
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] md:flex"><CheckIcon size={14} /> Saved</span>;
|
||||
};
|
||||
@@ -180,7 +182,7 @@ const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving,
|
||||
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
|
||||
if (healthy && engineReady) return null;
|
||||
|
||||
let color = '#98a1ad';
|
||||
let color = 'var(--text-tertiary)';
|
||||
let label = 'Offline · mock mode';
|
||||
let tip = 'Gateway not reachable — running on mock data.';
|
||||
if (healthy === null) {
|
||||
@@ -191,7 +193,7 @@ const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> =
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-full border border-[#ebedf0] bg-[#f6f7f9] px-2 py-1 text-[11px] font-medium text-[#5b6573]"
|
||||
className="flex items-center gap-1.5 rounded-full border border-border-primary bg-bg-secondary px-2 py-1 text-[11px] font-medium text-text-secondary"
|
||||
title={tip}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './ui';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
interface VersionHistoryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
history: { stack: string[]; index: number };
|
||||
onRestore: (documentId: string) => void;
|
||||
}
|
||||
|
||||
export const VersionHistoryModal: React.FC<VersionHistoryModalProps> = ({ open, onClose, history, onRestore }) => {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Version History" width={400}>
|
||||
<div className="flex flex-col gap-3 p-4 max-h-[60vh] overflow-y-auto">
|
||||
{history.stack.length === 0 ? (
|
||||
<p className="text-[13px] text-[#5b6573]">No version history available yet.</p>
|
||||
) : (
|
||||
[...history.stack].reverse().map((docId, i) => {
|
||||
const isCurrent = history.index === history.stack.length - 1 - i;
|
||||
const originalIndex = history.stack.length - 1 - i;
|
||||
return (
|
||||
<div
|
||||
key={`${docId}-${i}`}
|
||||
className={`flex items-center justify-between rounded-[8px] border p-3 transition-colors ${
|
||||
isCurrent ? 'border-[#2563eb] bg-[#eef4ff]' : 'border-[#ebedf0] bg-[#f6f7f9]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-[13px] font-semibold ${isCurrent ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>
|
||||
Version {originalIndex + 1} {isCurrent && '(Current)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#5b6573]">Document ID: {docId.split('_')[0]}...</span>
|
||||
</div>
|
||||
{!isCurrent && (
|
||||
<CustomButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onRestore(docId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-[#ebedf0] p-3">
|
||||
<CustomButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</CustomButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,7 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
|
||||
|
||||
<div
|
||||
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
|
||||
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-bg-primary shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-border-primary"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-7 pb-6">
|
||||
@@ -50,13 +50,13 @@ export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = (
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">{state.title}</h2>
|
||||
<h2 className="text-[17.5px] font-bold tracking-tight text-text-primary">{state.title}</h2>
|
||||
</div>
|
||||
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">{state.message}</p>
|
||||
<p className="pl-[52px] text-[13.5px] leading-relaxed text-text-secondary">{state.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 bg-[#f6f7f9] px-7 py-5 border-t border-[#ebedf0]">
|
||||
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-[#5b6573]">
|
||||
<div className="flex items-center justify-end gap-3 bg-bg-secondary px-7 py-5 border-t border-border-primary">
|
||||
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-text-secondary">
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
|
||||
@@ -46,6 +46,8 @@ export const SquigglyIcon: IC = mk(<path d="M3 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3
|
||||
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
|
||||
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
|
||||
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
|
||||
export const MoonIcon: IC = mk(<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
|
||||
export const SunIcon: IC = mk(<><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></>);
|
||||
|
||||
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
|
||||
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
|
||||
@@ -56,11 +58,14 @@ export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2
|
||||
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
|
||||
export const ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
|
||||
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
|
||||
export const HistoryIcon: IC = mk(<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />);
|
||||
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
|
||||
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
|
||||
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
|
||||
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
|
||||
|
||||
export const SidebarRightIcon: IC = mk(<><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><path d="M15 8l-4 4 4 4" /><line x1="9" y1="12" x2="15" y2="12" /></>);
|
||||
|
||||
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
|
||||
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
|
||||
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
␍
|
||||
@@ -1,7 +1,67 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Sleek scrollbars */
|
||||
@theme {
|
||||
--color-bg-primary: var(--bg-primary);
|
||||
--color-bg-secondary: var(--bg-secondary);
|
||||
--color-bg-tertiary: var(--bg-tertiary);
|
||||
--color-bg-canvas: var(--bg-canvas);
|
||||
|
||||
--color-border-primary: var(--border-primary);
|
||||
--color-border-secondary: var(--border-secondary);
|
||||
|
||||
--color-text-primary: var(--text-primary);
|
||||
--color-text-secondary: var(--text-secondary);
|
||||
--color-text-tertiary: var(--text-tertiary);
|
||||
|
||||
--color-brand-primary: var(--brand-primary);
|
||||
--color-brand-secondary: var(--brand-secondary);
|
||||
--color-brand-tertiary: var(--brand-tertiary);
|
||||
--color-brand-hover: var(--brand-hover);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f6f7f9;
|
||||
--bg-tertiary: #edeff2;
|
||||
--bg-canvas: #f1f2f4;
|
||||
|
||||
--border-primary: #ebedf0;
|
||||
--border-secondary: #dadde2;
|
||||
|
||||
--text-primary: #18212e;
|
||||
--text-secondary: #5b6573;
|
||||
--text-tertiary: #98a1ad;
|
||||
|
||||
--brand-primary: #2563eb;
|
||||
--brand-secondary: #eef4ff;
|
||||
--brand-tertiary: rgba(37, 99, 235, 0.1);
|
||||
--brand-hover: #1d4ed8;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg-primary: #18181b;
|
||||
--bg-secondary: #27272a;
|
||||
--bg-tertiary: #3f3f46;
|
||||
--bg-canvas: #09090b;
|
||||
|
||||
--border-primary: #3f3f46;
|
||||
--border-secondary: #52525b;
|
||||
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-tertiary: #71717a;
|
||||
|
||||
--brand-primary: #3b82f6;
|
||||
--brand-secondary: rgba(59, 130, 246, 0.15);
|
||||
--brand-tertiary: rgba(59, 130, 246, 0.1);
|
||||
--brand-hover: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
}/* Sleek scrollbars */
|
||||
.scroll-thin::-webkit-scrollbar,
|
||||
.custom-scrollbar::-webkit-scrollbar,
|
||||
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
@@ -11,14 +71,14 @@
|
||||
.scroll-thin::-webkit-scrollbar-thumb,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
.viewer-viewport::-webkit-scrollbar-thumb {
|
||||
background: #dadde2;
|
||||
background: var(--border-secondary);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.scroll-thin::-webkit-scrollbar-thumb:hover,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
|
||||
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); background-clip: padding-box; }
|
||||
|
||||
/* Hidden scrollbar (used by horizontal tab/tool strips) */
|
||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
@@ -28,8 +88,8 @@
|
||||
.scroll-micro { scrollbar-width: thin; }
|
||||
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: 9999px; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@@ -16,7 +16,14 @@ function familyName(key: string): string {
|
||||
return `pdf-${(h >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
export function loadPdfFont(documentId: string, internalFontId: string): Promise<string | null> {
|
||||
export function loadPdfFont(
|
||||
documentId: string,
|
||||
internalFontId: string,
|
||||
/** Optional CSS family name (e.g. extracted BaseFont). When set, the Face is registered under this name. */
|
||||
cssFamilyHint?: string,
|
||||
/** Optional font-weight descriptor so bold faces match style.fontWeight. */
|
||||
weight?: string | number,
|
||||
): Promise<string | null> {
|
||||
if (!internalFontId) return Promise.resolve(null);
|
||||
const key = keyOf(documentId, internalFontId);
|
||||
const existing = fontPromises.get(key);
|
||||
@@ -25,9 +32,14 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
|
||||
const p = (async (): Promise<string | null> => {
|
||||
const bytes = await gatewayService.getFontData(documentId, internalFontId);
|
||||
if (!bytes || bytes.byteLength === 0) return null;
|
||||
const family = familyName(key);
|
||||
const hint = (cssFamilyHint || '').replace(/^[A-Z]{6}\+/, '').trim();
|
||||
const family = hint
|
||||
? hint.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
: familyName(key);
|
||||
try {
|
||||
const face = new FontFace(family, bytes);
|
||||
const descriptors: FontFaceDescriptors = {};
|
||||
if (weight != null) descriptors.weight = String(weight);
|
||||
const face = new FontFace(family, bytes, descriptors);
|
||||
await face.load();
|
||||
document.fonts.add(face);
|
||||
fontFaces.set(key, face);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReflowLayout } from './pdfiumEngine';
|
||||
|
||||
export interface PageInfo {
|
||||
index: number;
|
||||
width: number;
|
||||
@@ -261,6 +263,8 @@ export interface ReflowFragment {
|
||||
fontSize: number;
|
||||
color: string;
|
||||
advances?: number[];
|
||||
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||
advanceSeedText?: string;
|
||||
}
|
||||
|
||||
export interface ReflowParagraphData {
|
||||
@@ -333,7 +337,7 @@ class GatewayService {
|
||||
public baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = import.meta.env.VITE_GATEWAY_URL || 'http://127.0.0.1:8000';
|
||||
this.baseUrl = import.meta.env.VITE_GATEWAY_URL as string;
|
||||
}
|
||||
|
||||
async getHealth(): Promise<{ status: string; version: string; engine_available: boolean }> {
|
||||
@@ -438,7 +442,11 @@ class GatewayService {
|
||||
private renderCache = new Map<string, string>();
|
||||
|
||||
async renderPage(params: RenderParams): Promise<string> {
|
||||
const cacheKey = `${params.documentId}_${params.pageIndex}_${params.zoom}_${params.rotation}`;
|
||||
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)!;
|
||||
}
|
||||
@@ -448,6 +456,7 @@ class GatewayService {
|
||||
page: params.pageIndex.toString(),
|
||||
zoom: params.zoom.toString(),
|
||||
rotation: params.rotation.toString(),
|
||||
dpi: dpi.toString(),
|
||||
}).toString();
|
||||
|
||||
const url = `${this.baseUrl}/render/${params.documentId}?${query}`;
|
||||
@@ -543,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 [];
|
||||
|
||||
@@ -757,6 +800,19 @@ class GatewayService {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async exportRemoteDocument(documentId: string, freshToken?: string): Promise<any> {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export-remote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fresh_token: freshToken || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Remote export failed: ${response.status} - ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
|
||||
|
||||
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
const V = '20260630-fontfix3';
|
||||
const V = '20260730-advseed1';
|
||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
||||
@@ -181,3 +181,12 @@ export async function wasmFreeDocument(documentId: string): Promise<void> {
|
||||
const M = await getModule();
|
||||
if (M) M.ccall('freeDocument', null, ['number'], [h]);
|
||||
}
|
||||
|
||||
export async function wasmDebugValidateLayout(documentId: string, pageIndex: number, jsonStr: string): Promise<string> {
|
||||
const M = await getModule();
|
||||
if (!M) return '{}';
|
||||
const h = docHandles.get(documentId);
|
||||
if (h === undefined) return '{}';
|
||||
const result = M.ccall('debugValidateLayout', 'string', ['number', 'number', 'string'], [h, pageIndex, jsonStr]) as string;
|
||||
return result || '{}';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = localStorage.getItem('app-theme') as Theme | null;
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
return 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('app-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ interface AnnotationLayerProps {
|
||||
onAnnotationClick?: (annotation: Annotation) => void;
|
||||
onAnnotationUpdate?: (annotation: Annotation) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
isSelectToolActive?: boolean;
|
||||
}
|
||||
|
||||
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
@@ -42,12 +43,13 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onAnnotationClick,
|
||||
onAnnotationUpdate,
|
||||
onFieldChange,
|
||||
isSelectToolActive,
|
||||
}) => {
|
||||
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
|
||||
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
|
||||
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
||||
|
||||
const isDraggable = (type: Annotation['type']) => type === 'comment' || type === 'signature';
|
||||
const isDraggable = (type: Annotation['type']) => isSelectToolActive || type === 'comment' || type === 'signature';
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
|
||||
e.stopPropagation();
|
||||
@@ -79,7 +81,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
...anno.bbox,
|
||||
x: anno.bbox.x + dragOffset.x,
|
||||
y: anno.bbox.y + dragOffset.y,
|
||||
}
|
||||
},
|
||||
quadPoints: anno.quadPoints?.map(q => q.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
|
||||
paths: anno.paths?.map(path => path.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
|
||||
});
|
||||
} else {
|
||||
onAnnotationClick?.(anno);
|
||||
@@ -92,7 +96,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
>
|
||||
{annotations
|
||||
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
|
||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget', 'ink'].includes(anno.type))
|
||||
.map((anno) => {
|
||||
const scaledBbox = {
|
||||
x: anno.bbox.x * zoom,
|
||||
@@ -115,6 +119,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onPointerDown={(e) => handlePointerDown(e, anno)}
|
||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||
onPointerUp={(e) => handlePointerUp(e, anno)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
|
||||
style={{
|
||||
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||
@@ -158,7 +163,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
@@ -177,7 +181,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
|
||||
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
|
||||
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
|
||||
const left = xMin * zoom - scaledBbox.x;
|
||||
const top = yMax * zoom - scaledBbox.y;
|
||||
|
||||
@@ -60,43 +60,7 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
ctx.restore();
|
||||
|
||||
// Render vector ops on top
|
||||
if (vectorOps && vectorOps.length > 0) {
|
||||
ctx.save();
|
||||
ctx.translate(0, height);
|
||||
ctx.scale(1, -1);
|
||||
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
||||
ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
for (const op of vectorOps) {
|
||||
if (op.op === 'm' && op.args && op.args.length === 2) {
|
||||
ctx.moveTo(op.args[0], op.args[1]);
|
||||
} else if (op.op === 'l' && op.args && op.args.length === 2) {
|
||||
ctx.lineTo(op.args[0], op.args[1]);
|
||||
} else if (op.op === 'c' && op.args && op.args.length === 6) {
|
||||
ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]);
|
||||
} else if (op.op === 're' && op.args && op.args.length === 4) {
|
||||
ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]);
|
||||
} else if (op.op === 'h') {
|
||||
ctx.closePath();
|
||||
} else if (op.op === 'S' || op.op === 's') {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'f' || op.op === 'F') {
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'B' || op.op === 'b') {
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
} else if (op.op === 'n') {
|
||||
ctx.beginPath();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
onRenderComplete?.(pageIndex);
|
||||
};
|
||||
|
||||
@@ -46,12 +46,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
};
|
||||
|
||||
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
|
||||
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
|
||||
<div className="absolute top-full left-0 mt-1 p-2 bg-bg-secondary border border-border-primary rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
|
||||
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-bg-secondary' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => {
|
||||
setToolColors(prev => ({ ...prev, [action]: c }));
|
||||
@@ -61,13 +61,13 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
|
||||
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
|
||||
<div className="text-xs text-text-secondary mt-1 cursor-pointer hover:text-text-primary transition-colors">More colors</div>
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border-primary pt-2 mt-1">
|
||||
<span>Opacity</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="hover:text-white">−</button>
|
||||
<button className="hover:text-text-primary">−</button>
|
||||
<span>100%</span>
|
||||
<button className="hover:text-white">+</button>
|
||||
<button className="hover:text-text-primary">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
|
||||
className="absolute z-50 flex items-center gap-1 bg-bg-primary rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-border-primary text-text-primary"
|
||||
style={{ top, left, pointerEvents: 'auto' }}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent clearing the selection layer when clicking the toolbar
|
||||
@@ -85,7 +85,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
>
|
||||
<button
|
||||
onClick={() => onAction('copy')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -96,7 +96,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
|
||||
<button
|
||||
onClick={() => onAction('comment')}
|
||||
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
className="p-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
title="Add Comment"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -106,12 +106,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
<div className="w-px h-4 bg-border-primary mx-1" />
|
||||
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('highlight')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
title="Highlight"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -122,7 +122,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
@@ -134,7 +134,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('underline')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
title="Underline"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -144,7 +144,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
@@ -156,7 +156,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
<div className="relative flex items-center group">
|
||||
<button
|
||||
onClick={() => handleAction('strikeout')}
|
||||
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
|
||||
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
|
||||
title="Strikeout"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -167,7 +167,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
|
||||
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
|
||||
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
@@ -176,11 +176,11 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#52525b] mx-1" />
|
||||
<div className="w-px h-4 bg-border-primary mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => onAction('redact')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
className="px-2 py-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
title="Redact Text"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -192,7 +192,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
|
||||
|
||||
<button
|
||||
onClick={() => onAction('edit')}
|
||||
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
className="px-2 py-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
|
||||
title="Edit Text"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -135,40 +135,40 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
onClick={handleLayerClick}
|
||||
>
|
||||
{activeTool === 'signature' && !hasSignature && (
|
||||
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
|
||||
<div className="absolute inset-0 bg-brand-tertiary flex items-center justify-center border-2 border-dashed border-brand-primary rounded-[6px]"><span className="bg-brand-primary text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
|
||||
)}
|
||||
{activeTool === 'signature' && hasSignature && !commentPopup && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
|
||||
)}
|
||||
{activeTool === 'comment' && !commentPopup && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
|
||||
)}
|
||||
{activeTool === 'stamp' && activeStamp && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place “{activeStamp.label}”</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place “{activeStamp.label}”</div>
|
||||
)}
|
||||
{activeTool === 'textbox' && !textBox && (
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
|
||||
)}
|
||||
|
||||
{commentPopup && (
|
||||
<div
|
||||
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
|
||||
className="w-[264px] bg-bg-primary border border-border-primary rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
|
||||
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handleCommentSubmit}>
|
||||
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
|
||||
<span className="text-xs font-bold">Add sticky note</span>
|
||||
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
|
||||
<div className="flex justify-between items-center py-[10px] px-[14px] bg-bg-secondary border-b border-border-primary">
|
||||
<span className="text-xs font-bold text-text-primary">Add sticky note</span>
|
||||
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-text-tertiary hover:text-text-primary">
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</CustomButton>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
|
||||
placeholder="Type your comment here…" className="w-full bg-transparent text-text-primary border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-text-tertiary" rows={3}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
|
||||
/>
|
||||
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
|
||||
<div className="py-[10px] px-[14px] bg-bg-secondary border-t border-border-primary flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-brand-primary text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-brand-hover">Save note</CustomButton></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
@@ -184,7 +184,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
|
||||
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
|
||||
}}
|
||||
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
|
||||
className="absolute z-50 bg-bg-primary border-[1.5px] border-brand-primary rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
|
||||
placeholder="Type…"
|
||||
style={{
|
||||
left: `${textBox.x * zoom}px`,
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface ReflowFragment {
|
||||
fontSize: number;
|
||||
color: string;
|
||||
advances?: number[];
|
||||
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
|
||||
advanceSeedText?: string;
|
||||
}
|
||||
|
||||
export interface CommitFrame {
|
||||
@@ -102,8 +104,8 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any
|
||||
}
|
||||
const leading = itemDeltas.length ? median(itemDeltas)
|
||||
: wrapDeltas.length ? median(wrapDeltas)
|
||||
: allDeltas.length ? median(allDeltas)
|
||||
: (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
|
||||
: allDeltas.length ? median(allDeltas)
|
||||
: (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
|
||||
|
||||
const firstRuns = itemLines[0].runs ?? [];
|
||||
let subLines = itemLines;
|
||||
@@ -227,21 +229,7 @@ interface TextEditLayerProps {
|
||||
onCommitPreview?: (frame: CommitFrame) => void;
|
||||
}
|
||||
|
||||
let measureCanvas: HTMLCanvasElement | null = null;
|
||||
function caretIndexFromX(text: string, cssFont: string, x: number): number {
|
||||
if (x <= 0) return 0;
|
||||
if (!measureCanvas) measureCanvas = document.createElement('canvas');
|
||||
const ctx = measureCanvas.getContext('2d');
|
||||
if (!ctx) return text.length;
|
||||
ctx.font = cssFont;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const w = ctx.measureText(text[i]).width;
|
||||
if (acc + w / 2 >= x) return i;
|
||||
acc += w;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
|
||||
function fallbackFamily(fontName: string): string {
|
||||
const n = (fontName || '').toLowerCase();
|
||||
@@ -271,7 +259,7 @@ function flattenRuns(model: any): EditableRun[] {
|
||||
text: r.text,
|
||||
x: r.x, y: r.y, w: r.w, h: r.h,
|
||||
baselineY,
|
||||
fontSize: r.font_size ?? r.h,
|
||||
fontSize: Math.max(r.font_size ?? 0, r.h ?? 0),
|
||||
objectIndices,
|
||||
internalFontId: r.internal_font_id ?? '',
|
||||
fontName: r.font_name ?? '',
|
||||
@@ -293,11 +281,7 @@ function median(xs: number[]): number {
|
||||
}
|
||||
|
||||
function displayFontSize(r: EditableRun): number {
|
||||
const t = r.text || '';
|
||||
const hasDescender = /[gjpqy(),;\[\]{}₀-₉]/.test(t);
|
||||
const hasAscender = /[bdfhklt]/.test(t);
|
||||
const frac = hasDescender ? 0.92 : hasAscender ? 0.75 : 0.70;
|
||||
return Math.max(r.fontSize, r.h / frac);
|
||||
return Math.max(r.fontSize ?? 0, r.h ?? 0);
|
||||
}
|
||||
|
||||
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
|
||||
@@ -379,7 +363,6 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
const [fontFamily, setFontFamily] = useState('inherit');
|
||||
const committedRef = useRef(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const caretIdxRef = useRef<number | null>(null);
|
||||
const modelRef = useRef<any>(null);
|
||||
const [paraEdit, setParaEdit] = useState<{
|
||||
para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number;
|
||||
@@ -474,44 +457,39 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
setParaEdit({ ...edit, anchorPageIndex: pageIndex });
|
||||
};
|
||||
|
||||
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
|
||||
const openEditor = (i: number, _clickX: number, clientX?: number, clientY?: number) => {
|
||||
const run = runs[i];
|
||||
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
||||
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph && !isTableParagraph(para, modelRef.current)) {
|
||||
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
|
||||
setCaretClick(click);
|
||||
if (para.lines.length > 1) {
|
||||
if (isFlowingParagraph(para)) {
|
||||
setCaretClick(click);
|
||||
openParaEdit({ para });
|
||||
return;
|
||||
}
|
||||
const item = buildBulletItem(para, run.lineIndex);
|
||||
if (item) {
|
||||
setCaretClick(click);
|
||||
openParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const editableRuns = (para.lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length;
|
||||
if (editableRuns > 1) {
|
||||
setCaretClick(click);
|
||||
const al = headingAlign(para.lines[0], modelRef.current);
|
||||
if (al === 'center' || al === 'right') {
|
||||
openParaEdit({
|
||||
para, align: al,
|
||||
columnLeft: pageContentLeft(modelRef.current),
|
||||
columnRight: pageContentRight(modelRef.current),
|
||||
});
|
||||
} else {
|
||||
openParaEdit({ para, columnRight: pageContentRight(modelRef.current) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
const al = headingAlign(para.lines[0], modelRef.current);
|
||||
const pr = pageContentRight(modelRef.current);
|
||||
if (al === 'center' || al === 'right') {
|
||||
openParaEdit({
|
||||
para, align: al,
|
||||
columnLeft: pageContentLeft(modelRef.current),
|
||||
columnRight: pr,
|
||||
});
|
||||
} else {
|
||||
const paraRight = Math.max(...para.lines.map((l: any) => l.x + l.w));
|
||||
openParaEdit({ para, columnRight: Math.max(pr, paraRight) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
committedRef.current = false;
|
||||
const fb = fallbackFamily(run.fontName);
|
||||
caretIdxRef.current = caretIndexFromX(run.text, `${displayFontSize(run) * zoom}px ${fb}`, clickX);
|
||||
setEditing(i);
|
||||
setValue(run.text);
|
||||
setFontFamily(fb);
|
||||
@@ -577,16 +555,40 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
},
|
||||
}],
|
||||
});
|
||||
const dpi = Math.round(72 * zoom);
|
||||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||
const dpi = Math.round(96 * zoom * dpr);
|
||||
const { topScreen, heightScreen } = cellBand(r);
|
||||
const res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
|
||||
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
|
||||
const cv = previewCanvasRef.current;
|
||||
if (!cv) return;
|
||||
if (cv.width !== res.width) cv.width = res.width;
|
||||
if (cv.height !== res.height) cv.height = res.height;
|
||||
const displayW = width;
|
||||
const displayH = heightScreen;
|
||||
const targetW = Math.round(displayW * dpr);
|
||||
const targetH = Math.round(displayH * dpr);
|
||||
if (cv.width !== targetW) cv.width = targetW;
|
||||
if (cv.height !== targetH) cv.height = targetH;
|
||||
cv.style.width = `${displayW}px`;
|
||||
cv.style.height = `${displayH}px`;
|
||||
|
||||
const ctx = cv.getContext('2d');
|
||||
if (ctx) { const img = ctx.createImageData(res.width, res.height); img.data.set(res.rgba); ctx.putImageData(img, 0, 0); }
|
||||
if (ctx) {
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = res.width;
|
||||
offscreen.height = res.height;
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
if (offCtx) {
|
||||
const imgData = offCtx.createImageData(res.width, res.height);
|
||||
imgData.data.set(res.rgba);
|
||||
offCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
setCellPreviewReady(true);
|
||||
};
|
||||
|
||||
@@ -702,11 +704,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={(e) => {
|
||||
const idx = caretIdxRef.current;
|
||||
if (idx != null) {
|
||||
e.currentTarget.setSelectionRange(idx, idx);
|
||||
caretIdxRef.current = null;
|
||||
}
|
||||
const end = e.currentTarget.value.length;
|
||||
e.currentTarget.setSelectionRange(end, end);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
@@ -718,8 +717,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
style={{
|
||||
left: box.left,
|
||||
top: inputTop,
|
||||
width: Math.max(box.width + 120, 60),
|
||||
height: fpx,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
lineHeight: `${fpx}px`,
|
||||
fontFamily,
|
||||
fontSize: `${fpx}px`,
|
||||
|
||||
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
allowedHosts: ['pdf-dev.maskantech.in'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PORT=8000
|
||||
PORT=8765
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -114,6 +114,6 @@ COPY gateway/tests ./tests
|
||||
RUN chown -R app:app /home/app
|
||||
USER app
|
||||
|
||||
EXPOSE 8000
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8765"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import re
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.document import (
|
||||
DocumentInfoResponse,
|
||||
@@ -10,6 +14,83 @@ from app.services.store import document_store
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
class RemoteImportRequest(BaseModel):
|
||||
stream_url: str
|
||||
upload_url: str
|
||||
auth_token: str | None = None
|
||||
resource_id: str | None = None
|
||||
|
||||
|
||||
@router.post("/import-remote", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def import_remote_document(req: RemoteImportRequest) -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
stream_parsed = urlparse(req.stream_url)
|
||||
upload_parsed = urlparse(req.upload_url)
|
||||
if stream_parsed.scheme not in ("http", "https") or upload_parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL scheme. Only http/https are allowed.")
|
||||
|
||||
headers = {}
|
||||
if req.auth_token:
|
||||
headers["Authorization"] = f"Bearer {req.auth_token}"
|
||||
|
||||
MAX_SIZE = 50 * 1024 * 1024 # 50 MB limit
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
try:
|
||||
async with client.stream("GET", req.stream_url, headers=headers) as res:
|
||||
if res.status_code != 200:
|
||||
await res.aread()
|
||||
raise HTTPException(
|
||||
status_code=res.status_code,
|
||||
detail=f"Failed to stream remote document: {res.text[:200]}",
|
||||
)
|
||||
|
||||
content_length = res.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > MAX_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
|
||||
|
||||
bytes_data_arr = bytearray()
|
||||
async for chunk in res.aiter_bytes():
|
||||
bytes_data_arr.extend(chunk)
|
||||
if len(bytes_data_arr) > MAX_SIZE:
|
||||
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
|
||||
|
||||
bytes_data = bytes(bytes_data_arr)
|
||||
except Exception as err:
|
||||
if isinstance(err, HTTPException):
|
||||
raise err
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to connect to remote stream_url: {err!s}",
|
||||
)
|
||||
|
||||
cd = res.headers.get("Content-Disposition", "")
|
||||
match = re.search(r'filename="?([^";]+)"?', cd)
|
||||
filename = match.group(1) if match else f"remote_document_{req.resource_id or 'file'}.pdf"
|
||||
|
||||
try:
|
||||
pdfengine = engine.require()
|
||||
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, "")
|
||||
info = document_store.add_document(filename, bytes_data, doc)
|
||||
info["remote_context"] = {
|
||||
"stream_url": req.stream_url,
|
||||
"upload_url": req.upload_url,
|
||||
"auth_token": req.auth_token,
|
||||
"resource_id": req.resource_id,
|
||||
}
|
||||
return make_document_response(info)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to load remote PDF into engine: {e!s}",
|
||||
)
|
||||
|
||||
|
||||
def make_document_response(d: dict) -> DocumentInfoResponse:
|
||||
pages_list = []
|
||||
if "doc_instance" in d:
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import httpx
|
||||
import logging
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import engine
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportRemoteRequest(BaseModel):
|
||||
fresh_token: str | None = None
|
||||
|
||||
@router.get("/{document_id}/export")
|
||||
def export_document(document_id: str):
|
||||
@@ -41,3 +50,83 @@ def export_document(document_id: str):
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{document_id}/export-remote")
|
||||
async def export_remote_document(document_id: str, body: ExportRemoteRequest | None = None):
|
||||
if not engine.is_available():
|
||||
raise HTTPException(status_code=501, detail="Engine bridge not available.")
|
||||
|
||||
d = document_store.get_document(document_id)
|
||||
if not d:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
perms = d.get("permissions") or {}
|
||||
if perms.get("canCopy", True) is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Exporting is not permitted by this document's restrictions (canCopy).",
|
||||
)
|
||||
|
||||
ctx = d.get("remote_context")
|
||||
if not ctx or not ctx.get("upload_url"):
|
||||
print(f"[export-remote] FAILING with 400: ctx={ctx}", flush=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Document does not have a registered remote upload context.",
|
||||
)
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
bytes_data = doc.save_full_for_export()
|
||||
filename = d["filename"]
|
||||
if not filename.endswith(".pdf"):
|
||||
filename += ".pdf"
|
||||
|
||||
headers = {}
|
||||
# Prefer fresh_token sent at save-time over the potentially-stale stored token
|
||||
token_val = (body.fresh_token if body and body.fresh_token else None) or ctx.get("auth_token")
|
||||
if token_val:
|
||||
headers["Authorization"] = f"Bearer {token_val}"
|
||||
csrf_token = str(uuid.uuid4())
|
||||
headers["Cookie"] = f"csrf_token={csrf_token}"
|
||||
headers["X-CSRF-Token"] = csrf_token
|
||||
|
||||
# Resolve folder_id from remote context or default to 0
|
||||
data = {"folder_id": str(ctx.get("folder_id", "0"))}
|
||||
if ctx.get("resource_id"):
|
||||
data["file_id"] = str(ctx["resource_id"])
|
||||
|
||||
upload_url = ctx["upload_url"]
|
||||
print(f"[export-remote] POST {upload_url} | file_id={data.get('file_id')} | filename={filename} | size={len(bytes_data)}", flush=True)
|
||||
|
||||
files = {"upload": (filename, bytes_data, "application/pdf")}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
|
||||
res = await client.post(upload_url, headers=headers, data=data, files=files)
|
||||
|
||||
print(f"[export-remote] Response status={res.status_code} body={res.text[:500]}", flush=True)
|
||||
|
||||
if res.status_code not in (200, 201):
|
||||
logger.error("[export-remote] upstream %s: %s", res.status_code, res.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"Remote upload failed (upstream status {res.status_code}).",
|
||||
)
|
||||
|
||||
try:
|
||||
remote_response = res.json() if res.content else {}
|
||||
except ValueError:
|
||||
remote_response = {}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Successfully exported updated document to remote host!",
|
||||
"remoteResponse": remote_response,
|
||||
}
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
logger.error(f"[export-remote] Unexpected error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Remote export failed.")
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ def replace_text_object(document_id: str, page_index: int, object_index: int, re
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
if "remote_context" in doc_info:
|
||||
new_info["remote_context"] = doc_info["remote_context"]
|
||||
return {"success": True, "newDocumentId": new_info["id"]}
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -249,6 +249,7 @@ class ReflowRun(BaseModel):
|
||||
fontSize: float
|
||||
color: str = "#000000"
|
||||
advances: list[float] | None = None
|
||||
advanceSeedText: str | None = None
|
||||
|
||||
|
||||
class ReflowParagraphData(BaseModel):
|
||||
@@ -438,6 +439,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
|
||||
permissions=doc_info.get("permissions"),
|
||||
)
|
||||
if "remote_context" in doc_info:
|
||||
new_info["remote_context"] = doc_info["remote_context"]
|
||||
|
||||
from app.services.render_cache import tile_cache
|
||||
tile_cache.invalidate_doc(doc_info.get("doc_hash", ""))
|
||||
@@ -460,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)
|
||||
|
||||
@@ -26,6 +26,7 @@ def render_page(
|
||||
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,
|
||||
@@ -202,9 +203,11 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
|
||||
|
||||
@compat_router.get("/render/{document_id}")
|
||||
def render_page_compat(
|
||||
request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
|
||||
request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0, dpi: int | None = None
|
||||
) -> Response:
|
||||
dpi = int(96 * zoom)
|
||||
if dpi is None:
|
||||
dpi = int(96 * zoom)
|
||||
print(f"[RENDER_COMPAT] document_id={document_id} page={page} zoom={zoom} dpi={dpi}")
|
||||
return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL)
|
||||
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"pydantic-settings==2.7.1",
|
||||
"python-multipart==0.0.19",
|
||||
"pillow==10.4.0",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -8,7 +8,7 @@ import urllib.request
|
||||
|
||||
sys.path.insert(0, "gateway")
|
||||
|
||||
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
||||
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||
|
||||
|
||||
def http_get(url):
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
|
||||
import httpx
|
||||
|
||||
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
|
||||
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8765")
|
||||
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
|
||||
|
||||
TARGET_PDFS = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
================================================================================
|
||||
FONT EXTRACTION API VALIDATION
|
||||
Server: http://localhost:8000
|
||||
Server: http://localhost:8765
|
||||
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
|
||||
Timestamp: 2026-06-02 12:36:47
|
||||
================================================================================
|
||||
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -10,7 +10,7 @@ if ($Port -le 0) {
|
||||
if ($envPort) {
|
||||
$Port = $envPort
|
||||
} else {
|
||||
$Port = 8000
|
||||
$Port = 8765
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</Filter/FlateDecode/Length 237>>stream
|
||||
xœÅ“=kÃ0EwýŠ;6Cä+É–-0†|Ø…@! ¡Sj')mMœ!¿¤…:¨*Â[îô÷<ÞI×Yßã3{(\@,@<‹¤ÙÔ
|
||||
û3’å¼YNÖ”er|ºëú¡};wã®víørè_ÚQUa:Ÿ‰Ó÷ª¯åSõ‘”ÑÒºÌ9‡\þI³i”†ï@øÛ2O«Gøj�•¨fâùÚþ�_DäSR§¤ðm�¯®þ5©ü,¢ZIM2½Íý�“&-nãÞ™_ع‰Æ.¬´¹%M¨ºŽ‡§$�sÁöñÌç¡oË~Z_‰w‘UAÆ
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
[ 8 0 R ]
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
6 4
|
||||
0000000269 00000 n
|
||||
0000000312 00000 n
|
||||
0000000403 00000 n
|
||||
0000000712 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||
startxref
|
||||
740
|
||||
%%EOF
|
||||
@@ -0,0 +1,42 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</Filter/FlateDecode/Length 169>>stream
|
||||
xœŽK‚@E÷ó+î²æ|ãc
|
||||
Dð5�¨ÌBˆV¦fT¢.üûQA-‚Vq7guîÇcÅ/[fp¤à83S• ¡�`f±Ê‚"€ç™ÝqÑôc}›ºÊhú±ª�ùÔ_ê¥ï#Œ#6|Toy¨AO&‰•»œ¤àÐW˜ªTÐ
8ôˆ½çØ®#×ÒöÐ)�œ%»ˆý¯BŠ_ï–t\!-×ù*ÈÙ,·J
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
[ 8 0 R ]
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
6 4
|
||||
0000000269 00000 n
|
||||
0000000312 00000 n
|
||||
0000000403 00000 n
|
||||
0000000644 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||
startxref
|
||||
672
|
||||
%%EOF
|
||||
@@ -0,0 +1,40 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Filter/FlateDecode/Length 832>>stream
|
||||
xœÅšMkTQ†÷ó+ÎR¦ù:9 ”B?fAP™EA\ˆ_(¨Xþ}¹Ó:-Âäº0séfV÷áMrNòžôÇ
|
||||
Ûô÷êi»ýqó©QûÕ°=kؾ¬N6×kjŸ~¶“W›ç¯ÎÛééÉç÷�>~¿ùðíççwOÞ
|
||||
Žw6äñÙY»¸º\ý¸ÿÎþËÛF»ßÔÈ",¢Ùà¶ýÚN6×jDmû±aÛÞ´×§¼>{Ó¶ÏÚzÛ^®ÖÏ/Wÿ�-àN ÙFyÖ3r•æÑAX2rT‘ Ìðà"°u誈)«à8—"x'PïœÁ«ò1¯Ü.«àÄ1b,QpÀ¦‘^jFUpÒ@Ìrn½Î1úLΫ®6&è"ˆé½ZUíÄ =ÓvR¥
X9"
û¦
|
||||
Ž@l3Õ^$š)ö¢¨‚¨/p¿¸/sÂ�€†ä•6´†=ˆz~§›±Ó`Ýå,ê< v;rûfA³”]u²˜¸cølr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3�GÏ7¹
ÌÙUsqξ¨bh,sûéüøÅf´ŸÎë¬A×½=|Ä«àê 숩ò*k 1EÝ›÷Fø°ò*k Îùµ^•s`wvðø9ß92ž9ç^åÈtèLØ‹FU"‚Ñe¡œc“%ytfDY ØB@±ç¯jhÙavÑ[òWtôf¤‚‹ÎÃdA£
Äc»¢>€çØçUlb OØeNTÌ)g½å²t9»Ê‘‰€ÄŒî¢¶sà’绪1Ë�3IØEí‹iÜ9“òMt·ŽLt
©Œ}·ŽLTWùл¥`¢¹jCŒ##W-æœUò*»ª[Â�¼ÊªfòÉ÷OV(Ó]ÔÁÈôV·f«ü@wp¢ééz�Öû?�ÀËܯAwœÆÓ^õÖ#¾3#I«þÉBø�Y áìàÓ>p‰JÌ0n÷�‰ò*8)�ëL©W½jâ4§ÎÁ«šÙý6�W½2ݯa�ßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚0ò?¦¦ÝtÕ>]§z©¡k}Ñ—«ßVn$
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
0000000269 00000 n
|
||||
6 2
|
||||
0000001173 00000 n
|
||||
0000001216 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||
startxref
|
||||
1307
|
||||
%%EOF
|
||||
@@ -0,0 +1,37 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Filter/FlateDecode/Length 417>>stream
|
||||
xœµ”;k\A…ûû+T&EfGéhËÂ>îÛÜÂ`R¿p ^ùûacHRÄCŠÓ¨ÒùF::OS¦Ó»|O/Åñ�˜¾S¦3ÊôyZµ«™éá™Vç‡v¾½ÜÒz½z¼}sÿíx÷õùñæÝ§’ý^Þn6´;ì§§ß}~uÞ-Ä?k&©H�p*hùB«vÕ˜˜i¹§LË‘®×^<» 0ÃeÞ|¤åŒæ….¦ùÃ~ú_ɳw8``ì=1hMÌEK�!0h5™¤§Î€+Ì3Ú0 1PÐ[‡7̘Gƒ
)B¬ÔîD‰+'Ï9jôÄÇ-€5y°s÷ïÆŒÝ„È©¨ÃºÉ ÇÉ�c¼w®¨°AQ5‰d
òœ_ÿüHƒ©2‹õ
>.Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬v�h£lÀ"É‚.€¸ ¹�JäД9Wéz‘Ý(£"Ù¥#n;„ÈWÖW*´i3U³Sî5tÖ?.¦Ïê}=
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
0000000269 00000 n
|
||||
6 2
|
||||
0000000758 00000 n
|
||||
0000000801 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||
startxref
|
||||
892
|
||||
%%EOF
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Locate the real failing PDF containing 'Professional Experiences' / Arial-BoldMT."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
sys.path.insert(0, r"c:\Users\Maskan\Desktop\pdf_editor\pdf\gateway")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
CANDS = [
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq (1).pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)verfication.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\DemoPdf.pdf"),
|
||||
Path(r"C:\Users\Maskan\Downloads\DemoPdf-1-3.pdf"),
|
||||
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\801c2dcf-afa2-462a-a71d-4b2e2599808f.pdf"),
|
||||
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\fc716e7e-aa82-4bb3-825a-51140db03c1b.pdf"),
|
||||
]
|
||||
|
||||
raw = Path(r"C:\Users\Maskan\Documents\pdf_html\backend\storage\raw")
|
||||
if raw.exists():
|
||||
CANDS.extend(sorted(raw.glob("*.pdf")))
|
||||
for d in raw.iterdir():
|
||||
if d.is_dir():
|
||||
CANDS.extend(sorted(d.glob("*.pdf")))
|
||||
|
||||
|
||||
def main():
|
||||
hits = []
|
||||
for p in CANDS:
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(p), "")
|
||||
except Exception as e:
|
||||
print(f"ERR load {p}: {e}")
|
||||
continue
|
||||
for pi in range(min(doc.page_count, 5)):
|
||||
page = doc.get_page(pi)
|
||||
try:
|
||||
model = page.extract_document_model()
|
||||
except Exception as e:
|
||||
print(f"ERR model {p} p{pi}: {e}")
|
||||
continue
|
||||
for para in model.paragraphs:
|
||||
text = " ".join("".join(r.text for r in ln.runs) for ln in para.lines)
|
||||
if "Professional Experience" in text:
|
||||
fonts = sorted({
|
||||
(r.font_name, r.internal_font_id, r.is_embedded, r.type, r.font_size)
|
||||
for ln in para.lines for r in ln.runs
|
||||
})
|
||||
hits.append((str(p), pi, text[:100], fonts))
|
||||
print("FOUND", p)
|
||||
print(" page", pi)
|
||||
print(" text", repr(text[:120]))
|
||||
print(" fonts", fonts)
|
||||
for f in doc.get_fonts(pi, pi):
|
||||
if "Arial-BoldMT_TrueType_32" in (f.internal_font_id or "") or (
|
||||
"Arial-BoldMT" in (f.font_name or "") and f.is_embedded
|
||||
):
|
||||
print("FONT", p, "page", pi, f.font_name, f.internal_font_id, "emb", f.is_embedded)
|
||||
print("hits", len(hits))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Edit-entry identity check for the voice-search bullet item (real resume).
|
||||
|
||||
Compares:
|
||||
1) Overlay CSS construction (mirrors ParagraphEditor Fixes 1-4 + buildBulletItem)
|
||||
2) Identity reflow region vs original region (pixel / glyph)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "bullet_edit_entry_report.json"
|
||||
TARGET = "voice search"
|
||||
|
||||
|
||||
def is_bullet(t: str) -> bool:
|
||||
t = (t or "").strip()
|
||||
return t in {"•", "●", "○", "◆", "■", "-", "–", "—", "*"} or (len(t) <= 3 and t[:1].isdigit() and t.endswith("."))
|
||||
|
||||
|
||||
def build_bullet_item(para, run_line_index: int):
|
||||
lines = para.lines
|
||||
col_left = min(l.x for l in lines)
|
||||
col_right = max(l.x + l.w for l in lines)
|
||||
|
||||
def lead_font(l):
|
||||
for r in l.runs:
|
||||
if (r.text or "").strip() and not is_bullet(r.text):
|
||||
return r.internal_font_id or ""
|
||||
return ""
|
||||
|
||||
hang = min((l.x for l in lines if l.x > col_left + 1), default=col_left + 8)
|
||||
flush_thresh = col_left + (hang - col_left) * 0.5
|
||||
|
||||
def flush_left(l):
|
||||
return l.x <= flush_thresh
|
||||
|
||||
def is_start(idx):
|
||||
l = lines[idx]
|
||||
if is_bullet((l.runs[0].text if l.runs else "") or ""):
|
||||
return True
|
||||
if idx == 0:
|
||||
return True
|
||||
return flush_left(l) and lead_font(l) and lead_font(l) != lead_font(lines[idx - 1])
|
||||
|
||||
start = run_line_index
|
||||
while start > 0 and not is_start(start):
|
||||
start -= 1
|
||||
end = run_line_index + 1
|
||||
while end < len(lines) and not is_start(end):
|
||||
end += 1
|
||||
item_lines = lines[start:end]
|
||||
deltas = []
|
||||
for i in range(start, end - 1):
|
||||
if hasattr(lines[i], "baseline_y") and hasattr(lines[i + 1], "baseline_y"):
|
||||
deltas.append(abs(lines[i].baseline_y - lines[i + 1].baseline_y))
|
||||
leading = sorted(deltas)[len(deltas) // 2] if deltas else 12.0
|
||||
|
||||
first_runs = list(item_lines[0].runs)
|
||||
sub_lines = item_lines
|
||||
if first_runs and is_bullet(first_runs[0].text):
|
||||
ti = 1
|
||||
while ti < len(first_runs) and not (first_runs[ti].text or "").strip():
|
||||
ti += 1
|
||||
text_runs = first_runs[ti:]
|
||||
text_indent = text_runs[0].x
|
||||
|
||||
class LW:
|
||||
pass
|
||||
|
||||
new_lines = []
|
||||
for idx, l in enumerate(item_lines):
|
||||
w = LW()
|
||||
if idx == 0:
|
||||
w.runs = text_runs
|
||||
w.x = text_indent
|
||||
w.w = (l.x + l.w) - text_indent
|
||||
else:
|
||||
w.runs = l.runs
|
||||
w.x = l.x
|
||||
w.w = l.w
|
||||
w.y = l.y
|
||||
w.h = l.h
|
||||
w.baseline_y = l.baseline_y
|
||||
new_lines.append(w)
|
||||
sub_lines = new_lines
|
||||
|
||||
class SP:
|
||||
pass
|
||||
|
||||
sp = SP()
|
||||
sp.lines = sub_lines
|
||||
return sp, col_left, leading, col_right, start, end
|
||||
|
||||
|
||||
def glyph_union(lines):
|
||||
minx = miny = 1e18
|
||||
maxx = maxy = -1e18
|
||||
ascent = descent = 0.0
|
||||
for ln in lines:
|
||||
for r in ln.runs:
|
||||
for g in r.glyphs:
|
||||
minx = min(minx, g.bbox_x)
|
||||
miny = min(miny, g.bbox_y)
|
||||
maxx = max(maxx, g.bbox_x + g.bbox_w)
|
||||
maxy = max(maxy, g.bbox_y + g.bbox_h)
|
||||
gbl = g.origin_y if g.origin_y else ln.baseline_y
|
||||
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
|
||||
descent = max(descent, gbl - g.bbox_y)
|
||||
return {
|
||||
"x": minx,
|
||||
"y": miny,
|
||||
"w": maxx - minx,
|
||||
"h": maxy - miny,
|
||||
"ascent": ascent,
|
||||
"descent": descent,
|
||||
"line_h": max(l.h for l in lines),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
para = None
|
||||
pi = -1
|
||||
for i, p in enumerate(model.paragraphs):
|
||||
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET.lower() in text.lower():
|
||||
para, pi = p, i
|
||||
break
|
||||
assert para is not None
|
||||
|
||||
# line index of voice-search bullet start
|
||||
run_line = 0
|
||||
for li, ln in enumerate(para.lines):
|
||||
t = "".join(r.text or "" for r in ln.runs)
|
||||
if "voice search" in t.lower() or (li and "Speech API" in "".join(r.text or "" for r in para.lines[li - 1].runs)):
|
||||
# find bullet start
|
||||
pass
|
||||
for li, ln in enumerate(para.lines):
|
||||
t = "".join(r.text or "" for r in ln.runs)
|
||||
if "Implemented a voice" in t:
|
||||
run_line = li
|
||||
break
|
||||
|
||||
sub, push_left, leading, col_right, start, end = build_bullet_item(para, run_line)
|
||||
box = glyph_union(sub.lines)
|
||||
layout = compute_layout(sub)
|
||||
|
||||
# dominant run
|
||||
dom = None
|
||||
for r in layout["seedRuns"]:
|
||||
if (r.get("text") or "").strip() and r.get("fid"):
|
||||
dom = r
|
||||
break
|
||||
font_name = (dom or {}).get("fontName") or ""
|
||||
extracted = re.sub(r"^[A-Z]{6}\+", "", font_name).strip() or "sans-serif"
|
||||
weight = 700 if re.search(r"bold|black|heavy", extracted, re.I) else 400
|
||||
line_height_pt = leading # leadingOverride ?? paraBox.lineHeight — override wins
|
||||
overlay = {
|
||||
"font_family": extracted,
|
||||
"font_weight": weight,
|
||||
"font_size": (dom or {}).get("size"),
|
||||
"line_height_pt": line_height_pt,
|
||||
"width_pt": box["w"],
|
||||
"height_pt": max(box["h"], line_height_pt),
|
||||
"left_pt": box["x"],
|
||||
"ascent": box["ascent"],
|
||||
"pdf_line_h": box["line_h"],
|
||||
"leading_override": leading,
|
||||
"column_left": push_left,
|
||||
"column_right": col_right,
|
||||
"seed_text": "".join(r["text"] for r in layout["seedRuns"]),
|
||||
"seed_has_bullet": any(is_bullet(r["text"]) for r in layout["seedRuns"]),
|
||||
"n_lines": len(sub.lines),
|
||||
"line_range": [start, end],
|
||||
}
|
||||
|
||||
checks = []
|
||||
checks.append(("font-family", font_name, extracted, extracted.lower() in (font_name or "").lower().replace("bcdjee+", "") or "arialmt" in extracted.lower()))
|
||||
checks.append(("font-weight", weight, weight, True))
|
||||
checks.append(("font-size", overlay["font_size"], overlay["font_size"], True))
|
||||
# Multi-line: CSS line-height should be baseline delta (leading), NOT ink line.h
|
||||
checks.append(("line-height(leading)", leading, line_height_pt, abs(leading - line_height_pt) < 0.01))
|
||||
checks.append(("width(ink)", box["w"], overlay["width_pt"], abs(box["w"] - overlay["width_pt"]) < 0.01))
|
||||
checks.append(("height(ink)", box["h"], overlay["height_pt"], abs(max(box["h"], line_height_pt) - overlay["height_pt"]) < 0.01))
|
||||
|
||||
# Identity reflow
|
||||
fid = (dom or {}).get("fid") or ""
|
||||
flat = extract_flat_runs(layout["seedRuns"], fid, (dom or {}).get("size") or 10, "#000000")
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "x")
|
||||
data["columnRight"] = col_right
|
||||
data["pushColumnLeft"] = push_left
|
||||
data["leading"] = leading
|
||||
data["columnLeft"] = layout["columnLeft"]
|
||||
op = {"version": "1.0", "operations": [{"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
||||
|
||||
# Original region crop
|
||||
y_top = box["y"] - 2
|
||||
h = box["h"] + 4
|
||||
dpi = 144
|
||||
orig_img = page.render_region_raw(dpi, y_top, h)
|
||||
|
||||
r = doc.apply_edits(json.dumps(op))
|
||||
page2 = doc.get_page(0)
|
||||
prev_img = page2.render_region_raw(dpi, y_top, h)
|
||||
|
||||
def sha(img):
|
||||
import hashlib
|
||||
return hashlib.sha256(bytes(img.data)).hexdigest()[:16] if img else None
|
||||
|
||||
pixel_match = False
|
||||
if orig_img and prev_img and orig_img.width == prev_img.width and orig_img.height == prev_img.height:
|
||||
a = bytes(orig_img.data)
|
||||
b = bytes(prev_img.data)
|
||||
pixel_match = a == b
|
||||
diff = sum(1 for i in range(0, len(a), 4) if a[i : i + 3] != b[i : i + 3])
|
||||
else:
|
||||
diff = -1
|
||||
|
||||
report = {
|
||||
"para_index": pi,
|
||||
"overlay": overlay,
|
||||
"checks": [{"property": a, "pdf": b, "overlay": c, "match": d} for a, b, c, d in checks],
|
||||
"identity_reflow": {
|
||||
"apply_ok": bool(r),
|
||||
"orig_sha": sha(orig_img),
|
||||
"prev_sha": sha(prev_img),
|
||||
"pixel_identical": pixel_match,
|
||||
"diff_pixels": diff,
|
||||
"region": {"y_top": y_top, "h": h, "dpi": dpi},
|
||||
},
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
print("OVERLAY", "ALL MATCH" if all(c[3] for c in checks) else "DIFFS")
|
||||
print("IDENTITY PIXELS", "MATCH" if pixel_match else f"DIFF ({diff})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Compare forensic snapshots and print diff tables."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def load(p):
|
||||
return json.loads(Path(p).read_text(encoding="utf-8-sig"))
|
||||
|
||||
def diff_table(a, b, keys):
|
||||
rows = []
|
||||
for k in keys:
|
||||
va, vb = a.get(k), b.get(k)
|
||||
changed = va != vb
|
||||
rows.append((k, va, vb, "CHANGED" if changed else "same"))
|
||||
return rows
|
||||
|
||||
def print_table(title, rows):
|
||||
print(f"\n=== {title} ===")
|
||||
print(f"{'Property':<22} {'Before':<40} {'After':<40} {'Status'}")
|
||||
for k, va, vb, st in rows:
|
||||
sa = json.dumps(va) if not isinstance(va, str) else va
|
||||
sb = json.dumps(vb) if not isinstance(vb, str) else vb
|
||||
if len(sa) > 38: sa = sa[:35] + "..."
|
||||
if len(sb) > 38: sb = sb[:35] + "..."
|
||||
print(f"{k:<22} {sa:<40} {sb:<40} {st}")
|
||||
|
||||
def compare_advances(orig, new):
|
||||
oa, na = orig or [], new or []
|
||||
diffs = []
|
||||
for i in range(max(len(oa), len(na))):
|
||||
ov = oa[i] if i < len(oa) else None
|
||||
nv = na[i] if i < len(na) else None
|
||||
if ov != nv:
|
||||
diffs.append((i, ov, nv))
|
||||
return diffs
|
||||
|
||||
def main():
|
||||
extract_path = Path(sys.argv[1] if len(sys.argv) > 1 else "forensic_extract_out.json")
|
||||
wasm_path = Path(sys.argv[2] if len(sys.argv) > 2 else "forensic_wasm_out.json")
|
||||
ex = load(extract_path)
|
||||
wasm = load(wasm_path)
|
||||
|
||||
keys = [
|
||||
"text", "fontId", "fontName", "fontSize", "color",
|
||||
"glyphCount", "advanceCount", "paragraphWidth", "baseline", "lineHeight",
|
||||
"origLinesProvided",
|
||||
]
|
||||
|
||||
orig = ex["original"]
|
||||
entry_payload = ex["editEntry"]
|
||||
one_payload = ex["editOne"]
|
||||
|
||||
# WASM layout snapshots
|
||||
from forensic_extract import snapshot_from_wasm_layout, para_signature # type: ignore
|
||||
seed_fn = orig.get("fontName", "")
|
||||
entry_wasm = snapshot_from_wasm_layout("ENTRY_WASM", wasm["editEntryWasm"]["layout"], seed_fn)
|
||||
one_wasm = snapshot_from_wasm_layout("ONE_WASM", wasm["editOneWasm"]["layout"], seed_fn)
|
||||
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (payload)", diff_table(orig, entry_payload, keys))
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (WASM layout)", diff_table(orig, entry_wasm, keys))
|
||||
|
||||
adv_diff_entry = compare_advances(orig.get("advances"), entry_wasm.get("advances"))
|
||||
if adv_diff_entry:
|
||||
print(f"\nAdvance diffs ORIGINAL vs ENTRY_WASM: {len(adv_diff_entry)} positions")
|
||||
for i, ov, nv in adv_diff_entry[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
else:
|
||||
print("\nAdvance arrays IDENTICAL: ORIGINAL vs ENTRY_WASM")
|
||||
|
||||
print_table("EDIT_ENTRY vs AFTER_ONE_CHAR (payload)", diff_table(entry_payload, one_payload, keys))
|
||||
print_table("ENTRY_WASM vs ONE_WASM", diff_table(entry_wasm, one_wasm, keys))
|
||||
|
||||
adv_diff_one = compare_advances(entry_wasm.get("advances"), one_wasm.get("advances"))
|
||||
if adv_diff_one:
|
||||
print(f"\nAdvance diffs ENTRY_WASM vs ONE_WASM: {len(adv_diff_one)} positions")
|
||||
for i, ov, nv in adv_diff_one[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
|
||||
# Line widths
|
||||
print("\n=== LINE WIDTHS ===")
|
||||
print(f"Original lineWidths: {orig.get('lineWidths')}")
|
||||
print(f"Entry WASM lineWidths: {entry_wasm.get('lineWidths')}")
|
||||
print(f"One WASM lineWidths: {one_wasm.get('lineWidths')}")
|
||||
|
||||
# Signatures chain
|
||||
sigs = {
|
||||
"original": para_signature(orig),
|
||||
"entry_payload": para_signature(entry_payload),
|
||||
"entry_wasm": para_signature(entry_wasm),
|
||||
"one_payload": para_signature(one_payload),
|
||||
"one_wasm": para_signature(one_wasm),
|
||||
}
|
||||
print("\n=== SIGNATURE CHAIN ===")
|
||||
prev = None
|
||||
for name, sig in sigs.items():
|
||||
changed = prev and sig != prev
|
||||
print(f" {name}: {sig}" + (" <-- FIRST CHANGE from " + prev_name if changed else ""))
|
||||
if changed and prev:
|
||||
pass
|
||||
prev_name = name
|
||||
prev = sig
|
||||
|
||||
# First mutation
|
||||
chain = list(sigs.items())
|
||||
base = chain[0][1]
|
||||
for i in range(1, len(chain)):
|
||||
if chain[i][1] != base:
|
||||
print(f"\nFIRST SIGNATURE CHANGE: {chain[i][0]} (vs original)")
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing).
|
||||
|
||||
Mirrors ParagraphEditor computeLayout + style construction for the real resume
|
||||
heading \"Professional Experience\" and ranks the first property that diverges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "edit_entry_overlay_report.json"
|
||||
TARGET = "Professional Experience"
|
||||
ZOOM = 1.0 # property ratios are zoom-invariant in pt space
|
||||
|
||||
|
||||
def measure_family(font_name: str) -> str:
|
||||
if re.search(r"times|serif", font_name, re.I):
|
||||
return "Times New Roman, serif"
|
||||
if re.search(r"courier|mono", font_name, re.I):
|
||||
return "Courier New, monospace"
|
||||
return "Arial, sans-serif"
|
||||
|
||||
|
||||
def page_content_right(model) -> float:
|
||||
right = float("-inf")
|
||||
for p in model.paragraphs:
|
||||
for ln in p.lines:
|
||||
if any((r.text or "").strip() for r in ln.runs):
|
||||
right = max(right, ln.x + ln.w)
|
||||
return right if right != float("-inf") else 0.0
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
height_pts = page.height
|
||||
|
||||
para = None
|
||||
for p in model.paragraphs:
|
||||
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in text:
|
||||
para = p
|
||||
break
|
||||
if para is None:
|
||||
raise SystemExit("paragraph not found")
|
||||
|
||||
# --- Extracted PDF metrics ---
|
||||
line = para.lines[0]
|
||||
run = line.runs[0]
|
||||
glyphs = list(run.glyphs)
|
||||
min_x = min(g.bbox_x for g in glyphs)
|
||||
min_y = min(g.bbox_y for g in glyphs)
|
||||
max_x = max(g.bbox_x + g.bbox_w for g in glyphs)
|
||||
max_y = max(g.bbox_y + g.bbox_h for g in glyphs)
|
||||
baseline = glyphs[0].origin_y
|
||||
ascent = max_y - baseline
|
||||
descent = baseline - min_y
|
||||
pdf = {
|
||||
"text": "".join(r.text or "" for ln in para.lines for r in ln.runs),
|
||||
"bbox": {"x": min_x, "y": min_y, "w": max_x - min_x, "h": max_y - min_y},
|
||||
"line_x": line.x,
|
||||
"line_y": line.y,
|
||||
"line_w": line.w,
|
||||
"line_h": line.h,
|
||||
"baseline_y": line.baseline_y,
|
||||
"ascent_pt": ascent,
|
||||
"descent_pt": descent,
|
||||
"width_pt": max_x - min_x,
|
||||
"height_pt": max_y - min_y,
|
||||
"font_name": run.font_name,
|
||||
"font_size_pt": run.font_size,
|
||||
"run_h": run.h,
|
||||
"internal_font_id": run.internal_font_id,
|
||||
"expected_font_weight": 700 if re.search(r"bold|black|heavy", run.font_name or "", re.I) else 400,
|
||||
}
|
||||
|
||||
# --- Overlay construction (mirrors ParagraphEditor + TextEditLayer openEditor) ---
|
||||
dom_size = max(run.font_size or 0, run.h or 0) or 12
|
||||
column_left = line.x
|
||||
para_right = line.x + line.w
|
||||
pr = page_content_right(model)
|
||||
column_right = max(pr, para_right) # TextEditLayer left-align path
|
||||
# single-line leading default in computeLayout:
|
||||
leading = dom_size * 1.2
|
||||
first_baseline_y = line.baseline_y
|
||||
font_name = run.font_name or ""
|
||||
family = measure_family(font_name)
|
||||
font_px = dom_size * ZOOM
|
||||
leading_px = leading * ZOOM
|
||||
col_left_px = column_left * ZOOM
|
||||
col_width_px = (column_right - column_left) * ZOOM
|
||||
col_height_px = 1 * leading_px # oldLineCount=1
|
||||
first_baseline_screen = (height_pts - first_baseline_y) * ZOOM
|
||||
editor_top = first_baseline_screen - font_px * 0.8
|
||||
ascent_heuristic = dom_size * 0.8
|
||||
|
||||
overlay = {
|
||||
"font_family": family,
|
||||
"font_size_pt": dom_size,
|
||||
"font_size_px": font_px,
|
||||
"font_weight": "(not set; browser default 400)",
|
||||
"line_height_pt": leading,
|
||||
"line_height_px": leading_px,
|
||||
"letter_spacing": "(not set; normal)",
|
||||
"width_pt": column_right - column_left,
|
||||
"width_px": col_width_px,
|
||||
"height_pt": leading, # oldLineCount * leading
|
||||
"height_px": col_height_px,
|
||||
"left_px": col_left_px,
|
||||
"top_px": editor_top,
|
||||
"ascent_heuristic_pt": ascent_heuristic,
|
||||
"transform": "none",
|
||||
"column_left_pt": column_left,
|
||||
"column_right_pt": column_right,
|
||||
"page_content_right_pt": pr,
|
||||
"style_source": {
|
||||
"measureFamily": "Arial, sans-serif because name matches neither times|serif nor courier|mono",
|
||||
"leading": "domSize * 1.2 (single-line; no baseline deltas)",
|
||||
"editorTop": "baselineScreen - fontPx * 0.8",
|
||||
"height": "oldLineCount * leadingPx",
|
||||
"width": "columnRightOverride - columnLeft (page content right, not text bbox)",
|
||||
},
|
||||
}
|
||||
|
||||
# Ordered property checks (construction order / visual identity order)
|
||||
checks = []
|
||||
|
||||
def add(prop, pdf_v, ov_v, match):
|
||||
checks.append({"property": prop, "pdf": pdf_v, "overlay": ov_v, "match": match})
|
||||
|
||||
bold = pdf["expected_font_weight"] >= 700
|
||||
add(
|
||||
"font-family",
|
||||
pdf["font_name"],
|
||||
family,
|
||||
(not bold) and ("arial" in (pdf["font_name"] or "").lower()),
|
||||
)
|
||||
add(
|
||||
"font-weight",
|
||||
pdf["expected_font_weight"],
|
||||
400,
|
||||
(not bold) or False, # BoldMT → no font-weight set → mismatch
|
||||
)
|
||||
add("font-size (pt)", pdf["font_size_pt"], overlay["font_size_pt"], abs(pdf["font_size_pt"] - overlay["font_size_pt"]) < 0.05)
|
||||
add("line-height / leading (pt)", pdf["line_h"], overlay["line_height_pt"], abs(pdf["line_h"] - overlay["line_height_pt"]) < 0.5)
|
||||
add("ascent (pt)", pdf["ascent_pt"], overlay["ascent_heuristic_pt"], abs(pdf["ascent_pt"] - overlay["ascent_heuristic_pt"]) < 0.5)
|
||||
add("descent (pt)", pdf["descent_pt"], "(not represented in overlay CSS)", False)
|
||||
add("height (pt)", pdf["height_pt"], overlay["height_pt"], abs(pdf["height_pt"] - overlay["height_pt"]) < 0.5)
|
||||
add("width (pt)", pdf["width_pt"], overlay["width_pt"], abs(pdf["width_pt"] - overlay["width_pt"]) < 1.0)
|
||||
add("letter-spacing", 0, "normal", True)
|
||||
add("transform", "none", "none", True)
|
||||
|
||||
first = next((c for c in checks if not c["match"]), None)
|
||||
|
||||
print("=== PDF paragraph ===")
|
||||
print(json.dumps(pdf, indent=2))
|
||||
print("\n=== Overlay applied (edit-entry) ===")
|
||||
print(json.dumps(overlay, indent=2))
|
||||
print("\n=== Property checks (order) ===")
|
||||
for c in checks:
|
||||
flag = "OK " if c["match"] else "DIFF"
|
||||
print(f" [{flag}] {c['property']}: pdf={c['pdf']!r} overlay={c['overlay']!r}")
|
||||
print("\n=== FIRST PROPERTY THAT CHANGES ON EDIT-ENTRY ===")
|
||||
print(json.dumps(first, indent=2))
|
||||
|
||||
report = {
|
||||
"scope": "edit-entry only (mouse click before typing); no reflow/typing",
|
||||
"pdf": pdf,
|
||||
"overlay": overlay,
|
||||
"checks": checks,
|
||||
"firstPropertyThatChanges": first,
|
||||
"notes": [
|
||||
"measureFamily maps Arial-BoldMT → 'Arial, sans-serif' and never sets font-weight:700",
|
||||
"That is the first identity/style mutation when the contentEditable overlay is created",
|
||||
"Subsequent geometric diffs: leading 11.09→14.4, ascent 8.74→9.6, height 11.09→14.4, width text→page column",
|
||||
],
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Stage 1-4 extraction + frontend layout mirror (ParagraphEditor.tsx)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
|
||||
def line_advances(line) -> tuple[dict[int, list[float]], float]:
|
||||
runs = line.runs
|
||||
seq: list[tuple[int, int, float]] = []
|
||||
aligned: dict[int, bool] = {}
|
||||
for ri, r in enumerate(runs):
|
||||
gs = r.glyphs
|
||||
ok = bool(r.text) and len(gs) == len(r.text)
|
||||
aligned[ri] = ok
|
||||
if ok:
|
||||
for ci, g in enumerate(gs):
|
||||
seq.append((ri, ci, g.origin_x))
|
||||
per_run: dict[int, list[float]] = {}
|
||||
for ri, r in enumerate(runs):
|
||||
if aligned.get(ri):
|
||||
per_run[ri] = [0.0] * len(r.text)
|
||||
anchor_x = seq[0][2] if seq else line.x
|
||||
for k, (ri, ci, ox) in enumerate(seq):
|
||||
gs = runs[ri].glyphs
|
||||
g = gs[ci]
|
||||
char_w = g.bbox_w if g.bbox_w > 0 else (runs[ri].font_size or 12) * 0.5
|
||||
per_run[ri][ci] = (seq[k + 1][2] - ox) if k + 1 < len(seq) else char_w
|
||||
for ri in list(per_run.keys()):
|
||||
if any(a <= 0 for a in per_run[ri]):
|
||||
del per_run[ri]
|
||||
return per_run, anchor_x
|
||||
|
||||
|
||||
def para_eff_size(lines) -> float:
|
||||
nominal = 0.0
|
||||
for line in lines:
|
||||
for r in line.runs:
|
||||
sz = max(r.font_size or 0, r.h or 0)
|
||||
if sz > nominal:
|
||||
nominal = sz
|
||||
return nominal or 12.0
|
||||
|
||||
|
||||
def median(xs: list[float]) -> float:
|
||||
if not xs:
|
||||
return 0.0
|
||||
s = sorted(xs)
|
||||
return s[len(s) // 2]
|
||||
|
||||
|
||||
def compute_layout(para) -> dict:
|
||||
lines = para.lines
|
||||
eff_size = para_eff_size(lines)
|
||||
column_left = float("inf")
|
||||
first_baseline_y = float("-inf")
|
||||
baselines: list[float] = []
|
||||
right_edges: list[float] = []
|
||||
object_indices: list[int] = []
|
||||
seed_runs: list[dict] = []
|
||||
orig_lines: list[dict] = []
|
||||
|
||||
for li, line in enumerate(lines):
|
||||
if line.baseline_y is not None:
|
||||
baselines.append(line.baseline_y)
|
||||
first_baseline_y = max(first_baseline_y, line.baseline_y)
|
||||
column_left = min(column_left, line.x)
|
||||
right_edges.append(line.x + line.w)
|
||||
line_runs = line.runs
|
||||
per_run, anchor_x = line_advances(line)
|
||||
line_frags: list[dict] = []
|
||||
for ri, r in enumerate(line_runs):
|
||||
for oi in (r.object_indices or []):
|
||||
object_indices.append(oi)
|
||||
orig = r.text or ""
|
||||
text = orig
|
||||
if li > 0 and ri == 0 and seed_runs:
|
||||
prev = seed_runs[-1]["text"]
|
||||
if prev and not prev.endswith((" ", "\t")) and not text.startswith((" ", "\t")):
|
||||
text = " " + text
|
||||
adv = per_run.get(ri)
|
||||
fc = getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000"
|
||||
safe_color = fc if fc and fc != "#ffffff00" else "#000000"
|
||||
r_size = max(r.font_size or 0, r.h or 0) or eff_size
|
||||
seed_runs.append({
|
||||
"text": text, "fid": r.internal_font_id or "", "size": r_size,
|
||||
"color": safe_color, "fontName": r.font_name or "", "advances": adv,
|
||||
})
|
||||
if orig:
|
||||
line_frags.append({
|
||||
"text": orig, "fid": r.internal_font_id or "", "size": r_size,
|
||||
"color": safe_color, "advances": adv,
|
||||
})
|
||||
if line_frags:
|
||||
orig_lines.append({"frags": line_frags, "x": anchor_x, "baselineY": line.baseline_y or 0})
|
||||
|
||||
max_right = max(right_edges) if right_edges else column_left + 250
|
||||
column_right = max(max_right, column_left + 250) if column_left != float("inf") else 250
|
||||
deltas = [baselines[i] - baselines[i + 1] for i in range(len(baselines) - 1)]
|
||||
dom_size = next((r["size"] for r in seed_runs if r["text"].strip()), 12.0)
|
||||
leading = abs(median(deltas)) if deltas else dom_size * 1.2
|
||||
col_w = column_right - column_left
|
||||
align = "left"
|
||||
if len(lines) >= 2:
|
||||
reaching = sum(1 for i in range(len(right_edges) - 1) if right_edges[i] >= column_right - col_w * 0.04)
|
||||
if reaching >= (len(lines) - 1) * 0.7:
|
||||
align = "justify"
|
||||
|
||||
return {
|
||||
"columnLeft": column_left, "columnRight": column_right,
|
||||
"firstBaselineY": first_baseline_y, "leading": leading,
|
||||
"oldLineCount": len(lines), "align": align,
|
||||
"objectIndices": object_indices, "seedRuns": seed_runs, "origLines": orig_lines,
|
||||
"domSize": dom_size,
|
||||
}
|
||||
|
||||
|
||||
def extract_flat_runs(seed_runs: list[dict], dominant_fid: str, dom_size: float, dom_color: str) -> list[dict]:
|
||||
"""Mirror extractFlatRuns when DOM matches seedRuns exactly (edit mode entry)."""
|
||||
out: list[dict] = []
|
||||
for r in seed_runs:
|
||||
if not r["text"]:
|
||||
continue
|
||||
frag = {
|
||||
"text": r["text"], "internalFontId": r["fid"] or dominant_fid,
|
||||
"fontSize": r["size"], "color": r["color"],
|
||||
}
|
||||
adv = r.get("advances")
|
||||
if adv and len(adv) == len(r["text"]):
|
||||
frag["advances"] = adv
|
||||
out.append(frag)
|
||||
return out
|
||||
|
||||
|
||||
def build_reflow_data(layout: dict, runs: list[dict], orig_lines: list[dict] | None, para_id: str) -> dict:
|
||||
line_position = {}
|
||||
if layout["origLines"]:
|
||||
line_position = {
|
||||
"lineX": [l["x"] for l in layout["origLines"]],
|
||||
"lineBaselineY": [l["baselineY"] for l in layout["origLines"]],
|
||||
}
|
||||
lines_data = {}
|
||||
if orig_lines:
|
||||
lines_data = {
|
||||
"lines": [
|
||||
[
|
||||
{
|
||||
"text": f["text"], "internalFontId": f["fid"],
|
||||
"fontSize": f["size"], "color": f["color"],
|
||||
**({"advances": f["advances"]} if f.get("advances") else {}),
|
||||
}
|
||||
for f in l["frags"]
|
||||
]
|
||||
for l in orig_lines
|
||||
],
|
||||
}
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip() and r["fid"] == dominant_fid), None)
|
||||
dom_size = (dom_run or layout["seedRuns"][0])["size"] if layout["seedRuns"] else 12.0
|
||||
dom_color = (dom_run or layout["seedRuns"][0])["color"] if layout["seedRuns"] else "#000000"
|
||||
out_runs = runs if runs else [{"text": " ", "internalFontId": dominant_fid, "fontSize": dom_size, "color": "#000000"}]
|
||||
return {
|
||||
"objectIndices": layout["objectIndices"],
|
||||
"runs": out_runs,
|
||||
**line_position,
|
||||
**lines_data,
|
||||
"columnLeft": layout["columnLeft"],
|
||||
"columnRight": layout["columnRight"],
|
||||
"pushColumnLeft": layout["columnLeft"],
|
||||
"firstBaselineY": layout["firstBaselineY"],
|
||||
"leading": layout["leading"],
|
||||
"oldLineCount": layout["oldLineCount"],
|
||||
"align": layout["align"],
|
||||
"paraId": para_id,
|
||||
}
|
||||
|
||||
|
||||
def paragraph_snapshot_from_extraction(para, layout: dict) -> dict:
|
||||
"""Original PDF paragraph metrics from extraction + derived advances."""
|
||||
lines = para.lines
|
||||
all_text = "".join(r.text for ln in lines for r in ln.runs)
|
||||
runs_detail = []
|
||||
for ln in lines:
|
||||
per_run, anchor_x = line_advances(ln)
|
||||
for ri, r in enumerate(ln.runs):
|
||||
adv = per_run.get(ri)
|
||||
runs_detail.append({
|
||||
"text": r.text,
|
||||
"fontId": r.internal_font_id,
|
||||
"fontName": r.font_name,
|
||||
"fontSize": max(r.font_size or 0, r.h or 0),
|
||||
"color": getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000",
|
||||
"glyphCount": len(r.glyphs),
|
||||
"advanceCount": len(adv) if adv else 0,
|
||||
"advances": adv,
|
||||
"bbox": [r.x, r.y, r.w, r.h],
|
||||
"lineWidth": sum(adv) if adv else r.w,
|
||||
})
|
||||
line_widths = [ln.w for ln in lines]
|
||||
orig_line_texts = ["".join(r.text for r in ln.runs) for ln in lines]
|
||||
return {
|
||||
"stage": "ORIGINAL_PDF_EXTRACTION",
|
||||
"text": all_text,
|
||||
"fontId": runs_detail[0]["fontId"] if runs_detail else "",
|
||||
"fontName": runs_detail[0]["fontName"] if runs_detail else "",
|
||||
"fontSize": layout["domSize"],
|
||||
"color": runs_detail[0]["color"] if runs_detail else "#000000",
|
||||
"glyphCount": sum(len(r.glyphs) for ln in lines for r in ln.runs),
|
||||
"advanceCount": sum(rd["advanceCount"] for rd in runs_detail),
|
||||
"advances": [a for rd in runs_detail for a in (rd["advances"] or [])],
|
||||
"boundingBox": [para.x, para.y, para.w, para.h],
|
||||
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
|
||||
"lineWidths": line_widths,
|
||||
"baseline": layout["firstBaselineY"],
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": layout["leading"],
|
||||
"origLines": orig_line_texts,
|
||||
"runs": runs_detail,
|
||||
"origLinesProvided": False,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_from_payload(stage: str, layout: dict, runs: list[dict], orig_lines: list[dict] | None) -> dict:
|
||||
all_text = "".join(r["text"] for r in runs)
|
||||
dom = runs[0] if runs else {}
|
||||
adv_flat = []
|
||||
for r in runs:
|
||||
if r.get("advances"):
|
||||
adv_flat.extend(r["advances"])
|
||||
return {
|
||||
"stage": stage,
|
||||
"text": all_text,
|
||||
"fontId": dom.get("internalFontId", ""),
|
||||
"fontName": layout["seedRuns"][0]["fontName"] if layout["seedRuns"] else "",
|
||||
"fontSize": dom.get("fontSize", layout["domSize"]),
|
||||
"color": dom.get("color", "#000000"),
|
||||
"glyphCount": len(all_text),
|
||||
"advanceCount": len(adv_flat),
|
||||
"advances": adv_flat,
|
||||
"boundingBox": None,
|
||||
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
|
||||
"lineWidths": None,
|
||||
"baseline": layout["firstBaselineY"],
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": layout["leading"],
|
||||
"origLines": ["".join(f["text"] for f in l["frags"]) for l in orig_lines] if orig_lines else None,
|
||||
"runs": [
|
||||
{
|
||||
"text": r["text"],
|
||||
"fontId": r.get("internalFontId"),
|
||||
"fontSize": r.get("fontSize"),
|
||||
"color": r.get("color"),
|
||||
"advanceCount": len(r.get("advances") or []),
|
||||
"advances": r.get("advances"),
|
||||
"advanceSource": "frontend_origin_x_derived" if r.get("advances") else "missing",
|
||||
}
|
||||
for r in runs
|
||||
],
|
||||
"origLinesProvided": orig_lines is not None,
|
||||
}
|
||||
|
||||
|
||||
def snapshot_from_wasm_layout(stage: str, layout_json: dict, seed_font_name: str) -> dict:
|
||||
lines = layout_json.get("lines") or []
|
||||
all_text = "".join(ln.get("text", "") for ln in lines)
|
||||
adv_flat = []
|
||||
line_widths = []
|
||||
for ln in lines:
|
||||
adv = ln.get("adv") or []
|
||||
adv_flat.extend(adv)
|
||||
line_widths.append(sum(adv))
|
||||
dom_fs = lines[0].get("fontSize") if lines else None
|
||||
return {
|
||||
"stage": stage,
|
||||
"text": all_text,
|
||||
"fontId": "",
|
||||
"fontName": seed_font_name,
|
||||
"fontSize": dom_fs,
|
||||
"color": None,
|
||||
"glyphCount": len(all_text),
|
||||
"advanceCount": len(adv_flat),
|
||||
"advances": adv_flat,
|
||||
"boundingBox": None,
|
||||
"paragraphWidth": (layout_json.get("columnRight") or 0) - (layout_json.get("columnLeft") or 0),
|
||||
"lineWidths": line_widths,
|
||||
"baseline": lines[0].get("baselineY") if lines else None,
|
||||
"ascent": None,
|
||||
"descent": None,
|
||||
"lineHeight": None,
|
||||
"origLines": [ln.get("text", "") for ln in lines],
|
||||
"runs": [
|
||||
{
|
||||
"text": ln.get("text"),
|
||||
"lineX0": ln.get("x0"),
|
||||
"fontSize": ln.get("fontSize"),
|
||||
"advanceCount": len(ln.get("adv") or []),
|
||||
"advances": ln.get("adv"),
|
||||
"advanceSum": sum(ln.get("adv") or []),
|
||||
"advanceSource": "wasm_reflow_output",
|
||||
}
|
||||
for ln in lines
|
||||
],
|
||||
"origLinesProvided": None,
|
||||
}
|
||||
|
||||
|
||||
def para_signature(snap: dict) -> str:
|
||||
parts = [
|
||||
snap.get("text") or "",
|
||||
str(snap.get("fontId") or ""),
|
||||
str(snap.get("fontSize") or ""),
|
||||
json.dumps(snap.get("advances") or []),
|
||||
json.dumps(snap.get("lineWidths") or []),
|
||||
str(snap.get("baseline") or ""),
|
||||
json.dumps(snap.get("boundingBox") or []),
|
||||
]
|
||||
return hashlib.sha256("".join(parts).encode()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
if len(sys.argv) > 1:
|
||||
pdf_path = Path(sys.argv[1])
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
fonts = doc.get_fonts(0, 0)
|
||||
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
dom_size = dom_run["size"]
|
||||
dom_color = dom_run["color"]
|
||||
|
||||
para_id = f"forensic-{hashlib.md5(str(pdf_path).encode()).hexdigest()[:8]}"
|
||||
|
||||
# Stage snapshots
|
||||
original = paragraph_snapshot_from_extraction(para, layout)
|
||||
|
||||
flat_entry = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_size, dom_color)
|
||||
payload_entry = build_reflow_data(layout, flat_entry, layout["origLines"], para_id)
|
||||
edit_entry = snapshot_from_payload("EDIT_MODE_ENTRY_PAYLOAD", layout, flat_entry, layout["origLines"])
|
||||
|
||||
# After one character
|
||||
flat_one = []
|
||||
for i, r in enumerate(flat_entry):
|
||||
if i == 0:
|
||||
flat_one.append({**r, "text": r["text"] + "x"})
|
||||
else:
|
||||
flat_one.append(r)
|
||||
payload_one = build_reflow_data(layout, flat_one, None, para_id)
|
||||
edit_one = snapshot_from_payload("AFTER_ONE_CHAR_PAYLOAD", layout, flat_one, None)
|
||||
|
||||
out = {
|
||||
"pdf": str(pdf_path),
|
||||
"fonts": [
|
||||
{
|
||||
"internalFontId": f.internal_font_id,
|
||||
"fontName": f.font_name,
|
||||
"ascent": f.ascent,
|
||||
"descent": f.descent,
|
||||
"capHeight": getattr(f, "cap_height", None),
|
||||
"unitsPerEm": getattr(f, "units_per_em", None),
|
||||
}
|
||||
for f in fonts
|
||||
],
|
||||
"layout": layout,
|
||||
"original": original,
|
||||
"editEntry": edit_entry,
|
||||
"editOne": edit_one,
|
||||
"payloads": {
|
||||
"editEntry": {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "forensic-entry", "type": "reflow_paragraph", "pageIndex": 0,
|
||||
"data": payload_entry,
|
||||
}],
|
||||
},
|
||||
"editOne": {
|
||||
"version": "1.0",
|
||||
"operations": [{
|
||||
"id": "forensic-one", "type": "reflow_paragraph", "pageIndex": 0,
|
||||
"data": payload_one,
|
||||
}],
|
||||
},
|
||||
},
|
||||
"signatures": {
|
||||
"original": para_signature(original),
|
||||
"editEntryPayload": para_signature(edit_entry),
|
||||
"editOnePayload": para_signature(edit_one),
|
||||
},
|
||||
}
|
||||
out_path = Path(__file__).resolve().parent / "forensic_extract_out.json"
|
||||
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
|
||||
print(json.dumps(out, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""First-property mutation on first keystroke (typing/reflow only).
|
||||
|
||||
Compares ORIGINAL extracted paragraph vs after typing one char at end.
|
||||
Mirrors ParagraphEditor when editedRef=true:
|
||||
- no lines[] payload (origLines dropped)
|
||||
- lineX / lineBaselineY still sent from layout
|
||||
- advances + advanceSeedText for the edited run
|
||||
|
||||
Prints the first property that diverges.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "first_keystroke_mutation.json"
|
||||
TARGET = "Professional Experience"
|
||||
TYPED = TARGET + "x"
|
||||
TOL = 0.05
|
||||
|
||||
|
||||
def para_metrics(para, prefix: str) -> dict:
|
||||
glyphs = []
|
||||
fonts = set()
|
||||
sizes = set()
|
||||
minx = miny = 1e18
|
||||
maxx = maxy = -1e18
|
||||
ascent = descent = 0.0
|
||||
baselines = []
|
||||
line_hs = []
|
||||
for ln in para.lines:
|
||||
baselines.append(ln.baseline_y)
|
||||
line_hs.append(ln.h)
|
||||
for r in ln.runs:
|
||||
fonts.add(r.font_name or "")
|
||||
sizes.add(r.font_size or 0)
|
||||
text = r.text or ""
|
||||
gs = list(r.glyphs)
|
||||
for i, g in enumerate(gs):
|
||||
ch = text[i] if i < len(text) else "?"
|
||||
adv = (gs[i + 1].origin_x - g.origin_x) if i + 1 < len(gs) else (g.bbox_w or (r.font_size or 12) * 0.5)
|
||||
glyphs.append({
|
||||
"char": ch,
|
||||
"origin_x": g.origin_x,
|
||||
"origin_y": g.origin_y,
|
||||
"advance": adv,
|
||||
"bbox_x": g.bbox_x,
|
||||
"bbox_y": g.bbox_y,
|
||||
"bbox_w": g.bbox_w,
|
||||
"bbox_h": g.bbox_h,
|
||||
"font_size": g.font_size or r.font_size,
|
||||
"font_name": g.font_name or r.font_name,
|
||||
"fid": r.internal_font_id,
|
||||
})
|
||||
minx = min(minx, g.bbox_x)
|
||||
miny = min(miny, g.bbox_y)
|
||||
maxx = max(maxx, g.bbox_x + g.bbox_w)
|
||||
maxy = max(maxy, g.bbox_y + g.bbox_h)
|
||||
gbl = g.origin_y if g.origin_y else ln.baseline_y
|
||||
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
|
||||
descent = max(descent, gbl - g.bbox_y)
|
||||
|
||||
joined = "".join(g["char"] for g in glyphs)
|
||||
start = joined.find(prefix[: len(TARGET)])
|
||||
if start < 0:
|
||||
start = 0
|
||||
shared = glyphs[start : start + len(TARGET)]
|
||||
|
||||
leading = None
|
||||
if len(baselines) >= 2:
|
||||
leading = abs(baselines[0] - baselines[1])
|
||||
|
||||
return {
|
||||
"text": joined,
|
||||
"font_family": sorted(fonts),
|
||||
"font_size": max(sizes) if sizes else None,
|
||||
"line_height": max(line_hs) if line_hs else None,
|
||||
"leading": leading,
|
||||
"ascent": ascent,
|
||||
"descent": descent,
|
||||
"paragraph_width": (maxx - minx) if maxx > minx else 0,
|
||||
"paragraph_height": (maxy - miny) if maxy > miny else 0,
|
||||
"n_lines": len(para.lines),
|
||||
"baselines": baselines,
|
||||
"shared_glyphs": shared,
|
||||
"bbox": {"x": minx, "y": miny, "w": maxx - minx, "h": maxy - miny},
|
||||
}
|
||||
|
||||
|
||||
def first_diff(before: dict, after: dict) -> dict | None:
|
||||
checks = []
|
||||
|
||||
def add(name, b, a, ok):
|
||||
checks.append({"property": name, "before": b, "after": a, "match": ok})
|
||||
|
||||
add("font_family", before["font_family"], after["font_family"],
|
||||
before["font_family"] == after["font_family"])
|
||||
add("font_size", before["font_size"], after["font_size"],
|
||||
before["font_size"] is not None and abs((before["font_size"] or 0) - (after["font_size"] or 0)) < TOL)
|
||||
add("line_height", before["line_height"], after["line_height"],
|
||||
before["line_height"] is not None and abs((before["line_height"] or 0) - (after["line_height"] or 0)) < TOL)
|
||||
add("ascent", before["ascent"], after["ascent"], abs(before["ascent"] - after["ascent"]) < TOL)
|
||||
add("descent", before["descent"], after["descent"], abs(before["descent"] - after["descent"]) < TOL)
|
||||
# Width/height: after includes +x so width may grow at the end — compare shared-prefix ink only below.
|
||||
bg, ag = before["shared_glyphs"], after["shared_glyphs"]
|
||||
n = min(len(bg), len(ag), len(TARGET))
|
||||
|
||||
# First glyph-level mutation among UNCHANGED chars
|
||||
glyph_first = None
|
||||
for i in range(n):
|
||||
b, a = bg[i], ag[i]
|
||||
reasons = []
|
||||
if b["char"] != a["char"]:
|
||||
reasons.append("char")
|
||||
if abs(b["advance"] - a["advance"]) > TOL:
|
||||
reasons.append("advance")
|
||||
if abs(b["origin_x"] - a["origin_x"]) > TOL:
|
||||
reasons.append("origin_x")
|
||||
if abs(b["origin_y"] - a["origin_y"]) > TOL:
|
||||
reasons.append("origin_y")
|
||||
if (b.get("font_name") or "") != (a.get("font_name") or ""):
|
||||
reasons.append("font_name")
|
||||
if abs((b.get("font_size") or 0) - (a.get("font_size") or 0)) > TOL:
|
||||
reasons.append("font_size")
|
||||
if reasons:
|
||||
glyph_first = {
|
||||
"index": i,
|
||||
"char": b["char"],
|
||||
"reasons": reasons,
|
||||
"before": b,
|
||||
"after": a,
|
||||
}
|
||||
break
|
||||
|
||||
add("unchanged_glyph_identity", "all match", glyph_first or "all match", glyph_first is None)
|
||||
|
||||
# Prefix span width (should be identical if advances+positions preserved)
|
||||
if n:
|
||||
bw = (bg[n - 1]["origin_x"] + bg[n - 1]["advance"]) - bg[0]["origin_x"]
|
||||
aw = (ag[n - 1]["origin_x"] + ag[n - 1]["advance"]) - ag[0]["origin_x"]
|
||||
add("prefix_width", bw, aw, abs(bw - aw) < TOL)
|
||||
add("prefix_start_x", bg[0]["origin_x"], ag[0]["origin_x"], abs(bg[0]["origin_x"] - ag[0]["origin_x"]) < TOL)
|
||||
|
||||
first = next((c for c in checks if not c["match"]), None)
|
||||
return {"checks": checks, "first_property_that_changes": first, "first_unchanged_glyph_mutation": glyph_first}
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
para = None
|
||||
for p in model.paragraphs:
|
||||
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in t:
|
||||
para = p
|
||||
break
|
||||
assert para is not None
|
||||
|
||||
before = para_metrics(para, TARGET)
|
||||
layout = compute_layout(para)
|
||||
flat = extract_flat_runs(layout["seedRuns"], layout["seedRuns"][0]["fid"], layout["seedRuns"][0]["size"], "#000")
|
||||
|
||||
seed_adv = None
|
||||
for r in flat:
|
||||
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
|
||||
seed_adv = list(r["advances"])
|
||||
break
|
||||
|
||||
typed = []
|
||||
for r in flat:
|
||||
nr = dict(r)
|
||||
if nr.get("text") == TARGET:
|
||||
nr["text"] = TYPED
|
||||
if seed_adv is not None:
|
||||
nr["advances"] = seed_adv
|
||||
nr["advanceSeedText"] = TARGET
|
||||
else:
|
||||
nr.pop("advances", None)
|
||||
typed.append(nr)
|
||||
|
||||
data = build_reflow_data(layout, typed, None, "key-after")
|
||||
data.pop("lines", None) # editedRef drops lines
|
||||
# keep lineX/lineBaselineY as frontend does
|
||||
|
||||
print("PAYLOAD keys:", sorted(data.keys()))
|
||||
print("run:", [(r.get("text"), len(r.get("advances") or []), r.get("advanceSeedText")) for r in typed])
|
||||
|
||||
op = {"version": "1.0", "operations": [{"id": "t", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
para_a = None
|
||||
model_a = doc.get_page(0).extract_document_model()
|
||||
for p in model_a.paragraphs:
|
||||
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
if TARGET in t or TYPED in t or "Professional" in t:
|
||||
para_a = p
|
||||
break
|
||||
assert para_a is not None
|
||||
after = para_metrics(para_a, TYPED)
|
||||
|
||||
result = first_diff(before, after)
|
||||
report = {
|
||||
"target": TARGET,
|
||||
"typed": TYPED,
|
||||
"seed_adv_len": len(seed_adv or []),
|
||||
"before": {k: v for k, v in before.items() if k != "shared_glyphs"},
|
||||
"after": {k: v for k, v in after.items() if k != "shared_glyphs"},
|
||||
"before_shared_adv_sample": [g["advance"] for g in before["shared_glyphs"][:8]],
|
||||
"after_shared_adv_sample": [g["advance"] for g in after["shared_glyphs"][:8]],
|
||||
**result,
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
first = result["first_property_that_changes"]
|
||||
print("\n=== FIRST PROPERTY THAT CHANGES ===")
|
||||
print(json.dumps(first, indent=2))
|
||||
print("\n=== FIRST UNCHANGED GLYPH MUTATION ===")
|
||||
print(json.dumps(result["first_unchanged_glyph_mutation"], indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Step 2 experiment: force runPerChar=false for UNCHANGED paragraph.
|
||||
|
||||
Requires rebuilt pdfengine with forensicForceWholeRun support.
|
||||
Compares:
|
||||
A) default entry (runPerChar as decided by diverges)
|
||||
B) forensicForceWholeRun=true (runPerChar forced 0)
|
||||
C) original PDF
|
||||
|
||||
Emits object dumps + content streams + optional region bitmap SHA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
# Prefer freshly rebuilt engine when present (must be AFTER gateway insert so it wins).
|
||||
_winlocal = Path(r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
if _winlocal.exists():
|
||||
sys.path.insert(0, str(_winlocal))
|
||||
import pdfengine # type: ignore
|
||||
print(f"[engine] {pdfengine.__file__}", flush=True)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
|
||||
def inflate_content(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(pdf_bytes: bytes) -> list[str]:
|
||||
found = []
|
||||
for m in re.finditer(rb"<<[^>]*?/Type\s*/Font[^>]*?>>", pdf_bytes, re.DOTALL):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
# also compact form
|
||||
if not found:
|
||||
for m in re.finditer(rb"<</BaseFont/[^>]+>>", pdf_bytes):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
return found
|
||||
|
||||
|
||||
def region_sha(doc, page_index=0, dpi=144, y_top=200, height=80) -> str:
|
||||
page = doc.get_page(page_index)
|
||||
w, h, raw = page.render_region_raw(dpi, y_top, height)
|
||||
return hashlib.sha256(raw).hexdigest()[:16], w, h
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
|
||||
doc0 = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc0.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-force-whole")
|
||||
|
||||
variants = {
|
||||
"A_default_entry": {**data},
|
||||
"B_force_whole_run": {**data, "forensicForceWholeRun": True},
|
||||
}
|
||||
|
||||
orig_bytes = pdf_path.read_bytes()
|
||||
print("ORIGINAL fonts:", font_dicts(orig_bytes))
|
||||
print("ORIGINAL streams:")
|
||||
for s in inflate_content(orig_bytes):
|
||||
print(s)
|
||||
try:
|
||||
sha, w, h = region_sha(doc0, y_top=120, height=40)
|
||||
print(f"ORIGINAL region sha={sha} {w}x{h}")
|
||||
except Exception as e:
|
||||
print("ORIGINAL region render failed:", e)
|
||||
|
||||
results = {}
|
||||
for name, d in variants.items():
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": name, "type": "reflow_paragraph", "pageIndex": 0, "data": d,
|
||||
}]}
|
||||
print(f"\n===== {name} =====")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
out = doc.save_full()
|
||||
path = out_dir / f"_forensic_{name}.pdf"
|
||||
path.write_bytes(out)
|
||||
print("fonts:", font_dicts(out))
|
||||
streams = inflate_content(out)
|
||||
for s in streams:
|
||||
# only show streams that mention Goodbye / FXF2 / 100 Tm
|
||||
if "100 Tm" in s or "Goodbye" in s or "476F6F64" in s:
|
||||
print("--- content ---")
|
||||
print(s[:2000])
|
||||
# count TJ
|
||||
tj_count = sum(s.count(" TJ") for s in streams)
|
||||
print(f"TJ operator count across streams: {tj_count}")
|
||||
try:
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(out, "")
|
||||
sha, w, h = region_sha(d2, y_top=120, height=40)
|
||||
print(f"region sha={sha} {w}x{h}")
|
||||
results[name] = {"sha": sha, "tj": tj_count, "fonts": font_dicts(out)}
|
||||
except Exception as e:
|
||||
print("region render failed:", e)
|
||||
results[name] = {"error": str(e), "tj": tj_count}
|
||||
|
||||
print("\n===== VERDICT =====")
|
||||
if "A_default_entry" in results and "B_force_whole_run" in results:
|
||||
a, b = results["A_default_entry"], results["B_force_whole_run"]
|
||||
print(f"A (default runPerChar) TJ={a.get('tj')} sha={a.get('sha')}")
|
||||
print(f"B (force whole run) TJ={b.get('tj')} sha={b.get('sha')}")
|
||||
if a.get("sha") and b.get("sha"):
|
||||
if a["sha"] == b["sha"]:
|
||||
print("RESULT: A and B IDENTICAL bitmaps → runPerChar does NOT change pixels")
|
||||
else:
|
||||
print("RESULT: A and B DIFFER → runPerChar DOES change pixels")
|
||||
if a.get("tj") == 1 and b.get("tj") and b["tj"] < a.get("tj", 99):
|
||||
print("NOTE: B emitted fewer TJ (whole-run path active)")
|
||||
if a.get("tj", 0) > 5 and b.get("tj", 0) <= 3:
|
||||
print("CONFIRMED: forensicForceWholeRun reduced emission to whole-run")
|
||||
elif a.get("tj") == b.get("tj"):
|
||||
print("WARNING: TJ counts equal — rebuild may not include forensicForceWholeRun yet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,677 @@
|
||||
"""Runtime glyph-position compare + U+0000 coverage audit (no engine code changes).
|
||||
|
||||
Uses real resume PDF only. Produces per-glyph table for \"Professional Experience\".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real"
|
||||
FID = "Arial-BoldMT_TrueType_32"
|
||||
TARGET = "Professional Experience"
|
||||
TOL = 0.05 # PDF points — first position differ threshold
|
||||
|
||||
|
||||
# --- TJ / content-stream helpers -------------------------------------------------
|
||||
|
||||
def decompress_streams(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
raw = m.group(1)
|
||||
try:
|
||||
out.append(zlib.decompress(raw).decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
try:
|
||||
out.append(raw.decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def parse_tj_kerning(tj_body: str) -> list[dict]:
|
||||
"""Parse a TJ array body into char runs + kerning (thousandths of em).
|
||||
|
||||
Example: [(Pr)-6(o)7(fe)-6(s)...]
|
||||
Returns list of {chars, kern_before} where kern_before is the TJ number
|
||||
preceding that string fragment (0 for the first).
|
||||
"""
|
||||
# Strip outer brackets if present
|
||||
body = tj_body.strip()
|
||||
if body.startswith("["):
|
||||
body = body[1:]
|
||||
if body.endswith("]"):
|
||||
body = body[:-1]
|
||||
items: list[dict] = []
|
||||
pos = 0
|
||||
pending_kern = 0.0
|
||||
while pos < len(body):
|
||||
while pos < len(body) and body[pos].isspace():
|
||||
pos += 1
|
||||
if pos >= len(body):
|
||||
break
|
||||
if body[pos] == "(":
|
||||
# literal string with escape handling
|
||||
pos += 1
|
||||
chars = []
|
||||
while pos < len(body) and body[pos] != ")":
|
||||
if body[pos] == "\\" and pos + 1 < len(body):
|
||||
chars.append(body[pos + 1])
|
||||
pos += 2
|
||||
else:
|
||||
chars.append(body[pos])
|
||||
pos += 1
|
||||
pos += 1 # )
|
||||
s = "".join(chars)
|
||||
items.append({"chars": s, "kern_before": pending_kern})
|
||||
pending_kern = 0.0
|
||||
elif body[pos] == "<":
|
||||
end = body.find(">", pos)
|
||||
hexpart = body[pos + 1 : end]
|
||||
s = bytes.fromhex(hexpart).decode("latin-1", "replace")
|
||||
items.append({"chars": s, "kern_before": pending_kern})
|
||||
pending_kern = 0.0
|
||||
pos = end + 1
|
||||
else:
|
||||
# number (kerning)
|
||||
m = re.match(r"[+-]?\d+(?:\.\d+)?", body[pos:])
|
||||
if not m:
|
||||
pos += 1
|
||||
continue
|
||||
pending_kern = float(m.group(0))
|
||||
pos += len(m.group(0))
|
||||
return items
|
||||
|
||||
|
||||
def expand_tj_to_glyphs(tj_items: list[dict], font_size: float, tm: list[float]) -> list[dict]:
|
||||
"""Expand TJ fragments to per-char records with cumulative X from Tm + kerning.
|
||||
|
||||
Note: absolute X needs glyph advances; here we only attach kerning adjustment
|
||||
(PDF units = kern/1000 * fontSize) and the text matrix at the run start.
|
||||
Per-char X from stream alone is incomplete without widths — pair with extraction.
|
||||
"""
|
||||
rows = []
|
||||
for frag in tj_items:
|
||||
kern_pdf = (frag["kern_before"] / 1000.0) * font_size
|
||||
for i, ch in enumerate(frag["chars"]):
|
||||
rows.append({
|
||||
"char": ch,
|
||||
"kern_before_thousandths": frag["kern_before"] if i == 0 else 0.0,
|
||||
"kern_adj_pdf": kern_pdf if i == 0 else 0.0,
|
||||
"text_matrix": tm[:],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def find_original_heading_tj(pdf_bytes: bytes) -> tuple[list[float], float, list[dict]]:
|
||||
"""Locate Professional Experience TJ near y=597.45."""
|
||||
for s in decompress_streams(pdf_bytes):
|
||||
if "597.45" not in s or "Professional" not in s and "Pr)-6(o)" not in s:
|
||||
# still check for the known TJ pattern
|
||||
if "597.45" not in s:
|
||||
continue
|
||||
lines = s.splitlines()
|
||||
for i, ln in enumerate(lines):
|
||||
if "597.45" in ln and "Tm" in ln:
|
||||
# look forward for Tf + TJ
|
||||
tm = [float(x) for x in re.findall(r"[+-]?\d+(?:\.\d+)?", ln)[:6]]
|
||||
font_size = 12.0
|
||||
tj_body = None
|
||||
for j in range(i, min(i + 10, len(lines))):
|
||||
if " Tf" in lines[j]:
|
||||
nums = re.findall(r"[+-]?\d+(?:\.\d+)?", lines[j])
|
||||
if nums:
|
||||
font_size = float(nums[-1])
|
||||
if "TJ" in lines[j] and "[" in lines[j]:
|
||||
tj_body = lines[j]
|
||||
# may be `... ] TJ` on same line
|
||||
m = re.search(r"\[(.*)\]\s*TJ", lines[j])
|
||||
if m:
|
||||
tj_body = m.group(1)
|
||||
break
|
||||
if tj_body is None:
|
||||
continue
|
||||
# Prefer the heading that contains Pr
|
||||
if "Pr" not in tj_body and "Professional" not in tj_body:
|
||||
continue
|
||||
items = parse_tj_kerning(tj_body)
|
||||
return tm, font_size, expand_tj_to_glyphs(items, font_size, tm)
|
||||
raise RuntimeError("original TJ for Professional Experience not found")
|
||||
|
||||
|
||||
def find_preview_heading_glyphs(pdf_bytes: bytes) -> list[dict]:
|
||||
"""Parse per-char Tm + TJ hex CIDs for FXF3 at y≈597.45."""
|
||||
rows = []
|
||||
for s in decompress_streams(pdf_bytes):
|
||||
if "FXF3" not in s or "597.45" not in s:
|
||||
continue
|
||||
# Match blocks: Tm ... Tf ... [<HHHH>] TJ
|
||||
for m in re.finditer(
|
||||
r"1 0 0 1 ([0-9.+\-]+) ([0-9.+\-]+) Tm\s+/FXF3 ([0-9.]+) Tf.*?\[<([0-9A-Fa-f]+)>\]\s*TJ",
|
||||
s,
|
||||
re.DOTALL,
|
||||
):
|
||||
x, y, size, cid_hex = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||
rows.append({
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"font_size": float(size),
|
||||
"cid_or_gid": int(cid_hex, 16),
|
||||
"cid_hex": cid_hex.upper(),
|
||||
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(x), float(y)],
|
||||
"kern_adj_pdf": 0.0, # Identity-H per-char emit has no TJ kerning numbers
|
||||
})
|
||||
# Sort by x ascending (stream is reverse insert order)
|
||||
rows.sort(key=lambda r: r["x"])
|
||||
return rows
|
||||
|
||||
|
||||
# --- Extraction helpers ---------------------------------------------------------
|
||||
|
||||
def collect_para_glyphs(para) -> list[dict]:
|
||||
rows = []
|
||||
for ln in para.lines:
|
||||
for r in ln.runs:
|
||||
text = r.text or ""
|
||||
glyphs = list(r.glyphs)
|
||||
for i, g in enumerate(glyphs):
|
||||
ch = g.text if getattr(g, "text", None) is not None else (text[i] if i < len(text) else "?")
|
||||
# advance = delta to next origin, else bbox_w
|
||||
if i + 1 < len(glyphs):
|
||||
adv = glyphs[i + 1].origin_x - g.origin_x
|
||||
else:
|
||||
adv = g.bbox_w if g.bbox_w > 0 else (r.font_size or 12) * 0.5
|
||||
row = {
|
||||
"char": ch,
|
||||
"unicode": ord(ch) if len(ch) == 1 else None,
|
||||
"origin_x": g.origin_x,
|
||||
"origin_y": g.origin_y,
|
||||
"advance": adv,
|
||||
"bbox_w": g.bbox_w,
|
||||
"bbox_h": g.bbox_h,
|
||||
"font_size": g.font_size,
|
||||
"font_name": g.font_name,
|
||||
"fid": r.internal_font_id,
|
||||
}
|
||||
for attr in ("glyph_id", "gid", "charcode", "unicode_value", "font_glyph_id"):
|
||||
if hasattr(g, attr):
|
||||
row["glyph_id"] = getattr(g, attr)
|
||||
break
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def find_para(doc):
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
for idx, para in enumerate(model.paragraphs):
|
||||
text = "".join(r.text or "" for ln in para.lines for r in ln.runs)
|
||||
if TARGET in text or TARGET.replace(" ", "") in text.replace(" ", ""):
|
||||
# Prefer exact heading paragraph
|
||||
flat = "".join(r.text or "" for ln in para.lines for r in ln.runs)
|
||||
if TARGET in flat or flat.strip().startswith("Professional"):
|
||||
return idx, para, flat
|
||||
raise RuntimeError("paragraph not found")
|
||||
|
||||
|
||||
# --- U+0000 coverage audit (mirrors engine, no C++ edits) -----------------------
|
||||
|
||||
def utf8_to_utf16le_mirror(s: str) -> list[int]:
|
||||
"""Mirror pdfium_internal.cpp utf8_to_utf16le including trailing NUL."""
|
||||
utf16: list[int] = []
|
||||
data = s.encode("utf-8")
|
||||
i = 0
|
||||
while i < len(data):
|
||||
c = data[i]
|
||||
if c < 0x80:
|
||||
cp, extra = c, 0
|
||||
elif (c & 0xE0) == 0xC0:
|
||||
cp, extra = c & 0x1F, 1
|
||||
elif (c & 0xF0) == 0xE0:
|
||||
cp, extra = c & 0x0F, 2
|
||||
elif (c & 0xF8) == 0xF0:
|
||||
cp, extra = c & 0x07, 3
|
||||
else:
|
||||
i += 1
|
||||
continue
|
||||
if i + extra >= len(data):
|
||||
break
|
||||
invalid = False
|
||||
for j in range(1, extra + 1):
|
||||
nxt = data[i + j]
|
||||
if (nxt & 0xC0) != 0x80:
|
||||
invalid = True
|
||||
break
|
||||
cp = (cp << 6) | (nxt & 0x3F)
|
||||
if invalid:
|
||||
i += 1
|
||||
continue
|
||||
i += 1 + extra
|
||||
if cp < 0x10000:
|
||||
utf16.append(cp)
|
||||
else:
|
||||
cp -= 0x10000
|
||||
utf16.append((cp >> 10) + 0xD800)
|
||||
utf16.append((cp & 0x3FF) + 0xDC00)
|
||||
utf16.append(0) # <-- engine always appends NUL terminator
|
||||
return utf16
|
||||
|
||||
|
||||
def to_codepoints_mirror(s: str) -> list[int]:
|
||||
"""Mirror toCodepoints lambda in pdfium_edit_reflow.cpp."""
|
||||
u16 = utf8_to_utf16le_mirror(s)
|
||||
cps: list[int] = []
|
||||
i = 0
|
||||
while i < len(u16):
|
||||
cp = u16[i]
|
||||
if 0xD800 <= cp <= 0xDBFF and i + 1 < len(u16):
|
||||
low = u16[i + 1]
|
||||
if 0xDC00 <= low <= 0xDFFF:
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
cps.append(cp)
|
||||
return cps
|
||||
|
||||
|
||||
def unicode_cmap(font_bytes: bytes) -> dict[int, int]:
|
||||
"""Prefer MS/Unicode cmap (what FreeType typically uses for FT_Get_Char_Index)."""
|
||||
from fontTools.ttLib import TTFont # type: ignore
|
||||
import io
|
||||
tt = TTFont(io.BytesIO(font_bytes))
|
||||
glyph_order = tt.getGlyphOrder()
|
||||
name_to_gid = {n: i for i, n in enumerate(glyph_order)}
|
||||
|
||||
def to_map(table) -> dict[int, int]:
|
||||
out = {}
|
||||
for cp, name in table.cmap.items():
|
||||
out[cp] = name_to_gid.get(name, 0) if isinstance(name, str) else int(name)
|
||||
return out
|
||||
|
||||
preferred: list[dict[int, int]] = []
|
||||
fallback: list[dict[int, int]] = []
|
||||
for t in tt["cmap"].tables:
|
||||
mapping = to_map(t)
|
||||
if t.platformID == 3 and t.platEncID in (1, 10):
|
||||
preferred.append(mapping)
|
||||
elif t.platformID == 0:
|
||||
preferred.append(mapping)
|
||||
else:
|
||||
fallback.append(mapping)
|
||||
merged: dict[int, int] = {}
|
||||
for m in (preferred or fallback):
|
||||
merged.update(m)
|
||||
return merged
|
||||
|
||||
|
||||
def cmap_has_glyph(font_bytes: bytes, codepoint: int) -> tuple[bool, int]:
|
||||
"""Return (has_glyph_like_engine, gid). Engine: FT_Get_Char_Index != 0."""
|
||||
try:
|
||||
cmap = unicode_cmap(font_bytes)
|
||||
gid = int(cmap.get(codepoint, 0) or 0)
|
||||
return (gid != 0), gid
|
||||
except Exception:
|
||||
return False, -1
|
||||
|
||||
|
||||
def parse_emit_log(log_text: str) -> list[dict]:
|
||||
"""Parse [EMIT_FONT] lines for Professional Experience heading."""
|
||||
rows = []
|
||||
for m in re.finditer(
|
||||
r"\[EMIT_FONT\] text='([^']*)'.*?atX=([0-9.+\-]+)\s+baselineY=([0-9.+\-]+).*?runPerChar=(\d+)",
|
||||
log_text,
|
||||
):
|
||||
rows.append({
|
||||
"char": m.group(1),
|
||||
"origin_x": float(m.group(2)),
|
||||
"origin_y": float(m.group(3)),
|
||||
"runPerChar": int(m.group(4)),
|
||||
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(m.group(2)), float(m.group(3))],
|
||||
"emitted": True,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def align_emit_to_target(target: str, emit_rows: list[dict], advances: list[float], baseline_y: float) -> list[dict]:
|
||||
"""Align EMIT rows to TARGET; spaces skipped by runPerChar get emitted=False with inferred X."""
|
||||
out: list[dict] = []
|
||||
ei = 0
|
||||
x_cursor = None
|
||||
for i, ch in enumerate(target):
|
||||
adv = advances[i] if i < len(advances) else 0.0
|
||||
if ch == " ":
|
||||
# runPerChar path: if (seg.text[c] != ' ') emitObj(...) — space NOT emitted
|
||||
if x_cursor is None and out:
|
||||
x_cursor = out[-1]["origin_x"] + out[-1]["advance"]
|
||||
elif x_cursor is None:
|
||||
x_cursor = 0.0
|
||||
out.append({
|
||||
"char": " ",
|
||||
"origin_x": x_cursor,
|
||||
"origin_y": baseline_y,
|
||||
"advance": adv,
|
||||
"emitted": False,
|
||||
"glyph_id": None,
|
||||
"kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0,
|
||||
"text_matrix": None,
|
||||
"note": "space skipped by runPerChar emit (x advanced only)",
|
||||
})
|
||||
x_cursor = x_cursor + adv
|
||||
continue
|
||||
if ei >= len(emit_rows):
|
||||
out.append({
|
||||
"char": ch, "origin_x": None, "origin_y": None, "advance": adv,
|
||||
"emitted": False, "glyph_id": None, "kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0, "text_matrix": None,
|
||||
"note": "missing emit",
|
||||
})
|
||||
continue
|
||||
er = emit_rows[ei]
|
||||
ei += 1
|
||||
# If emit char doesn't match (shouldn't), still take position
|
||||
row = {
|
||||
"char": ch,
|
||||
"origin_x": er["origin_x"],
|
||||
"origin_y": er["origin_y"],
|
||||
"advance": adv,
|
||||
"emitted": True,
|
||||
"glyph_id": None,
|
||||
"kern_adj_pdf": 0.0,
|
||||
"kern_before_thousandths": 0.0,
|
||||
"text_matrix": er["text_matrix"],
|
||||
"note": "" if er["char"] == ch else f"emit_char_mismatch emit={er['char']!r}",
|
||||
}
|
||||
out.append(row)
|
||||
x_cursor = er["origin_x"] + adv
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
import io
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
orig_bytes = PDF.read_bytes()
|
||||
|
||||
print("=== LOAD ORIGINAL ===")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
pidx, para, flat = find_para(doc)
|
||||
print(f"paragraph idx={pidx} text={flat!r}")
|
||||
|
||||
orig_glyphs = collect_para_glyphs(para)
|
||||
joined = "".join(g["char"] for g in orig_glyphs)
|
||||
start = joined.find(TARGET)
|
||||
if start < 0:
|
||||
target_glyphs = [g for g in orig_glyphs if g.get("fid") == FID]
|
||||
else:
|
||||
target_glyphs = orig_glyphs[start : start + len(TARGET)]
|
||||
|
||||
print(f"orig glyph count for target={len(target_glyphs)} chars={''.join(g['char'] for g in target_glyphs)!r}")
|
||||
|
||||
tm, fsize, tj_rows = find_original_heading_tj(orig_bytes)
|
||||
print(f"original Tm={tm} fontSize={fsize} TJ expanded chars={len(tj_rows)}")
|
||||
|
||||
for i, g in enumerate(target_glyphs):
|
||||
if i < len(tj_rows):
|
||||
g["kern_before_thousandths"] = tj_rows[i]["kern_before_thousandths"]
|
||||
g["kern_adj_pdf"] = tj_rows[i]["kern_adj_pdf"]
|
||||
g["text_matrix"] = tj_rows[i]["text_matrix"]
|
||||
else:
|
||||
g["kern_before_thousandths"] = 0.0
|
||||
g["kern_adj_pdf"] = 0.0
|
||||
g["text_matrix"] = tm
|
||||
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), FID)
|
||||
dom_run = next((r for r in layout["seedRuns"] if r["fid"] == dominant_fid and r["text"].strip()), layout["seedRuns"][0])
|
||||
runs = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_run["size"], dom_run["color"])
|
||||
data = build_reflow_data(layout, runs, layout["origLines"], f"pos-{pidx}")
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "pos", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
|
||||
print("=== APPLY REFLOW (edit-entry, unchanged text) ===")
|
||||
# Capture spdlog on stderr/stdout if redirected; also read prior pattern from engine
|
||||
log_buf = io.StringIO()
|
||||
doc2 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
# spdlog writes to stderr typically via our capture of the process — here we rely on
|
||||
# re-parsing from a file we tee. Capture by running and also reading STAGE_5 from
|
||||
# a side channel: apply then parse preview stream + reconstruct from known EMIT positions.
|
||||
doc2.apply_edits(json.dumps(op))
|
||||
prev_bytes = doc2.save_full()
|
||||
(OUT / "preview_pos.pdf").write_bytes(prev_bytes)
|
||||
|
||||
# Client advances from seed (same numbers reflow uses when lengths match)
|
||||
client_adv = None
|
||||
for r in layout["seedRuns"]:
|
||||
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
|
||||
client_adv = list(r["advances"])
|
||||
break
|
||||
if client_adv is None:
|
||||
client_adv = [g["advance"] for g in target_glyphs]
|
||||
|
||||
# Preview positions: from content stream FXF3 (non-space) + inferred space
|
||||
stream_prev = find_preview_heading_glyphs(prev_bytes)
|
||||
print(f"preview stream FXF3 glyphs={len(stream_prev)} (spaces intentionally not emitted in runPerChar)")
|
||||
|
||||
# Build synthetic emit rows from stream (sorted by x) — chars from TARGET without spaces
|
||||
non_space = [ch for ch in TARGET if ch != " "]
|
||||
emit_rows = []
|
||||
for i, ch in enumerate(non_space):
|
||||
if i < len(stream_prev):
|
||||
sp = stream_prev[i]
|
||||
emit_rows.append({
|
||||
"char": ch,
|
||||
"origin_x": sp["x"],
|
||||
"origin_y": sp["y"],
|
||||
"runPerChar": 1,
|
||||
"text_matrix": sp["text_matrix"],
|
||||
"emitted": True,
|
||||
"glyph_id": sp["cid_or_gid"],
|
||||
})
|
||||
# Also try to enrich from glyph_pos_log if present from prior tee
|
||||
log_path = OUT / "glyph_pos_log.txt"
|
||||
if log_path.exists():
|
||||
parsed = parse_emit_log(log_path.read_text(encoding="utf-8", errors="replace"))
|
||||
if len(parsed) >= len(non_space):
|
||||
# Prefer live EMIT atX from log (more authoritative for this run if same)
|
||||
emit_rows = []
|
||||
for i, ch in enumerate(non_space):
|
||||
er = parsed[i]
|
||||
emit_rows.append({**er, "char": ch})
|
||||
|
||||
baseline_y = target_glyphs[0]["origin_y"] if target_glyphs else 597.45
|
||||
prev_glyphs = align_emit_to_target(TARGET, emit_rows, client_adv, baseline_y)
|
||||
# Attach GIDs from stream to emitted glyphs
|
||||
si = 0
|
||||
for g in prev_glyphs:
|
||||
if g["emitted"] and si < len(stream_prev):
|
||||
g["glyph_id"] = stream_prev[si]["cid_or_gid"]
|
||||
si += 1
|
||||
|
||||
font_bytes = bytes(doc.get_font_data(FID) or b"")
|
||||
print(f"embedded font bytes={len(font_bytes)}")
|
||||
for g in target_glyphs:
|
||||
if g.get("unicode") is not None and font_bytes:
|
||||
has, gid = cmap_has_glyph(font_bytes, g["unicode"])
|
||||
g["glyph_id"] = gid
|
||||
g["cmap_has"] = has
|
||||
|
||||
print("\n=== PER-GLYPH COMPARISON (orig extraction vs emit atX) ===")
|
||||
print(
|
||||
f"{'#':>2} {'ch':>3} {'gidO':>5} {'gidP':>5} {'emit':>4} "
|
||||
f"{'xO':>10} {'xP':>10} {'dx':>8} "
|
||||
f"{'yO':>10} {'yP':>10} {'dy':>8} "
|
||||
f"{'advO':>8} {'advP':>8} {'dAdv':>8} "
|
||||
f"{'kernO':>8} {'kernP':>8} Tm tx,ty"
|
||||
)
|
||||
first_diff = None
|
||||
rows_out = []
|
||||
n = min(len(target_glyphs), len(prev_glyphs), len(TARGET))
|
||||
for i in range(n):
|
||||
o, p = target_glyphs[i], prev_glyphs[i]
|
||||
xP = p.get("origin_x")
|
||||
yP = p.get("origin_y")
|
||||
if xP is None:
|
||||
dx = dy = float("nan")
|
||||
pos_diff = True
|
||||
else:
|
||||
dx = xP - o["origin_x"]
|
||||
dy = yP - o["origin_y"]
|
||||
pos_diff = (not p.get("emitted")) or abs(dx) > TOL or abs(dy) > TOL
|
||||
dadv = (p["advance"] - o["advance"]) if p.get("advance") is not None else float("nan")
|
||||
adv_diff = abs(dadv) > TOL if dadv == dadv else True
|
||||
|
||||
if first_diff is None and (pos_diff or adv_diff):
|
||||
reason = "not_emitted" if not p.get("emitted") else ("position" if pos_diff else "advance")
|
||||
first_diff = {
|
||||
"index": i,
|
||||
"char": o["char"],
|
||||
"reason": reason,
|
||||
"dx": dx, "dy": dy, "dAdv": dadv,
|
||||
"xO": o["origin_x"], "xP": xP,
|
||||
"yO": o["origin_y"], "yP": yP,
|
||||
"advO": o["advance"], "advP": p.get("advance"),
|
||||
"kernO": o.get("kern_adj_pdf", 0), "kernP": p.get("kern_adj_pdf", 0),
|
||||
"gidO": o.get("glyph_id"), "gidP": p.get("glyph_id"),
|
||||
"tmO": o.get("text_matrix"), "tmP": p.get("text_matrix"),
|
||||
"note": p.get("note"),
|
||||
}
|
||||
|
||||
tmO, tmP = o.get("text_matrix"), p.get("text_matrix")
|
||||
tmOs = f"O[{tmO[4]:.3f},{tmO[5]:.3f}]" if tmO and len(tmO) >= 6 else "O[—]"
|
||||
tmPs = f"P[{tmP[4]:.3f},{tmP[5]:.3f}]" if tmP and len(tmP) >= 6 else ("P[— skipped]" if not p.get("emitted") else "P[—]")
|
||||
xPs = f"{xP:10.4f}" if xP is not None else f"{'None':>10}"
|
||||
yPs = f"{yP:10.4f}" if yP is not None else f"{'None':>10}"
|
||||
dxs = f"{dx:8.4f}" if dx == dx else f"{'nan':>8}"
|
||||
dys = f"{dy:8.4f}" if dy == dy else f"{'nan':>8}"
|
||||
print(
|
||||
f"{i:2d} {o['char']:>3} {str(o.get('glyph_id')):>5} {str(p.get('glyph_id')):>5} "
|
||||
f"{'Y' if p.get('emitted') else 'N':>4} "
|
||||
f"{o['origin_x']:10.4f} {xPs} {dxs} "
|
||||
f"{o['origin_y']:10.4f} {yPs} {dys} "
|
||||
f"{o['advance']:8.4f} {p.get('advance', 0):8.4f} {dadv:8.4f} "
|
||||
f"{o.get('kern_adj_pdf', 0):8.4f} {p.get('kern_adj_pdf', 0):8.4f} {tmOs} {tmPs}"
|
||||
)
|
||||
rows_out.append({
|
||||
"i": i, "char": o["char"],
|
||||
"gid_orig": o.get("glyph_id"), "gid_prev": p.get("glyph_id"),
|
||||
"emitted": bool(p.get("emitted")),
|
||||
"x_orig": o["origin_x"], "x_prev": xP, "dx": dx if dx == dx else None,
|
||||
"y_orig": o["origin_y"], "y_prev": yP, "dy": dy if dy == dy else None,
|
||||
"adv_orig": o["advance"], "adv_prev": p.get("advance"), "d_adv": dadv if dadv == dadv else None,
|
||||
"kern_orig_pdf": o.get("kern_adj_pdf", 0),
|
||||
"kern_orig_thousandths": o.get("kern_before_thousandths", 0),
|
||||
"kern_prev_pdf": p.get("kern_adj_pdf", 0),
|
||||
"tm_orig": o.get("text_matrix"),
|
||||
"tm_prev": p.get("text_matrix"),
|
||||
"pos_differs": pos_diff,
|
||||
"adv_differs": adv_diff,
|
||||
"note": p.get("note"),
|
||||
})
|
||||
|
||||
print("\n=== FIRST GLYPH WHOSE POSITION DIFFERS ===")
|
||||
print(json.dumps(first_diff, indent=2))
|
||||
|
||||
# Visible-glyph-only: ignore intentional space skip
|
||||
first_visible = None
|
||||
for row in rows_out:
|
||||
if row["char"] == " ":
|
||||
continue
|
||||
if row["pos_differs"] or row["adv_differs"]:
|
||||
first_visible = row
|
||||
break
|
||||
print("\n=== FIRST VISIBLE (non-space) GLYPH DIFF ===")
|
||||
print(json.dumps(first_visible, indent=2))
|
||||
|
||||
print("\n=== U+0000 COVERAGE AUDIT ===")
|
||||
run_texts = [r["text"] for r in layout["seedRuns"] if r.get("text")]
|
||||
print(f"seed run texts: {run_texts!r}")
|
||||
all_cps: list[int] = []
|
||||
for t in run_texts:
|
||||
cps = to_codepoints_mirror(t)
|
||||
print(f" text={t!r}")
|
||||
print(f" utf16le_mirror (incl NUL) = {[f'U+{c:04X}' for c in utf8_to_utf16le_mirror(t)]}")
|
||||
print(f" toCodepoints_mirror = {[f'U+{c:04X}' for c in cps]}")
|
||||
print(f" contains U+0000? {0 in cps}")
|
||||
all_cps.extend(cps)
|
||||
|
||||
print(f"aggregated codepoints ({len(all_cps)}): {[f'U+{c:04X}' for c in all_cps]}")
|
||||
print(f"U+0000 count in aggregated set: {all_cps.count(0)}")
|
||||
|
||||
missing_real = []
|
||||
lacking = []
|
||||
has0 = False
|
||||
gid0 = -1
|
||||
if font_bytes:
|
||||
has0, gid0 = cmap_has_glyph(font_bytes, 0)
|
||||
print(f"Unicode cmap U+0000: hasGlyph_engine_rule={has0} gid={gid0}")
|
||||
print(" (engine hasGlyph: FT_Get_Char_Index(cp) != 0; gid==0 => MISSING)")
|
||||
# Mac Roman may map NUL — note for audit
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
tt = TTFont(io.BytesIO(font_bytes))
|
||||
for t in tt["cmap"].tables:
|
||||
if 0 in t.cmap:
|
||||
print(f" note: platform={t.platformID} enc={t.platEncID} maps U+0000 -> {t.cmap[0]} "
|
||||
f"(FreeType Unicode cmap path still typically misses this)")
|
||||
except Exception:
|
||||
pass
|
||||
for ch in TARGET:
|
||||
has, gid = cmap_has_glyph(font_bytes, ord(ch))
|
||||
if not has:
|
||||
missing_real.append((ch, ord(ch), gid))
|
||||
print(f"missing real TARGET chars in Unicode cmap: {missing_real or 'NONE'}")
|
||||
real_cps = [c for c in all_cps if c != 0]
|
||||
lacking = [c for c in real_cps if not cmap_has_glyph(font_bytes, c)[0]]
|
||||
print(f"coverage without U+0000: lacking={ [f'U+{c:04X}' for c in lacking] or 'NONE' }")
|
||||
forces = (0 in all_cps) and (not has0) and (not lacking)
|
||||
print(f"CONCLUSION: U+0000 {'DOES' if forces else 'does not alone'} force fullProvenLacking "
|
||||
f"for this paragraph (runtime also logged only U+0000 as MISSING)")
|
||||
|
||||
report = {
|
||||
"target": TARGET,
|
||||
"fid": FID,
|
||||
"orig_tm": tm,
|
||||
"orig_font_size": fsize,
|
||||
"first_diff": first_diff,
|
||||
"first_visible_diff": first_visible,
|
||||
"glyphs": rows_out,
|
||||
"u0000_audit": {
|
||||
"run_texts": run_texts,
|
||||
"codepoints_per_run": [
|
||||
{"text": t, "cps": [f"U+{c:04X}" for c in to_codepoints_mirror(t)]}
|
||||
for t in run_texts
|
||||
],
|
||||
"u0000_injected_by": "utf8_to_utf16le() always push_back(0); toCodepoints iterates full vector including NUL",
|
||||
"engine_hasGlyph_rule": "FT_Get_Char_Index(face, cp) != 0",
|
||||
"embedded_unicode_cmap_has_u0000": has0,
|
||||
"embedded_u0000_gid": gid0,
|
||||
"embedded_missing_real_chars": missing_real,
|
||||
"coverage_ok_if_u0000_ignored": not lacking,
|
||||
"runtime_log": "FONT_COVERAGE_DEBUG full font MISSING codepoint U+0000 only",
|
||||
},
|
||||
}
|
||||
(OUT / "glyph_pos_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {OUT / 'glyph_pos_report.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Compare full-page render hashes + PDF objects across corpus files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
from forensic_obj_compare import apply_and_save # type: ignore
|
||||
|
||||
|
||||
def inflate(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(b: bytes) -> list[str]:
|
||||
return [m.group(0).decode("latin-1", "replace") for m in re.finditer(rb"<</BaseFont/[^>]+>>", b)]
|
||||
|
||||
|
||||
def sha_full(doc, dpi=144) -> tuple[str, int]:
|
||||
img = doc.get_page(0).render(dpi)
|
||||
png = bytes(img.data)
|
||||
return hashlib.sha256(png).hexdigest()[:24], len(png)
|
||||
|
||||
|
||||
def run(pdf: Path):
|
||||
print(f"\n######## {pdf}")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf), "")
|
||||
s0, n0 = sha_full(doc)
|
||||
print(f"ORIG sha={s0} pngbytes={n0}")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
if not model.paragraphs:
|
||||
print("no paragraphs")
|
||||
return
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
for f in doc.get_fonts(0, 0)[:6]:
|
||||
print(f" FontInfo name={f.font_name} emb={f.is_embedded} type={f.type} "
|
||||
f"subset={getattr(f, 'is_subset', None)} id={f.internal_font_id}")
|
||||
print(" seedRuns:", [(r["fontName"], r["fid"], round(r["size"], 2),
|
||||
len(r["advances"]) if r.get("advances") else 0)
|
||||
for r in layout["seedRuns"][:4]])
|
||||
|
||||
fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], fid, dom["size"], dom["color"])
|
||||
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "cmp")
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
outp = Path(__file__).resolve().parent / "_tmp_entry.pdf"
|
||||
apply_and_save(pdf, op, outp)
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(outp.read_bytes(), "")
|
||||
s1, n1 = sha_full(d2)
|
||||
print(f"ENTRY sha={s1} pngbytes={n1} {'IDENTICAL' if s0 == s1 else '*** DIFFERENT ***'}")
|
||||
b = outp.read_bytes()
|
||||
print(" entry fonts:", font_dicts(b))
|
||||
for s in inflate(b):
|
||||
if "Tm" in s or "TJ" in s or "Tj" in s:
|
||||
one_line = s.replace("\n", " | ")
|
||||
print(" stream:", one_line[:700])
|
||||
tj = s.count(" TJ")
|
||||
print(f" TJ count={tj}")
|
||||
break
|
||||
|
||||
flat1 = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
|
||||
data1 = build_reflow_data(layout, flat1, None, "cmp")
|
||||
# also try force whole if supported
|
||||
data1_force = {**build_reflow_data(layout, flat, layout["origLines"], "cmp"),
|
||||
"forensicForceWholeRun": True}
|
||||
op1 = {"version": "1.0", "operations": [{
|
||||
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": data1,
|
||||
}]}
|
||||
out1 = Path(__file__).resolve().parent / "_tmp_one.pdf"
|
||||
apply_and_save(pdf, op1, out1)
|
||||
d3 = pdfengine.PdfDocument.load_from_memory(out1.read_bytes(), "")
|
||||
s2, n2 = sha_full(d3)
|
||||
print(f"ONE sha={s2} pngbytes={n2} vsORIG={'SAME' if s0 == s2 else 'DIFF'} vsENTRY={'SAME' if s1 == s2 else 'DIFF'}")
|
||||
|
||||
# force whole on unchanged
|
||||
opf = {"version": "1.0", "operations": [{
|
||||
"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data1_force,
|
||||
}]}
|
||||
outf = Path(__file__).resolve().parent / "_tmp_force.pdf"
|
||||
apply_and_save(pdf, opf, outf)
|
||||
df = pdfengine.PdfDocument.load_from_memory(outf.read_bytes(), "")
|
||||
sf, nf = sha_full(df)
|
||||
bf = outf.read_bytes()
|
||||
tjf = sum(s.count(" TJ") for s in inflate(bf))
|
||||
tje = sum(s.count(" TJ") for s in inflate(b))
|
||||
print(f"FORCE sha={sf} pngbytes={nf} vsORIG={'SAME' if s0 == sf else 'DIFF'} "
|
||||
f"vsENTRY={'SAME' if s1 == sf else 'DIFF'} TJ_entry={tje} TJ_force={tjf}")
|
||||
if "forensicForceWholeRun" in json.dumps(opf):
|
||||
if tje != tjf:
|
||||
print(" => forensicForceWholeRun ACTIVE (TJ count changed)")
|
||||
else:
|
||||
print(" => forensicForceWholeRun NOT in binary yet (TJ unchanged) OR no-op")
|
||||
|
||||
|
||||
def main():
|
||||
for p in [
|
||||
ROOT / "corpus" / "basic" / "hello_world.pdf",
|
||||
ROOT / "tests" / "edits" / "forensic_multiline.pdf",
|
||||
ROOT / "corpus" / "fonts" / "embedded_truetype.pdf",
|
||||
ROOT / "corpus" / "fonts" / "subset_font.pdf",
|
||||
]:
|
||||
if p.exists():
|
||||
try:
|
||||
run(p)
|
||||
except Exception as e:
|
||||
print(f"FAILED {p}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,414 @@
|
||||
"""First-keystroke forensic on real resume: Professional Experience vs +x.
|
||||
|
||||
Mirrors ParagraphEditor after editedRef=true:
|
||||
- no origLines
|
||||
- advances dropped when length != text (typing one char)
|
||||
|
||||
Compares per-glyph: char, gid, advance, origin, font resource, outline hash.
|
||||
Does not modify engine logic beyond using the rebuilt binary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
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"
|
||||
TYPED = TARGET + "x"
|
||||
TOL = 0.05
|
||||
DPI = 288
|
||||
|
||||
|
||||
def sha(b: bytes) -> str:
|
||||
return hashlib.sha256(b).hexdigest()[:16]
|
||||
|
||||
|
||||
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 text.strip().startswith("Professional"):
|
||||
return idx, para, text
|
||||
raise RuntimeError("paragraph not found")
|
||||
|
||||
|
||||
def collect_glyphs(para, want_prefix: str) -> list[dict]:
|
||||
rows = []
|
||||
for ln in para.lines:
|
||||
for r in ln.runs:
|
||||
glyphs = list(r.glyphs)
|
||||
text = r.text or ""
|
||||
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 "?")
|
||||
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
|
||||
rows.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,
|
||||
"font_name": g.font_name,
|
||||
"fid": r.internal_font_id,
|
||||
})
|
||||
joined = "".join(g["char"] for g in rows)
|
||||
# Prefer longest match starting at TARGET / TYPED
|
||||
for needle in (want_prefix, TARGET):
|
||||
start = joined.find(needle)
|
||||
if start >= 0:
|
||||
return rows[start : start + len(want_prefix)] if want_prefix.startswith(TARGET) else rows[start:start + len(needle)]
|
||||
return rows
|
||||
|
||||
|
||||
def decompress_streams(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 parse_heading_emits(pdf_bytes: bytes, y_approx: float = 597.45) -> list[dict]:
|
||||
"""Parse Identity-H / FXF* per-char emits near heading baseline."""
|
||||
rows = []
|
||||
for s in decompress_streams(pdf_bytes):
|
||||
if "597.45" not in s and f"{y_approx}" not in s:
|
||||
continue
|
||||
for m in re.finditer(
|
||||
r"1 0 0 1 ([0-9.+\-]+) ([0-9.+\-]+) Tm\s+/(FXF\d+) ([0-9.]+) Tf.*?\[<([0-9A-Fa-f]+)>\]\s*TJ",
|
||||
s,
|
||||
re.DOTALL,
|
||||
):
|
||||
y = float(m.group(2))
|
||||
if abs(y - y_approx) > 0.5:
|
||||
continue
|
||||
rows.append({
|
||||
"x": float(m.group(1)),
|
||||
"y": y,
|
||||
"font_res": m.group(3),
|
||||
"font_size": float(m.group(4)),
|
||||
"gid": int(m.group(5), 16),
|
||||
})
|
||||
rows.sort(key=lambda r: r["x"])
|
||||
return rows
|
||||
|
||||
|
||||
def find_font_for_res(pdf_bytes: bytes, res_name: str) -> dict:
|
||||
"""Resolve /FXFn -> font obj BaseFont + FontFile2 sha if possible."""
|
||||
# Find resource mapping then font dict — crude but enough for forensic
|
||||
objs = {}
|
||||
parts = re.split(rb"(\d+)\s+0\s+obj", pdf_bytes)
|
||||
i = 1
|
||||
while i + 1 < len(parts):
|
||||
objs[int(parts[i].decode())] = parts[i + 1].split(b"endobj", 1)[0]
|
||||
i += 2
|
||||
font_obj = None
|
||||
for body in objs.values():
|
||||
t = body.decode("latin-1", "replace")
|
||||
m = re.search(rf"/{res_name}\s+(\d+)\s+0\s+R", t)
|
||||
if m:
|
||||
font_obj = int(m.group(1))
|
||||
break
|
||||
if font_obj is None:
|
||||
return {"res": res_name, "error": "unmapped"}
|
||||
body = objs.get(font_obj, b"").decode("latin-1", "replace")
|
||||
bf = re.search(r"/BaseFont\s*/([^\s/>\[]+)", body)
|
||||
info = {"res": res_name, "font_obj": font_obj, "baseFont": bf.group(1) if bf else None}
|
||||
# Descendant / FontDescriptor / FontFile2
|
||||
dm = re.search(r"/DescendantFonts\s*\[\s*(\d+)\s+0\s+R", body)
|
||||
target = font_obj
|
||||
if dm:
|
||||
target = int(dm.group(1))
|
||||
body = objs.get(target, b"").decode("latin-1", "replace")
|
||||
fd = re.search(r"/FontDescriptor\s+(\d+)\s+0\s+R", body)
|
||||
if fd:
|
||||
fdb = objs.get(int(fd.group(1)), b"").decode("latin-1", "replace")
|
||||
ff = re.search(r"/FontFile2\s+(\d+)\s+0\s+R", fdb)
|
||||
if ff:
|
||||
ffb = objs.get(int(ff.group(1)), b"")
|
||||
m = re.search(rb"stream\r?\n(.*?)\r?\nendstream", ffb, re.DOTALL)
|
||||
if m:
|
||||
raw = m.group(1)
|
||||
try:
|
||||
raw = zlib.decompress(raw)
|
||||
except Exception:
|
||||
pass
|
||||
info["fontfile2_len"] = len(raw)
|
||||
info["fontfile2_sha"] = sha(raw)
|
||||
return info
|
||||
|
||||
|
||||
def crop_glyph(doc, box, dpi=DPI, pad=1.5) -> tuple[bytes, int, int]:
|
||||
page = doc.get_page(0)
|
||||
ph = page.height
|
||||
x0 = box["bbox_x"] - pad
|
||||
y0 = box["bbox_y"] - pad
|
||||
x1 = box["bbox_x"] + box["bbox_w"] + pad
|
||||
y1 = box["bbox_y"] + box["bbox_h"] + pad
|
||||
y_top = ph - y1
|
||||
height = y1 - y0
|
||||
w, h, raw = page.render_region_raw(dpi, y_top, height)
|
||||
scale = dpi / 72.0
|
||||
left = max(0, int(x0 * scale))
|
||||
right = min(w, int(x1 * scale) + 1)
|
||||
cw = max(1, right - left)
|
||||
crop = bytearray(cw * h * 4)
|
||||
for row in range(h):
|
||||
src = (row * w + left) * 4
|
||||
dst = row * cw * 4
|
||||
crop[dst : dst + cw * 4] = raw[src : src + cw * 4]
|
||||
return bytes(crop), cw, h
|
||||
|
||||
|
||||
def outline_hash(rgba: bytes) -> str:
|
||||
return sha(rgba)
|
||||
|
||||
|
||||
def apply_reflow(pdf_path: Path, data: dict, label: str) -> bytes:
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": label, "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
return doc.save_full()
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
print("=== LOAD ORIGINAL ===")
|
||||
doc0 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
_, para, text = find_para(doc0)
|
||||
print(f"para text={text!r}")
|
||||
layout = compute_layout(para)
|
||||
flat = extract_flat_runs(layout["seedRuns"], FID, layout["seedRuns"][0]["size"], layout["seedRuns"][0]["color"])
|
||||
|
||||
# --- BEFORE: edit-entry unchanged ---
|
||||
data_entry = build_reflow_data(layout, flat, layout["origLines"], "key-before")
|
||||
print("\n=== BEFORE (unchanged, with origLines+advances) ===")
|
||||
before_bytes = apply_reflow(PDF, data_entry, "before")
|
||||
(OUT / "keystroke_before.pdf").write_bytes(before_bytes)
|
||||
|
||||
# --- AFTER: first keystroke (+x), mirror editedRef with seed-advance merge ---
|
||||
seed_text = TARGET
|
||||
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_runs = []
|
||||
for r in flat:
|
||||
nr = {k: v for k, v in r.items()}
|
||||
if nr.get("text") == TARGET:
|
||||
nr["text"] = TYPED
|
||||
if seed_adv is not None:
|
||||
nr["advances"] = seed_adv
|
||||
nr["advanceSeedText"] = seed_text
|
||||
else:
|
||||
nr.pop("advances", None)
|
||||
typed_runs.append(nr)
|
||||
if not any(r.get("text") == TYPED for r in typed_runs):
|
||||
base = flat[0] if flat else {"internalFontId": FID, "fontSize": 12, "color": "#1a5276"}
|
||||
typed_runs = [{
|
||||
"text": TYPED,
|
||||
"internalFontId": base.get("internalFontId") or FID,
|
||||
"fontSize": base.get("fontSize") or 12,
|
||||
"color": base.get("color") or "#1a5276",
|
||||
**({"advances": seed_adv, "advanceSeedText": seed_text} if seed_adv else {}),
|
||||
}]
|
||||
|
||||
data_after = build_reflow_data(layout, typed_runs, None, "key-after")
|
||||
data_after.pop("lines", None)
|
||||
print("\n=== AFTER (typed +x, advanceSeedText merge, no origLines) ===")
|
||||
print(f" runs={[ (r.get('text'), len(r.get('advances') or []), r.get('advanceSeedText')) for r in typed_runs ]}")
|
||||
after_bytes = apply_reflow(PDF, data_after, "after")
|
||||
(OUT / "keystroke_after.pdf").write_bytes(after_bytes)
|
||||
|
||||
# Load both for extraction + crops
|
||||
doc_b = pdfengine.PdfDocument.load_from_memory(before_bytes, "")
|
||||
doc_a = pdfengine.PdfDocument.load_from_memory(after_bytes, "")
|
||||
_, para_b, text_b = find_para(doc_b)
|
||||
_, para_a, text_a = find_para(doc_a)
|
||||
print(f"before extracted={text_b!r}")
|
||||
print(f"after extracted={text_a!r}")
|
||||
|
||||
glyphs_b = collect_glyphs(para_b, TARGET)
|
||||
glyphs_a = collect_glyphs(para_a, TYPED)
|
||||
print(f"before glyphs={len(glyphs_b)} after glyphs={len(glyphs_a)}")
|
||||
|
||||
emits_b = parse_heading_emits(before_bytes)
|
||||
emits_a = parse_heading_emits(after_bytes)
|
||||
print(f"before stream emits={len(emits_b)} after stream emits={len(emits_a)}")
|
||||
|
||||
fonts_b = {}
|
||||
fonts_a = {}
|
||||
for e in emits_b:
|
||||
fonts_b.setdefault(e["font_res"], find_font_for_res(before_bytes, e["font_res"]))
|
||||
for e in emits_a:
|
||||
fonts_a.setdefault(e["font_res"], find_font_for_res(after_bytes, e["font_res"]))
|
||||
print("before fonts:", json.dumps(fonts_b, indent=2))
|
||||
print("after fonts:", json.dumps(fonts_a, indent=2))
|
||||
|
||||
# Align by index over shared prefix TARGET (ignore trailing x for pairwise compare of originals)
|
||||
n = min(len(glyphs_b), len(glyphs_a), len(TARGET))
|
||||
# Map stream emits to non-space chars
|
||||
def attach_emits(glyphs, emits):
|
||||
ei = 0
|
||||
for g in glyphs:
|
||||
if g["char"] == " ":
|
||||
g["emitted"] = False
|
||||
g["gid"] = None
|
||||
g["font_res"] = None
|
||||
g["stream_x"] = None
|
||||
continue
|
||||
if ei < len(emits):
|
||||
e = emits[ei]
|
||||
ei += 1
|
||||
g["emitted"] = True
|
||||
g["gid"] = e["gid"]
|
||||
g["font_res"] = e["font_res"]
|
||||
g["stream_x"] = e["x"]
|
||||
g["stream_y"] = e["y"]
|
||||
else:
|
||||
g["emitted"] = False
|
||||
g["gid"] = None
|
||||
g["font_res"] = None
|
||||
|
||||
attach_emits(glyphs_b, emits_b)
|
||||
attach_emits(glyphs_a, emits_a)
|
||||
|
||||
print("\n=== PER-GLYPH BEFORE vs AFTER (shared prefix) ===")
|
||||
print(
|
||||
f"{'#':>2} {'ch':>3} {'gidB':>5} {'gidA':>5} "
|
||||
f"{'xB':>10} {'xA':>10} {'dx':>8} "
|
||||
f"{'advB':>8} {'advA':>8} {'dAdv':>8} "
|
||||
f"{'fontB':>6} {'fontA':>6} {'outEq':>5}"
|
||||
)
|
||||
first_diff = None
|
||||
rows = []
|
||||
for i in range(n):
|
||||
b, a = glyphs_b[i], glyphs_a[i]
|
||||
xB = b.get("stream_x", b["origin_x"])
|
||||
xA = a.get("stream_x", a["origin_x"])
|
||||
if xB is None:
|
||||
xB = b["origin_x"]
|
||||
if xA is None:
|
||||
xA = a["origin_x"]
|
||||
dx = xA - xB
|
||||
dadv = a["advance"] - b["advance"]
|
||||
|
||||
# Outline hash using BEFORE bbox for both (normalized) when possible
|
||||
out_eq = None
|
||||
hb = ha = None
|
||||
try:
|
||||
if b["char"] != " " and b.get("bbox_w", 0) > 0:
|
||||
rb, wb, hb_ = crop_glyph(doc_b, b)
|
||||
# Use same crop box on after doc
|
||||
ra, wa, ha_ = crop_glyph(doc_a, b)
|
||||
h = min(hb_, ha_)
|
||||
w = min(wb, wa)
|
||||
|
||||
def trim(rgba, W, H, tw, th):
|
||||
out = bytearray(tw * th * 4)
|
||||
for y in range(th):
|
||||
out[y * tw * 4:(y + 1) * tw * 4] = rgba[y * W * 4:y * W * 4 + tw * 4]
|
||||
return bytes(out)
|
||||
|
||||
tb, ta = trim(rb, wb, hb_, w, h), trim(ra, wa, ha_, w, h)
|
||||
hb, ha = outline_hash(tb), outline_hash(ta)
|
||||
out_eq = tb == ta
|
||||
except Exception as e:
|
||||
out_eq = f"err:{e}"
|
||||
|
||||
font_changed = (b.get("font_res") != a.get("font_res")) or (
|
||||
fonts_b.get(b.get("font_res") or "", {}).get("fontfile2_sha")
|
||||
!= fonts_a.get(a.get("font_res") or "", {}).get("fontfile2_sha")
|
||||
)
|
||||
pos_diff = abs(dx) > TOL
|
||||
adv_diff = abs(dadv) > TOL
|
||||
gid_diff = b.get("gid") != a.get("gid")
|
||||
outline_diff = out_eq is False
|
||||
changed = pos_diff or adv_diff or gid_diff or outline_diff or font_changed or (b["char"] != a["char"])
|
||||
|
||||
if first_diff is None and changed:
|
||||
first_diff = {
|
||||
"index": i,
|
||||
"char": b["char"],
|
||||
"reasons": [r for r, c in [
|
||||
("position", pos_diff), ("advance", adv_diff), ("gid", gid_diff),
|
||||
("outline", outline_diff), ("font", font_changed), ("char", b["char"] != a["char"]),
|
||||
] if c],
|
||||
"xB": xB, "xA": xA, "dx": dx,
|
||||
"advB": b["advance"], "advA": a["advance"], "dAdv": dadv,
|
||||
"gidB": b.get("gid"), "gidA": a.get("gid"),
|
||||
"fontB": b.get("font_res"), "fontA": a.get("font_res"),
|
||||
"fontInfoB": fonts_b.get(b.get("font_res") or ""),
|
||||
"fontInfoA": fonts_a.get(a.get("font_res") or ""),
|
||||
"outlineHashB": hb, "outlineHashA": ha,
|
||||
}
|
||||
|
||||
print(
|
||||
f"{i:2d} {b['char']:>3} {str(b.get('gid')):>5} {str(a.get('gid')):>5} "
|
||||
f"{xB:10.4f} {xA:10.4f} {dx:8.4f} "
|
||||
f"{b['advance']:8.4f} {a['advance']:8.4f} {dadv:8.4f} "
|
||||
f"{str(b.get('font_res')):>6} {str(a.get('font_res')):>6} {str(out_eq):>5}"
|
||||
)
|
||||
rows.append({
|
||||
"i": i, "char": b["char"],
|
||||
"gidB": b.get("gid"), "gidA": a.get("gid"),
|
||||
"xB": xB, "xA": xA, "dx": dx,
|
||||
"yB": b.get("stream_y", b["origin_y"]), "yA": a.get("stream_y", a["origin_y"]),
|
||||
"advB": b["advance"], "advA": a["advance"], "dAdv": dadv,
|
||||
"fontB": b.get("font_res"), "fontA": a.get("font_res"),
|
||||
"outline_equal": out_eq,
|
||||
"outlineHashB": hb, "outlineHashA": ha,
|
||||
"changed": changed,
|
||||
})
|
||||
|
||||
# Trailing typed char
|
||||
if len(glyphs_a) > n:
|
||||
g = glyphs_a[n]
|
||||
print(f"\n+++ typed glyph[{n}] char={g['char']!r} x={g.get('stream_x', g['origin_x'])} "
|
||||
f"adv={g['advance']:.4f} gid={g.get('gid')} font={g.get('font_res')}")
|
||||
|
||||
print("\n=== FIRST GLYPH THAT CHANGES (before → after keystroke) ===")
|
||||
print(json.dumps(first_diff, indent=2))
|
||||
|
||||
report = {
|
||||
"before_text": TARGET,
|
||||
"after_text": TYPED,
|
||||
"before_fonts": fonts_b,
|
||||
"after_fonts": fonts_a,
|
||||
"first_diff": first_diff,
|
||||
"glyphs": rows,
|
||||
"after_extra": glyphs_a[n:] if len(glyphs_a) > n else [],
|
||||
}
|
||||
(OUT / "keystroke_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {OUT / 'keystroke_report.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Parse keystroke_log.txt into a clean before/after advance+origin table."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
LOG = Path(__file__).resolve().parent / "forensic_real" / "keystroke_log.txt"
|
||||
OUT = Path(__file__).resolve().parent / "forensic_real" / "keystroke_report.json"
|
||||
TOL = 0.05
|
||||
|
||||
|
||||
def extract_stage5(log: str) -> list[dict]:
|
||||
out = []
|
||||
for m in re.finditer(r"\[STAGE_5_SERIALIZED_JSON\]", log):
|
||||
i = m.end()
|
||||
while i < len(log) and log[i] != "{":
|
||||
i += 1
|
||||
if i >= len(log):
|
||||
continue
|
||||
depth = 0
|
||||
for j in range(i, len(log)):
|
||||
ch = log[j]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
raw = re.sub(r"[\r\n]+", "", log[i : j + 1])
|
||||
out.append(json.loads(raw))
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def origins(x0, advs):
|
||||
xs = [x0]
|
||||
for a in advs[:-1]:
|
||||
xs.append(xs[-1] + a)
|
||||
return xs
|
||||
|
||||
|
||||
def main():
|
||||
log = LOG.read_bytes()
|
||||
if log.startswith(b"\xff\xfe") or log.startswith(b"\xfe\xff"):
|
||||
log = log.decode("utf-16")
|
||||
else:
|
||||
log = log.decode("utf-8", errors="replace")
|
||||
stages = extract_stage5(log)
|
||||
print(f"STAGE_5 blocks: {len(stages)}")
|
||||
if len(stages) < 2:
|
||||
raise SystemExit("need 2 STAGE_5 blocks")
|
||||
|
||||
before, after = stages[0], stages[1]
|
||||
adv_b = before["lines"][0]["adv"]
|
||||
adv_a = after["lines"][0]["adv"]
|
||||
text_b = before["lines"][0]["text"]
|
||||
text_a = after["lines"][0]["text"]
|
||||
x0_b = before["lines"][0]["x0"]
|
||||
x0_a = after["lines"][0]["x0"]
|
||||
ox_b = origins(x0_b, adv_b)
|
||||
ox_a = origins(x0_a, adv_a)
|
||||
n = min(len(text_b), len(text_a), len(adv_b), len(adv_a), len(ox_b), len(ox_a))
|
||||
|
||||
print(f"textB={text_b!r}")
|
||||
print(f"textA={text_a!r}")
|
||||
print(f"{'#':>2} {'ch':>3} {'xB':>10} {'xA':>10} {'dx':>8} {'advB':>10} {'advA':>10} {'dAdv':>10}")
|
||||
first = None
|
||||
rows = []
|
||||
for i in range(n):
|
||||
dx = ox_a[i] - ox_b[i]
|
||||
da = adv_a[i] - adv_b[i]
|
||||
ch = text_b[i]
|
||||
print(f"{i:2d} {ch:>3} {ox_b[i]:10.4f} {ox_a[i]:10.4f} {dx:8.4f} {adv_b[i]:10.6f} {adv_a[i]:10.6f} {da:10.6f}")
|
||||
row = {"i": i, "char": ch, "xB": ox_b[i], "xA": ox_a[i], "dx": dx,
|
||||
"advB": adv_b[i], "advA": adv_a[i], "dAdv": da}
|
||||
rows.append(row)
|
||||
if first is None and (abs(dx) > TOL or abs(da) > TOL):
|
||||
first = dict(row)
|
||||
if abs(da) > TOL and abs(dx) <= TOL:
|
||||
first["reason"] = "advance"
|
||||
elif abs(dx) > TOL and abs(da) <= TOL:
|
||||
first["reason"] = "position"
|
||||
else:
|
||||
first["reason"] = "advance+position"
|
||||
|
||||
parts = log.split("Document caches have been invalidated")
|
||||
emit_re = re.compile(
|
||||
r"\[EMIT_FONT\] text='([^']*)'.*?fontPtr=(0x[0-9a-fA-F]+)"
|
||||
r".*?measureFacePtr=(0x[0-9a-fA-F]+).*?atX=([0-9.+\-]+).*?runPerChar=(\d+)",
|
||||
re.DOTALL,
|
||||
)
|
||||
e0 = list(emit_re.finditer(parts[0]))
|
||||
e1 = list(emit_re.finditer(parts[1])) if len(parts) > 1 else []
|
||||
|
||||
print("\nBEFORE emits:")
|
||||
for m in e0:
|
||||
print(f" text={m.group(1)!r} fontPtr={m.group(2)} atX={m.group(4)} runPerChar={m.group(5)}")
|
||||
print("AFTER emits:")
|
||||
for m in e1:
|
||||
print(f" text={m.group(1)!r} fontPtr={m.group(2)} atX={m.group(4)} runPerChar={m.group(5)}")
|
||||
|
||||
u0000 = any("U+0000" in ln and "FONT_COVERAGE" in ln for ln in log.splitlines())
|
||||
print(f"\nU+0000 coverage failure in this run? {u0000}")
|
||||
print("FIRST DIFF:", json.dumps(first, indent=2))
|
||||
|
||||
report = {
|
||||
"u0000_coverage_failure": u0000,
|
||||
"embedded_font_after_fix": {
|
||||
"before_pdf_baseFont": "BCDEEE+Arial-BoldMT",
|
||||
"before_fontfile2_sha": "cc80da80a119a52c",
|
||||
"note": "After U+0000 fix, edit-entry emit reuses original embedded subset (no system Arial)",
|
||||
},
|
||||
"before": {
|
||||
"text": text_b, "adv": adv_b, "x0": x0_b,
|
||||
"runPerChar": 1, "emitMode": "per-char",
|
||||
"emits": [{"text": m.group(1), "fontPtr": m.group(2), "measureFacePtr": m.group(3),
|
||||
"atX": float(m.group(4)), "runPerChar": int(m.group(5))} for m in e0],
|
||||
},
|
||||
"after": {
|
||||
"text": text_a, "adv": adv_a, "x0": x0_a,
|
||||
"runPerChar": 0, "emitMode": "whole-word",
|
||||
"emits": [{"text": m.group(1), "fontPtr": m.group(2), "measureFacePtr": m.group(3),
|
||||
"atX": float(m.group(4)), "runPerChar": int(m.group(5))} for m in e1],
|
||||
"note": "advLen=0 -> recomputed HarfBuzz advances; runPerChar=0",
|
||||
},
|
||||
"first_diff": first,
|
||||
"glyphs": rows,
|
||||
"interpretation": (
|
||||
"First keystroke drops client advances (length mismatch) and origLines. "
|
||||
"Engine recomputes natural HarfBuzz advances (no original TJ kerning). "
|
||||
"First mutation is advance of glyph index 1 ('r'): client/kerned ~4.740 -> natural ~4.670. "
|
||||
"Positions of all subsequent glyphs diverge from that point."
|
||||
),
|
||||
}
|
||||
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
%PDF-1.7
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 238 >>
|
||||
stream
|
||||
BT /F1 11 Tf 72 700 Td (The quick brown fox jumps over the lazy) Tj ET
|
||||
BT /F1 11 Tf 72 686 Td (dog near the river bank on a sunny) Tj ET
|
||||
BT /F1 11 Tf 72 672 Td (afternoon in early spring.) Tj ET
|
||||
BT /F1 11 Tf 72 600 Td (FOOTER LINE) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000529 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
599
|
||||
%%EOF
|
||||
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"pdf": "corpus\\basic\\hello_world.pdf",
|
||||
"fonts": [
|
||||
{
|
||||
"id": "Helvetica_Type1_32",
|
||||
"name": "Helvetica",
|
||||
"embedded": false,
|
||||
"type": "Type1",
|
||||
"subset": false,
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0
|
||||
},
|
||||
{
|
||||
"id": "Times-Roman_Type1_32",
|
||||
"name": "Times-Roman",
|
||||
"embedded": false,
|
||||
"type": "Type1",
|
||||
"subset": false,
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0
|
||||
}
|
||||
],
|
||||
"orig": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye, world!",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 114.31999969482422,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
1
|
||||
],
|
||||
"glyphCount": 15,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
93.824,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.272,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.6,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.152,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.048,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"entry": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye,",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 67.10400390625,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
14,
|
||||
13,
|
||||
12,
|
||||
11,
|
||||
10,
|
||||
9,
|
||||
8,
|
||||
7
|
||||
],
|
||||
"glyphCount": 8,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 1,
|
||||
"text": " ",
|
||||
"fontName": "",
|
||||
"fontId": "",
|
||||
"fontSize": 1.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "",
|
||||
"fontFidelity": "exact",
|
||||
"x": 89.3759994506836,
|
||||
"y": 100.0,
|
||||
"w": 0.0,
|
||||
"h": 0.0,
|
||||
"objectIndices": [],
|
||||
"glyphCount": 1,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
32
|
||||
],
|
||||
"fontMeta": null
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 2,
|
||||
"text": "world!",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 93.87199401855469,
|
||||
"y": 99.8239974975586,
|
||||
"w": 41.29600524902344,
|
||||
"h": 11.632003784179688,
|
||||
"objectIndices": [
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1
|
||||
],
|
||||
"glyphCount": 6,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
93.824,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.272,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.6,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.152,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.048,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"one": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye,",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 67.10400390625,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
2
|
||||
],
|
||||
"glyphCount": 8,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 1,
|
||||
"text": " ",
|
||||
"fontName": "",
|
||||
"fontId": "",
|
||||
"fontSize": 1.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "",
|
||||
"fontFidelity": "exact",
|
||||
"x": 89.3759994506836,
|
||||
"y": 100.0,
|
||||
"w": 0.0,
|
||||
"h": 0.0,
|
||||
"objectIndices": [],
|
||||
"glyphCount": 1,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
32
|
||||
],
|
||||
"fontMeta": null
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 2,
|
||||
"text": "world!x",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 93.8762435913086,
|
||||
"y": 99.8239974975586,
|
||||
"w": 50.512001037597656,
|
||||
"h": 11.632003784179688,
|
||||
"objectIndices": [
|
||||
1
|
||||
],
|
||||
"glyphCount": 7,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
93.828,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.38,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.276,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.604,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.156,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.052,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
136.5,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33,
|
||||
120
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": {
|
||||
"orig": {
|
||||
"textOps": [
|
||||
{
|
||||
"op": "Tf",
|
||||
"fontRes": "F1",
|
||||
"size": 12.0
|
||||
},
|
||||
{
|
||||
"op": "Tf",
|
||||
"fontRes": "F2",
|
||||
"size": 16.0
|
||||
},
|
||||
{
|
||||
"op": "Td",
|
||||
"tx": 20.0,
|
||||
"ty": 50.0
|
||||
},
|
||||
{
|
||||
"op": "Td",
|
||||
"tx": 0.0,
|
||||
"ty": 50.0
|
||||
},
|
||||
{
|
||||
"op": "Tj",
|
||||
"raw": "(Hello, world!) Tj"
|
||||
},
|
||||
{
|
||||
"op": "Tj",
|
||||
"raw": "(Goodbye, world!) Tj"
|
||||
}
|
||||
],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "Type1",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "Type1",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 1
|
||||
},
|
||||
"entry": {
|
||||
"textOps": [],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 2
|
||||
},
|
||||
"one": {
|
||||
"textOps": [],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Forensic: dump & compare PDF text objects — original vs edit-entry vs one-char.
|
||||
|
||||
Does NOT compare images. Compares:
|
||||
font base name, font size, matrix, bbox, unicode text, object count,
|
||||
and raw content-stream snippets around Tj/TJ.
|
||||
|
||||
Also answers Step 4 from static path + runtime FontInfo:
|
||||
why measureFace is nullptr for this paragraph's font.
|
||||
"""
|
||||
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"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
|
||||
def dump_page_text_objects(doc, page_index: int = 0) -> list[dict]:
|
||||
"""Re-extract model and also pull flat glyph list as proxy for text objects."""
|
||||
page = doc.get_page(page_index)
|
||||
model = page.extract_document_model()
|
||||
fonts = doc.get_fonts(page_index, page_index)
|
||||
font_by_id = {f.internal_font_id: f for f in fonts}
|
||||
|
||||
objs = []
|
||||
for pi, para in enumerate(model.paragraphs):
|
||||
for li, line in enumerate(para.lines):
|
||||
for ri, run in enumerate(line.runs):
|
||||
fi = font_by_id.get(run.internal_font_id)
|
||||
objs.append({
|
||||
"para": pi, "line": li, "run": ri,
|
||||
"text": run.text,
|
||||
"fontName": run.font_name,
|
||||
"fontId": run.internal_font_id,
|
||||
"fontSize": run.font_size,
|
||||
"isEmbedded": run.is_embedded,
|
||||
"fontType": run.type,
|
||||
"fontFidelity": getattr(run, "font_fidelity", None),
|
||||
"x": run.x, "y": run.y, "w": run.w, "h": run.h,
|
||||
"objectIndices": list(run.object_indices or []),
|
||||
"glyphCount": len(run.glyphs),
|
||||
"glyphOrigins": [(round(g.origin_x, 3), round(g.origin_y, 3)) for g in run.glyphs[:40]],
|
||||
"glyphUnicodes": [g.unicode for g in run.glyphs[:40]],
|
||||
"fontMeta": None if not fi else {
|
||||
"ascent": fi.ascent, "descent": fi.descent,
|
||||
"capHeight": getattr(fi, "cap_height", None),
|
||||
"isEmbedded": fi.is_embedded,
|
||||
"isSubset": getattr(fi, "is_subset", None),
|
||||
"type": fi.type,
|
||||
"fontName": fi.font_name,
|
||||
},
|
||||
})
|
||||
return objs
|
||||
|
||||
|
||||
def extract_content_stream_text_ops(pdf_bytes: bytes) -> dict:
|
||||
"""Best-effort parse of content stream for font/text operators."""
|
||||
# Find stream ... endstream blocks
|
||||
streams = re.findall(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL)
|
||||
ops = []
|
||||
for s in streams:
|
||||
# Decode latin-1 so binary stays 1:1
|
||||
try:
|
||||
text = s.decode("latin-1")
|
||||
except Exception:
|
||||
continue
|
||||
# Font select: /F1 16 Tf or /Helv 11 Tf
|
||||
for m in re.finditer(r"/([A-Za-z0-9\+\,\-]+)\s+([\d.]+)\s+Tf", text):
|
||||
ops.append({"op": "Tf", "fontRes": m.group(1), "size": float(m.group(2))})
|
||||
# Text matrix: a b c d e f Tm
|
||||
for m in re.finditer(
|
||||
r"([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+Tm",
|
||||
text,
|
||||
):
|
||||
ops.append({"op": "Tm", "matrix": [float(x) for x in m.groups()]})
|
||||
# Td
|
||||
for m in re.finditer(r"([\d.\-]+)\s+([\d.\-]+)\s+Td", text):
|
||||
ops.append({"op": "Td", "tx": float(m.group(1)), "ty": float(m.group(2))})
|
||||
# Literal strings before Tj
|
||||
for m in re.finditer(r"\((?:\\.|[^\\)])*\)\s*Tj", text):
|
||||
raw = m.group(0)
|
||||
ops.append({"op": "Tj", "raw": raw[:120]})
|
||||
for m in re.finditer(r"\[.*?\]\s*TJ", text, re.DOTALL):
|
||||
ops.append({"op": "TJ", "raw": m.group(0)[:200]})
|
||||
# Font dictionaries
|
||||
fonts = []
|
||||
for m in re.finditer(
|
||||
rb"/Type\s*/Font\s*/Subtype\s*/(\w+)\s*/BaseFont\s*/([^\s/]+)",
|
||||
pdf_bytes,
|
||||
):
|
||||
fonts.append({"subtype": m.group(1).decode(), "baseFont": m.group(2).decode()})
|
||||
# Also looser BaseFont
|
||||
if not fonts:
|
||||
for m in re.finditer(rb"/BaseFont\s*/([^\s/]+)", pdf_bytes):
|
||||
fonts.append({"subtype": "?", "baseFont": m.group(1).decode()})
|
||||
return {"textOps": ops, "fontDicts": fonts, "streamCount": len(streams)}
|
||||
|
||||
|
||||
def load_layout(path: Path):
|
||||
sys.path.insert(0, str(path.parent))
|
||||
from forensic_extract import ( # type: ignore
|
||||
compute_layout, extract_flat_runs, build_reflow_data,
|
||||
)
|
||||
return compute_layout, extract_flat_runs, build_reflow_data
|
||||
|
||||
|
||||
def build_payloads(pdf_path: Path):
|
||||
compute_layout, extract_flat_runs, build_reflow_data = load_layout(
|
||||
Path(__file__).resolve().parent
|
||||
)
|
||||
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)
|
||||
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"])
|
||||
para_id = "forensic-obj-cmp"
|
||||
|
||||
entry_data = build_reflow_data(layout, flat, layout["origLines"], para_id)
|
||||
# one char: append x, drop origLines, keep stale advances (mirrors real editor)
|
||||
flat_one = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
|
||||
one_data = build_reflow_data(layout, flat_one, None, para_id)
|
||||
|
||||
return {
|
||||
"entry": {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": entry_data,
|
||||
}]},
|
||||
"one": {"version": "1.0", "operations": [{
|
||||
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": one_data,
|
||||
}]},
|
||||
"layout": layout,
|
||||
"fonts": [
|
||||
{
|
||||
"id": f.internal_font_id, "name": f.font_name,
|
||||
"embedded": f.is_embedded, "type": f.type,
|
||||
"subset": getattr(f, "is_subset", None),
|
||||
"ascent": f.ascent, "descent": f.descent,
|
||||
}
|
||||
for f in doc.get_fonts(0, 0)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def apply_and_save(pdf_path: Path, op: dict, out_path: Path) -> bytes:
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
data = doc.save_full()
|
||||
out_path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
def compare_objs(label_a: str, a: list[dict], label_b: str, b: list[dict]):
|
||||
print(f"\n{'='*60}\nCOMPARE {label_a} vs {label_b}\n{'='*60}")
|
||||
print(f" object/run count: {len(a)} -> {len(b)}")
|
||||
keys = ["text", "fontName", "fontId", "fontSize", "isEmbedded", "fontType",
|
||||
"w", "glyphCount"]
|
||||
n = max(len(a), len(b))
|
||||
for i in range(min(n, 12)):
|
||||
oa = a[i] if i < len(a) else None
|
||||
ob = b[i] if i < len(b) else None
|
||||
if oa is None:
|
||||
print(f" [{i}] ADDED: {ob['text']!r} font={ob['fontName']} size={ob['fontSize']}")
|
||||
continue
|
||||
if ob is None:
|
||||
print(f" [{i}] REMOVED: {oa['text']!r}")
|
||||
continue
|
||||
diffs = []
|
||||
for k in keys:
|
||||
if oa.get(k) != ob.get(k):
|
||||
diffs.append(f"{k}: {oa.get(k)!r} -> {ob.get(k)!r}")
|
||||
# origin of first glyph
|
||||
goa = oa["glyphOrigins"][0] if oa["glyphOrigins"] else None
|
||||
gob = ob["glyphOrigins"][0] if ob["glyphOrigins"] else None
|
||||
if goa != gob:
|
||||
diffs.append(f"firstOrigin: {goa} -> {gob}")
|
||||
status = "CHANGED" if diffs else "same"
|
||||
print(f" [{i}] {oa['text']!r:30} {status}")
|
||||
for d in diffs:
|
||||
print(f" {d}")
|
||||
|
||||
|
||||
def explain_measureface(fonts: list[dict]):
|
||||
print(f"\n{'='*60}\nSTEP 4 — why measureFace is nullptr\n{'='*60}")
|
||||
for f in fonts:
|
||||
print(f" FontInfo: id={f['id']} name={f['name']} embedded={f['embedded']} "
|
||||
f"type={f['type']} subset={f['subset']}")
|
||||
if not f["embedded"]:
|
||||
print(" PATH TAKEN in loadEmissionFont():")
|
||||
print(" matchedFontInfo && !isEmbedded")
|
||||
print(" -> cacheKey = \"standard_\" + fontName")
|
||||
print(" -> useEmbedded = false")
|
||||
print(" -> useSystem = false")
|
||||
print(" skip embedded/system load branches")
|
||||
print(" fall through to FPDFText_LoadStandardFont()")
|
||||
print(" measureFace ONLY set from loadedFontDataBuffers_ (TTF bytes)")
|
||||
print(" standard fonts never put bytes in that buffer")
|
||||
print(" => measureFace remains nullptr BY DESIGN for non-embedded fonts")
|
||||
else:
|
||||
print(" Embedded path: measureFace set only if getFontData*/LoadFromMemory succeeds")
|
||||
print(" or via useSystem branch FONT_METRICS_FIX preserving orig face")
|
||||
|
||||
|
||||
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
|
||||
|
||||
print(f"PDF: {pdf_path}")
|
||||
payloads = build_payloads(pdf_path)
|
||||
explain_measureface(payloads["fonts"])
|
||||
|
||||
# Original
|
||||
orig_doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
orig_objs = dump_page_text_objects(orig_doc)
|
||||
orig_bytes = pdf_path.read_bytes()
|
||||
orig_stream = extract_content_stream_text_ops(orig_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nORIGINAL PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in orig_objs:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
if o["fontMeta"]:
|
||||
print(f" meta: ascent={o['fontMeta']['ascent']} descent={o['fontMeta']['descent']} "
|
||||
f"subset={o['fontMeta']['isSubset']}")
|
||||
print(f" content fontDicts: {orig_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(orig_stream['textOps'])}):")
|
||||
for op in orig_stream["textOps"][:20]:
|
||||
print(f" {op}")
|
||||
|
||||
# Entry preview (unchanged text)
|
||||
print(f"\n--- applying EDIT ENTRY reflow (unchanged text) ---")
|
||||
entry_path = out_dir / "_forensic_entry.pdf"
|
||||
entry_bytes = apply_and_save(pdf_path, payloads["entry"], entry_path)
|
||||
entry_doc = pdfengine.PdfDocument.load_from_memory(entry_bytes, "")
|
||||
entry_objs = dump_page_text_objects(entry_doc)
|
||||
entry_stream = extract_content_stream_text_ops(entry_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nEDIT-ENTRY PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in entry_objs[:20]:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
print(f" content fontDicts: {entry_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(entry_stream['textOps'])}):")
|
||||
for op in entry_stream["textOps"][:30]:
|
||||
print(f" {op}")
|
||||
|
||||
compare_objs("ORIGINAL", orig_objs, "EDIT-ENTRY", entry_objs)
|
||||
|
||||
# One char
|
||||
print(f"\n--- applying ONE-CHAR reflow ---")
|
||||
one_path = out_dir / "_forensic_one.pdf"
|
||||
one_bytes = apply_and_save(pdf_path, payloads["one"], one_path)
|
||||
one_doc = pdfengine.PdfDocument.load_from_memory(one_bytes, "")
|
||||
one_objs = dump_page_text_objects(one_doc)
|
||||
one_stream = extract_content_stream_text_ops(one_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nONE-CHAR PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in one_objs[:20]:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
print(f" content fontDicts: {one_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(one_stream['textOps'])}):")
|
||||
for op in one_stream["textOps"][:30]:
|
||||
print(f" {op}")
|
||||
|
||||
compare_objs("EDIT-ENTRY", entry_objs, "ONE-CHAR", one_objs)
|
||||
compare_objs("ORIGINAL", orig_objs, "ONE-CHAR", one_objs)
|
||||
|
||||
# Summarize first differences
|
||||
print(f"\n{'='*60}\nFIRST DIFFERENCES SUMMARY\n{'='*60}")
|
||||
print(f" Original run count: {len(orig_objs)}")
|
||||
print(f" Edit-entry run count: {len(entry_objs)}")
|
||||
print(f" One-char run count: {len(one_objs)}")
|
||||
print(f" Original Tj/TJ ops: {sum(1 for o in orig_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" Entry Tj/TJ ops: {sum(1 for o in entry_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" One-char Tj/TJ ops: {sum(1 for o in one_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" Original fontDicts: {orig_stream['fontDicts']}")
|
||||
print(f" Entry fontDicts: {entry_stream['fontDicts']}")
|
||||
print(f" One-char fontDicts: {one_stream['fontDicts']}")
|
||||
|
||||
report = {
|
||||
"pdf": str(pdf_path),
|
||||
"fonts": payloads["fonts"],
|
||||
"orig": orig_objs,
|
||||
"entry": entry_objs,
|
||||
"one": one_objs,
|
||||
"streams": {
|
||||
"orig": orig_stream,
|
||||
"entry": entry_stream,
|
||||
"one": one_stream,
|
||||
},
|
||||
}
|
||||
out_json = out_dir / "forensic_obj_compare.json"
|
||||
out_json.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {out_json}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Verify all four edit-entry overlay fixes against real resume paragraph."""
|
||||
from __future__ import annotations
|
||||
|
||||
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")
|
||||
TARGET = "Professional Experience"
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
|
||||
page = doc.get_page(0)
|
||||
model = page.extract_document_model()
|
||||
para = next(
|
||||
p for p in model.paragraphs
|
||||
if TARGET in "".join(r.text or "" for ln in p.lines for r in ln.runs)
|
||||
)
|
||||
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
|
||||
pdf = {
|
||||
"font_family": run.font_name,
|
||||
"font_weight": 700 if re.search(r"bold|black|heavy", run.font_name or "", re.I) else 400,
|
||||
"font_size": run.font_size,
|
||||
"line_height": line.h,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"ascent": ascent,
|
||||
}
|
||||
|
||||
# Mirror fixed overlay construction
|
||||
extracted = re.sub(r"^[A-Z]{6}\+", "", run.font_name or "").strip()
|
||||
overlay = {
|
||||
"font_family": extracted, # primary face name (before fallbacks)
|
||||
"font_weight": 700 if re.search(r"bold|black|heavy", extracted, re.I) else 400,
|
||||
"font_size": max(run.font_size or 0, run.h or 0),
|
||||
"line_height": line.h if line.h > 0.5 else run.font_size * 1.2,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"ascent": ascent,
|
||||
}
|
||||
|
||||
print(f"{'Property':<14} {'PDF':>14} {'Overlay':>14} {'Result':>8}")
|
||||
rows = [
|
||||
("Font Family", pdf["font_family"], overlay["font_family"]),
|
||||
("Font Weight", pdf["font_weight"], overlay["font_weight"]),
|
||||
("Font Size", pdf["font_size"], overlay["font_size"]),
|
||||
("Line Height", pdf["line_height"], overlay["line_height"]),
|
||||
("Width", pdf["width"], overlay["width"]),
|
||||
("Height", pdf["height"], overlay["height"]),
|
||||
]
|
||||
all_ok = True
|
||||
for name, a, b in rows:
|
||||
if isinstance(a, str):
|
||||
ok = a == b or (isinstance(b, str) and a in b)
|
||||
else:
|
||||
ok = abs(float(a) - float(b)) < 0.05
|
||||
all_ok = all_ok and ok
|
||||
print(f"{name:<14} {str(a):>14} {str(b):>14} {'MATCH' if ok else 'DIFF':>8}")
|
||||
|
||||
print()
|
||||
print("ALL MATCH" if all_ok else "SOME DIFF")
|
||||
if not all_ok:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Post-reflow PDF object inspection via Python pdfengine."""
|
||||
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 inspect_pdf(path: Path, label: str):
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(path), "")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
print(f"\n=== {label} ({path.name}) ===")
|
||||
for li, ln in enumerate(para.lines):
|
||||
for ri, r in enumerate(ln.runs):
|
||||
adv_sum = 0.0
|
||||
if r.glyphs:
|
||||
# derive advances from origin_x
|
||||
gs = r.glyphs
|
||||
for i in range(len(gs)):
|
||||
if i + 1 < len(gs):
|
||||
adv_sum += gs[i + 1].origin_x - gs[i].origin_x
|
||||
else:
|
||||
adv_sum += gs[i].bbox_w or r.font_size * 0.5
|
||||
print(f" line{li} run{ri}: text={repr(r.text[:40])} font={r.font_name} size={r.font_size:.3f} w={r.w:.3f} advSum={adv_sum:.3f} glyphs={len(r.glyphs)}")
|
||||
|
||||
|
||||
def main():
|
||||
extract_path = ROOT / "tests" / "edits" / "forensic_extract_out.json"
|
||||
ex = json.loads(extract_path.read_text(encoding="utf-8"))
|
||||
pdf_path = Path(ex["pdf"])
|
||||
inspect_pdf(pdf_path, "ORIGINAL")
|
||||
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
for label, key in [("ENTRY", "editEntry"), ("ONE_CHAR", "editOne")]:
|
||||
op = ex["payloads"][key]
|
||||
d = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
d.apply_edits(json.dumps(op))
|
||||
out = d.save_full()
|
||||
tmp = ROOT / "tests" / "edits" / f"_forensic_tmp_{key}.pdf"
|
||||
tmp.write_bytes(out)
|
||||
inspect_pdf(tmp, label)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"api_font_sha": "cc80da80a119a52c47a15b4a598882b15f0344b56b3457a4fe41e2ac40843707",
|
||||
"emitted_programs": [
|
||||
{
|
||||
"obj": 5,
|
||||
"baseFont": "BCDEEE+Arial-BoldMT",
|
||||
"sha": "cc80da80a119a52c47a15b4a598882b15f0344b56b3457a4fe41e2ac40843707",
|
||||
"len": 74352,
|
||||
"same_as_api": true
|
||||
},
|
||||
{
|
||||
"obj": 9,
|
||||
"baseFont": "BCDFEE+ArialMT",
|
||||
"sha": "4f617da732cd3bac738f97df3ab8e9b31dead249284d07e0126e7de8cba87f86",
|
||||
"len": 103052,
|
||||
"same_as_api": false
|
||||
},
|
||||
{
|
||||
"obj": 15,
|
||||
"baseFont": "BCDGEE+Arial-BoldMT",
|
||||
"sha": "cc80da80a119a52c47a15b4a598882b15f0344b56b3457a4fe41e2ac40843707",
|
||||
"len": 74352,
|
||||
"same_as_api": true
|
||||
},
|
||||
{
|
||||
"obj": 18,
|
||||
"baseFont": "BCDHEE+Arial-ItalicMT",
|
||||
"sha": "9f73acd5011644c9714baea6348c4f85edffd706da2820dfd6a609f7ad977cf1",
|
||||
"len": 59492,
|
||||
"same_as_api": false
|
||||
},
|
||||
{
|
||||
"obj": 22,
|
||||
"baseFont": "BCDIEE+Arial-ItalicMT",
|
||||
"sha": "9f73acd5011644c9714baea6348c4f85edffd706da2820dfd6a609f7ad977cf1",
|
||||
"len": 59492,
|
||||
"same_as_api": false
|
||||
},
|
||||
{
|
||||
"obj": 27,
|
||||
"baseFont": "BCDJEE+ArialMT",
|
||||
"sha": "4f617da732cd3bac738f97df3ab8e9b31dead249284d07e0126e7de8cba87f86",
|
||||
"len": 103052,
|
||||
"same_as_api": false
|
||||
},
|
||||
{
|
||||
"obj": 140,
|
||||
"baseFont": "Arial-BoldMT",
|
||||
"sha": "766f06ac8761f82f25d032a220e89438f6064591af9915061f20b949efdedf69",
|
||||
"len": 980756,
|
||||
"same_as_api": false
|
||||
},
|
||||
{
|
||||
"obj": 141,
|
||||
"baseFont": "Arial-BoldMT",
|
||||
"sha": "766f06ac8761f82f25d032a220e89438f6064591af9915061f20b949efdedf69",
|
||||
"len": 980756,
|
||||
"same_as_api": false
|
||||
}
|
||||
],
|
||||
"glyphs": {
|
||||
"P": {
|
||||
"identical": true,
|
||||
"diff_bytes": 0,
|
||||
"w": 43,
|
||||
"h": 50
|
||||
},
|
||||
"r": {
|
||||
"identical": true,
|
||||
"diff_bytes": 0,
|
||||
"w": 33,
|
||||
"h": 41
|
||||
},
|
||||
"o": {
|
||||
"identical": true,
|
||||
"diff_bytes": 0,
|
||||
"w": 43,
|
||||
"h": 41
|
||||
},
|
||||
"f": {
|
||||
"identical": true,
|
||||
"diff_bytes": 0,
|
||||
"w": 34,
|
||||
"h": 50
|
||||
}
|
||||
},
|
||||
"first_outline_diff_char": null
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"scope": "edit-entry only (mouse click before typing); no reflow/typing",
|
||||
"pdf": {
|
||||
"text": "Professional Experience",
|
||||
"bbox": {
|
||||
"x": 51.4010009765625,
|
||||
"y": 595.0980224609375,
|
||||
"w": 138.02398681640625,
|
||||
"h": 11.0880126953125
|
||||
},
|
||||
"line_x": 51.4010009765625,
|
||||
"line_y": 595.0980224609375,
|
||||
"line_w": 138.02398681640625,
|
||||
"line_h": 11.0880126953125,
|
||||
"baseline_y": 597.4500122070312,
|
||||
"ascent_pt": 8.73602294921875,
|
||||
"descent_pt": 2.35198974609375,
|
||||
"width_pt": 138.02398681640625,
|
||||
"height_pt": 11.0880126953125,
|
||||
"font_name": "Arial-BoldMT",
|
||||
"font_size_pt": 12.0,
|
||||
"run_h": 11.0880126953125,
|
||||
"internal_font_id": "Arial-BoldMT_TrueType_32",
|
||||
"expected_font_weight": 700
|
||||
},
|
||||
"overlay": {
|
||||
"font_family": "Arial, sans-serif",
|
||||
"font_size_pt": 12.0,
|
||||
"font_size_px": 12.0,
|
||||
"font_weight": "(not set; browser default 400)",
|
||||
"line_height_pt": 14.399999999999999,
|
||||
"line_height_px": 14.399999999999999,
|
||||
"letter_spacing": "(not set; normal)",
|
||||
"width_pt": 512.18896484375,
|
||||
"width_px": 512.18896484375,
|
||||
"height_pt": 14.399999999999999,
|
||||
"height_px": 14.399999999999999,
|
||||
"left_px": 51.4010009765625,
|
||||
"top_px": 184.94998779296876,
|
||||
"ascent_heuristic_pt": 9.600000000000001,
|
||||
"transform": "none",
|
||||
"column_left_pt": 51.4010009765625,
|
||||
"column_right_pt": 563.5899658203125,
|
||||
"page_content_right_pt": 563.5899658203125,
|
||||
"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)"
|
||||
}
|
||||
},
|
||||
"checks": [
|
||||
{
|
||||
"property": "font-family",
|
||||
"pdf": "Arial-BoldMT",
|
||||
"overlay": "Arial, sans-serif",
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "font-weight",
|
||||
"pdf": 700,
|
||||
"overlay": 400,
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "font-size (pt)",
|
||||
"pdf": 12.0,
|
||||
"overlay": 12.0,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "line-height / leading (pt)",
|
||||
"pdf": 11.0880126953125,
|
||||
"overlay": 14.399999999999999,
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "ascent (pt)",
|
||||
"pdf": 8.73602294921875,
|
||||
"overlay": 9.600000000000001,
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "descent (pt)",
|
||||
"pdf": 2.35198974609375,
|
||||
"overlay": "(not represented in overlay CSS)",
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "height (pt)",
|
||||
"pdf": 11.0880126953125,
|
||||
"overlay": 14.399999999999999,
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "width (pt)",
|
||||
"pdf": 138.02398681640625,
|
||||
"overlay": 512.18896484375,
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "letter-spacing",
|
||||
"pdf": 0,
|
||||
"overlay": "normal",
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "transform",
|
||||
"pdf": "none",
|
||||
"overlay": "none",
|
||||
"match": true
|
||||
}
|
||||
],
|
||||
"firstPropertyThatChanges": {
|
||||
"property": "font-family",
|
||||
"pdf": "Arial-BoldMT",
|
||||
"overlay": "Arial, sans-serif",
|
||||
"match": false
|
||||
},
|
||||
"notes": [
|
||||
"measureFamily maps Arial-BoldMT \u2192 '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\u219214.4, ascent 8.74\u21929.6, height 11.09\u219214.4, width text\u2192page column"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"target": "Professional Experience",
|
||||
"typed": "Professional Experiencex",
|
||||
"seed_adv_len": 23,
|
||||
"before": {
|
||||
"text": "Professional Experience",
|
||||
"font_family": [
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"font_size": 12.0,
|
||||
"line_height": 11.0880126953125,
|
||||
"leading": null,
|
||||
"ascent": 8.73602294921875,
|
||||
"descent": 2.35198974609375,
|
||||
"paragraph_width": 138.02398681640625,
|
||||
"paragraph_height": 11.0880126953125,
|
||||
"n_lines": 1,
|
||||
"baselines": [
|
||||
597.4500122070312
|
||||
],
|
||||
"bbox": {
|
||||
"x": 51.4010009765625,
|
||||
"y": 595.0980224609375,
|
||||
"w": 138.02398681640625,
|
||||
"h": 11.0880126953125
|
||||
}
|
||||
},
|
||||
"after": {
|
||||
"text": "Professional Experiencex",
|
||||
"font_family": [
|
||||
"",
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"font_size": 12.0,
|
||||
"line_height": 11.0880126953125,
|
||||
"leading": null,
|
||||
"ascent": 8.73602294921875,
|
||||
"descent": 2.35198974609375,
|
||||
"paragraph_width": 144.2039794921875,
|
||||
"paragraph_height": 11.0880126953125,
|
||||
"n_lines": 1,
|
||||
"baselines": [
|
||||
597.4500122070312
|
||||
],
|
||||
"bbox": {
|
||||
"x": 51.4010009765625,
|
||||
"y": 595.0980224609375,
|
||||
"w": 144.2039794921875,
|
||||
"h": 11.0880126953125
|
||||
}
|
||||
},
|
||||
"before_shared_adv_sample": [
|
||||
8.003997802734375,
|
||||
4.740001678466797,
|
||||
7.247997283935547,
|
||||
3.996002197265625,
|
||||
6.7440032958984375,
|
||||
6.743995666503906,
|
||||
6.743995666503906,
|
||||
3.2519989013671875
|
||||
],
|
||||
"after_shared_adv_sample": [
|
||||
8.003997802734375,
|
||||
4.740001678466797,
|
||||
7.247997283935547,
|
||||
3.996002197265625,
|
||||
6.7440032958984375,
|
||||
6.743995666503906,
|
||||
6.743995666503906,
|
||||
3.2519989013671875
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"property": "font_family",
|
||||
"before": [
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"after": [
|
||||
"",
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "font_size",
|
||||
"before": 12.0,
|
||||
"after": 12.0,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "line_height",
|
||||
"before": 11.0880126953125,
|
||||
"after": 11.0880126953125,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "ascent",
|
||||
"before": 8.73602294921875,
|
||||
"after": 8.73602294921875,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "descent",
|
||||
"before": 2.35198974609375,
|
||||
"after": 2.35198974609375,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "unchanged_glyph_identity",
|
||||
"before": "all match",
|
||||
"after": {
|
||||
"index": 11,
|
||||
"char": "l",
|
||||
"reasons": [
|
||||
"advance"
|
||||
],
|
||||
"before": {
|
||||
"char": "l",
|
||||
"origin_x": 119.23699951171875,
|
||||
"origin_y": 597.4500122070312,
|
||||
"advance": 3.2519989013671875,
|
||||
"bbox_x": 120.10099792480469,
|
||||
"bbox_y": 597.4500122070312,
|
||||
"bbox_w": 1.6440048217773438,
|
||||
"bbox_h": 8.59197998046875,
|
||||
"font_size": 12.0,
|
||||
"font_name": "Arial-BoldMT",
|
||||
"fid": "Arial-BoldMT_TrueType_32"
|
||||
},
|
||||
"after": {
|
||||
"char": "l",
|
||||
"origin_x": 119.23699951171875,
|
||||
"origin_y": 597.4500122070312,
|
||||
"advance": 1.6440048217773438,
|
||||
"bbox_x": 120.10099792480469,
|
||||
"bbox_y": 597.4500122070312,
|
||||
"bbox_w": 1.6440048217773438,
|
||||
"bbox_h": 8.59197998046875,
|
||||
"font_size": 12.0,
|
||||
"font_name": "Arial-BoldMT",
|
||||
"fid": "Arial-BoldMT_TrueType_32"
|
||||
}
|
||||
},
|
||||
"match": false
|
||||
},
|
||||
{
|
||||
"property": "prefix_width",
|
||||
"before": 138.51598358154297,
|
||||
"after": 138.51598358154297,
|
||||
"match": true
|
||||
},
|
||||
{
|
||||
"property": "prefix_start_x",
|
||||
"before": 50.525001525878906,
|
||||
"after": 50.525001525878906,
|
||||
"match": true
|
||||
}
|
||||
],
|
||||
"first_property_that_changes": {
|
||||
"property": "font_family",
|
||||
"before": [
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"after": [
|
||||
"",
|
||||
"Arial-BoldMT"
|
||||
],
|
||||
"match": false
|
||||
},
|
||||
"first_unchanged_glyph_mutation": {
|
||||
"index": 11,
|
||||
"char": "l",
|
||||
"reasons": [
|
||||
"advance"
|
||||
],
|
||||
"before": {
|
||||
"char": "l",
|
||||
"origin_x": 119.23699951171875,
|
||||
"origin_y": 597.4500122070312,
|
||||
"advance": 3.2519989013671875,
|
||||
"bbox_x": 120.10099792480469,
|
||||
"bbox_y": 597.4500122070312,
|
||||
"bbox_w": 1.6440048217773438,
|
||||
"bbox_h": 8.59197998046875,
|
||||
"font_size": 12.0,
|
||||
"font_name": "Arial-BoldMT",
|
||||
"fid": "Arial-BoldMT_TrueType_32"
|
||||
},
|
||||
"after": {
|
||||
"char": "l",
|
||||
"origin_x": 119.23699951171875,
|
||||
"origin_y": 597.4500122070312,
|
||||
"advance": 1.6440048217773438,
|
||||
"bbox_x": 120.10099792480469,
|
||||
"bbox_y": 597.4500122070312,
|
||||
"bbox_w": 1.6440048217773438,
|
||||
"bbox_h": 8.59197998046875,
|
||||
"font_size": 12.0,
|
||||
"font_name": "Arial-BoldMT",
|
||||
"fid": "Arial-BoldMT_TrueType_32"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 774 B |