14 Commits
Author SHA1 Message Date
azeem a766d6d0eb Merge pull request 'ribai' (#81) from ribai into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/81
2026-08-06 06:20:05 +00:00
momorew 9134b5e205 path fixed 2026-08-04 19:24:29 +05:30
momorew 676fec5c8b save button issue 2026-08-03 16:06:58 +05:30
momorew bcdc1fca14 added the required things for the integration of the pdf editor 2026-08-03 15:17:31 +05:30
momorew 33ecf9be84 Merge branch 'fix_issue' of https://gitea.maskantech.in/gitea_admin/pdf into ribai 2026-07-31 15:06:45 +05:30
momorew 6a40ec738d Merge branch 'azeem' of https://gitea.maskantech.in/gitea_admin/pdf into ribai 2026-07-31 12:04:35 +05:30
saqib mir da872bf4eb fix the issue 2026-07-31 10:50:37 +05:30
saqib mir ca3d124b0d fix 2026-07-30 16:48:46 +05:30
azeeee05 dde7829966 feat: implement PDF viewer interface with annotation layer, thumbnail management, and toolbar components 2026-07-29 18:46:28 +05:30
saqib mir 94b7f28d9c fix 2026-07-27 15:16:50 +05:30
saqib mir 00c8839cdc cursor issue 2026-07-27 11:28:53 +05:30
azeeee05 79f32024da feat: configure Vite with React, Tailwind CSS, and allowed development hosts 2026-07-24 11:50:10 +05:30
azeem 15b756e7a2 Merge pull request 'feat: implement gateway service and add font extraction validation tests' (#79) from azeem into docker
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/79
2026-07-23 11:33:16 +00:00
azeeee05 671697d161 feat: implement gateway service and add font extraction validation tests 2026-07-23 17:02:45 +05:30
137 changed files with 13407 additions and 1194 deletions
+9 -2
View File
@@ -6,14 +6,21 @@
"name": "win-local",
"displayName": "Windows • Debug (local — build dir outside OneDrive/spaces)",
"inherits": "windows-debug",
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local"
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local",
"cacheVariables": {
"PDFENGINE_WITH_PDFIUM": "ON",
"PDFENGINE_WITH_QPDF": "ON"
}
},
{
"name": "win-local-pdfium",
"displayName": "Windows • RelWithDebInfo + PDFium (static CRT — build dir outside OneDrive/spaces)",
"inherits": "windows-release",
"binaryDir": "C:/Users/@USERNAME@/pdfeng-build/win-local-pdfium",
"cacheVariables": { "PDFENGINE_WITH_PDFIUM": "ON" }
"cacheVariables": {
"PDFENGINE_WITH_PDFIUM": "ON",
"PDFENGINE_WITH_QPDF": "ON"
}
}
],
"buildPresets": [
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ find_package(pybind11 CONFIG REQUIRED)
# Declare the python module target. We name the target pdfengine_py to avoid
# target collision with the static C++ library pdfengine, but we set the
# OUTPUT_NAME to pdfengine to produce the correct importable module.
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp)
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp $<TARGET_OBJECTS:pdfengine>)
set_target_properties(pdfengine_py PROPERTIES
OUTPUT_NAME "pdfengine"
+3
View File
@@ -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());
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+4 -4
View File
@@ -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:
+25 -27
View File
@@ -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()
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
#include <expected>
#include <cstdint>
#include "pdfengine/pdf_document.hpp"
namespace pdfengine {
struct Rect {
double x = 0.0;
double y = 0.0;
double width = 0.0;
double height = 0.0;
};
using RectList = std::vector<Rect>;
struct ParagraphBounds {
Rect rect;
};
struct GlyphInfo {
uint32_t glyphId = 0;
uint32_t cluster = 0;
double x = 0.0;
double y = 0.0;
double advance = 0.0;
double width = 0.0;
double ascent = 0.0;
double descent = 0.0;
std::string text;
};
struct LineInfo {
int id = 0;
Rect rect;
double baselineY = 0.0;
};
struct CaretState {
int offset = 0;
Rect rect;
};
// Internal comprehensive layout state
struct LayoutResult {
ParagraphBounds bounds;
std::vector<LineInfo> lines;
std::vector<GlyphInfo> glyphs;
std::vector<Rect> selectionRects;
CaretState caret;
RectList dirtyRects;
};
// Stable, lightweight view for WASM export
struct LayoutView {
std::vector<LineInfo> lines;
std::vector<GlyphInfo> glyphs;
CaretState caret;
RectList dirtyRects;
};
class EditSession {
public:
virtual ~EditSession() = default;
static std::shared_ptr<EditSession> StartEditSession(
std::shared_ptr<PdfDocument> doc,
int pageIndex,
const std::string& paraId
);
// Returns a stable, lightweight view of the layout
virtual LayoutView GetLayoutView() const = 0;
// Geometry queries against the cached layout
// offset represents the caret insertion point (between glyphs)
virtual int HitTest(double x, double y) const = 0;
virtual Rect GetCaretRect(int offset) const = 0;
virtual std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const = 0;
// Mutates paragraph, marks cache dirty, recalculates
virtual void ApplyEdit(const std::string& editOpJson) = 0;
// Rendering & Lifecycle
virtual std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const = 0;
virtual bool CommitEdit() = 0;
virtual void CancelEdit() = 0;
};
} // namespace pdfengine
+11 -1
View File
@@ -1,12 +1,21 @@
#pragma once
#include <expected>
#include <memory>
#include <string>
#include <vector>
#include <cstdint>
#include <array>
#if __has_include(<expected>)
#include <expected>
#elif __has_include(<tl/expected.hpp>)
#include <tl/expected.hpp>
namespace std {
using tl::expected;
using tl::unexpected;
}
#endif
namespace pdfengine {
enum class EngineError {
@@ -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
+3 -2
View File
@@ -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);
+11 -4
View File
@@ -133,19 +133,23 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
(void)op;
if (pageCount() <= 1) {
spdlog::error("Cannot delete the only page in the document");
return std::unexpected(EngineError::Unknown);
}
FPDFPage_Delete(doc_, pageIndex);
return {};
}
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_reorder operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
@@ -167,7 +171,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
return std::unexpected(EngineError::Unknown);
}
return {};
}
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
}
+244 -48
View File
@@ -1,5 +1,7 @@
#include "parser/pdfium_internal.hpp"
#include "fonts/face/free_type_manager.hpp"
#include "fonts/face/font_face.hpp"
#include "pdfengine/text_layout_engine.hpp"
namespace pdfengine::parser {
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
@@ -10,7 +12,17 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
}
auto data = op["data"];
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector<double> advances; };
struct RunStyle {
std::string text;
std::string internalFontId;
double fontSize;
unsigned int r, g, b;
std::vector<double> advances;
// When set (and advances.size() == advanceSeedText.size()), advances are
// metrics for advanceSeedText; merge onto text via LCP/LCS so typing
// preserves kerning on the unchanged prefix/suffix.
std::string advanceSeedText;
};
std::vector<RunStyle> runs;
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
r = 0; g = 0; b = 0;
@@ -30,11 +42,12 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
RunStyle rs;
rs.text = rj.value("text", "");
rs.internalFontId = rj.value("internalFontId", "");
rs.fontSize = rj.value("fontSize", 12.0);
rs.fontSize = rj.value("fontSize", 0.0);
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
if (rj.contains("advances") && rj["advances"].is_array()) {
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
}
rs.advanceSeedText = rj.value("advanceSeedText", "");
runs.push_back(std::move(rs));
};
std::vector<std::vector<int>> providedLines;
@@ -142,47 +155,46 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
}
{
std::unordered_map<std::string, double> exactByBaseName;
std::vector<double> exactSizes;
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
}
}
+180
View File
@@ -0,0 +1,180 @@
#include "pdfengine/edit_session.hpp"
#include "parser/pdfium_edit_session.hpp"
#include "parser/pdfium_document.hpp"
#include <spdlog/spdlog.h>
#include <cmath>
namespace pdfengine {
std::shared_ptr<EditSession> EditSession::StartEditSession(
std::shared_ptr<PdfDocument> doc,
int pageIndex,
const std::string& paraId
) {
if (!doc) {
spdlog::error("StartEditSession: null document");
return nullptr;
}
return std::make_shared<parser::PdfiumEditSession>(doc, pageIndex, paraId);
}
} // namespace pdfengine
namespace pdfengine::parser {
PdfiumEditSession::PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId)
: doc_(doc), pageIndex_(pageIndex), paraId_(paraId) {
spdlog::info("Started edit session on page {} for para {}", pageIndex_, paraId_);
// In a full implementation, we'd extract text, bounds, and fonts from doc_ using paraId_.
// For now, populate dummy state for unit testing the architecture.
currentText_ = "Mock initial text for paragraph.";
constraints_.columnLeft = 100.0;
constraints_.columnRight = 400.0;
constraints_.firstBaselineY = 700.0;
constraints_.leading = 14.0;
isDirty_ = true;
}
PdfiumEditSession::~PdfiumEditSession() {}
void PdfiumEditSession::RebuildLayoutIfNeeded() const {
if (!isDirty_) return;
if (currentFace_) {
cachedLayout_ = layoutEngine_.ComputeLayout(
currentText_, constraints_, *currentFace_, currentFontSize_
);
} else {
// Mock fallback if face not loaded yet
cachedLayout_.lines.clear();
cachedLayout_.glyphs.clear();
GlyphInfo g;
g.x = constraints_.columnLeft;
g.y = constraints_.firstBaselineY;
g.width = 5.0;
g.advance = 6.0;
g.ascent = 10.0;
g.descent = -2.0;
g.cluster = 0;
if (!currentText_.empty()) {
LineInfo line;
line.id = 0;
line.baselineY = constraints_.firstBaselineY;
for (size_t i = 0; i < currentText_.size(); ++i) {
g.cluster = static_cast<uint32_t>(i);
g.text = currentText_.substr(i, 1);
cachedLayout_.glyphs.push_back(g);
g.x += g.advance;
}
line.rect = Rect{constraints_.columnLeft, constraints_.firstBaselineY - g.ascent,
g.x - constraints_.columnLeft, g.ascent - g.descent};
cachedLayout_.lines.push_back(line);
}
}
isDirty_ = false;
}
LayoutView PdfiumEditSession::GetLayoutView() const {
RebuildLayoutIfNeeded();
return LayoutView{
cachedLayout_.lines,
cachedLayout_.glyphs,
cachedLayout_.caret,
cachedLayout_.dirtyRects
};
}
int PdfiumEditSession::HitTest(double x, double y) const {
RebuildLayoutIfNeeded();
// Find closest glyph by checking boundary/midpoint logic
int closestCluster = 0;
double minDistanceSq = 1e9;
for (const auto& glyph : cachedLayout_.glyphs) {
// Find distance to bounding box
double dx = 0.0;
if (x < glyph.x) dx = glyph.x - x;
else if (x > glyph.x + glyph.advance) dx = x - (glyph.x + glyph.advance);
double gy = glyph.y - glyph.ascent;
double gh = glyph.ascent - glyph.descent;
double dy = 0.0;
if (y < gy) dy = gy - y;
else if (y > gy + gh) dy = y - (gy + gh);
double distSq = dx * dx + dy * dy;
if (distSq < minDistanceSq) {
minDistanceSq = distSq;
// Midpoint logic: if past midpoint, insertion point is after this glyph
if (x > glyph.x + glyph.advance / 2.0) {
closestCluster = glyph.cluster + 1;
} else {
closestCluster = glyph.cluster;
}
}
}
return closestCluster;
}
Rect PdfiumEditSession::GetCaretRect(int offset) const {
RebuildLayoutIfNeeded();
if (cachedLayout_.glyphs.empty()) {
return Rect{constraints_.columnLeft, constraints_.firstBaselineY - 10.0, 1.0, 12.0};
}
// Caret is positioned *between* glyphs at the insertion point `offset`.
// If offset matches a cluster, place it at the left edge of that glyph.
for (const auto& glyph : cachedLayout_.glyphs) {
if (glyph.cluster == static_cast<uint32_t>(offset)) {
return Rect{glyph.x, glyph.y - glyph.ascent, 1.0, glyph.ascent - glyph.descent};
}
}
// If offset is beyond all glyphs, place it at the right edge of the last glyph.
const auto& lastGlyph = cachedLayout_.glyphs.back();
return Rect{lastGlyph.x + lastGlyph.advance, lastGlyph.y - lastGlyph.ascent, 1.0, lastGlyph.ascent - lastGlyph.descent};
}
std::vector<Rect> PdfiumEditSession::GetSelectionRects(int startOffset, int endOffset) const {
RebuildLayoutIfNeeded();
(void)startOffset; (void)endOffset;
return {};
}
void PdfiumEditSession::ApplyEdit(const std::string& editOpJson) {
// Phase 1.1: Modify state and mark dirty
spdlog::info("ApplyEdit called with {}", editOpJson);
// Very naive edit implementation for testing
// e.g. editOpJson = "append:A"
if (editOpJson.find("append:") == 0) {
currentText_ += editOpJson.substr(7);
}
isDirty_ = true;
}
std::vector<uint8_t> PdfiumEditSession::RenderDirtyRegion(int dpi, const Rect& region) const {
(void)dpi; (void)region;
return {};
}
bool PdfiumEditSession::CommitEdit() {
spdlog::info("CommitEdit called");
return true;
}
void PdfiumEditSession::CancelEdit() {
spdlog::info("CancelEdit called");
}
} // namespace pdfengine::parser
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "pdfengine/edit_session.hpp"
#include "pdfengine/text_layout_engine.hpp"
#include "parser/pdfium_internal.hpp"
#include <memory>
#include <string>
namespace pdfengine::parser {
class PdfiumEditSession : public EditSession {
public:
PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId);
~PdfiumEditSession() override;
LayoutView GetLayoutView() const override;
int HitTest(double x, double y) const override;
Rect GetCaretRect(int offset) const override;
std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const override;
void ApplyEdit(const std::string& editOpJson) override;
std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const override;
bool CommitEdit() override;
void CancelEdit() override;
private:
void RebuildLayoutIfNeeded() const;
std::shared_ptr<PdfDocument> doc_;
int pageIndex_;
std::string paraId_;
// Live editing state
std::string currentText_;
text::LayoutConstraints constraints_;
std::shared_ptr<fonts::FontFace> currentFace_;
double currentFontSize_ = 12.0;
// Layout cache and engine
mutable text::TextLayoutEngine layoutEngine_;
mutable LayoutResult cachedLayout_;
mutable bool isDirty_ = true;
};
} // namespace pdfengine::parser
+77 -3
View File
@@ -2,8 +2,8 @@
namespace pdfengine::parser {
#ifdef PDFENGINE_WITH_PDFIUM
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
#ifdef PDFENGINE_WITH_PDFIUM
fonts::pdf_fonts::ReconstructedFont rf;
if (!sfnt.empty()) {
rf.ok = true;
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
}
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
reconstructedFonts_[internalFontId] = std::move(rf);
#else
(void)internalFontId; (void)sfnt;
#endif
}
std::expected<std::vector<uint8_t>, EngineError>
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
#ifdef PDFENGINE_WITH_PDFIUM
const auto* rf = lookupReconFont(internalFontId);
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
return std::unexpected(EngineError::Unknown);
#else
(void)internalFontId;
return std::unexpected(EngineError::Unknown);
#endif
}
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
#ifdef PDFENGINE_WITH_PDFIUM
#ifdef PDFENGINE_WITH_QPDF
return getReconstructedEmbeddedFont(internalFontId);
#else
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
auto it = reconstructedFonts_.find(internalFontId);
return it != reconstructedFonts_.end() ? &it->second : nullptr;
#endif
}
#else
(void)internalFontId;
return nullptr;
#endif
}
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
const fonts::pdf_fonts::ReconstructedFont*
@@ -103,12 +115,23 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
if (out.resolved) {
for (uint32_t cp : codepoints) {
// U+0000 is a string terminator, never glyph content.
if (cp == 0) continue;
if (!out.resolved->hasGlyph(cp)) {
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
if (isSubsetFont) {
subsetLacksGlyphs = true;
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' subset MISSING codepoint U+{:04X} (char='{}')",
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
} else {
fontSupportsAll = false;
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' full font MISSING codepoint U+{:04X} (char='{}')",
internalFontId, cp, (cp >= 0x20 && cp < 0x7F) ? std::string(1, (char)cp) : std::string("?"));
}
}
}
} else {
fontSupportsAll = false;
spdlog::info("[FONT_COVERAGE_DEBUG] internalFontId='{}' no resolved face - forcing system fallback", internalFontId);
}
std::string cacheKey;
@@ -178,7 +201,30 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
}
}
// CRITICAL FIX: Even though we are emitting with a system font (because the PDF subset
// font does not contain the newly typed glyph), we must still use the ORIGINAL embedded
// subset font face for HarfBuzz shaping / advance measurement. This preserves the exact
// glyph advance widths, ascent, descent, and visual size of all original characters so
// the text does not visually shrink or change weight after the first keystroke.
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
std::vector<uint8_t> origBytes;
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
perObj.has_value() && !perObj->empty()) {
origBytes = std::move(*perObj);
} else if (auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
fontDataRes.has_value() && !fontDataRes.value().empty()) {
origBytes = std::move(fontDataRes.value());
}
if (!origBytes.empty()) {
auto mf = std::make_shared<fonts::FontFace>();
if (mf->loadFromMemory(origBytes)) {
out.measureFace = mf;
spdlog::info("[FONT_METRICS_FIX] internalFontId='{}' preserving original embedded face as measureFace despite system emission font - visual size preserved", internalFontId);
}
}
}
}
if (!out.font && matchedFontInfo && matchedFontInfo->isEmbedded &&
classifyFontFidelity(*matchedFontInfo) != "exact") {
const auto* rf = lookupReconFont(internalFontId);
@@ -211,7 +257,24 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
if (!out.font) {
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
spdlog::info("[FONT_FALLBACK_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} subsetLacksGlyphs={} fontSupportsAll={} cacheKey='{}' fallbackStd='{}'",
internalFontId, useEmbedded, useSystem,
matchedFontInfo ? matchedFontInfo->isSubset : false,
(bool)(!out.resolved ? false : (isSubsetFont ? subsetLacksGlyphs : !fontSupportsAll)),
(bool)fontSupportsAll, cacheKey, fontName);
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
// Standard fonts have no SFNT bytes → measureFace cannot be created from them.
spdlog::info("[MEASUREFACE_NULL] reason=standard_font_fallback internalFontId='{}' "
"isEmbedded={} matched={} useEmbedded={} useSystem={} fontName='{}' "
"fontPtr={} — no TTF/OTF bytes available for HarfBuzz measureFace",
internalFontId,
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
(bool)matchedFontInfo, useEmbedded, useSystem, fontName, (void*)out.font);
} else {
spdlog::info("[FONT_LOAD_DEBUG] internalFontId='{}' useEmbedded={} useSystem={} isSubsetFont={} measureFace={} cacheKey='{}'",
internalFontId, useEmbedded, useSystem,
matchedFontInfo ? matchedFontInfo->isSubset : false,
(bool)out.measureFace, cacheKey);
}
if (out.font) {
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
@@ -220,8 +283,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
auto mf = std::make_shared<fonts::FontFace>();
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
} else if (!out.measureFace) {
spdlog::info("[MEASUREFACE_NULL] reason=no_sfnt_bytes_in_buffer internalFontId='{}' cacheKey='{}' "
"useEmbedded={} useSystem={} — emit font loaded but measureFace still null",
internalFontId, cacheKey, useEmbedded, useSystem);
}
}
spdlog::info("[LOAD_EMISSION_FONT_RESULT] internalFontId='{}' fontPtr={} measureFacePtr={} "
"useEmbedded={} useSystem={} isEmbedded={} isSubset={}",
internalFontId, (void*)out.font,
(void*)(out.measureFace ? out.measureFace.get() : nullptr),
useEmbedded, useSystem,
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
matchedFontInfo ? matchedFontInfo->isSubset : false);
return out;
}
#endif
+26
View File
@@ -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
+13
View File
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
if (it != objToIndex.end()) {
g.pageObjectIndex = it->second;
}
FS_MATRIX mtx;
if (FPDFPageObj_GetMatrix(textObj, &mtx)) {
double scaleY1 = std::hypot(static_cast<double>(mtx.c), static_cast<double>(mtx.d));
double scaleY2 = std::hypot(static_cast<double>(mtx.a), static_cast<double>(mtx.b));
double scaleY = (std::max)(scaleY1, scaleY2);
if (scaleY > 0.001) {
double trueSize = g.fontSize * scaleY;
if (trueSize > 0.1) g.fontSize = trueSize;
}
}
}
if (g.bboxH > g.fontSize * 0.8) {
g.fontSize = (std::max)(g.fontSize, g.bboxH);
}
documentGlyphs.push_back(g);
+135
View File
@@ -0,0 +1,135 @@
#include "pdfengine/text_layout_engine.hpp"
#include <cmath>
namespace pdfengine::text {
LineBreaker::LineBreaker(const LayoutConstraints& constraints)
: constraints_(constraints),
currentX_(constraints.columnLeft),
currentY_(constraints.firstBaselineY) {}
void LineBreaker::ProcessRun(
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
const std::string& runText,
double fontSize,
const fonts::FontFace& face,
double scale
) {
// Basic line breaker mimicking the HarfBuzz scaling loop from pdfium_edit_reflow
// In a full implementation, we'd find spaces and wrap. For now, just lay out linearly
// and wrap aggressively at columnRight.
FT_Face ftFace = face.getFace();
double upm = ftFace->units_per_EM;
double fontAscent = (ftFace->ascender / upm) * 1000.0 * scale;
double fontDescent = (ftFace->descender / upm) * 1000.0 * scale;
for (size_t i = 0; i < shapedGlyphs.size(); ++i) {
const auto& glyph = shapedGlyphs[i];
// Very rudimentary word wrap for demonstration
if (currentX_ + glyph.advanceX * scale > constraints_.columnRight && !currentLineGlyphs_.empty()) {
CommitLine();
}
GlyphInfo info;
info.glyphId = glyph.glyphIndex;
info.cluster = glyph.clusterIndex;
info.x = currentX_ + glyph.offsetX * scale;
info.y = currentY_ + glyph.offsetY * scale;
info.advance = glyph.advanceX * scale;
info.width = info.advance; // Simplification
info.ascent = fontAscent;
info.descent = fontDescent;
// Extract substring for this cluster if possible
if (info.cluster < runText.size()) {
info.text = runText.substr(info.cluster, 1);
}
currentLineGlyphs_.push_back(info);
currentX_ += info.advance;
}
}
void LineBreaker::CommitLine() {
if (currentLineGlyphs_.empty()) return;
LineInfo line;
line.id = static_cast<int>(finishedLines_.size());
line.baselineY = currentY_;
if (!currentLineGlyphs_.empty()) {
line.rect.x = currentLineGlyphs_.front().x;
line.rect.y = currentY_ - currentLineGlyphs_.front().ascent;
line.rect.width = currentX_ - line.rect.x;
line.rect.height = currentLineGlyphs_.front().ascent - currentLineGlyphs_.front().descent;
}
finishedLines_.push_back(line);
for (auto& g : currentLineGlyphs_) {
allGlyphs_.push_back(g);
}
currentLineGlyphs_.clear();
// Advance to next line
currentX_ = constraints_.columnLeft;
currentY_ -= constraints_.leading; // Assuming standard PDF Y-up coordinates
}
void LineBreaker::Finalize(LayoutResult& outLayout) {
CommitLine(); // Commit any remaining glyphs
outLayout.lines = std::move(finishedLines_);
outLayout.glyphs = std::move(allGlyphs_);
// Compute bounding box
if (!outLayout.lines.empty()) {
double minX = outLayout.lines.front().rect.x;
double maxX = minX;
double minY = outLayout.lines.front().rect.y;
double maxY = minY + outLayout.lines.front().rect.height;
for (const auto& line : outLayout.lines) {
if (line.rect.x < minX) minX = line.rect.x;
if (line.rect.x + line.rect.width > maxX) maxX = line.rect.x + line.rect.width;
if (line.rect.y < minY) minY = line.rect.y;
if (line.rect.y + line.rect.height > maxY) maxY = line.rect.y + line.rect.height;
}
outLayout.bounds.rect.x = minX;
outLayout.bounds.rect.y = minY;
outLayout.bounds.rect.width = maxX - minX;
outLayout.bounds.rect.height = maxY - minY;
}
}
TextLayoutEngine::TextLayoutEngine() = default;
LayoutResult TextLayoutEngine::ComputeLayout(
const std::string& text,
const LayoutConstraints& constraints,
fonts::FontFace& fontFace,
double fontSize
) {
LayoutResult result;
if (text.empty() || constraints.columnRight <= constraints.columnLeft) {
return result;
}
// 1000 is the standard reference size used in HarfBuzz scaling in this engine
constexpr unsigned int kRefSize = 1000;
auto shapedGlyphs = shaper_.shapeRun(text, fontFace, kRefSize);
double scale = fontSize / static_cast<double>(kRefSize);
LineBreaker breaker(constraints);
breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale);
breaker.Finalize(result);
return result;
}
} // namespace pdfengine::text
+1
View File
@@ -9,6 +9,7 @@ add_executable(pdfengine_smoke
document_load_test.cpp
page_render_test.cpp
document_edit_test.cpp
edit_session_test.cpp
font_diagnostics_test.cpp
text_encoding_test.cpp
skia_renderer_test.cpp
+57
View File
@@ -0,0 +1,57 @@
#include "document_test_helpers.hpp"
#include "pdfengine/pdf_document.hpp"
#include "pdfengine/edit_session.hpp"
#include <string>
#include <vector>
namespace pdfengine {
TEST(EditSessionTest, StartEditSessionAndBasics) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto session = EditSession::StartEditSession(doc, 0, "para_1");
ASSERT_NE(session, nullptr);
// Initial Layout check (Mocked initial text: "Mock initial text for paragraph.")
auto layout = session->GetLayoutView();
EXPECT_FALSE(layout.lines.empty());
EXPECT_FALSE(layout.glyphs.empty());
// HitTest against mocked coordinates
// We mocked columnLeft at 100.0, advance 6.0 per char
// Char 0 at x=100.0
int hit = session->HitTest(102.0, 700.0);
EXPECT_EQ(hit, 0); // Closest to first cluster
// Char 1 at x=106.0
hit = session->HitTest(108.0, 700.0);
EXPECT_EQ(hit, 1); // Closest to second cluster
// GetCaretRect
Rect caret0 = session->GetCaretRect(0);
EXPECT_EQ(caret0.x, 100.0);
EXPECT_EQ(caret0.y, 690.0); // 700 - 10(ascent)
Rect caret1 = session->GetCaretRect(1);
EXPECT_EQ(caret1.x, 106.0);
// ApplyEdit sets dirty flag
session->ApplyEdit("append:!");
auto layout2 = session->GetLayoutView();
EXPECT_GT(layout2.glyphs.size(), layout.glyphs.size()); // Appended '!'
bool committed = session->CommitEdit();
EXPECT_TRUE(committed);
}
} // namespace pdfengine
+1
View File
@@ -0,0 +1 @@
VITE_GATEWAY_URL=http://127.0.0.1:8765
+122 -18
View File
@@ -7,6 +7,7 @@ import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { RedactPagesModal } from './components/RedactPagesModal';
import { AboutModal } from './components/AboutModal';
import { VersionHistoryModal } from './components/VersionHistoryModal';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
@@ -57,17 +58,20 @@ function App() {
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
const [fonts, setFonts] = useState<FontInfo[]>([]);
const [outline, setOutline] = useState<OutlineItem[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [engineReady, setEngineReady] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
@@ -110,6 +114,39 @@ function App() {
(async () => {
try {
setIsLoading(true);
const params = new URLSearchParams(window.location.search);
const streamUrl = params.get('stream_url');
const uploadUrl = params.get('upload_url');
const token = params.get('token');
const resourceId = params.get('resource_id');
if (streamUrl && uploadUrl && token) {
try {
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || '';
const res = await fetch(`${gatewayUrl}/documents/import-remote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stream_url: streamUrl,
upload_url: uploadUrl,
auth_token: token,
resource_id: resourceId || undefined,
}),
});
if (res.ok) {
const docInfo = await res.json();
setDocuments([docInfo]);
openDocument(docInfo.id);
return;
}
console.error('Remote import failed:', res.status, await res.text().catch(() => ''));
setImportError('Failed to open the remote document.');
} catch (err) {
console.error('Remote import failed:', err);
setImportError('Failed to open the remote document.');
}
}
const docs = await gatewayService.listDocuments();
setDocuments(docs);
if (docs.length > 0) {
@@ -464,6 +501,9 @@ function App() {
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
if (activeTool !== 'redact') {
setActiveTool('redact');
}
};
const handleApplyRedactions = () => {
@@ -509,14 +549,48 @@ function App() {
}
};
const urlParams = new URLSearchParams(window.location.search);
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
// Grab the token that was injected into the URL when this iframe was opened.
// This is always fresher than whatever the gateway has cached.
const urlToken = urlParams.get('token') || undefined;
const handleSave = async () => {
if (!activeDoc) return;
setIsSaving(true);
try {
if (isRemote) {
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
} else {
// When running locally outside an iframe, edits are already auto-saved to the gateway DB.
// We just simulate a save delay to provide UI feedback, instead of downloading the file.
await new Promise(resolve => setTimeout(resolve, 600));
}
} catch (e) {
console.error('Save failed', e);
alert('Save failed: ' + String(e));
} finally {
setIsSaving(false);
}
};
const handleExport = async () => {
if (!activeDoc) return;
if (!can('canCopy')) { denyToast('Exporting'); return; }
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>
+179 -121
View File
@@ -3,17 +3,20 @@ import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
import { EmptyState, Popover } from './ui';
import { EmptyState } from './ui';
import {
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon,
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph, TrashIcon, HistoryIcon, XIcon, MoonIcon, SunIcon,
} from './icons';
import { useTheme } from '../lib/useTheme';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms' | 'history';
interface InspectorPanelProps {
activeTab: InspectorTab;
onTabChange: (t: InspectorTab) => void;
isExpanded: boolean;
onExpandedChange: (expanded: boolean) => void;
documents: DocumentInfo[];
selectedDocumentId: string;
@@ -48,6 +51,9 @@ interface InspectorPanelProps {
metadata: DocumentMetadata | null;
permissions?: PDFPermissions;
fonts: FontInfo[];
history?: { stack: string[]; index: number };
onRestoreHistory?: (docId: string) => void;
}
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
@@ -58,6 +64,7 @@ const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boo
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
{ id: 'outline', label: 'Outline', icon: <OutlineIcon /> },
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
{ id: 'history', label: 'History', icon: <HistoryIcon /> },
];
function formatBytes(bytes?: number) {
@@ -70,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>
);
};
+17 -35
View File
@@ -88,40 +88,6 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
</CustomButton>
)}
</div>
<div className="flex justify-between w-full">
{onMoveUp ? (
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveUp();
}}
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Up"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
</CustomButton>
) : (
<div />
)}
{onMoveDown ? (
<CustomButton variant="unstyled"
onClick={(e) => {
e.stopPropagation();
onMoveDown();
}}
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Down"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</CustomButton>
) : (
<div />
)}
</div>
</div>
</>
) : (
@@ -133,7 +99,23 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
</div>
)}
</div>
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
<div className="flex items-center justify-center gap-2 mt-1 px-1">
{onMoveUp ? (
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveUp(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Left">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</CustomButton>
) : <div className="w-4 h-4 shrink-0" />}
<span className="text-[11px] font-semibold text-[#5b6573] text-center min-w-[40px]">Page {pageIndex + 1}</span>
{onMoveDown ? (
<CustomButton variant="unstyled" onClick={(e) => { e.stopPropagation(); onMoveDown(); }} className="p-0.5 text-[#98a1ad] hover:text-[#18212e] transition-colors cursor-pointer" title="Move Page Right">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</CustomButton>
) : <div className="w-4 h-4 shrink-0" />}
</div>
</div>
);
};
+14 -14
View File
@@ -72,12 +72,12 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
disabled
? 'cursor-not-allowed text-[#c5cad1]'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
</CustomButton>
@@ -93,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
};
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1 shrink-0 min-h-[16px]" />
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
<InfoIcon size={19} />
</CustomButton>
@@ -110,23 +110,23 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
<HelpIcon size={19} />
</CustomButton>
)}
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-[#18212e]">Shortcuts</p>
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
<span className="text-[#5b6573]">{t.label}</span>
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
<span className="text-text-secondary">{t.label}</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
</div>
))}
<div className="my-1 h-px bg-[#ebedf0]" />
<div className="my-1 h-px bg-border-primary" />
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-[#5b6573]">Undo / Redo</span>
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
<span className="text-text-secondary">Undo / Redo</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
</div>
</div>
</Popover>
+75 -13
View File
@@ -23,6 +23,10 @@ interface ToolbarProps {
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null;
onUpdateAnnotation?: (patch: Partial<import('../viewer/AnnotationLayer').Annotation>) => void;
onDeleteAnnotation?: () => void;
onDeselectAnnotation?: () => void;
}
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
@@ -51,36 +55,83 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
};
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{children}</span>
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
);
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-text-tertiary">{children}</span>
);
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
}) => {
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
if (selectedAnnotation) {
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
return (
<div
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-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>
);
+49 -47
View File
@@ -3,7 +3,7 @@ import React, { useRef } from 'react';
import { Popover } from './ui';
import {
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon,
} from './icons';
interface TopBarProps {
@@ -26,6 +26,8 @@ interface TopBarProps {
onExport: () => void;
onPrint: () => void;
onUpload: (file: File) => void;
onShowVersionHistory?: () => void;
onSave?: () => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
canPrint?: boolean;
@@ -38,8 +40,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
export const TopBar: React.FC<TopBarProps> = ({
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
isInspectorOpen, onToggleInspector,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onSave,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -51,109 +52,110 @@ export const TopBar: React.FC<TopBarProps> = ({
return (
<header
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
className="flex shrink-0 items-center justify-between gap-3 border-b border-border-primary bg-bg-primary"
style={{ height: '56px', paddingLeft: '24px', paddingRight: '24px' }}
>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-brand-primary text-white shadow-sm">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
</svg>
</div>
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">PDF Editor</span>
<span className="text-[14px] font-extrabold tracking-tight text-text-primary">PDF Editor</span>
</div>
<Popover
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'}`}>
File <ChevronDownIcon size={14} />
</CustomButton>
)}
>
<div className="flex flex-col text-[13px]">
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>} onClick={() => onShowVersionHistory?.()} disabled={!documentName}>Version History</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
</div>
</Popover>
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
<UploadIcon size={18} />
</CustomButton>
{documentName && (
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
{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
+5
View File
@@ -46,6 +46,8 @@ export const SquigglyIcon: IC = mk(<path d="M3 12c1.5-3 3-3 4.5 0s3 3 4.5 0 3-3
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
export const MoonIcon: IC = mk(<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />);
export const SunIcon: IC = mk(<><circle cx="12" cy="12" r="5" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></>);
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
@@ -56,11 +58,14 @@ export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
export const ChevronUpIcon: IC = mk(<path d="M18 15l-6-6-6 6" />);
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
export const HistoryIcon: IC = mk(<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />);
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
export const SidebarRightIcon: IC = mk(<><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><path d="M15 8l-4 4 4 4" /><line x1="9" y1="12" x2="15" y2="12" /></>);
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
+15 -15
View File
@@ -30,7 +30,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
{open && (
<div
style={{ width }}
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-border-primary bg-bg-primary p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
@@ -57,12 +57,12 @@ export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, p
title={c}
onClick={() => onChange(c)}
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-brand-primary ring-offset-1' : 'border-border-secondary'
}`}
style={{ background: c }}
/>
))}
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-border-secondary"
title="Custom color"
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
@@ -82,7 +82,7 @@ interface SliderProps {
}
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
<div className="flex items-center gap-2">
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
{label && <span className="text-[11px] font-medium text-text-secondary">{label}</span>}
<input
type="range"
min={min}
@@ -91,9 +91,9 @@ export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onCha
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width }}
className="accent-[#2563eb]"
className="accent-brand-primary"
/>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-text-primary">{value}{suffix}</span>
</div>
);
@@ -106,17 +106,17 @@ interface EmptyStateProps {
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
{icon && (
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-bg-tertiary text-text-tertiary">
{icon}
</div>
)}
{badge && (
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
<span className="rounded-full bg-brand-secondary px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand-primary">
{badge}
</span>
)}
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
<p className="text-[13.5px] font-semibold text-text-primary">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-text-tertiary">{hint}</p>}
</div>
);
@@ -140,15 +140,15 @@ export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, wi
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
<div
style={{ width }}
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-border-primary bg-bg-primary shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
<div className="flex items-center justify-between border-b border-border-primary px-6 py-4">
<h2 className="text-[15px] font-bold text-text-primary">{title}</h2>
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
</div>
<div className="overflow-y-auto p-6">{children}</div>
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
{footer && <div className="flex justify-end gap-2.5 border-t border-border-primary bg-bg-secondary px-6 py-4">{footer}</div>}
</div>
</div>
);
@@ -184,7 +184,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
)
}
>
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
<p className="text-[13px] leading-relaxed text-text-secondary">{state?.message}</p>
</Modal>
);
+65 -5
View File
@@ -1,7 +1,67 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
/* Sleek scrollbars */
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-canvas: var(--bg-canvas);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-brand-primary: var(--brand-primary);
--color-brand-secondary: var(--brand-secondary);
--color-brand-tertiary: var(--brand-tertiary);
--color-brand-hover: var(--brand-hover);
}
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f7f9;
--bg-tertiary: #edeff2;
--bg-canvas: #f1f2f4;
--border-primary: #ebedf0;
--border-secondary: #dadde2;
--text-primary: #18212e;
--text-secondary: #5b6573;
--text-tertiary: #98a1ad;
--brand-primary: #2563eb;
--brand-secondary: #eef4ff;
--brand-tertiary: rgba(37, 99, 235, 0.1);
--brand-hover: #1d4ed8;
}
html.dark {
--bg-primary: #18181b;
--bg-secondary: #27272a;
--bg-tertiary: #3f3f46;
--bg-canvas: #09090b;
--border-primary: #3f3f46;
--border-secondary: #52525b;
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--brand-primary: #3b82f6;
--brand-secondary: rgba(59, 130, 246, 0.15);
--brand-tertiary: rgba(59, 130, 246, 0.1);
--brand-hover: #60a5fa;
}
body {
background-color: var(--bg-canvas);
color: var(--text-primary);
}/* Sleek scrollbars */
.scroll-thin::-webkit-scrollbar,
.custom-scrollbar::-webkit-scrollbar,
.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; }
@@ -11,14 +71,14 @@
.scroll-thin::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb,
.viewer-viewport::-webkit-scrollbar-thumb {
background: #dadde2;
background: var(--border-secondary);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.scroll-thin::-webkit-scrollbar-thumb:hover,
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); background-clip: padding-box; }
/* Hidden scrollbar (used by horizontal tab/tool strips) */
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
@@ -28,8 +88,8 @@
.scroll-micro { scrollbar-width: thin; }
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
.scroll-micro::-webkit-scrollbar-thumb { background: var(--border-secondary); border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
/* Animations */
@keyframes spin { to { transform: rotate(360deg); } }
+15 -3
View File
@@ -16,7 +16,14 @@ function familyName(key: string): string {
return `pdf-${(h >>> 0).toString(16)}`;
}
export function loadPdfFont(documentId: string, internalFontId: string): Promise<string | null> {
export function loadPdfFont(
documentId: string,
internalFontId: string,
/** Optional CSS family name (e.g. extracted BaseFont). When set, the Face is registered under this name. */
cssFamilyHint?: string,
/** Optional font-weight descriptor so bold faces match style.fontWeight. */
weight?: string | number,
): Promise<string | null> {
if (!internalFontId) return Promise.resolve(null);
const key = keyOf(documentId, internalFontId);
const existing = fontPromises.get(key);
@@ -25,9 +32,14 @@ export function loadPdfFont(documentId: string, internalFontId: string): Promise
const p = (async (): Promise<string | null> => {
const bytes = await gatewayService.getFontData(documentId, internalFontId);
if (!bytes || bytes.byteLength === 0) return null;
const family = familyName(key);
const hint = (cssFamilyHint || '').replace(/^[A-Z]{6}\+/, '').trim();
const family = hint
? hint.replace(/[^a-zA-Z0-9_-]/g, '-')
: familyName(key);
try {
const face = new FontFace(family, bytes);
const descriptors: FontFaceDescriptors = {};
if (weight != null) descriptors.weight = String(weight);
const face = new FontFace(family, bytes, descriptors);
await face.load();
document.fonts.add(face);
fontFaces.set(key, face);
+58 -2
View File
@@ -1,3 +1,5 @@
import type { ReflowLayout } from './pdfiumEngine';
export interface PageInfo {
index: number;
width: number;
@@ -261,6 +263,8 @@ export interface ReflowFragment {
fontSize: number;
color: string;
advances?: number[];
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
advanceSeedText?: string;
}
export interface ReflowParagraphData {
@@ -333,7 +337,7 @@ class GatewayService {
public baseUrl: string;
constructor() {
this.baseUrl = import.meta.env.VITE_GATEWAY_URL || 'http://127.0.0.1:8000';
this.baseUrl = import.meta.env.VITE_GATEWAY_URL as string;
}
async getHealth(): Promise<{ status: string; version: string; engine_available: boolean }> {
@@ -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}`);
+10 -1
View File
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
if (!modulePromise) {
modulePromise = (async () => {
try {
const V = '20260630-fontfix3';
const V = '20260730-advseed1';
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
@@ -181,3 +181,12 @@ export async function wasmFreeDocument(documentId: string): Promise<void> {
const M = await getModule();
if (M) M.ccall('freeDocument', null, ['number'], [h]);
}
export async function wasmDebugValidateLayout(documentId: string, pageIndex: number, jsonStr: string): Promise<string> {
const M = await getModule();
if (!M) return '{}';
const h = docHandles.get(documentId);
if (h === undefined) return '{}';
const result = M.ccall('debugValidateLayout', 'string', ['number', 'number', 'string'], [h, pageIndex, jsonStr]) as string;
return result || '{}';
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('app-theme') as Theme | null;
if (savedTheme) {
return savedTheme;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
return 'light';
});
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('app-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return { theme, toggleTheme };
}
+8 -5
View File
@@ -31,6 +31,7 @@ interface AnnotationLayerProps {
onAnnotationClick?: (annotation: Annotation) => void;
onAnnotationUpdate?: (annotation: Annotation) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
isSelectToolActive?: boolean;
}
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
@@ -42,12 +43,13 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onAnnotationClick,
onAnnotationUpdate,
onFieldChange,
isSelectToolActive,
}) => {
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
const isDraggable = (type: Annotation['type']) => type === 'comment' || type === 'signature';
const isDraggable = (type: Annotation['type']) => isSelectToolActive || type === 'comment' || type === 'signature';
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
e.stopPropagation();
@@ -79,7 +81,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
...anno.bbox,
x: anno.bbox.x + dragOffset.x,
y: anno.bbox.y + dragOffset.y,
}
},
quadPoints: anno.quadPoints?.map(q => q.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
paths: anno.paths?.map(path => path.map(pt => ({ x: pt.x + dragOffset.x, y: pt.y + dragOffset.y }))),
});
} else {
onAnnotationClick?.(anno);
@@ -92,7 +96,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
>
{annotations
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget'].includes(anno.type))
.filter((anno) => ['highlight', 'comment', 'strikeout', 'underline', 'squiggly', 'signature', 'widget', 'ink'].includes(anno.type))
.map((anno) => {
const scaledBbox = {
x: anno.bbox.x * zoom,
@@ -115,6 +119,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onPointerDown={(e) => handlePointerDown(e, anno)}
onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)}
onMouseDown={(e) => e.stopPropagation()}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
style={{
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
@@ -158,7 +163,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
@@ -177,7 +181,6 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
-36
View File
@@ -60,43 +60,7 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
ctx.drawImage(img, 0, 0, width, height);
ctx.restore();
// Render vector ops on top
if (vectorOps && vectorOps.length > 0) {
ctx.save();
ctx.translate(0, height);
ctx.scale(1, -1);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.fillStyle = 'rgba(0, 0, 255, 0.2)';
ctx.lineWidth = 1;
ctx.beginPath();
for (const op of vectorOps) {
if (op.op === 'm' && op.args && op.args.length === 2) {
ctx.moveTo(op.args[0], op.args[1]);
} else if (op.op === 'l' && op.args && op.args.length === 2) {
ctx.lineTo(op.args[0], op.args[1]);
} else if (op.op === 'c' && op.args && op.args.length === 6) {
ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]);
} else if (op.op === 're' && op.args && op.args.length === 4) {
ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]);
} else if (op.op === 'h') {
ctx.closePath();
} else if (op.op === 'S' || op.op === 's') {
ctx.stroke();
ctx.beginPath();
} else if (op.op === 'f' || op.op === 'F') {
ctx.fill();
ctx.beginPath();
} else if (op.op === 'B' || op.op === 'b') {
ctx.fill();
ctx.stroke();
ctx.beginPath();
} else if (op.op === 'n') {
ctx.beginPath();
}
}
ctx.restore();
}
onRenderComplete?.(pageIndex);
};
+19 -19
View File
@@ -46,12 +46,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
};
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="absolute top-full left-0 mt-1 p-2 bg-bg-secondary border border-border-primary rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="grid grid-cols-5 gap-1">
{COLORS.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-bg-secondary' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
@@ -61,13 +61,13 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
/>
))}
</div>
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
<div className="text-xs text-text-secondary mt-1 cursor-pointer hover:text-text-primary transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-text-secondary border-t border-border-primary pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<button className="hover:text-text-primary"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
<button className="hover:text-text-primary">+</button>
</div>
</div>
</div>
@@ -76,7 +76,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
return (
<div
ref={menuRef}
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
className="absolute z-50 flex items-center gap-1 bg-bg-primary rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-border-primary text-text-primary"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
@@ -85,7 +85,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors"
title="Copy"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -96,7 +96,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors"
title="Add Comment"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -106,12 +106,12 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="w-px h-4 bg-border-primary mx-1" />
<div className="relative flex items-center group">
<button
onClick={() => handleAction('highlight')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
title="Highlight"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -122,7 +122,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
@@ -134,7 +134,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<div className="relative flex items-center group">
<button
onClick={() => handleAction('underline')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
title="Underline"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -144,7 +144,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
@@ -156,7 +156,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<div className="relative flex items-center group">
<button
onClick={() => handleAction('strikeout')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
className="p-1.5 rounded-l hover:bg-bg-tertiary flex items-center justify-center transition-colors"
title="Strikeout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -167,7 +167,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
className="p-1.5 rounded-r hover:bg-bg-tertiary flex items-center justify-center transition-colors border-l border-transparent group-hover:border-border-primary"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
@@ -176,11 +176,11 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="w-px h-4 bg-border-primary mx-1" />
<button
onClick={() => onAction('redact')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
className="px-2 py-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Redact Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -192,7 +192,7 @@ export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ select
<button
onClick={() => onAction('edit')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
className="px-2 py-1.5 rounded hover:bg-bg-tertiary flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Edit Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+12 -12
View File
@@ -135,40 +135,40 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
onClick={handleLayerClick}
>
{activeTool === 'signature' && !hasSignature && (
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
<div className="absolute inset-0 bg-brand-tertiary flex items-center justify-center border-2 border-dashed border-brand-primary rounded-[6px]"><span className="bg-brand-primary text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
)}
{activeTool === 'signature' && hasSignature && !commentPopup && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
)}
{activeTool === 'comment' && !commentPopup && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp.label}</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp.label}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-text-primary text-bg-primary text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
)}
{commentPopup && (
<div
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
className="w-[264px] bg-bg-primary border border-border-primary rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleCommentSubmit}>
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
<span className="text-xs font-bold">Add sticky note</span>
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
<div className="flex justify-between items-center py-[10px] px-[14px] bg-bg-secondary border-b border-border-primary">
<span className="text-xs font-bold text-text-primary">Add sticky note</span>
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-text-tertiary hover:text-text-primary">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</CustomButton>
</div>
<textarea
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
placeholder="Type your comment here…" className="w-full bg-transparent text-text-primary border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-text-tertiary" rows={3}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
/>
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
<div className="py-[10px] px-[14px] bg-bg-secondary border-t border-border-primary flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-brand-primary text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-brand-hover">Save note</CustomButton></div>
</form>
</div>
)}
@@ -184,7 +184,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
}}
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
className="absolute z-50 bg-bg-primary border-[1.5px] border-brand-primary rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
placeholder="Type…"
style={{
left: `${textBox.x * zoom}px`,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -55
View File
@@ -30,6 +30,8 @@ export interface ReflowFragment {
fontSize: number;
color: string;
advances?: number[];
/** When set, `advances` are metrics for this seed string (not necessarily `text`). */
advanceSeedText?: string;
}
export interface CommitFrame {
@@ -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`,
+3
View File
@@ -4,4 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
allowedHosts: ['pdf-dev.maskantech.in'],
},
})
+3 -3
View File
@@ -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"]
+4 -4
View File
@@ -59,7 +59,7 @@ Run the service:
Using the startup script (Windows):
```powershell
# Default port (8000)
# Default port (8765)
powershell -File scripts/start_gateway.ps1
# Custom port (e.g. 8080)
@@ -68,7 +68,7 @@ powershell -File scripts/start_gateway.ps1 -Port 8080
Or run directly via uvicorn (cross-platform):
```sh
# Default port (8000)
# Default port (8765)
uvicorn app.main:app --reload
# Custom port (e.g. 8080)
@@ -100,8 +100,8 @@ Additionally, the gateway startup scripts and frontend configuration support:
| Var | Default | Meaning |
|--------------------|-------------------------|--------------------------------------------------|
| `PORT` | `8000` | Gateway listening port (used by `start_gateway.ps1`). |
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8000` | URL of the gateway API (used by the frontend). |
| `PORT` | `8765` | Gateway listening port (used by `start_gateway.ps1`). |
| `VITE_GATEWAY_URL` | `http://127.0.0.1:8765` | URL of the gateway API (used by the frontend). |
A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored
via the repo-wide `.venv/` and Python rules — add `.env` to your local
+81
View File
@@ -1,4 +1,8 @@
import re
import httpx
from urllib.parse import urlparse
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel
from app.schemas.document import (
DocumentInfoResponse,
@@ -10,6 +14,83 @@ from app.services.store import document_store
router = APIRouter(prefix="/documents", tags=["documents"])
class RemoteImportRequest(BaseModel):
stream_url: str
upload_url: str
auth_token: str | None = None
resource_id: str | None = None
@router.post("/import-remote", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
async def import_remote_document(req: RemoteImportRequest) -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
stream_parsed = urlparse(req.stream_url)
upload_parsed = urlparse(req.upload_url)
if stream_parsed.scheme not in ("http", "https") or upload_parsed.scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail="Invalid URL scheme. Only http/https are allowed.")
headers = {}
if req.auth_token:
headers["Authorization"] = f"Bearer {req.auth_token}"
MAX_SIZE = 50 * 1024 * 1024 # 50 MB limit
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
try:
async with client.stream("GET", req.stream_url, headers=headers) as res:
if res.status_code != 200:
await res.aread()
raise HTTPException(
status_code=res.status_code,
detail=f"Failed to stream remote document: {res.text[:200]}",
)
content_length = res.headers.get("Content-Length")
if content_length and int(content_length) > MAX_SIZE:
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
bytes_data_arr = bytearray()
async for chunk in res.aiter_bytes():
bytes_data_arr.extend(chunk)
if len(bytes_data_arr) > MAX_SIZE:
raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).")
bytes_data = bytes(bytes_data_arr)
except Exception as err:
if isinstance(err, HTTPException):
raise err
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to connect to remote stream_url: {err!s}",
)
cd = res.headers.get("Content-Disposition", "")
match = re.search(r'filename="?([^";]+)"?', cd)
filename = match.group(1) if match else f"remote_document_{req.resource_id or 'file'}.pdf"
try:
pdfengine = engine.require()
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, "")
info = document_store.add_document(filename, bytes_data, doc)
info["remote_context"] = {
"stream_url": req.stream_url,
"upload_url": req.upload_url,
"auth_token": req.auth_token,
"resource_id": req.resource_id,
}
return make_document_response(info)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to load remote PDF into engine: {e!s}",
)
def make_document_response(d: dict) -> DocumentInfoResponse:
pages_list = []
if "doc_instance" in d:
+89
View File
@@ -1,9 +1,18 @@
import httpx
import logging
import uuid
from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel
from app.services import engine
from app.services.store import document_store
router = APIRouter(tags=["documents"])
logger = logging.getLogger(__name__)
class ExportRemoteRequest(BaseModel):
fresh_token: str | None = None
@router.get("/{document_id}/export")
def export_document(document_id: str):
@@ -41,3 +50,83 @@ def export_document(document_id: str):
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/{document_id}/export-remote")
async def export_remote_document(document_id: str, body: ExportRemoteRequest | None = None):
if not engine.is_available():
raise HTTPException(status_code=501, detail="Engine bridge not available.")
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=404, detail="Document not found")
perms = d.get("permissions") or {}
if perms.get("canCopy", True) is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Exporting is not permitted by this document's restrictions (canCopy).",
)
ctx = d.get("remote_context")
if not ctx or not ctx.get("upload_url"):
print(f"[export-remote] FAILING with 400: ctx={ctx}", flush=True)
raise HTTPException(
status_code=400,
detail="Document does not have a registered remote upload context.",
)
try:
doc = d["doc_instance"]
bytes_data = doc.save_full_for_export()
filename = d["filename"]
if not filename.endswith(".pdf"):
filename += ".pdf"
headers = {}
# Prefer fresh_token sent at save-time over the potentially-stale stored token
token_val = (body.fresh_token if body and body.fresh_token else None) or ctx.get("auth_token")
if token_val:
headers["Authorization"] = f"Bearer {token_val}"
csrf_token = str(uuid.uuid4())
headers["Cookie"] = f"csrf_token={csrf_token}"
headers["X-CSRF-Token"] = csrf_token
# Resolve folder_id from remote context or default to 0
data = {"folder_id": str(ctx.get("folder_id", "0"))}
if ctx.get("resource_id"):
data["file_id"] = str(ctx["resource_id"])
upload_url = ctx["upload_url"]
print(f"[export-remote] POST {upload_url} | file_id={data.get('file_id')} | filename={filename} | size={len(bytes_data)}", flush=True)
files = {"upload": (filename, bytes_data, "application/pdf")}
async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client:
res = await client.post(upload_url, headers=headers, data=data, files=files)
print(f"[export-remote] Response status={res.status_code} body={res.text[:500]}", flush=True)
if res.status_code not in (200, 201):
logger.error("[export-remote] upstream %s: %s", res.status_code, res.text[:500])
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Remote upload failed (upstream status {res.status_code}).",
)
try:
remote_response = res.json() if res.content else {}
except ValueError:
remote_response = {}
return {
"success": True,
"message": "Successfully exported updated document to remote host!",
"remoteResponse": remote_response,
}
except Exception as e:
if isinstance(e, HTTPException):
raise e
logger.error(f"[export-remote] Unexpected error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Remote export failed.")
@@ -94,6 +94,8 @@ def replace_text_object(document_id: str, page_index: int, object_index: int, re
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
permissions=doc_info.get("permissions"),
)
if "remote_context" in doc_info:
new_info["remote_context"] = doc_info["remote_context"]
return {"success": True, "newDocumentId": new_info["id"]}
except HTTPException:
raise
+152
View File
@@ -249,6 +249,7 @@ class ReflowRun(BaseModel):
fontSize: float
color: str = "#000000"
advances: list[float] | None = None
advanceSeedText: str | None = None
class ReflowParagraphData(BaseModel):
@@ -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)
+5 -2
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"pydantic-settings==2.7.1",
"python-multipart==0.0.19",
"pillow==10.4.0",
"httpx==0.28.1",
]
[project.optional-dependencies]
+1 -1
View File
@@ -8,7 +8,7 @@ import urllib.request
sys.path.insert(0, "gateway")
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8000")
BASE = os.environ.get("GATEWAY_URL", "http://localhost:8765")
def http_get(url):
+1 -1
View File
@@ -12,7 +12,7 @@ import time
import httpx
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000")
BASE_URL = os.environ.get("GATEWAY_URL", "http://localhost:8765")
CORPUS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "fonts"))
TARGET_PDFS = [
+1 -1
View File
@@ -1,6 +1,6 @@
================================================================================
FONT EXTRACTION API VALIDATION
Server: http://localhost:8000
Server: http://localhost:8765
Corpus: C:\Users\Maskan\Desktop\pdf_editor\pdf\corpus\fonts
Timestamp: 2026-06-02 12:36:47
================================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+1 -1
View File
@@ -10,7 +10,7 @@ if ($Port -le 0) {
if ($envPort) {
$Port = $envPort
} else {
$Port = 8000
$Port = 8765
}
}
+42
View File
@@ -0,0 +1,42 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
8 0 obj
<</Filter/FlateDecode/Length 237>>stream
xœÅ“=kÃ0EwýŠ;6Cä+É–-0†|Ø…@! ¡Sj')mMœ!¿¤…*Â[îô÷<ÞI×Yßã3 {(\@,@<‹¤ÙÔ
û3’å¼YNÖ”er|ºëú¡};wã®víørè_ÚQUa:Ÿ‰Ó÷ª¯åSõ‘”ÑÒºÌ9‡\þI³i”†ï@øÛ2O«Gøj•¨ùÚþ_DäSR§¤ðm¯®þ5©ü,¢ZIM2½Íý“&-nãÞ™_ع‰Æ.¬´¹%M¨ºŽ‡§$sÁöñÌç¡oË~Z_‰w‘UAÆ
endstream
endobj
9 0 obj
[ 8 0 R ]
endobj
xref
0 4
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
6 4
0000000269 00000 n
0000000312 00000 n
0000000403 00000 n
0000000712 00000 n
trailer
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
startxref
740
%%EOF
@@ -0,0 +1,42 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
8 0 obj
<</Filter/FlateDecode/Length 169>>stream
xœ­ŽK ‚@E÷ó+î²æ|ãc
Dð5¨ÌBˆV¦fT¢.üûQA-Vq7guîÇcÅ/[fp¤à83S• ¡`f±Ê‚"€ç™ÝqÑôc}›ºÊhú±ªùÔ_ê¥ï#Œ#6|Toy¨AO&‰•»œ¤àÐW˜ªTÐ 8ôˆ½çØ®#×ÒöÐ)œ%»ˆý¯BŠ_ï–t\!-×ù*ÈÙ,·J
endstream
endobj
9 0 obj
[ 8 0 R ]
endobj
xref
0 4
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
6 4
0000000269 00000 n
0000000312 00000 n
0000000403 00000 n
0000000644 00000 n
trailer
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
startxref
672
%%EOF
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
4 0 obj
<</Filter/FlateDecode/Length 832>>stream
xœÅšMkTQ †÷ó+ÎR¦ù:9 ”B?fAP™EA\ˆ_(¨Xþ}¹Ó:-Âäº0séfV÷áMrNòžôÇ
Ûô÷êi»ýqó©QûÕ°=kؾ¬N6×kjŸ~¶“W›ç¯ÎÛééÉç÷>~¿ùðíççwOÞ
Žw6äñÙY»¸º\ý¸ÿÎþËÛF»ßÔÈ",¢Ùà¶ýÚN6×jDmû±aÛÞ´×§¼>{Ó¶ÏÚzÛ^®ÖÏ/Wÿ-àN ÙFy Ö3r•æÑAX2rT Ìðà"°u誈)«à8 —"x'PïœÁ«ò­1¯Ü.«àÄ1b,QpÀ¦‘^jFUpÒ@Ìrn½Î1úLΫ®6&è"ˆé½ZUíÄ =ÓvR¥ X9" û¦
Ž@l3Õ^$š)ö¢¨‚¨/p¿¸ /s€†ä•6´†=ˆz~§›±Ó`Ýå,ê< v;rûfA³”]u²˜¸lr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3GÏ7¹ ÌÙU­sqξ¨b h,sûéüøÅf´ŸÎë¬A×½=|Ä«àê ìˆ©ò*k  1EÝ›÷Fø°ò*k Îùµ^•s`wvðø9ß92ž9ç^åÈtèLØ‹FU"‚Ñe¡œc“%ytfDY ØB@±ç¯jhÙavÑ[òWtôf¤‚‹ÎÃdA£ Äc»¢>€çØçUlb OØeNTÌ)g½å²t9»Ê‘‰€ÄŒî¢¶sà’绪3IØEí‹iÜ9“òMt·ŽLt ©Œ}·ŽLTWùл¥`¢¹jCŒ##W-æœUò*»ª[¼ʪfòÉ÷OV(Ó]ÔÁÈôV·f«ü@wp¢éézÖû?ÀËܯAwœÆÓ^õÖ#¾3#I«þÉBøY áìàÓ>p‰JÌ0n÷‰ò*8)ëL©W½jâ4§ÎÁ«šÙý6W½2ݯaßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚?¦¦ÝtÕ>]§z©¡k}Ñ—«ßVn$
endstream
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
xref
0 5
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
0000000269 00000 n
6 2
0000001173 00000 n
0000001216 00000 n
trailer
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
startxref
1307
%%EOF
+37
View File
@@ -0,0 +1,37 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
4 0 obj
<</Filter/FlateDecode/Length 417>>stream
xœµ”;k\A …ûû+T&EfGéhËÂ>î ÛÜÂ`R¿p ^ùûacHRÄCŠ Ó¨ÒùF::OS¦Ó»|O/Åñ˜¾S¦3ÊôyZµ«™éá™Vç‡v¾½ÜÒz½z¼}sÿíx÷õùñæÝ§’ý^Þn6´;ì§§ß}~uÞ-Ä?k&©Hp*hùB«vÕ˜˜i¹§LË‘®×^<» 0ÃeÞ|¤åŒæ….¦ùÃ~ú_É ³w8``ì=1hMÌEK!0h5™¤§Î€+Ì3Ú0 1PÐ[‡7̘G­ƒ )B¬ÔîD‰+'Ï9jôÄÇ-€5y°s÷ïÆŒÝ„È©¨ÃºÉ ÇÉc¼w ®¨°AQ5‰d òœ_ÿü©2‹õ >. Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬vh£lÀ"É‚.€¸ ¹JäД9Wéz‘Ý(£"Ù¥#n;„ÈWÖW*´i3U³Sî5tÖ?Ïê}=
endstream
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
xref
0 5
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
0000000269 00000 n
6 2
0000000758 00000 n
0000000801 00000 n
trailer
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
startxref
892
%%EOF
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
"""Locate the real failing PDF containing 'Professional Experiences' / Arial-BoldMT."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
sys.path.insert(0, r"c:\Users\Maskan\Desktop\pdf_editor\pdf\gateway")
import pdfengine # type: ignore
CANDS = [
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf"),
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq.pdf"),
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)vq (1).pdf"),
Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume (1)verfication.pdf"),
Path(r"C:\Users\Maskan\Downloads\DemoPdf.pdf"),
Path(r"C:\Users\Maskan\Downloads\DemoPdf-1-3.pdf"),
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\801c2dcf-afa2-462a-a71d-4b2e2599808f.pdf"),
Path(r"C:\Users\Maskan\Desktop\docQube\docqube_backend\app\temp_uploads\fc716e7e-aa82-4bb3-825a-51140db03c1b.pdf"),
]
raw = Path(r"C:\Users\Maskan\Documents\pdf_html\backend\storage\raw")
if raw.exists():
CANDS.extend(sorted(raw.glob("*.pdf")))
for d in raw.iterdir():
if d.is_dir():
CANDS.extend(sorted(d.glob("*.pdf")))
def main():
hits = []
for p in CANDS:
if not p.exists():
continue
try:
doc = pdfengine.PdfDocument.load_from_file(str(p), "")
except Exception as e:
print(f"ERR load {p}: {e}")
continue
for pi in range(min(doc.page_count, 5)):
page = doc.get_page(pi)
try:
model = page.extract_document_model()
except Exception as e:
print(f"ERR model {p} p{pi}: {e}")
continue
for para in model.paragraphs:
text = " ".join("".join(r.text for r in ln.runs) for ln in para.lines)
if "Professional Experience" in text:
fonts = sorted({
(r.font_name, r.internal_font_id, r.is_embedded, r.type, r.font_size)
for ln in para.lines for r in ln.runs
})
hits.append((str(p), pi, text[:100], fonts))
print("FOUND", p)
print(" page", pi)
print(" text", repr(text[:120]))
print(" fonts", fonts)
for f in doc.get_fonts(pi, pi):
if "Arial-BoldMT_TrueType_32" in (f.internal_font_id or "") or (
"Arial-BoldMT" in (f.font_name or "") and f.is_embedded
):
print("FONT", p, "page", pi, f.font_name, f.internal_font_id, "emb", f.is_embedded)
print("hits", len(hits))
if __name__ == "__main__":
main()
+254
View File
@@ -0,0 +1,254 @@
"""Edit-entry identity check for the voice-search bullet item (real resume).
Compares:
1) Overlay CSS construction (mirrors ParagraphEditor Fixes 1-4 + buildBulletItem)
2) Identity reflow region vs original region (pixel / glyph)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
OUT = Path(__file__).resolve().parent / "forensic_real" / "bullet_edit_entry_report.json"
TARGET = "voice search"
def is_bullet(t: str) -> bool:
t = (t or "").strip()
return t in {"", "", "", "", "", "-", "", "", "*"} or (len(t) <= 3 and t[:1].isdigit() and t.endswith("."))
def build_bullet_item(para, run_line_index: int):
lines = para.lines
col_left = min(l.x for l in lines)
col_right = max(l.x + l.w for l in lines)
def lead_font(l):
for r in l.runs:
if (r.text or "").strip() and not is_bullet(r.text):
return r.internal_font_id or ""
return ""
hang = min((l.x for l in lines if l.x > col_left + 1), default=col_left + 8)
flush_thresh = col_left + (hang - col_left) * 0.5
def flush_left(l):
return l.x <= flush_thresh
def is_start(idx):
l = lines[idx]
if is_bullet((l.runs[0].text if l.runs else "") or ""):
return True
if idx == 0:
return True
return flush_left(l) and lead_font(l) and lead_font(l) != lead_font(lines[idx - 1])
start = run_line_index
while start > 0 and not is_start(start):
start -= 1
end = run_line_index + 1
while end < len(lines) and not is_start(end):
end += 1
item_lines = lines[start:end]
deltas = []
for i in range(start, end - 1):
if hasattr(lines[i], "baseline_y") and hasattr(lines[i + 1], "baseline_y"):
deltas.append(abs(lines[i].baseline_y - lines[i + 1].baseline_y))
leading = sorted(deltas)[len(deltas) // 2] if deltas else 12.0
first_runs = list(item_lines[0].runs)
sub_lines = item_lines
if first_runs and is_bullet(first_runs[0].text):
ti = 1
while ti < len(first_runs) and not (first_runs[ti].text or "").strip():
ti += 1
text_runs = first_runs[ti:]
text_indent = text_runs[0].x
class LW:
pass
new_lines = []
for idx, l in enumerate(item_lines):
w = LW()
if idx == 0:
w.runs = text_runs
w.x = text_indent
w.w = (l.x + l.w) - text_indent
else:
w.runs = l.runs
w.x = l.x
w.w = l.w
w.y = l.y
w.h = l.h
w.baseline_y = l.baseline_y
new_lines.append(w)
sub_lines = new_lines
class SP:
pass
sp = SP()
sp.lines = sub_lines
return sp, col_left, leading, col_right, start, end
def glyph_union(lines):
minx = miny = 1e18
maxx = maxy = -1e18
ascent = descent = 0.0
for ln in lines:
for r in ln.runs:
for g in r.glyphs:
minx = min(minx, g.bbox_x)
miny = min(miny, g.bbox_y)
maxx = max(maxx, g.bbox_x + g.bbox_w)
maxy = max(maxy, g.bbox_y + g.bbox_h)
gbl = g.origin_y if g.origin_y else ln.baseline_y
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
descent = max(descent, gbl - g.bbox_y)
return {
"x": minx,
"y": miny,
"w": maxx - minx,
"h": maxy - miny,
"ascent": ascent,
"descent": descent,
"line_h": max(l.h for l in lines),
}
def main():
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
page = doc.get_page(0)
model = page.extract_document_model()
para = None
pi = -1
for i, p in enumerate(model.paragraphs):
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET.lower() in text.lower():
para, pi = p, i
break
assert para is not None
# line index of voice-search bullet start
run_line = 0
for li, ln in enumerate(para.lines):
t = "".join(r.text or "" for r in ln.runs)
if "voice search" in t.lower() or (li and "Speech API" in "".join(r.text or "" for r in para.lines[li - 1].runs)):
# find bullet start
pass
for li, ln in enumerate(para.lines):
t = "".join(r.text or "" for r in ln.runs)
if "Implemented a voice" in t:
run_line = li
break
sub, push_left, leading, col_right, start, end = build_bullet_item(para, run_line)
box = glyph_union(sub.lines)
layout = compute_layout(sub)
# dominant run
dom = None
for r in layout["seedRuns"]:
if (r.get("text") or "").strip() and r.get("fid"):
dom = r
break
font_name = (dom or {}).get("fontName") or ""
extracted = re.sub(r"^[A-Z]{6}\+", "", font_name).strip() or "sans-serif"
weight = 700 if re.search(r"bold|black|heavy", extracted, re.I) else 400
line_height_pt = leading # leadingOverride ?? paraBox.lineHeight — override wins
overlay = {
"font_family": extracted,
"font_weight": weight,
"font_size": (dom or {}).get("size"),
"line_height_pt": line_height_pt,
"width_pt": box["w"],
"height_pt": max(box["h"], line_height_pt),
"left_pt": box["x"],
"ascent": box["ascent"],
"pdf_line_h": box["line_h"],
"leading_override": leading,
"column_left": push_left,
"column_right": col_right,
"seed_text": "".join(r["text"] for r in layout["seedRuns"]),
"seed_has_bullet": any(is_bullet(r["text"]) for r in layout["seedRuns"]),
"n_lines": len(sub.lines),
"line_range": [start, end],
}
checks = []
checks.append(("font-family", font_name, extracted, extracted.lower() in (font_name or "").lower().replace("bcdjee+", "") or "arialmt" in extracted.lower()))
checks.append(("font-weight", weight, weight, True))
checks.append(("font-size", overlay["font_size"], overlay["font_size"], True))
# Multi-line: CSS line-height should be baseline delta (leading), NOT ink line.h
checks.append(("line-height(leading)", leading, line_height_pt, abs(leading - line_height_pt) < 0.01))
checks.append(("width(ink)", box["w"], overlay["width_pt"], abs(box["w"] - overlay["width_pt"]) < 0.01))
checks.append(("height(ink)", box["h"], overlay["height_pt"], abs(max(box["h"], line_height_pt) - overlay["height_pt"]) < 0.01))
# Identity reflow
fid = (dom or {}).get("fid") or ""
flat = extract_flat_runs(layout["seedRuns"], fid, (dom or {}).get("size") or 10, "#000000")
data = build_reflow_data(layout, flat, layout["origLines"], "x")
data["columnRight"] = col_right
data["pushColumnLeft"] = push_left
data["leading"] = leading
data["columnLeft"] = layout["columnLeft"]
op = {"version": "1.0", "operations": [{"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
# Original region crop
y_top = box["y"] - 2
h = box["h"] + 4
dpi = 144
orig_img = page.render_region_raw(dpi, y_top, h)
r = doc.apply_edits(json.dumps(op))
page2 = doc.get_page(0)
prev_img = page2.render_region_raw(dpi, y_top, h)
def sha(img):
import hashlib
return hashlib.sha256(bytes(img.data)).hexdigest()[:16] if img else None
pixel_match = False
if orig_img and prev_img and orig_img.width == prev_img.width and orig_img.height == prev_img.height:
a = bytes(orig_img.data)
b = bytes(prev_img.data)
pixel_match = a == b
diff = sum(1 for i in range(0, len(a), 4) if a[i : i + 3] != b[i : i + 3])
else:
diff = -1
report = {
"para_index": pi,
"overlay": overlay,
"checks": [{"property": a, "pdf": b, "overlay": c, "match": d} for a, b, c, d in checks],
"identity_reflow": {
"apply_ok": bool(r),
"orig_sha": sha(orig_img),
"prev_sha": sha(prev_img),
"pixel_identical": pixel_match,
"diff_pixels": diff,
"region": {"y_top": y_top, "h": h, "dpi": dpi},
},
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
print("OVERLAY", "ALL MATCH" if all(c[3] for c in checks) else "DIFFS")
print("IDENTITY PIXELS", "MATCH" if pixel_match else f"DIFF ({diff})")
if __name__ == "__main__":
main()
+112
View File
@@ -0,0 +1,112 @@
"""Compare forensic snapshots and print diff tables."""
import json
import sys
from pathlib import Path
def load(p):
return json.loads(Path(p).read_text(encoding="utf-8-sig"))
def diff_table(a, b, keys):
rows = []
for k in keys:
va, vb = a.get(k), b.get(k)
changed = va != vb
rows.append((k, va, vb, "CHANGED" if changed else "same"))
return rows
def print_table(title, rows):
print(f"\n=== {title} ===")
print(f"{'Property':<22} {'Before':<40} {'After':<40} {'Status'}")
for k, va, vb, st in rows:
sa = json.dumps(va) if not isinstance(va, str) else va
sb = json.dumps(vb) if not isinstance(vb, str) else vb
if len(sa) > 38: sa = sa[:35] + "..."
if len(sb) > 38: sb = sb[:35] + "..."
print(f"{k:<22} {sa:<40} {sb:<40} {st}")
def compare_advances(orig, new):
oa, na = orig or [], new or []
diffs = []
for i in range(max(len(oa), len(na))):
ov = oa[i] if i < len(oa) else None
nv = na[i] if i < len(na) else None
if ov != nv:
diffs.append((i, ov, nv))
return diffs
def main():
extract_path = Path(sys.argv[1] if len(sys.argv) > 1 else "forensic_extract_out.json")
wasm_path = Path(sys.argv[2] if len(sys.argv) > 2 else "forensic_wasm_out.json")
ex = load(extract_path)
wasm = load(wasm_path)
keys = [
"text", "fontId", "fontName", "fontSize", "color",
"glyphCount", "advanceCount", "paragraphWidth", "baseline", "lineHeight",
"origLinesProvided",
]
orig = ex["original"]
entry_payload = ex["editEntry"]
one_payload = ex["editOne"]
# WASM layout snapshots
from forensic_extract import snapshot_from_wasm_layout, para_signature # type: ignore
seed_fn = orig.get("fontName", "")
entry_wasm = snapshot_from_wasm_layout("ENTRY_WASM", wasm["editEntryWasm"]["layout"], seed_fn)
one_wasm = snapshot_from_wasm_layout("ONE_WASM", wasm["editOneWasm"]["layout"], seed_fn)
print_table("ORIGINAL vs EDIT_MODE_ENTRY (payload)", diff_table(orig, entry_payload, keys))
print_table("ORIGINAL vs EDIT_MODE_ENTRY (WASM layout)", diff_table(orig, entry_wasm, keys))
adv_diff_entry = compare_advances(orig.get("advances"), entry_wasm.get("advances"))
if adv_diff_entry:
print(f"\nAdvance diffs ORIGINAL vs ENTRY_WASM: {len(adv_diff_entry)} positions")
for i, ov, nv in adv_diff_entry[:20]:
print(f" [{i}] {ov} -> {nv}")
else:
print("\nAdvance arrays IDENTICAL: ORIGINAL vs ENTRY_WASM")
print_table("EDIT_ENTRY vs AFTER_ONE_CHAR (payload)", diff_table(entry_payload, one_payload, keys))
print_table("ENTRY_WASM vs ONE_WASM", diff_table(entry_wasm, one_wasm, keys))
adv_diff_one = compare_advances(entry_wasm.get("advances"), one_wasm.get("advances"))
if adv_diff_one:
print(f"\nAdvance diffs ENTRY_WASM vs ONE_WASM: {len(adv_diff_one)} positions")
for i, ov, nv in adv_diff_one[:20]:
print(f" [{i}] {ov} -> {nv}")
# Line widths
print("\n=== LINE WIDTHS ===")
print(f"Original lineWidths: {orig.get('lineWidths')}")
print(f"Entry WASM lineWidths: {entry_wasm.get('lineWidths')}")
print(f"One WASM lineWidths: {one_wasm.get('lineWidths')}")
# Signatures chain
sigs = {
"original": para_signature(orig),
"entry_payload": para_signature(entry_payload),
"entry_wasm": para_signature(entry_wasm),
"one_payload": para_signature(one_payload),
"one_wasm": para_signature(one_wasm),
}
print("\n=== SIGNATURE CHAIN ===")
prev = None
for name, sig in sigs.items():
changed = prev and sig != prev
print(f" {name}: {sig}" + (" <-- FIRST CHANGE from " + prev_name if changed else ""))
if changed and prev:
pass
prev_name = name
prev = sig
# First mutation
chain = list(sigs.items())
base = chain[0][1]
for i in range(1, len(chain)):
if chain[i][1] != base:
print(f"\nFIRST SIGNATURE CHANGE: {chain[i][0]} (vs original)")
break
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
"""Edit-entry overlay vs extracted PDF paragraph (no reflow / no typing).
Mirrors ParagraphEditor computeLayout + style construction for the real resume
heading \"Professional Experience\" and ranks the first property that diverges.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
import pdfengine # type: ignore
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
OUT = Path(__file__).resolve().parent / "forensic_real" / "edit_entry_overlay_report.json"
TARGET = "Professional Experience"
ZOOM = 1.0 # property ratios are zoom-invariant in pt space
def measure_family(font_name: str) -> str:
if re.search(r"times|serif", font_name, re.I):
return "Times New Roman, serif"
if re.search(r"courier|mono", font_name, re.I):
return "Courier New, monospace"
return "Arial, sans-serif"
def page_content_right(model) -> float:
right = float("-inf")
for p in model.paragraphs:
for ln in p.lines:
if any((r.text or "").strip() for r in ln.runs):
right = max(right, ln.x + ln.w)
return right if right != float("-inf") else 0.0
def main():
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
page = doc.get_page(0)
model = page.extract_document_model()
height_pts = page.height
para = None
for p in model.paragraphs:
text = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET in text:
para = p
break
if para is None:
raise SystemExit("paragraph not found")
# --- Extracted PDF metrics ---
line = para.lines[0]
run = line.runs[0]
glyphs = list(run.glyphs)
min_x = min(g.bbox_x for g in glyphs)
min_y = min(g.bbox_y for g in glyphs)
max_x = max(g.bbox_x + g.bbox_w for g in glyphs)
max_y = max(g.bbox_y + g.bbox_h for g in glyphs)
baseline = glyphs[0].origin_y
ascent = max_y - baseline
descent = baseline - min_y
pdf = {
"text": "".join(r.text or "" for ln in para.lines for r in ln.runs),
"bbox": {"x": min_x, "y": min_y, "w": max_x - min_x, "h": max_y - min_y},
"line_x": line.x,
"line_y": line.y,
"line_w": line.w,
"line_h": line.h,
"baseline_y": line.baseline_y,
"ascent_pt": ascent,
"descent_pt": descent,
"width_pt": max_x - min_x,
"height_pt": max_y - min_y,
"font_name": run.font_name,
"font_size_pt": run.font_size,
"run_h": run.h,
"internal_font_id": run.internal_font_id,
"expected_font_weight": 700 if re.search(r"bold|black|heavy", run.font_name or "", re.I) else 400,
}
# --- Overlay construction (mirrors ParagraphEditor + TextEditLayer openEditor) ---
dom_size = max(run.font_size or 0, run.h or 0) or 12
column_left = line.x
para_right = line.x + line.w
pr = page_content_right(model)
column_right = max(pr, para_right) # TextEditLayer left-align path
# single-line leading default in computeLayout:
leading = dom_size * 1.2
first_baseline_y = line.baseline_y
font_name = run.font_name or ""
family = measure_family(font_name)
font_px = dom_size * ZOOM
leading_px = leading * ZOOM
col_left_px = column_left * ZOOM
col_width_px = (column_right - column_left) * ZOOM
col_height_px = 1 * leading_px # oldLineCount=1
first_baseline_screen = (height_pts - first_baseline_y) * ZOOM
editor_top = first_baseline_screen - font_px * 0.8
ascent_heuristic = dom_size * 0.8
overlay = {
"font_family": family,
"font_size_pt": dom_size,
"font_size_px": font_px,
"font_weight": "(not set; browser default 400)",
"line_height_pt": leading,
"line_height_px": leading_px,
"letter_spacing": "(not set; normal)",
"width_pt": column_right - column_left,
"width_px": col_width_px,
"height_pt": leading, # oldLineCount * leading
"height_px": col_height_px,
"left_px": col_left_px,
"top_px": editor_top,
"ascent_heuristic_pt": ascent_heuristic,
"transform": "none",
"column_left_pt": column_left,
"column_right_pt": column_right,
"page_content_right_pt": pr,
"style_source": {
"measureFamily": "Arial, sans-serif because name matches neither times|serif nor courier|mono",
"leading": "domSize * 1.2 (single-line; no baseline deltas)",
"editorTop": "baselineScreen - fontPx * 0.8",
"height": "oldLineCount * leadingPx",
"width": "columnRightOverride - columnLeft (page content right, not text bbox)",
},
}
# Ordered property checks (construction order / visual identity order)
checks = []
def add(prop, pdf_v, ov_v, match):
checks.append({"property": prop, "pdf": pdf_v, "overlay": ov_v, "match": match})
bold = pdf["expected_font_weight"] >= 700
add(
"font-family",
pdf["font_name"],
family,
(not bold) and ("arial" in (pdf["font_name"] or "").lower()),
)
add(
"font-weight",
pdf["expected_font_weight"],
400,
(not bold) or False, # BoldMT → no font-weight set → mismatch
)
add("font-size (pt)", pdf["font_size_pt"], overlay["font_size_pt"], abs(pdf["font_size_pt"] - overlay["font_size_pt"]) < 0.05)
add("line-height / leading (pt)", pdf["line_h"], overlay["line_height_pt"], abs(pdf["line_h"] - overlay["line_height_pt"]) < 0.5)
add("ascent (pt)", pdf["ascent_pt"], overlay["ascent_heuristic_pt"], abs(pdf["ascent_pt"] - overlay["ascent_heuristic_pt"]) < 0.5)
add("descent (pt)", pdf["descent_pt"], "(not represented in overlay CSS)", False)
add("height (pt)", pdf["height_pt"], overlay["height_pt"], abs(pdf["height_pt"] - overlay["height_pt"]) < 0.5)
add("width (pt)", pdf["width_pt"], overlay["width_pt"], abs(pdf["width_pt"] - overlay["width_pt"]) < 1.0)
add("letter-spacing", 0, "normal", True)
add("transform", "none", "none", True)
first = next((c for c in checks if not c["match"]), None)
print("=== PDF paragraph ===")
print(json.dumps(pdf, indent=2))
print("\n=== Overlay applied (edit-entry) ===")
print(json.dumps(overlay, indent=2))
print("\n=== Property checks (order) ===")
for c in checks:
flag = "OK " if c["match"] else "DIFF"
print(f" [{flag}] {c['property']}: pdf={c['pdf']!r} overlay={c['overlay']!r}")
print("\n=== FIRST PROPERTY THAT CHANGES ON EDIT-ENTRY ===")
print(json.dumps(first, indent=2))
report = {
"scope": "edit-entry only (mouse click before typing); no reflow/typing",
"pdf": pdf,
"overlay": overlay,
"checks": checks,
"firstPropertyThatChanges": first,
"notes": [
"measureFamily maps Arial-BoldMT → 'Arial, sans-serif' and never sets font-weight:700",
"That is the first identity/style mutation when the contentEditable overlay is created",
"Subsequent geometric diffs: leading 11.09→14.4, ascent 8.74→9.6, height 11.09→14.4, width text→page column",
],
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nWrote {OUT}")
if __name__ == "__main__":
main()
+407
View File
@@ -0,0 +1,407 @@
"""Stage 1-4 extraction + frontend layout mirror (ParagraphEditor.tsx)."""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
def line_advances(line) -> tuple[dict[int, list[float]], float]:
runs = line.runs
seq: list[tuple[int, int, float]] = []
aligned: dict[int, bool] = {}
for ri, r in enumerate(runs):
gs = r.glyphs
ok = bool(r.text) and len(gs) == len(r.text)
aligned[ri] = ok
if ok:
for ci, g in enumerate(gs):
seq.append((ri, ci, g.origin_x))
per_run: dict[int, list[float]] = {}
for ri, r in enumerate(runs):
if aligned.get(ri):
per_run[ri] = [0.0] * len(r.text)
anchor_x = seq[0][2] if seq else line.x
for k, (ri, ci, ox) in enumerate(seq):
gs = runs[ri].glyphs
g = gs[ci]
char_w = g.bbox_w if g.bbox_w > 0 else (runs[ri].font_size or 12) * 0.5
per_run[ri][ci] = (seq[k + 1][2] - ox) if k + 1 < len(seq) else char_w
for ri in list(per_run.keys()):
if any(a <= 0 for a in per_run[ri]):
del per_run[ri]
return per_run, anchor_x
def para_eff_size(lines) -> float:
nominal = 0.0
for line in lines:
for r in line.runs:
sz = max(r.font_size or 0, r.h or 0)
if sz > nominal:
nominal = sz
return nominal or 12.0
def median(xs: list[float]) -> float:
if not xs:
return 0.0
s = sorted(xs)
return s[len(s) // 2]
def compute_layout(para) -> dict:
lines = para.lines
eff_size = para_eff_size(lines)
column_left = float("inf")
first_baseline_y = float("-inf")
baselines: list[float] = []
right_edges: list[float] = []
object_indices: list[int] = []
seed_runs: list[dict] = []
orig_lines: list[dict] = []
for li, line in enumerate(lines):
if line.baseline_y is not None:
baselines.append(line.baseline_y)
first_baseline_y = max(first_baseline_y, line.baseline_y)
column_left = min(column_left, line.x)
right_edges.append(line.x + line.w)
line_runs = line.runs
per_run, anchor_x = line_advances(line)
line_frags: list[dict] = []
for ri, r in enumerate(line_runs):
for oi in (r.object_indices or []):
object_indices.append(oi)
orig = r.text or ""
text = orig
if li > 0 and ri == 0 and seed_runs:
prev = seed_runs[-1]["text"]
if prev and not prev.endswith((" ", "\t")) and not text.startswith((" ", "\t")):
text = " " + text
adv = per_run.get(ri)
fc = getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000"
safe_color = fc if fc and fc != "#ffffff00" else "#000000"
r_size = max(r.font_size or 0, r.h or 0) or eff_size
seed_runs.append({
"text": text, "fid": r.internal_font_id or "", "size": r_size,
"color": safe_color, "fontName": r.font_name or "", "advances": adv,
})
if orig:
line_frags.append({
"text": orig, "fid": r.internal_font_id or "", "size": r_size,
"color": safe_color, "advances": adv,
})
if line_frags:
orig_lines.append({"frags": line_frags, "x": anchor_x, "baselineY": line.baseline_y or 0})
max_right = max(right_edges) if right_edges else column_left + 250
column_right = max(max_right, column_left + 250) if column_left != float("inf") else 250
deltas = [baselines[i] - baselines[i + 1] for i in range(len(baselines) - 1)]
dom_size = next((r["size"] for r in seed_runs if r["text"].strip()), 12.0)
leading = abs(median(deltas)) if deltas else dom_size * 1.2
col_w = column_right - column_left
align = "left"
if len(lines) >= 2:
reaching = sum(1 for i in range(len(right_edges) - 1) if right_edges[i] >= column_right - col_w * 0.04)
if reaching >= (len(lines) - 1) * 0.7:
align = "justify"
return {
"columnLeft": column_left, "columnRight": column_right,
"firstBaselineY": first_baseline_y, "leading": leading,
"oldLineCount": len(lines), "align": align,
"objectIndices": object_indices, "seedRuns": seed_runs, "origLines": orig_lines,
"domSize": dom_size,
}
def extract_flat_runs(seed_runs: list[dict], dominant_fid: str, dom_size: float, dom_color: str) -> list[dict]:
"""Mirror extractFlatRuns when DOM matches seedRuns exactly (edit mode entry)."""
out: list[dict] = []
for r in seed_runs:
if not r["text"]:
continue
frag = {
"text": r["text"], "internalFontId": r["fid"] or dominant_fid,
"fontSize": r["size"], "color": r["color"],
}
adv = r.get("advances")
if adv and len(adv) == len(r["text"]):
frag["advances"] = adv
out.append(frag)
return out
def build_reflow_data(layout: dict, runs: list[dict], orig_lines: list[dict] | None, para_id: str) -> dict:
line_position = {}
if layout["origLines"]:
line_position = {
"lineX": [l["x"] for l in layout["origLines"]],
"lineBaselineY": [l["baselineY"] for l in layout["origLines"]],
}
lines_data = {}
if orig_lines:
lines_data = {
"lines": [
[
{
"text": f["text"], "internalFontId": f["fid"],
"fontSize": f["size"], "color": f["color"],
**({"advances": f["advances"]} if f.get("advances") else {}),
}
for f in l["frags"]
]
for l in orig_lines
],
}
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip() and r["fid"] == dominant_fid), None)
dom_size = (dom_run or layout["seedRuns"][0])["size"] if layout["seedRuns"] else 12.0
dom_color = (dom_run or layout["seedRuns"][0])["color"] if layout["seedRuns"] else "#000000"
out_runs = runs if runs else [{"text": " ", "internalFontId": dominant_fid, "fontSize": dom_size, "color": "#000000"}]
return {
"objectIndices": layout["objectIndices"],
"runs": out_runs,
**line_position,
**lines_data,
"columnLeft": layout["columnLeft"],
"columnRight": layout["columnRight"],
"pushColumnLeft": layout["columnLeft"],
"firstBaselineY": layout["firstBaselineY"],
"leading": layout["leading"],
"oldLineCount": layout["oldLineCount"],
"align": layout["align"],
"paraId": para_id,
}
def paragraph_snapshot_from_extraction(para, layout: dict) -> dict:
"""Original PDF paragraph metrics from extraction + derived advances."""
lines = para.lines
all_text = "".join(r.text for ln in lines for r in ln.runs)
runs_detail = []
for ln in lines:
per_run, anchor_x = line_advances(ln)
for ri, r in enumerate(ln.runs):
adv = per_run.get(ri)
runs_detail.append({
"text": r.text,
"fontId": r.internal_font_id,
"fontName": r.font_name,
"fontSize": max(r.font_size or 0, r.h or 0),
"color": getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000",
"glyphCount": len(r.glyphs),
"advanceCount": len(adv) if adv else 0,
"advances": adv,
"bbox": [r.x, r.y, r.w, r.h],
"lineWidth": sum(adv) if adv else r.w,
})
line_widths = [ln.w for ln in lines]
orig_line_texts = ["".join(r.text for r in ln.runs) for ln in lines]
return {
"stage": "ORIGINAL_PDF_EXTRACTION",
"text": all_text,
"fontId": runs_detail[0]["fontId"] if runs_detail else "",
"fontName": runs_detail[0]["fontName"] if runs_detail else "",
"fontSize": layout["domSize"],
"color": runs_detail[0]["color"] if runs_detail else "#000000",
"glyphCount": sum(len(r.glyphs) for ln in lines for r in ln.runs),
"advanceCount": sum(rd["advanceCount"] for rd in runs_detail),
"advances": [a for rd in runs_detail for a in (rd["advances"] or [])],
"boundingBox": [para.x, para.y, para.w, para.h],
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
"lineWidths": line_widths,
"baseline": layout["firstBaselineY"],
"ascent": None,
"descent": None,
"lineHeight": layout["leading"],
"origLines": orig_line_texts,
"runs": runs_detail,
"origLinesProvided": False,
}
def snapshot_from_payload(stage: str, layout: dict, runs: list[dict], orig_lines: list[dict] | None) -> dict:
all_text = "".join(r["text"] for r in runs)
dom = runs[0] if runs else {}
adv_flat = []
for r in runs:
if r.get("advances"):
adv_flat.extend(r["advances"])
return {
"stage": stage,
"text": all_text,
"fontId": dom.get("internalFontId", ""),
"fontName": layout["seedRuns"][0]["fontName"] if layout["seedRuns"] else "",
"fontSize": dom.get("fontSize", layout["domSize"]),
"color": dom.get("color", "#000000"),
"glyphCount": len(all_text),
"advanceCount": len(adv_flat),
"advances": adv_flat,
"boundingBox": None,
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
"lineWidths": None,
"baseline": layout["firstBaselineY"],
"ascent": None,
"descent": None,
"lineHeight": layout["leading"],
"origLines": ["".join(f["text"] for f in l["frags"]) for l in orig_lines] if orig_lines else None,
"runs": [
{
"text": r["text"],
"fontId": r.get("internalFontId"),
"fontSize": r.get("fontSize"),
"color": r.get("color"),
"advanceCount": len(r.get("advances") or []),
"advances": r.get("advances"),
"advanceSource": "frontend_origin_x_derived" if r.get("advances") else "missing",
}
for r in runs
],
"origLinesProvided": orig_lines is not None,
}
def snapshot_from_wasm_layout(stage: str, layout_json: dict, seed_font_name: str) -> dict:
lines = layout_json.get("lines") or []
all_text = "".join(ln.get("text", "") for ln in lines)
adv_flat = []
line_widths = []
for ln in lines:
adv = ln.get("adv") or []
adv_flat.extend(adv)
line_widths.append(sum(adv))
dom_fs = lines[0].get("fontSize") if lines else None
return {
"stage": stage,
"text": all_text,
"fontId": "",
"fontName": seed_font_name,
"fontSize": dom_fs,
"color": None,
"glyphCount": len(all_text),
"advanceCount": len(adv_flat),
"advances": adv_flat,
"boundingBox": None,
"paragraphWidth": (layout_json.get("columnRight") or 0) - (layout_json.get("columnLeft") or 0),
"lineWidths": line_widths,
"baseline": lines[0].get("baselineY") if lines else None,
"ascent": None,
"descent": None,
"lineHeight": None,
"origLines": [ln.get("text", "") for ln in lines],
"runs": [
{
"text": ln.get("text"),
"lineX0": ln.get("x0"),
"fontSize": ln.get("fontSize"),
"advanceCount": len(ln.get("adv") or []),
"advances": ln.get("adv"),
"advanceSum": sum(ln.get("adv") or []),
"advanceSource": "wasm_reflow_output",
}
for ln in lines
],
"origLinesProvided": None,
}
def para_signature(snap: dict) -> str:
parts = [
snap.get("text") or "",
str(snap.get("fontId") or ""),
str(snap.get("fontSize") or ""),
json.dumps(snap.get("advances") or []),
json.dumps(snap.get("lineWidths") or []),
str(snap.get("baseline") or ""),
json.dumps(snap.get("boundingBox") or []),
]
return hashlib.sha256("".join(parts).encode()).hexdigest()
def main():
pdf_path = ROOT / "corpus" / "basic" / "hello_world.pdf"
if len(sys.argv) > 1:
pdf_path = Path(sys.argv[1])
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
model = doc.get_page(0).extract_document_model()
para = model.paragraphs[0]
layout = compute_layout(para)
fonts = doc.get_fonts(0, 0)
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
dom_size = dom_run["size"]
dom_color = dom_run["color"]
para_id = f"forensic-{hashlib.md5(str(pdf_path).encode()).hexdigest()[:8]}"
# Stage snapshots
original = paragraph_snapshot_from_extraction(para, layout)
flat_entry = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_size, dom_color)
payload_entry = build_reflow_data(layout, flat_entry, layout["origLines"], para_id)
edit_entry = snapshot_from_payload("EDIT_MODE_ENTRY_PAYLOAD", layout, flat_entry, layout["origLines"])
# After one character
flat_one = []
for i, r in enumerate(flat_entry):
if i == 0:
flat_one.append({**r, "text": r["text"] + "x"})
else:
flat_one.append(r)
payload_one = build_reflow_data(layout, flat_one, None, para_id)
edit_one = snapshot_from_payload("AFTER_ONE_CHAR_PAYLOAD", layout, flat_one, None)
out = {
"pdf": str(pdf_path),
"fonts": [
{
"internalFontId": f.internal_font_id,
"fontName": f.font_name,
"ascent": f.ascent,
"descent": f.descent,
"capHeight": getattr(f, "cap_height", None),
"unitsPerEm": getattr(f, "units_per_em", None),
}
for f in fonts
],
"layout": layout,
"original": original,
"editEntry": edit_entry,
"editOne": edit_one,
"payloads": {
"editEntry": {
"version": "1.0",
"operations": [{
"id": "forensic-entry", "type": "reflow_paragraph", "pageIndex": 0,
"data": payload_entry,
}],
},
"editOne": {
"version": "1.0",
"operations": [{
"id": "forensic-one", "type": "reflow_paragraph", "pageIndex": 0,
"data": payload_one,
}],
},
},
"signatures": {
"original": para_signature(original),
"editEntryPayload": para_signature(edit_entry),
"editOnePayload": para_signature(edit_one),
},
}
out_path = Path(__file__).resolve().parent / "forensic_extract_out.json"
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(json.dumps(out, indent=2))
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,233 @@
"""First-property mutation on first keystroke (typing/reflow only).
Compares ORIGINAL extracted paragraph vs after typing one char at end.
Mirrors ParagraphEditor when editedRef=true:
- no lines[] payload (origLines dropped)
- lineX / lineBaselineY still sent from layout
- advances + advanceSeedText for the edited run
Prints the first property that diverges.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
OUT = Path(__file__).resolve().parent / "forensic_real" / "first_keystroke_mutation.json"
TARGET = "Professional Experience"
TYPED = TARGET + "x"
TOL = 0.05
def para_metrics(para, prefix: str) -> dict:
glyphs = []
fonts = set()
sizes = set()
minx = miny = 1e18
maxx = maxy = -1e18
ascent = descent = 0.0
baselines = []
line_hs = []
for ln in para.lines:
baselines.append(ln.baseline_y)
line_hs.append(ln.h)
for r in ln.runs:
fonts.add(r.font_name or "")
sizes.add(r.font_size or 0)
text = r.text or ""
gs = list(r.glyphs)
for i, g in enumerate(gs):
ch = text[i] if i < len(text) else "?"
adv = (gs[i + 1].origin_x - g.origin_x) if i + 1 < len(gs) else (g.bbox_w or (r.font_size or 12) * 0.5)
glyphs.append({
"char": ch,
"origin_x": g.origin_x,
"origin_y": g.origin_y,
"advance": adv,
"bbox_x": g.bbox_x,
"bbox_y": g.bbox_y,
"bbox_w": g.bbox_w,
"bbox_h": g.bbox_h,
"font_size": g.font_size or r.font_size,
"font_name": g.font_name or r.font_name,
"fid": r.internal_font_id,
})
minx = min(minx, g.bbox_x)
miny = min(miny, g.bbox_y)
maxx = max(maxx, g.bbox_x + g.bbox_w)
maxy = max(maxy, g.bbox_y + g.bbox_h)
gbl = g.origin_y if g.origin_y else ln.baseline_y
ascent = max(ascent, (g.bbox_y + g.bbox_h) - gbl)
descent = max(descent, gbl - g.bbox_y)
joined = "".join(g["char"] for g in glyphs)
start = joined.find(prefix[: len(TARGET)])
if start < 0:
start = 0
shared = glyphs[start : start + len(TARGET)]
leading = None
if len(baselines) >= 2:
leading = abs(baselines[0] - baselines[1])
return {
"text": joined,
"font_family": sorted(fonts),
"font_size": max(sizes) if sizes else None,
"line_height": max(line_hs) if line_hs else None,
"leading": leading,
"ascent": ascent,
"descent": descent,
"paragraph_width": (maxx - minx) if maxx > minx else 0,
"paragraph_height": (maxy - miny) if maxy > miny else 0,
"n_lines": len(para.lines),
"baselines": baselines,
"shared_glyphs": shared,
"bbox": {"x": minx, "y": miny, "w": maxx - minx, "h": maxy - miny},
}
def first_diff(before: dict, after: dict) -> dict | None:
checks = []
def add(name, b, a, ok):
checks.append({"property": name, "before": b, "after": a, "match": ok})
add("font_family", before["font_family"], after["font_family"],
before["font_family"] == after["font_family"])
add("font_size", before["font_size"], after["font_size"],
before["font_size"] is not None and abs((before["font_size"] or 0) - (after["font_size"] or 0)) < TOL)
add("line_height", before["line_height"], after["line_height"],
before["line_height"] is not None and abs((before["line_height"] or 0) - (after["line_height"] or 0)) < TOL)
add("ascent", before["ascent"], after["ascent"], abs(before["ascent"] - after["ascent"]) < TOL)
add("descent", before["descent"], after["descent"], abs(before["descent"] - after["descent"]) < TOL)
# Width/height: after includes +x so width may grow at the end — compare shared-prefix ink only below.
bg, ag = before["shared_glyphs"], after["shared_glyphs"]
n = min(len(bg), len(ag), len(TARGET))
# First glyph-level mutation among UNCHANGED chars
glyph_first = None
for i in range(n):
b, a = bg[i], ag[i]
reasons = []
if b["char"] != a["char"]:
reasons.append("char")
if abs(b["advance"] - a["advance"]) > TOL:
reasons.append("advance")
if abs(b["origin_x"] - a["origin_x"]) > TOL:
reasons.append("origin_x")
if abs(b["origin_y"] - a["origin_y"]) > TOL:
reasons.append("origin_y")
if (b.get("font_name") or "") != (a.get("font_name") or ""):
reasons.append("font_name")
if abs((b.get("font_size") or 0) - (a.get("font_size") or 0)) > TOL:
reasons.append("font_size")
if reasons:
glyph_first = {
"index": i,
"char": b["char"],
"reasons": reasons,
"before": b,
"after": a,
}
break
add("unchanged_glyph_identity", "all match", glyph_first or "all match", glyph_first is None)
# Prefix span width (should be identical if advances+positions preserved)
if n:
bw = (bg[n - 1]["origin_x"] + bg[n - 1]["advance"]) - bg[0]["origin_x"]
aw = (ag[n - 1]["origin_x"] + ag[n - 1]["advance"]) - ag[0]["origin_x"]
add("prefix_width", bw, aw, abs(bw - aw) < TOL)
add("prefix_start_x", bg[0]["origin_x"], ag[0]["origin_x"], abs(bg[0]["origin_x"] - ag[0]["origin_x"]) < TOL)
first = next((c for c in checks if not c["match"]), None)
return {"checks": checks, "first_property_that_changes": first, "first_unchanged_glyph_mutation": glyph_first}
def main():
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
page = doc.get_page(0)
model = page.extract_document_model()
para = None
for p in model.paragraphs:
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET in t:
para = p
break
assert para is not None
before = para_metrics(para, TARGET)
layout = compute_layout(para)
flat = extract_flat_runs(layout["seedRuns"], layout["seedRuns"][0]["fid"], layout["seedRuns"][0]["size"], "#000")
seed_adv = None
for r in flat:
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
seed_adv = list(r["advances"])
break
typed = []
for r in flat:
nr = dict(r)
if nr.get("text") == TARGET:
nr["text"] = TYPED
if seed_adv is not None:
nr["advances"] = seed_adv
nr["advanceSeedText"] = TARGET
else:
nr.pop("advances", None)
typed.append(nr)
data = build_reflow_data(layout, typed, None, "key-after")
data.pop("lines", None) # editedRef drops lines
# keep lineX/lineBaselineY as frontend does
print("PAYLOAD keys:", sorted(data.keys()))
print("run:", [(r.get("text"), len(r.get("advances") or []), r.get("advanceSeedText")) for r in typed])
op = {"version": "1.0", "operations": [{"id": "t", "type": "reflow_paragraph", "pageIndex": 0, "data": data}]}
doc.apply_edits(json.dumps(op))
para_a = None
model_a = doc.get_page(0).extract_document_model()
for p in model_a.paragraphs:
t = "".join(r.text or "" for ln in p.lines for r in ln.runs)
if TARGET in t or TYPED in t or "Professional" in t:
para_a = p
break
assert para_a is not None
after = para_metrics(para_a, TYPED)
result = first_diff(before, after)
report = {
"target": TARGET,
"typed": TYPED,
"seed_adv_len": len(seed_adv or []),
"before": {k: v for k, v in before.items() if k != "shared_glyphs"},
"after": {k: v for k, v in after.items() if k != "shared_glyphs"},
"before_shared_adv_sample": [g["advance"] for g in before["shared_glyphs"][:8]],
"after_shared_adv_sample": [g["advance"] for g in after["shared_glyphs"][:8]],
**result,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report, indent=2))
first = result["first_property_that_changes"]
print("\n=== FIRST PROPERTY THAT CHANGES ===")
print(json.dumps(first, indent=2))
print("\n=== FIRST UNCHANGED GLYPH MUTATION ===")
print(json.dumps(result["first_unchanged_glyph_mutation"], indent=2))
if __name__ == "__main__":
main()
+138
View File
@@ -0,0 +1,138 @@
"""Step 2 experiment: force runPerChar=false for UNCHANGED paragraph.
Requires rebuilt pdfengine with forensicForceWholeRun support.
Compares:
A) default entry (runPerChar as decided by diverges)
B) forensicForceWholeRun=true (runPerChar forced 0)
C) original PDF
Emits object dumps + content streams + optional region bitmap SHA.
"""
from __future__ import annotations
import hashlib
import json
import sys
import zlib
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
# Prefer freshly rebuilt engine when present (must be AFTER gateway insert so it wins).
_winlocal = Path(r"C:\Users\Maskan\pdfeng-build\win-local\lib")
if _winlocal.exists():
sys.path.insert(0, str(_winlocal))
import pdfengine # type: ignore
print(f"[engine] {pdfengine.__file__}", flush=True)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
def inflate_content(pdf_bytes: bytes) -> list[str]:
out = []
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
try:
out.append(zlib.decompress(m.group(1)).decode("latin-1", errors="replace"))
except Exception:
pass
return out
def font_dicts(pdf_bytes: bytes) -> list[str]:
found = []
for m in re.finditer(rb"<<[^>]*?/Type\s*/Font[^>]*?>>", pdf_bytes, re.DOTALL):
found.append(m.group(0).decode("latin-1", errors="replace"))
# also compact form
if not found:
for m in re.finditer(rb"<</BaseFont/[^>]+>>", pdf_bytes):
found.append(m.group(0).decode("latin-1", errors="replace"))
return found
def region_sha(doc, page_index=0, dpi=144, y_top=200, height=80) -> str:
page = doc.get_page(page_index)
w, h, raw = page.render_region_raw(dpi, y_top, height)
return hashlib.sha256(raw).hexdigest()[:16], w, h
def main():
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
out_dir = Path(__file__).resolve().parent
doc0 = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
model = doc0.get_page(0).extract_document_model()
para = model.paragraphs[0]
layout = compute_layout(para)
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-force-whole")
variants = {
"A_default_entry": {**data},
"B_force_whole_run": {**data, "forensicForceWholeRun": True},
}
orig_bytes = pdf_path.read_bytes()
print("ORIGINAL fonts:", font_dicts(orig_bytes))
print("ORIGINAL streams:")
for s in inflate_content(orig_bytes):
print(s)
try:
sha, w, h = region_sha(doc0, y_top=120, height=40)
print(f"ORIGINAL region sha={sha} {w}x{h}")
except Exception as e:
print("ORIGINAL region render failed:", e)
results = {}
for name, d in variants.items():
op = {"version": "1.0", "operations": [{
"id": name, "type": "reflow_paragraph", "pageIndex": 0, "data": d,
}]}
print(f"\n===== {name} =====")
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
doc.apply_edits(json.dumps(op))
out = doc.save_full()
path = out_dir / f"_forensic_{name}.pdf"
path.write_bytes(out)
print("fonts:", font_dicts(out))
streams = inflate_content(out)
for s in streams:
# only show streams that mention Goodbye / FXF2 / 100 Tm
if "100 Tm" in s or "Goodbye" in s or "476F6F64" in s:
print("--- content ---")
print(s[:2000])
# count TJ
tj_count = sum(s.count(" TJ") for s in streams)
print(f"TJ operator count across streams: {tj_count}")
try:
d2 = pdfengine.PdfDocument.load_from_memory(out, "")
sha, w, h = region_sha(d2, y_top=120, height=40)
print(f"region sha={sha} {w}x{h}")
results[name] = {"sha": sha, "tj": tj_count, "fonts": font_dicts(out)}
except Exception as e:
print("region render failed:", e)
results[name] = {"error": str(e), "tj": tj_count}
print("\n===== VERDICT =====")
if "A_default_entry" in results and "B_force_whole_run" in results:
a, b = results["A_default_entry"], results["B_force_whole_run"]
print(f"A (default runPerChar) TJ={a.get('tj')} sha={a.get('sha')}")
print(f"B (force whole run) TJ={b.get('tj')} sha={b.get('sha')}")
if a.get("sha") and b.get("sha"):
if a["sha"] == b["sha"]:
print("RESULT: A and B IDENTICAL bitmaps → runPerChar does NOT change pixels")
else:
print("RESULT: A and B DIFFER → runPerChar DOES change pixels")
if a.get("tj") == 1 and b.get("tj") and b["tj"] < a.get("tj", 99):
print("NOTE: B emitted fewer TJ (whole-run path active)")
if a.get("tj", 0) > 5 and b.get("tj", 0) <= 3:
print("CONFIRMED: forensicForceWholeRun reduced emission to whole-run")
elif a.get("tj") == b.get("tj"):
print("WARNING: TJ counts equal — rebuild may not include forensicForceWholeRun yet")
if __name__ == "__main__":
main()
+677
View File
@@ -0,0 +1,677 @@
"""Runtime glyph-position compare + U+0000 coverage audit (no engine code changes).
Uses real resume PDF only. Produces per-glyph table for \"Professional Experience\".
"""
from __future__ import annotations
import json
import re
import struct
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
sys.path.insert(0, r"C:\Users\Maskan\pdfeng-build\win-local\lib")
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
PDF = Path(r"C:\Users\Maskan\Downloads\Saqib_Ali_Mir_Resume.pdf")
OUT = Path(__file__).resolve().parent / "forensic_real"
FID = "Arial-BoldMT_TrueType_32"
TARGET = "Professional Experience"
TOL = 0.05 # PDF points — first position differ threshold
# --- TJ / content-stream helpers -------------------------------------------------
def decompress_streams(pdf_bytes: bytes) -> list[str]:
out = []
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
raw = m.group(1)
try:
out.append(zlib.decompress(raw).decode("latin-1", "replace"))
except Exception:
try:
out.append(raw.decode("latin-1", "replace"))
except Exception:
pass
return out
def parse_tj_kerning(tj_body: str) -> list[dict]:
"""Parse a TJ array body into char runs + kerning (thousandths of em).
Example: [(Pr)-6(o)7(fe)-6(s)...]
Returns list of {chars, kern_before} where kern_before is the TJ number
preceding that string fragment (0 for the first).
"""
# Strip outer brackets if present
body = tj_body.strip()
if body.startswith("["):
body = body[1:]
if body.endswith("]"):
body = body[:-1]
items: list[dict] = []
pos = 0
pending_kern = 0.0
while pos < len(body):
while pos < len(body) and body[pos].isspace():
pos += 1
if pos >= len(body):
break
if body[pos] == "(":
# literal string with escape handling
pos += 1
chars = []
while pos < len(body) and body[pos] != ")":
if body[pos] == "\\" and pos + 1 < len(body):
chars.append(body[pos + 1])
pos += 2
else:
chars.append(body[pos])
pos += 1
pos += 1 # )
s = "".join(chars)
items.append({"chars": s, "kern_before": pending_kern})
pending_kern = 0.0
elif body[pos] == "<":
end = body.find(">", pos)
hexpart = body[pos + 1 : end]
s = bytes.fromhex(hexpart).decode("latin-1", "replace")
items.append({"chars": s, "kern_before": pending_kern})
pending_kern = 0.0
pos = end + 1
else:
# number (kerning)
m = re.match(r"[+-]?\d+(?:\.\d+)?", body[pos:])
if not m:
pos += 1
continue
pending_kern = float(m.group(0))
pos += len(m.group(0))
return items
def expand_tj_to_glyphs(tj_items: list[dict], font_size: float, tm: list[float]) -> list[dict]:
"""Expand TJ fragments to per-char records with cumulative X from Tm + kerning.
Note: absolute X needs glyph advances; here we only attach kerning adjustment
(PDF units = kern/1000 * fontSize) and the text matrix at the run start.
Per-char X from stream alone is incomplete without widths pair with extraction.
"""
rows = []
for frag in tj_items:
kern_pdf = (frag["kern_before"] / 1000.0) * font_size
for i, ch in enumerate(frag["chars"]):
rows.append({
"char": ch,
"kern_before_thousandths": frag["kern_before"] if i == 0 else 0.0,
"kern_adj_pdf": kern_pdf if i == 0 else 0.0,
"text_matrix": tm[:],
})
return rows
def find_original_heading_tj(pdf_bytes: bytes) -> tuple[list[float], float, list[dict]]:
"""Locate Professional Experience TJ near y=597.45."""
for s in decompress_streams(pdf_bytes):
if "597.45" not in s or "Professional" not in s and "Pr)-6(o)" not in s:
# still check for the known TJ pattern
if "597.45" not in s:
continue
lines = s.splitlines()
for i, ln in enumerate(lines):
if "597.45" in ln and "Tm" in ln:
# look forward for Tf + TJ
tm = [float(x) for x in re.findall(r"[+-]?\d+(?:\.\d+)?", ln)[:6]]
font_size = 12.0
tj_body = None
for j in range(i, min(i + 10, len(lines))):
if " Tf" in lines[j]:
nums = re.findall(r"[+-]?\d+(?:\.\d+)?", lines[j])
if nums:
font_size = float(nums[-1])
if "TJ" in lines[j] and "[" in lines[j]:
tj_body = lines[j]
# may be `... ] TJ` on same line
m = re.search(r"\[(.*)\]\s*TJ", lines[j])
if m:
tj_body = m.group(1)
break
if tj_body is None:
continue
# Prefer the heading that contains Pr
if "Pr" not in tj_body and "Professional" not in tj_body:
continue
items = parse_tj_kerning(tj_body)
return tm, font_size, expand_tj_to_glyphs(items, font_size, tm)
raise RuntimeError("original TJ for Professional Experience not found")
def find_preview_heading_glyphs(pdf_bytes: bytes) -> list[dict]:
"""Parse per-char Tm + TJ hex CIDs for FXF3 at y≈597.45."""
rows = []
for s in decompress_streams(pdf_bytes):
if "FXF3" not in s or "597.45" not in s:
continue
# Match blocks: Tm ... Tf ... [<HHHH>] TJ
for m in re.finditer(
r"1 0 0 1 ([0-9.+\-]+) ([0-9.+\-]+) Tm\s+/FXF3 ([0-9.]+) Tf.*?\[<([0-9A-Fa-f]+)>\]\s*TJ",
s,
re.DOTALL,
):
x, y, size, cid_hex = m.group(1), m.group(2), m.group(3), m.group(4)
rows.append({
"x": float(x),
"y": float(y),
"font_size": float(size),
"cid_or_gid": int(cid_hex, 16),
"cid_hex": cid_hex.upper(),
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(x), float(y)],
"kern_adj_pdf": 0.0, # Identity-H per-char emit has no TJ kerning numbers
})
# Sort by x ascending (stream is reverse insert order)
rows.sort(key=lambda r: r["x"])
return rows
# --- Extraction helpers ---------------------------------------------------------
def collect_para_glyphs(para) -> list[dict]:
rows = []
for ln in para.lines:
for r in ln.runs:
text = r.text or ""
glyphs = list(r.glyphs)
for i, g in enumerate(glyphs):
ch = g.text if getattr(g, "text", None) is not None else (text[i] if i < len(text) else "?")
# advance = delta to next origin, else bbox_w
if i + 1 < len(glyphs):
adv = glyphs[i + 1].origin_x - g.origin_x
else:
adv = g.bbox_w if g.bbox_w > 0 else (r.font_size or 12) * 0.5
row = {
"char": ch,
"unicode": ord(ch) if len(ch) == 1 else None,
"origin_x": g.origin_x,
"origin_y": g.origin_y,
"advance": adv,
"bbox_w": g.bbox_w,
"bbox_h": g.bbox_h,
"font_size": g.font_size,
"font_name": g.font_name,
"fid": r.internal_font_id,
}
for attr in ("glyph_id", "gid", "charcode", "unicode_value", "font_glyph_id"):
if hasattr(g, attr):
row["glyph_id"] = getattr(g, attr)
break
rows.append(row)
return rows
def find_para(doc):
page = doc.get_page(0)
model = page.extract_document_model()
for idx, para in enumerate(model.paragraphs):
text = "".join(r.text or "" for ln in para.lines for r in ln.runs)
if TARGET in text or TARGET.replace(" ", "") in text.replace(" ", ""):
# Prefer exact heading paragraph
flat = "".join(r.text or "" for ln in para.lines for r in ln.runs)
if TARGET in flat or flat.strip().startswith("Professional"):
return idx, para, flat
raise RuntimeError("paragraph not found")
# --- U+0000 coverage audit (mirrors engine, no C++ edits) -----------------------
def utf8_to_utf16le_mirror(s: str) -> list[int]:
"""Mirror pdfium_internal.cpp utf8_to_utf16le including trailing NUL."""
utf16: list[int] = []
data = s.encode("utf-8")
i = 0
while i < len(data):
c = data[i]
if c < 0x80:
cp, extra = c, 0
elif (c & 0xE0) == 0xC0:
cp, extra = c & 0x1F, 1
elif (c & 0xF0) == 0xE0:
cp, extra = c & 0x0F, 2
elif (c & 0xF8) == 0xF0:
cp, extra = c & 0x07, 3
else:
i += 1
continue
if i + extra >= len(data):
break
invalid = False
for j in range(1, extra + 1):
nxt = data[i + j]
if (nxt & 0xC0) != 0x80:
invalid = True
break
cp = (cp << 6) | (nxt & 0x3F)
if invalid:
i += 1
continue
i += 1 + extra
if cp < 0x10000:
utf16.append(cp)
else:
cp -= 0x10000
utf16.append((cp >> 10) + 0xD800)
utf16.append((cp & 0x3FF) + 0xDC00)
utf16.append(0) # <-- engine always appends NUL terminator
return utf16
def to_codepoints_mirror(s: str) -> list[int]:
"""Mirror toCodepoints lambda in pdfium_edit_reflow.cpp."""
u16 = utf8_to_utf16le_mirror(s)
cps: list[int] = []
i = 0
while i < len(u16):
cp = u16[i]
if 0xD800 <= cp <= 0xDBFF and i + 1 < len(u16):
low = u16[i + 1]
if 0xDC00 <= low <= 0xDFFF:
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00)
i += 2
else:
i += 1
else:
i += 1
cps.append(cp)
return cps
def unicode_cmap(font_bytes: bytes) -> dict[int, int]:
"""Prefer MS/Unicode cmap (what FreeType typically uses for FT_Get_Char_Index)."""
from fontTools.ttLib import TTFont # type: ignore
import io
tt = TTFont(io.BytesIO(font_bytes))
glyph_order = tt.getGlyphOrder()
name_to_gid = {n: i for i, n in enumerate(glyph_order)}
def to_map(table) -> dict[int, int]:
out = {}
for cp, name in table.cmap.items():
out[cp] = name_to_gid.get(name, 0) if isinstance(name, str) else int(name)
return out
preferred: list[dict[int, int]] = []
fallback: list[dict[int, int]] = []
for t in tt["cmap"].tables:
mapping = to_map(t)
if t.platformID == 3 and t.platEncID in (1, 10):
preferred.append(mapping)
elif t.platformID == 0:
preferred.append(mapping)
else:
fallback.append(mapping)
merged: dict[int, int] = {}
for m in (preferred or fallback):
merged.update(m)
return merged
def cmap_has_glyph(font_bytes: bytes, codepoint: int) -> tuple[bool, int]:
"""Return (has_glyph_like_engine, gid). Engine: FT_Get_Char_Index != 0."""
try:
cmap = unicode_cmap(font_bytes)
gid = int(cmap.get(codepoint, 0) or 0)
return (gid != 0), gid
except Exception:
return False, -1
def parse_emit_log(log_text: str) -> list[dict]:
"""Parse [EMIT_FONT] lines for Professional Experience heading."""
rows = []
for m in re.finditer(
r"\[EMIT_FONT\] text='([^']*)'.*?atX=([0-9.+\-]+)\s+baselineY=([0-9.+\-]+).*?runPerChar=(\d+)",
log_text,
):
rows.append({
"char": m.group(1),
"origin_x": float(m.group(2)),
"origin_y": float(m.group(3)),
"runPerChar": int(m.group(4)),
"text_matrix": [1.0, 0.0, 0.0, 1.0, float(m.group(2)), float(m.group(3))],
"emitted": True,
})
return rows
def align_emit_to_target(target: str, emit_rows: list[dict], advances: list[float], baseline_y: float) -> list[dict]:
"""Align EMIT rows to TARGET; spaces skipped by runPerChar get emitted=False with inferred X."""
out: list[dict] = []
ei = 0
x_cursor = None
for i, ch in enumerate(target):
adv = advances[i] if i < len(advances) else 0.0
if ch == " ":
# runPerChar path: if (seg.text[c] != ' ') emitObj(...) — space NOT emitted
if x_cursor is None and out:
x_cursor = out[-1]["origin_x"] + out[-1]["advance"]
elif x_cursor is None:
x_cursor = 0.0
out.append({
"char": " ",
"origin_x": x_cursor,
"origin_y": baseline_y,
"advance": adv,
"emitted": False,
"glyph_id": None,
"kern_adj_pdf": 0.0,
"kern_before_thousandths": 0.0,
"text_matrix": None,
"note": "space skipped by runPerChar emit (x advanced only)",
})
x_cursor = x_cursor + adv
continue
if ei >= len(emit_rows):
out.append({
"char": ch, "origin_x": None, "origin_y": None, "advance": adv,
"emitted": False, "glyph_id": None, "kern_adj_pdf": 0.0,
"kern_before_thousandths": 0.0, "text_matrix": None,
"note": "missing emit",
})
continue
er = emit_rows[ei]
ei += 1
# If emit char doesn't match (shouldn't), still take position
row = {
"char": ch,
"origin_x": er["origin_x"],
"origin_y": er["origin_y"],
"advance": adv,
"emitted": True,
"glyph_id": None,
"kern_adj_pdf": 0.0,
"kern_before_thousandths": 0.0,
"text_matrix": er["text_matrix"],
"note": "" if er["char"] == ch else f"emit_char_mismatch emit={er['char']!r}",
}
out.append(row)
x_cursor = er["origin_x"] + adv
return out
def main():
import io
from contextlib import redirect_stderr, redirect_stdout
OUT.mkdir(parents=True, exist_ok=True)
orig_bytes = PDF.read_bytes()
print("=== LOAD ORIGINAL ===")
doc = pdfengine.PdfDocument.load_from_file(str(PDF), "")
pidx, para, flat = find_para(doc)
print(f"paragraph idx={pidx} text={flat!r}")
orig_glyphs = collect_para_glyphs(para)
joined = "".join(g["char"] for g in orig_glyphs)
start = joined.find(TARGET)
if start < 0:
target_glyphs = [g for g in orig_glyphs if g.get("fid") == FID]
else:
target_glyphs = orig_glyphs[start : start + len(TARGET)]
print(f"orig glyph count for target={len(target_glyphs)} chars={''.join(g['char'] for g in target_glyphs)!r}")
tm, fsize, tj_rows = find_original_heading_tj(orig_bytes)
print(f"original Tm={tm} fontSize={fsize} TJ expanded chars={len(tj_rows)}")
for i, g in enumerate(target_glyphs):
if i < len(tj_rows):
g["kern_before_thousandths"] = tj_rows[i]["kern_before_thousandths"]
g["kern_adj_pdf"] = tj_rows[i]["kern_adj_pdf"]
g["text_matrix"] = tj_rows[i]["text_matrix"]
else:
g["kern_before_thousandths"] = 0.0
g["kern_adj_pdf"] = 0.0
g["text_matrix"] = tm
layout = compute_layout(para)
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), FID)
dom_run = next((r for r in layout["seedRuns"] if r["fid"] == dominant_fid and r["text"].strip()), layout["seedRuns"][0])
runs = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_run["size"], dom_run["color"])
data = build_reflow_data(layout, runs, layout["origLines"], f"pos-{pidx}")
op = {"version": "1.0", "operations": [{
"id": "pos", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
}]}
print("=== APPLY REFLOW (edit-entry, unchanged text) ===")
# Capture spdlog on stderr/stdout if redirected; also read prior pattern from engine
log_buf = io.StringIO()
doc2 = pdfengine.PdfDocument.load_from_file(str(PDF), "")
# spdlog writes to stderr typically via our capture of the process — here we rely on
# re-parsing from a file we tee. Capture by running and also reading STAGE_5 from
# a side channel: apply then parse preview stream + reconstruct from known EMIT positions.
doc2.apply_edits(json.dumps(op))
prev_bytes = doc2.save_full()
(OUT / "preview_pos.pdf").write_bytes(prev_bytes)
# Client advances from seed (same numbers reflow uses when lengths match)
client_adv = None
for r in layout["seedRuns"]:
if r.get("text") == TARGET and r.get("advances") and len(r["advances"]) == len(TARGET):
client_adv = list(r["advances"])
break
if client_adv is None:
client_adv = [g["advance"] for g in target_glyphs]
# Preview positions: from content stream FXF3 (non-space) + inferred space
stream_prev = find_preview_heading_glyphs(prev_bytes)
print(f"preview stream FXF3 glyphs={len(stream_prev)} (spaces intentionally not emitted in runPerChar)")
# Build synthetic emit rows from stream (sorted by x) — chars from TARGET without spaces
non_space = [ch for ch in TARGET if ch != " "]
emit_rows = []
for i, ch in enumerate(non_space):
if i < len(stream_prev):
sp = stream_prev[i]
emit_rows.append({
"char": ch,
"origin_x": sp["x"],
"origin_y": sp["y"],
"runPerChar": 1,
"text_matrix": sp["text_matrix"],
"emitted": True,
"glyph_id": sp["cid_or_gid"],
})
# Also try to enrich from glyph_pos_log if present from prior tee
log_path = OUT / "glyph_pos_log.txt"
if log_path.exists():
parsed = parse_emit_log(log_path.read_text(encoding="utf-8", errors="replace"))
if len(parsed) >= len(non_space):
# Prefer live EMIT atX from log (more authoritative for this run if same)
emit_rows = []
for i, ch in enumerate(non_space):
er = parsed[i]
emit_rows.append({**er, "char": ch})
baseline_y = target_glyphs[0]["origin_y"] if target_glyphs else 597.45
prev_glyphs = align_emit_to_target(TARGET, emit_rows, client_adv, baseline_y)
# Attach GIDs from stream to emitted glyphs
si = 0
for g in prev_glyphs:
if g["emitted"] and si < len(stream_prev):
g["glyph_id"] = stream_prev[si]["cid_or_gid"]
si += 1
font_bytes = bytes(doc.get_font_data(FID) or b"")
print(f"embedded font bytes={len(font_bytes)}")
for g in target_glyphs:
if g.get("unicode") is not None and font_bytes:
has, gid = cmap_has_glyph(font_bytes, g["unicode"])
g["glyph_id"] = gid
g["cmap_has"] = has
print("\n=== PER-GLYPH COMPARISON (orig extraction vs emit atX) ===")
print(
f"{'#':>2} {'ch':>3} {'gidO':>5} {'gidP':>5} {'emit':>4} "
f"{'xO':>10} {'xP':>10} {'dx':>8} "
f"{'yO':>10} {'yP':>10} {'dy':>8} "
f"{'advO':>8} {'advP':>8} {'dAdv':>8} "
f"{'kernO':>8} {'kernP':>8} Tm tx,ty"
)
first_diff = None
rows_out = []
n = min(len(target_glyphs), len(prev_glyphs), len(TARGET))
for i in range(n):
o, p = target_glyphs[i], prev_glyphs[i]
xP = p.get("origin_x")
yP = p.get("origin_y")
if xP is None:
dx = dy = float("nan")
pos_diff = True
else:
dx = xP - o["origin_x"]
dy = yP - o["origin_y"]
pos_diff = (not p.get("emitted")) or abs(dx) > TOL or abs(dy) > TOL
dadv = (p["advance"] - o["advance"]) if p.get("advance") is not None else float("nan")
adv_diff = abs(dadv) > TOL if dadv == dadv else True
if first_diff is None and (pos_diff or adv_diff):
reason = "not_emitted" if not p.get("emitted") else ("position" if pos_diff else "advance")
first_diff = {
"index": i,
"char": o["char"],
"reason": reason,
"dx": dx, "dy": dy, "dAdv": dadv,
"xO": o["origin_x"], "xP": xP,
"yO": o["origin_y"], "yP": yP,
"advO": o["advance"], "advP": p.get("advance"),
"kernO": o.get("kern_adj_pdf", 0), "kernP": p.get("kern_adj_pdf", 0),
"gidO": o.get("glyph_id"), "gidP": p.get("glyph_id"),
"tmO": o.get("text_matrix"), "tmP": p.get("text_matrix"),
"note": p.get("note"),
}
tmO, tmP = o.get("text_matrix"), p.get("text_matrix")
tmOs = f"O[{tmO[4]:.3f},{tmO[5]:.3f}]" if tmO and len(tmO) >= 6 else "O[—]"
tmPs = f"P[{tmP[4]:.3f},{tmP[5]:.3f}]" if tmP and len(tmP) >= 6 else ("P[— skipped]" if not p.get("emitted") else "P[—]")
xPs = f"{xP:10.4f}" if xP is not None else f"{'None':>10}"
yPs = f"{yP:10.4f}" if yP is not None else f"{'None':>10}"
dxs = f"{dx:8.4f}" if dx == dx else f"{'nan':>8}"
dys = f"{dy:8.4f}" if dy == dy else f"{'nan':>8}"
print(
f"{i:2d} {o['char']:>3} {str(o.get('glyph_id')):>5} {str(p.get('glyph_id')):>5} "
f"{'Y' if p.get('emitted') else 'N':>4} "
f"{o['origin_x']:10.4f} {xPs} {dxs} "
f"{o['origin_y']:10.4f} {yPs} {dys} "
f"{o['advance']:8.4f} {p.get('advance', 0):8.4f} {dadv:8.4f} "
f"{o.get('kern_adj_pdf', 0):8.4f} {p.get('kern_adj_pdf', 0):8.4f} {tmOs} {tmPs}"
)
rows_out.append({
"i": i, "char": o["char"],
"gid_orig": o.get("glyph_id"), "gid_prev": p.get("glyph_id"),
"emitted": bool(p.get("emitted")),
"x_orig": o["origin_x"], "x_prev": xP, "dx": dx if dx == dx else None,
"y_orig": o["origin_y"], "y_prev": yP, "dy": dy if dy == dy else None,
"adv_orig": o["advance"], "adv_prev": p.get("advance"), "d_adv": dadv if dadv == dadv else None,
"kern_orig_pdf": o.get("kern_adj_pdf", 0),
"kern_orig_thousandths": o.get("kern_before_thousandths", 0),
"kern_prev_pdf": p.get("kern_adj_pdf", 0),
"tm_orig": o.get("text_matrix"),
"tm_prev": p.get("text_matrix"),
"pos_differs": pos_diff,
"adv_differs": adv_diff,
"note": p.get("note"),
})
print("\n=== FIRST GLYPH WHOSE POSITION DIFFERS ===")
print(json.dumps(first_diff, indent=2))
# Visible-glyph-only: ignore intentional space skip
first_visible = None
for row in rows_out:
if row["char"] == " ":
continue
if row["pos_differs"] or row["adv_differs"]:
first_visible = row
break
print("\n=== FIRST VISIBLE (non-space) GLYPH DIFF ===")
print(json.dumps(first_visible, indent=2))
print("\n=== U+0000 COVERAGE AUDIT ===")
run_texts = [r["text"] for r in layout["seedRuns"] if r.get("text")]
print(f"seed run texts: {run_texts!r}")
all_cps: list[int] = []
for t in run_texts:
cps = to_codepoints_mirror(t)
print(f" text={t!r}")
print(f" utf16le_mirror (incl NUL) = {[f'U+{c:04X}' for c in utf8_to_utf16le_mirror(t)]}")
print(f" toCodepoints_mirror = {[f'U+{c:04X}' for c in cps]}")
print(f" contains U+0000? {0 in cps}")
all_cps.extend(cps)
print(f"aggregated codepoints ({len(all_cps)}): {[f'U+{c:04X}' for c in all_cps]}")
print(f"U+0000 count in aggregated set: {all_cps.count(0)}")
missing_real = []
lacking = []
has0 = False
gid0 = -1
if font_bytes:
has0, gid0 = cmap_has_glyph(font_bytes, 0)
print(f"Unicode cmap U+0000: hasGlyph_engine_rule={has0} gid={gid0}")
print(" (engine hasGlyph: FT_Get_Char_Index(cp) != 0; gid==0 => MISSING)")
# Mac Roman may map NUL — note for audit
try:
from fontTools.ttLib import TTFont
tt = TTFont(io.BytesIO(font_bytes))
for t in tt["cmap"].tables:
if 0 in t.cmap:
print(f" note: platform={t.platformID} enc={t.platEncID} maps U+0000 -> {t.cmap[0]} "
f"(FreeType Unicode cmap path still typically misses this)")
except Exception:
pass
for ch in TARGET:
has, gid = cmap_has_glyph(font_bytes, ord(ch))
if not has:
missing_real.append((ch, ord(ch), gid))
print(f"missing real TARGET chars in Unicode cmap: {missing_real or 'NONE'}")
real_cps = [c for c in all_cps if c != 0]
lacking = [c for c in real_cps if not cmap_has_glyph(font_bytes, c)[0]]
print(f"coverage without U+0000: lacking={ [f'U+{c:04X}' for c in lacking] or 'NONE' }")
forces = (0 in all_cps) and (not has0) and (not lacking)
print(f"CONCLUSION: U+0000 {'DOES' if forces else 'does not alone'} force fullProvenLacking "
f"for this paragraph (runtime also logged only U+0000 as MISSING)")
report = {
"target": TARGET,
"fid": FID,
"orig_tm": tm,
"orig_font_size": fsize,
"first_diff": first_diff,
"first_visible_diff": first_visible,
"glyphs": rows_out,
"u0000_audit": {
"run_texts": run_texts,
"codepoints_per_run": [
{"text": t, "cps": [f"U+{c:04X}" for c in to_codepoints_mirror(t)]}
for t in run_texts
],
"u0000_injected_by": "utf8_to_utf16le() always push_back(0); toCodepoints iterates full vector including NUL",
"engine_hasGlyph_rule": "FT_Get_Char_Index(face, cp) != 0",
"embedded_unicode_cmap_has_u0000": has0,
"embedded_u0000_gid": gid0,
"embedded_missing_real_chars": missing_real,
"coverage_ok_if_u0000_ignored": not lacking,
"runtime_log": "FONT_COVERAGE_DEBUG full font MISSING codepoint U+0000 only",
},
}
(OUT / "glyph_pos_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nWrote {OUT / 'glyph_pos_report.json'}")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""Compare full-page render hashes + PDF objects across corpus files."""
from __future__ import annotations
import hashlib
import json
import re
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
from forensic_obj_compare import apply_and_save # type: ignore
def inflate(pdf_bytes: bytes) -> list[str]:
out = []
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
try:
out.append(zlib.decompress(m.group(1)).decode("latin-1", "replace"))
except Exception:
pass
return out
def font_dicts(b: bytes) -> list[str]:
return [m.group(0).decode("latin-1", "replace") for m in re.finditer(rb"<</BaseFont/[^>]+>>", b)]
def sha_full(doc, dpi=144) -> tuple[str, int]:
img = doc.get_page(0).render(dpi)
png = bytes(img.data)
return hashlib.sha256(png).hexdigest()[:24], len(png)
def run(pdf: Path):
print(f"\n######## {pdf}")
doc = pdfengine.PdfDocument.load_from_file(str(pdf), "")
s0, n0 = sha_full(doc)
print(f"ORIG sha={s0} pngbytes={n0}")
model = doc.get_page(0).extract_document_model()
if not model.paragraphs:
print("no paragraphs")
return
para = model.paragraphs[0]
layout = compute_layout(para)
for f in doc.get_fonts(0, 0)[:6]:
print(f" FontInfo name={f.font_name} emb={f.is_embedded} type={f.type} "
f"subset={getattr(f, 'is_subset', None)} id={f.internal_font_id}")
print(" seedRuns:", [(r["fontName"], r["fid"], round(r["size"], 2),
len(r["advances"]) if r.get("advances") else 0)
for r in layout["seedRuns"][:4]])
fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], fid, dom["size"], dom["color"])
data = build_reflow_data(layout, flat, layout["origLines"], "cmp")
op = {"version": "1.0", "operations": [{
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
}]}
outp = Path(__file__).resolve().parent / "_tmp_entry.pdf"
apply_and_save(pdf, op, outp)
d2 = pdfengine.PdfDocument.load_from_memory(outp.read_bytes(), "")
s1, n1 = sha_full(d2)
print(f"ENTRY sha={s1} pngbytes={n1} {'IDENTICAL' if s0 == s1 else '*** DIFFERENT ***'}")
b = outp.read_bytes()
print(" entry fonts:", font_dicts(b))
for s in inflate(b):
if "Tm" in s or "TJ" in s or "Tj" in s:
one_line = s.replace("\n", " | ")
print(" stream:", one_line[:700])
tj = s.count(" TJ")
print(f" TJ count={tj}")
break
flat1 = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
data1 = build_reflow_data(layout, flat1, None, "cmp")
# also try force whole if supported
data1_force = {**build_reflow_data(layout, flat, layout["origLines"], "cmp"),
"forensicForceWholeRun": True}
op1 = {"version": "1.0", "operations": [{
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": data1,
}]}
out1 = Path(__file__).resolve().parent / "_tmp_one.pdf"
apply_and_save(pdf, op1, out1)
d3 = pdfengine.PdfDocument.load_from_memory(out1.read_bytes(), "")
s2, n2 = sha_full(d3)
print(f"ONE sha={s2} pngbytes={n2} vsORIG={'SAME' if s0 == s2 else 'DIFF'} vsENTRY={'SAME' if s1 == s2 else 'DIFF'}")
# force whole on unchanged
opf = {"version": "1.0", "operations": [{
"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data1_force,
}]}
outf = Path(__file__).resolve().parent / "_tmp_force.pdf"
apply_and_save(pdf, opf, outf)
df = pdfengine.PdfDocument.load_from_memory(outf.read_bytes(), "")
sf, nf = sha_full(df)
bf = outf.read_bytes()
tjf = sum(s.count(" TJ") for s in inflate(bf))
tje = sum(s.count(" TJ") for s in inflate(b))
print(f"FORCE sha={sf} pngbytes={nf} vsORIG={'SAME' if s0 == sf else 'DIFF'} "
f"vsENTRY={'SAME' if s1 == sf else 'DIFF'} TJ_entry={tje} TJ_force={tjf}")
if "forensicForceWholeRun" in json.dumps(opf):
if tje != tjf:
print(" => forensicForceWholeRun ACTIVE (TJ count changed)")
else:
print(" => forensicForceWholeRun NOT in binary yet (TJ unchanged) OR no-op")
def main():
for p in [
ROOT / "corpus" / "basic" / "hello_world.pdf",
ROOT / "tests" / "edits" / "forensic_multiline.pdf",
ROOT / "corpus" / "fonts" / "embedded_truetype.pdf",
ROOT / "corpus" / "fonts" / "subset_font.pdf",
]:
if p.exists():
try:
run(p)
except Exception as e:
print(f"FAILED {p}: {e}")
if __name__ == "__main__":
main()
+414
View File
@@ -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()
+140
View File
@@ -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()
+35
View File
@@ -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
+849
View File
@@ -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
}
}
}
+308
View File
@@ -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()
Binary file not shown.
@@ -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()
+48
View File
@@ -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()
Binary file not shown.
@@ -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"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

Some files were not shown because too many files have changed in this diff Show More