fix
This commit is contained in:
@@ -6,7 +6,11 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
---
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -6,7 +6,7 @@ find_package(pybind11 CONFIG REQUIRED)
|
||||
# Declare the python module target. We name the target pdfengine_py to avoid
|
||||
# target collision with the static C++ library pdfengine, but we set the
|
||||
# OUTPUT_NAME to pdfengine to produce the correct importable module.
|
||||
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp)
|
||||
pybind11_add_module(pdfengine_py python/pdfengine_py.cpp $<TARGET_OBJECTS:pdfengine>)
|
||||
|
||||
set_target_properties(pdfengine_py PROPERTIES
|
||||
OUTPUT_NAME "pdfengine"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 646 B |
+25
-27
@@ -14,6 +14,7 @@ add_library(pdfengine OBJECT
|
||||
src/parser/content_stream_parser.cpp
|
||||
src/parser/decoration_builder.cpp
|
||||
src/text/selection.cpp
|
||||
src/text/text_layout_engine.cpp
|
||||
src/fonts/face/font_face.cpp
|
||||
src/fonts/face/free_type_manager.cpp
|
||||
src/fonts/loader/font_resolver.cpp
|
||||
@@ -32,6 +33,30 @@ add_library(pdfengine OBJECT
|
||||
src/fonts/pdf_fonts/encoding/encoding.cpp
|
||||
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
|
||||
src/fonts/pdf_fonts/encoding/cjk_collection_db.cpp
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/pdfium_internal.cpp
|
||||
src/parser/pdfium_reflow.cpp
|
||||
src/parser/pdfium_page.cpp
|
||||
src/parser/pdfium_page_model.cpp
|
||||
src/parser/pdfium_fonts.cpp
|
||||
src/parser/pdfium_edit_session.cpp
|
||||
src/parser/pdfium_edit.cpp
|
||||
src/parser/pdfium_edit_replace.cpp
|
||||
src/parser/pdfium_edit_reflow.cpp
|
||||
src/parser/pdfium_edit_annotations.cpp
|
||||
src/parser/pdfium_edit_pages.cpp
|
||||
src/parser/pdfium_edit_images.cpp
|
||||
src/qpdf/qpdf_extractor.cpp
|
||||
src/qpdf/qpdf_font_extractor.cpp
|
||||
src/qpdf/qpdf_writer.cpp
|
||||
src/qpdf/qpdf_resource_resolver.cpp
|
||||
src/core/image_decoder.cpp
|
||||
src/parser/lexer.cpp
|
||||
src/parser/parser.cpp
|
||||
src/parser/content_builder.cpp
|
||||
src/serializer/content_serializer.cpp
|
||||
src/serializer/ast_serializer.cpp
|
||||
)
|
||||
add_library(pdfengine::pdfengine ALIAS pdfengine)
|
||||
set_target_properties(pdfengine PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
@@ -56,21 +81,6 @@ target_link_libraries(pdfengine
|
||||
)
|
||||
|
||||
if(PDFENGINE_WITH_PDFIUM)
|
||||
target_sources(pdfengine PRIVATE
|
||||
src/parser/pdfium_loader.cpp
|
||||
src/parser/pdfium_document.cpp
|
||||
src/parser/pdfium_internal.cpp
|
||||
src/parser/pdfium_reflow.cpp
|
||||
src/parser/pdfium_page.cpp
|
||||
src/parser/pdfium_page_model.cpp
|
||||
src/parser/pdfium_fonts.cpp
|
||||
src/parser/pdfium_edit.cpp
|
||||
src/parser/pdfium_edit_replace.cpp
|
||||
src/parser/pdfium_edit_reflow.cpp
|
||||
src/parser/pdfium_edit_annotations.cpp
|
||||
src/parser/pdfium_edit_pages.cpp
|
||||
src/parser/pdfium_edit_images.cpp
|
||||
)
|
||||
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
|
||||
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_PDFIUM)
|
||||
# PDFium statically bundles its own libjpeg, zlib, etc. which conflicts with vcpkg.
|
||||
@@ -98,18 +108,6 @@ if(PDFENGINE_WITH_QPDF)
|
||||
if(NOT TARGET jpeg)
|
||||
add_library(jpeg ALIAS JPEG::JPEG)
|
||||
endif()
|
||||
target_sources(pdfengine PRIVATE
|
||||
src/qpdf/qpdf_extractor.cpp
|
||||
src/qpdf/qpdf_font_extractor.cpp
|
||||
src/qpdf/qpdf_writer.cpp
|
||||
src/qpdf/qpdf_resource_resolver.cpp
|
||||
src/core/image_decoder.cpp
|
||||
src/parser/lexer.cpp
|
||||
src/parser/parser.cpp
|
||||
src/parser/content_builder.cpp
|
||||
src/serializer/content_serializer.cpp
|
||||
src/serializer/ast_serializer.cpp
|
||||
)
|
||||
target_link_libraries(pdfengine PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
|
||||
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_QPDF)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <expected>
|
||||
#include <cstdint>
|
||||
#include "pdfengine/pdf_document.hpp"
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
struct Rect {
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
double width = 0.0;
|
||||
double height = 0.0;
|
||||
};
|
||||
|
||||
using RectList = std::vector<Rect>;
|
||||
|
||||
struct ParagraphBounds {
|
||||
Rect rect;
|
||||
};
|
||||
|
||||
struct GlyphInfo {
|
||||
uint32_t glyphId = 0;
|
||||
uint32_t cluster = 0;
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
double advance = 0.0;
|
||||
double width = 0.0;
|
||||
double ascent = 0.0;
|
||||
double descent = 0.0;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
struct LineInfo {
|
||||
int id = 0;
|
||||
Rect rect;
|
||||
double baselineY = 0.0;
|
||||
};
|
||||
|
||||
struct CaretState {
|
||||
int offset = 0;
|
||||
Rect rect;
|
||||
};
|
||||
|
||||
// Internal comprehensive layout state
|
||||
struct LayoutResult {
|
||||
ParagraphBounds bounds;
|
||||
std::vector<LineInfo> lines;
|
||||
std::vector<GlyphInfo> glyphs;
|
||||
std::vector<Rect> selectionRects;
|
||||
CaretState caret;
|
||||
RectList dirtyRects;
|
||||
};
|
||||
|
||||
// Stable, lightweight view for WASM export
|
||||
struct LayoutView {
|
||||
std::vector<LineInfo> lines;
|
||||
std::vector<GlyphInfo> glyphs;
|
||||
CaretState caret;
|
||||
RectList dirtyRects;
|
||||
};
|
||||
|
||||
class EditSession {
|
||||
public:
|
||||
virtual ~EditSession() = default;
|
||||
|
||||
static std::shared_ptr<EditSession> StartEditSession(
|
||||
std::shared_ptr<PdfDocument> doc,
|
||||
int pageIndex,
|
||||
const std::string& paraId
|
||||
);
|
||||
|
||||
// Returns a stable, lightweight view of the layout
|
||||
virtual LayoutView GetLayoutView() const = 0;
|
||||
|
||||
// Geometry queries against the cached layout
|
||||
// offset represents the caret insertion point (between glyphs)
|
||||
virtual int HitTest(double x, double y) const = 0;
|
||||
virtual Rect GetCaretRect(int offset) const = 0;
|
||||
virtual std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const = 0;
|
||||
|
||||
// Mutates paragraph, marks cache dirty, recalculates
|
||||
virtual void ApplyEdit(const std::string& editOpJson) = 0;
|
||||
|
||||
// Rendering & Lifecycle
|
||||
virtual std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const = 0;
|
||||
virtual bool CommitEdit() = 0;
|
||||
virtual void CancelEdit() = 0;
|
||||
};
|
||||
|
||||
} // namespace pdfengine
|
||||
@@ -1,12 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <expected>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <array>
|
||||
|
||||
#if __has_include(<expected>)
|
||||
#include <expected>
|
||||
#elif __has_include(<tl/expected.hpp>)
|
||||
#include <tl/expected.hpp>
|
||||
namespace std {
|
||||
using tl::expected;
|
||||
using tl::unexpected;
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
enum class EngineError {
|
||||
@@ -276,6 +285,7 @@ public:
|
||||
virtual std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) = 0;
|
||||
|
||||
|
||||
[[nodiscard]] virtual std::string validateLayout(int pageIndex, const std::string& jsonStr) { (void)pageIndex; (void)jsonStr; return "{}"; }
|
||||
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
|
||||
|
||||
[[nodiscard]] virtual bool lastReflowOverflowed() const { return false; }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include "fonts/shaping/hb_shaper.hpp"
|
||||
#include "fonts/face/font_face.hpp"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace pdfengine::text {
|
||||
|
||||
struct LayoutConstraints {
|
||||
double columnLeft = 0.0;
|
||||
double columnRight = 0.0;
|
||||
double firstBaselineY = 0.0;
|
||||
double leading = 0.0;
|
||||
std::string align = "left";
|
||||
double hangingIndent = 0.0;
|
||||
};
|
||||
|
||||
class LineBreaker {
|
||||
public:
|
||||
LineBreaker(const LayoutConstraints& constraints);
|
||||
|
||||
// Processes a stream of shaped glyphs and applies line breaking
|
||||
void ProcessRun(
|
||||
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
|
||||
const std::string& runText,
|
||||
double fontSize,
|
||||
const fonts::FontFace& face,
|
||||
double scale
|
||||
);
|
||||
|
||||
// Finalizes the layout and populates the LayoutResult
|
||||
void Finalize(LayoutResult& outLayout);
|
||||
|
||||
private:
|
||||
LayoutConstraints constraints_;
|
||||
double currentX_;
|
||||
double currentY_;
|
||||
|
||||
// Internal state for word wrapping
|
||||
std::vector<GlyphInfo> currentLineGlyphs_;
|
||||
std::vector<LineInfo> finishedLines_;
|
||||
std::vector<GlyphInfo> allGlyphs_;
|
||||
|
||||
void CommitLine();
|
||||
};
|
||||
|
||||
class TextLayoutEngine {
|
||||
public:
|
||||
TextLayoutEngine();
|
||||
|
||||
// Computes layout for a given text run (simplified for single font for now)
|
||||
LayoutResult ComputeLayout(
|
||||
const std::string& text,
|
||||
const LayoutConstraints& constraints,
|
||||
fonts::FontFace& fontFace,
|
||||
double fontSize
|
||||
);
|
||||
|
||||
private:
|
||||
fonts::HbShaper shaper_;
|
||||
};
|
||||
|
||||
} // namespace pdfengine::text
|
||||
@@ -103,6 +103,7 @@ public:
|
||||
void invalidateCaches();
|
||||
|
||||
std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) override;
|
||||
std::string validateLayout(int pageIndex, const std::string& jsonStr) override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
|
||||
@@ -151,13 +152,14 @@ private:
|
||||
double fontSize, const std::vector<uint32_t>& codepoints,
|
||||
const std::vector<int>& srcObjects = {},
|
||||
FPDF_FONT reuseFont = nullptr);
|
||||
#endif
|
||||
|
||||
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
|
||||
mutable std::mutex reconstructedFontsMutex_;
|
||||
void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> getReconstructedFontData(const std::string& internalFontId) override;
|
||||
const fonts::pdf_fonts::ReconstructedFont* lookupReconFont(const std::string& internalFontId);
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
||||
#endif
|
||||
|
||||
@@ -180,7 +182,6 @@ private:
|
||||
std::expected<void, EngineError> applyOp_pageReorder(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex);
|
||||
#endif
|
||||
};
|
||||
|
||||
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8);
|
||||
|
||||
@@ -133,19 +133,23 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
(void)op;
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
@@ -167,7 +171,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
#include "fonts/face/free_type_manager.hpp"
|
||||
#include "fonts/face/font_face.hpp"
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
|
||||
@@ -30,7 +32,7 @@ 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>());
|
||||
@@ -142,46 +144,43 @@ 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());
|
||||
auto toCodepoints = [](const std::string& s) {
|
||||
auto u16 = utf8_to_utf16le(s);
|
||||
@@ -241,7 +240,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,6 +258,9 @@ 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);
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) {
|
||||
@@ -268,9 +270,17 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
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={} diverges={} forceWhole={} runPerChar={} natural0={:.4f} client0={:.4f} measureFace={}",
|
||||
ri, runs[ri].text.size(), runs[ri].advances.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, runs[ri].text.size(), runs[ri].advances.size(), forensicForceWholeRun);
|
||||
}
|
||||
}
|
||||
auto charAdvAt = [&](int ri, size_t off) -> double {
|
||||
@@ -336,6 +346,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 +358,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 +435,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 +540,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 +566,61 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string PdfiumDocument::validateLayout(int pageIndex, const std::string& jsonStr) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
try {
|
||||
auto data = nlohmann::json::parse(jsonStr);
|
||||
if (!data.is_object()) return "{}";
|
||||
|
||||
std::string text = "";
|
||||
std::string internalFontId = "";
|
||||
double fontSize = 12.0;
|
||||
|
||||
if (data.contains("runs") && data["runs"].is_array() && !data["runs"].empty()) {
|
||||
auto run = data["runs"][0];
|
||||
text = run.value("text", "");
|
||||
internalFontId = run.value("internalFontId", "");
|
||||
fontSize = run.value("fontSize", 12.0);
|
||||
}
|
||||
|
||||
text::LayoutConstraints constraints;
|
||||
constraints.columnLeft = data.value("columnLeft", 0.0);
|
||||
constraints.columnRight = data.value("columnRight", 0.0);
|
||||
constraints.firstBaselineY = data.value("firstBaselineY", 0.0);
|
||||
constraints.leading = data.value("leading", 0.0);
|
||||
|
||||
auto fontDataRes = getFontData(internalFontId);
|
||||
auto face = std::make_shared<fonts::FontFace>();
|
||||
if (!fontDataRes || !face->loadFromMemory(*fontDataRes)) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
text::TextLayoutEngine engine;
|
||||
auto layout = engine.ComputeLayout(text, constraints, *face, fontSize);
|
||||
|
||||
nlohmann::json out;
|
||||
out["lines"] = nlohmann::json::array();
|
||||
for (const auto& l : layout.lines) {
|
||||
out["lines"].push_back({
|
||||
{"rect", {{"x", l.rect.x}, {"y", l.rect.y}, {"width", l.rect.width}, {"height", l.rect.height}}}
|
||||
});
|
||||
}
|
||||
out["glyphs"] = nlohmann::json::array();
|
||||
for (const auto& g : layout.glyphs) {
|
||||
out["glyphs"].push_back({
|
||||
{"x", g.x}, {"y", g.y}, {"width", g.width}, {"advance", g.advance},
|
||||
{"ascent", g.ascent}, {"descent", g.descent}, {"cluster", g.cluster},
|
||||
{"text", g.text}
|
||||
});
|
||||
}
|
||||
return out.dump();
|
||||
} catch (...) {
|
||||
return "{}";
|
||||
}
|
||||
#else
|
||||
(void)pageIndex; (void)jsonStr;
|
||||
return "{}";
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include "parser/pdfium_edit_session.hpp"
|
||||
#include "parser/pdfium_document.hpp"
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <cmath>
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
std::shared_ptr<EditSession> EditSession::StartEditSession(
|
||||
std::shared_ptr<PdfDocument> doc,
|
||||
int pageIndex,
|
||||
const std::string& paraId
|
||||
) {
|
||||
if (!doc) {
|
||||
spdlog::error("StartEditSession: null document");
|
||||
return nullptr;
|
||||
}
|
||||
return std::make_shared<parser::PdfiumEditSession>(doc, pageIndex, paraId);
|
||||
}
|
||||
|
||||
} // namespace pdfengine
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
PdfiumEditSession::PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId)
|
||||
: doc_(doc), pageIndex_(pageIndex), paraId_(paraId) {
|
||||
spdlog::info("Started edit session on page {} for para {}", pageIndex_, paraId_);
|
||||
|
||||
// In a full implementation, we'd extract text, bounds, and fonts from doc_ using paraId_.
|
||||
// For now, populate dummy state for unit testing the architecture.
|
||||
currentText_ = "Mock initial text for paragraph.";
|
||||
constraints_.columnLeft = 100.0;
|
||||
constraints_.columnRight = 400.0;
|
||||
constraints_.firstBaselineY = 700.0;
|
||||
constraints_.leading = 14.0;
|
||||
isDirty_ = true;
|
||||
}
|
||||
|
||||
PdfiumEditSession::~PdfiumEditSession() {}
|
||||
|
||||
void PdfiumEditSession::RebuildLayoutIfNeeded() const {
|
||||
if (!isDirty_) return;
|
||||
|
||||
if (currentFace_) {
|
||||
cachedLayout_ = layoutEngine_.ComputeLayout(
|
||||
currentText_, constraints_, *currentFace_, currentFontSize_
|
||||
);
|
||||
} else {
|
||||
// Mock fallback if face not loaded yet
|
||||
cachedLayout_.lines.clear();
|
||||
cachedLayout_.glyphs.clear();
|
||||
|
||||
GlyphInfo g;
|
||||
g.x = constraints_.columnLeft;
|
||||
g.y = constraints_.firstBaselineY;
|
||||
g.width = 5.0;
|
||||
g.advance = 6.0;
|
||||
g.ascent = 10.0;
|
||||
g.descent = -2.0;
|
||||
g.cluster = 0;
|
||||
|
||||
if (!currentText_.empty()) {
|
||||
LineInfo line;
|
||||
line.id = 0;
|
||||
line.baselineY = constraints_.firstBaselineY;
|
||||
|
||||
for (size_t i = 0; i < currentText_.size(); ++i) {
|
||||
g.cluster = static_cast<uint32_t>(i);
|
||||
g.text = currentText_.substr(i, 1);
|
||||
cachedLayout_.glyphs.push_back(g);
|
||||
g.x += g.advance;
|
||||
}
|
||||
|
||||
line.rect = Rect{constraints_.columnLeft, constraints_.firstBaselineY - g.ascent,
|
||||
g.x - constraints_.columnLeft, g.ascent - g.descent};
|
||||
cachedLayout_.lines.push_back(line);
|
||||
}
|
||||
}
|
||||
|
||||
isDirty_ = false;
|
||||
}
|
||||
|
||||
LayoutView PdfiumEditSession::GetLayoutView() const {
|
||||
RebuildLayoutIfNeeded();
|
||||
return LayoutView{
|
||||
cachedLayout_.lines,
|
||||
cachedLayout_.glyphs,
|
||||
cachedLayout_.caret,
|
||||
cachedLayout_.dirtyRects
|
||||
};
|
||||
}
|
||||
|
||||
int PdfiumEditSession::HitTest(double x, double y) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
|
||||
// Find closest glyph by checking boundary/midpoint logic
|
||||
int closestCluster = 0;
|
||||
double minDistanceSq = 1e9;
|
||||
|
||||
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||
// Find distance to bounding box
|
||||
double dx = 0.0;
|
||||
if (x < glyph.x) dx = glyph.x - x;
|
||||
else if (x > glyph.x + glyph.advance) dx = x - (glyph.x + glyph.advance);
|
||||
|
||||
double gy = glyph.y - glyph.ascent;
|
||||
double gh = glyph.ascent - glyph.descent;
|
||||
double dy = 0.0;
|
||||
if (y < gy) dy = gy - y;
|
||||
else if (y > gy + gh) dy = y - (gy + gh);
|
||||
|
||||
double distSq = dx * dx + dy * dy;
|
||||
if (distSq < minDistanceSq) {
|
||||
minDistanceSq = distSq;
|
||||
// Midpoint logic: if past midpoint, insertion point is after this glyph
|
||||
if (x > glyph.x + glyph.advance / 2.0) {
|
||||
closestCluster = glyph.cluster + 1;
|
||||
} else {
|
||||
closestCluster = glyph.cluster;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return closestCluster;
|
||||
}
|
||||
|
||||
Rect PdfiumEditSession::GetCaretRect(int offset) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
|
||||
if (cachedLayout_.glyphs.empty()) {
|
||||
return Rect{constraints_.columnLeft, constraints_.firstBaselineY - 10.0, 1.0, 12.0};
|
||||
}
|
||||
|
||||
// Caret is positioned *between* glyphs at the insertion point `offset`.
|
||||
// If offset matches a cluster, place it at the left edge of that glyph.
|
||||
for (const auto& glyph : cachedLayout_.glyphs) {
|
||||
if (glyph.cluster == static_cast<uint32_t>(offset)) {
|
||||
return Rect{glyph.x, glyph.y - glyph.ascent, 1.0, glyph.ascent - glyph.descent};
|
||||
}
|
||||
}
|
||||
|
||||
// If offset is beyond all glyphs, place it at the right edge of the last glyph.
|
||||
const auto& lastGlyph = cachedLayout_.glyphs.back();
|
||||
return Rect{lastGlyph.x + lastGlyph.advance, lastGlyph.y - lastGlyph.ascent, 1.0, lastGlyph.ascent - lastGlyph.descent};
|
||||
}
|
||||
|
||||
std::vector<Rect> PdfiumEditSession::GetSelectionRects(int startOffset, int endOffset) const {
|
||||
RebuildLayoutIfNeeded();
|
||||
(void)startOffset; (void)endOffset;
|
||||
return {};
|
||||
}
|
||||
|
||||
void PdfiumEditSession::ApplyEdit(const std::string& editOpJson) {
|
||||
// Phase 1.1: Modify state and mark dirty
|
||||
spdlog::info("ApplyEdit called with {}", editOpJson);
|
||||
|
||||
// Very naive edit implementation for testing
|
||||
// e.g. editOpJson = "append:A"
|
||||
if (editOpJson.find("append:") == 0) {
|
||||
currentText_ += editOpJson.substr(7);
|
||||
}
|
||||
|
||||
isDirty_ = true;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> PdfiumEditSession::RenderDirtyRegion(int dpi, const Rect& region) const {
|
||||
(void)dpi; (void)region;
|
||||
return {};
|
||||
}
|
||||
|
||||
bool PdfiumEditSession::CommitEdit() {
|
||||
spdlog::info("CommitEdit called");
|
||||
return true;
|
||||
}
|
||||
|
||||
void PdfiumEditSession::CancelEdit() {
|
||||
spdlog::info("CancelEdit called");
|
||||
}
|
||||
|
||||
} // namespace pdfengine::parser
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
class PdfiumEditSession : public EditSession {
|
||||
public:
|
||||
PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId);
|
||||
~PdfiumEditSession() override;
|
||||
|
||||
LayoutView GetLayoutView() const override;
|
||||
int HitTest(double x, double y) const override;
|
||||
Rect GetCaretRect(int offset) const override;
|
||||
std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const override;
|
||||
|
||||
void ApplyEdit(const std::string& editOpJson) override;
|
||||
|
||||
std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const override;
|
||||
bool CommitEdit() override;
|
||||
void CancelEdit() override;
|
||||
|
||||
private:
|
||||
void RebuildLayoutIfNeeded() const;
|
||||
|
||||
std::shared_ptr<PdfDocument> doc_;
|
||||
int pageIndex_;
|
||||
std::string paraId_;
|
||||
|
||||
// Live editing state
|
||||
std::string currentText_;
|
||||
text::LayoutConstraints constraints_;
|
||||
std::shared_ptr<fonts::FontFace> currentFace_;
|
||||
double currentFontSize_ = 12.0;
|
||||
|
||||
// Layout cache and engine
|
||||
mutable text::TextLayoutEngine layoutEngine_;
|
||||
mutable LayoutResult cachedLayout_;
|
||||
mutable bool isDirty_ = true;
|
||||
};
|
||||
|
||||
} // namespace pdfengine::parser
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
fonts::pdf_fonts::ReconstructedFont rf;
|
||||
if (!sfnt.empty()) {
|
||||
rf.ok = true;
|
||||
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
|
||||
reconstructedFonts_[internalFontId] = std::move(rf);
|
||||
#else
|
||||
(void)internalFontId; (void)sfnt;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError>
|
||||
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#else
|
||||
(void)internalFontId;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
return getReconstructedEmbeddedFont(internalFontId);
|
||||
#else
|
||||
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
|
||||
auto it = reconstructedFonts_.find(internalFontId);
|
||||
return it != reconstructedFonts_.end() ? &it->second : nullptr;
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
(void)internalFontId;
|
||||
return nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
const fonts::pdf_fonts::ReconstructedFont*
|
||||
@@ -104,11 +116,20 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
if (out.resolved) {
|
||||
for (uint32_t cp : codepoints) {
|
||||
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 +199,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 +255,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 +281,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
||||
} else if (!out.measureFace) {
|
||||
spdlog::info("[MEASUREFACE_NULL] reason=no_sfnt_bytes_in_buffer internalFontId='{}' cacheKey='{}' "
|
||||
"useEmbedded={} useSystem={} — emit font loaded but measureFace still null",
|
||||
internalFontId, cacheKey, useEmbedded, useSystem);
|
||||
}
|
||||
}
|
||||
spdlog::info("[LOAD_EMISSION_FONT_RESULT] internalFontId='{}' fontPtr={} measureFacePtr={} "
|
||||
"useEmbedded={} useSystem={} isEmbedded={} isSubset={}",
|
||||
internalFontId, (void*)out.font,
|
||||
(void*)(out.measureFace ? out.measureFace.get() : nullptr),
|
||||
useEmbedded, useSystem,
|
||||
matchedFontInfo ? matchedFontInfo->isEmbedded : false,
|
||||
matchedFontInfo ? matchedFontInfo->isSubset : false);
|
||||
return out;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -136,6 +136,9 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
||||
const auto* bgra = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
|
||||
const int stride = FPDFBitmap_GetStride(bitmap);
|
||||
std::vector<uint8_t> rgba(static_cast<size_t>(w) * regionH * 4);
|
||||
|
||||
bool allAlpha255 = true;
|
||||
size_t non255Count = 0;
|
||||
for (int y = 0; y < regionH; ++y) {
|
||||
const uint8_t* src = bgra + static_cast<size_t>(y) * stride;
|
||||
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
|
||||
@@ -144,8 +147,31 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
|
||||
dst[x * 4 + 1] = src[x * 4 + 1];
|
||||
dst[x * 4 + 2] = src[x * 4 + 0];
|
||||
dst[x * 4 + 3] = src[x * 4 + 3];
|
||||
if (src[x * 4 + 3] != 255) {
|
||||
allAlpha255 = false;
|
||||
non255Count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 1. Region width: {}, height: {} (dpi: {}, yTopPt: {:.2f}, heightPt: {:.2f}, pagePt: {:.2f}x{:.2f})",
|
||||
w, regionH, dpi, yTopPt, heightPt, width(), height());
|
||||
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 2. Initialized with opaque white fill (0xFFFFFFFF): TRUE (via FPDFBitmap_FillRect)");
|
||||
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 3. Content scope: Entire page vertical slice from yTopPx={} to yTopPx+regionH={} (renders ALL page objects in slice, not just edited paragraph)",
|
||||
yTopPx, yTopPx + regionH);
|
||||
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 4. Alpha transparency check: allAlpha255={}, non255Count={}",
|
||||
allAlpha255 ? "TRUE (100% opaque, alpha=255 everywhere)" : "FALSE", non255Count);
|
||||
|
||||
std::vector<uint8_t> pngBytes = encodeBgraToPng(bgra, w, regionH, stride);
|
||||
if (!pngBytes.empty()) {
|
||||
FILE* f = fopen("debug_preview_region.png", "wb");
|
||||
if (f) {
|
||||
fwrite(pngBytes.data(), 1, pngBytes.size(), f);
|
||||
fclose(f);
|
||||
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] Saved preview bitmap to debug_preview_region.png (size: {} bytes)", pngBytes.size());
|
||||
}
|
||||
}
|
||||
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
return PageImage{w, regionH, std::move(rgba)};
|
||||
#else
|
||||
|
||||
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
||||
if (it != objToIndex.end()) {
|
||||
g.pageObjectIndex = it->second;
|
||||
}
|
||||
FS_MATRIX mtx;
|
||||
if (FPDFPageObj_GetMatrix(textObj, &mtx)) {
|
||||
double scaleY1 = std::hypot(static_cast<double>(mtx.c), static_cast<double>(mtx.d));
|
||||
double scaleY2 = std::hypot(static_cast<double>(mtx.a), static_cast<double>(mtx.b));
|
||||
double scaleY = (std::max)(scaleY1, scaleY2);
|
||||
if (scaleY > 0.001) {
|
||||
double trueSize = g.fontSize * scaleY;
|
||||
if (trueSize > 0.1) g.fontSize = trueSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (g.bboxH > g.fontSize * 0.8) {
|
||||
g.fontSize = (std::max)(g.fontSize, g.bboxH);
|
||||
}
|
||||
|
||||
documentGlyphs.push_back(g);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "pdfengine/text_layout_engine.hpp"
|
||||
#include <cmath>
|
||||
|
||||
namespace pdfengine::text {
|
||||
|
||||
LineBreaker::LineBreaker(const LayoutConstraints& constraints)
|
||||
: constraints_(constraints),
|
||||
currentX_(constraints.columnLeft),
|
||||
currentY_(constraints.firstBaselineY) {}
|
||||
|
||||
void LineBreaker::ProcessRun(
|
||||
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
|
||||
const std::string& runText,
|
||||
double fontSize,
|
||||
const fonts::FontFace& face,
|
||||
double scale
|
||||
) {
|
||||
// Basic line breaker mimicking the HarfBuzz scaling loop from pdfium_edit_reflow
|
||||
// In a full implementation, we'd find spaces and wrap. For now, just lay out linearly
|
||||
// and wrap aggressively at columnRight.
|
||||
|
||||
FT_Face ftFace = face.getFace();
|
||||
double upm = ftFace->units_per_EM;
|
||||
double fontAscent = (ftFace->ascender / upm) * 1000.0 * scale;
|
||||
double fontDescent = (ftFace->descender / upm) * 1000.0 * scale;
|
||||
|
||||
for (size_t i = 0; i < shapedGlyphs.size(); ++i) {
|
||||
const auto& glyph = shapedGlyphs[i];
|
||||
|
||||
// Very rudimentary word wrap for demonstration
|
||||
if (currentX_ + glyph.advanceX * scale > constraints_.columnRight && !currentLineGlyphs_.empty()) {
|
||||
CommitLine();
|
||||
}
|
||||
|
||||
GlyphInfo info;
|
||||
info.glyphId = glyph.glyphIndex;
|
||||
info.cluster = glyph.clusterIndex;
|
||||
info.x = currentX_ + glyph.offsetX * scale;
|
||||
info.y = currentY_ + glyph.offsetY * scale;
|
||||
info.advance = glyph.advanceX * scale;
|
||||
info.width = info.advance; // Simplification
|
||||
info.ascent = fontAscent;
|
||||
info.descent = fontDescent;
|
||||
|
||||
// Extract substring for this cluster if possible
|
||||
if (info.cluster < runText.size()) {
|
||||
info.text = runText.substr(info.cluster, 1);
|
||||
}
|
||||
|
||||
currentLineGlyphs_.push_back(info);
|
||||
currentX_ += info.advance;
|
||||
}
|
||||
}
|
||||
|
||||
void LineBreaker::CommitLine() {
|
||||
if (currentLineGlyphs_.empty()) return;
|
||||
|
||||
LineInfo line;
|
||||
line.id = static_cast<int>(finishedLines_.size());
|
||||
line.baselineY = currentY_;
|
||||
|
||||
if (!currentLineGlyphs_.empty()) {
|
||||
line.rect.x = currentLineGlyphs_.front().x;
|
||||
line.rect.y = currentY_ - currentLineGlyphs_.front().ascent;
|
||||
line.rect.width = currentX_ - line.rect.x;
|
||||
line.rect.height = currentLineGlyphs_.front().ascent - currentLineGlyphs_.front().descent;
|
||||
}
|
||||
|
||||
finishedLines_.push_back(line);
|
||||
|
||||
for (auto& g : currentLineGlyphs_) {
|
||||
allGlyphs_.push_back(g);
|
||||
}
|
||||
currentLineGlyphs_.clear();
|
||||
|
||||
// Advance to next line
|
||||
currentX_ = constraints_.columnLeft;
|
||||
currentY_ -= constraints_.leading; // Assuming standard PDF Y-up coordinates
|
||||
}
|
||||
|
||||
void LineBreaker::Finalize(LayoutResult& outLayout) {
|
||||
CommitLine(); // Commit any remaining glyphs
|
||||
|
||||
outLayout.lines = std::move(finishedLines_);
|
||||
outLayout.glyphs = std::move(allGlyphs_);
|
||||
|
||||
// Compute bounding box
|
||||
if (!outLayout.lines.empty()) {
|
||||
double minX = outLayout.lines.front().rect.x;
|
||||
double maxX = minX;
|
||||
double minY = outLayout.lines.front().rect.y;
|
||||
double maxY = minY + outLayout.lines.front().rect.height;
|
||||
|
||||
for (const auto& line : outLayout.lines) {
|
||||
if (line.rect.x < minX) minX = line.rect.x;
|
||||
if (line.rect.x + line.rect.width > maxX) maxX = line.rect.x + line.rect.width;
|
||||
if (line.rect.y < minY) minY = line.rect.y;
|
||||
if (line.rect.y + line.rect.height > maxY) maxY = line.rect.y + line.rect.height;
|
||||
}
|
||||
|
||||
outLayout.bounds.rect.x = minX;
|
||||
outLayout.bounds.rect.y = minY;
|
||||
outLayout.bounds.rect.width = maxX - minX;
|
||||
outLayout.bounds.rect.height = maxY - minY;
|
||||
}
|
||||
}
|
||||
|
||||
TextLayoutEngine::TextLayoutEngine() = default;
|
||||
|
||||
LayoutResult TextLayoutEngine::ComputeLayout(
|
||||
const std::string& text,
|
||||
const LayoutConstraints& constraints,
|
||||
fonts::FontFace& fontFace,
|
||||
double fontSize
|
||||
) {
|
||||
LayoutResult result;
|
||||
|
||||
if (text.empty() || constraints.columnRight <= constraints.columnLeft) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1000 is the standard reference size used in HarfBuzz scaling in this engine
|
||||
constexpr unsigned int kRefSize = 1000;
|
||||
auto shapedGlyphs = shaper_.shapeRun(text, fontFace, kRefSize);
|
||||
|
||||
double scale = fontSize / static_cast<double>(kRefSize);
|
||||
|
||||
LineBreaker breaker(constraints);
|
||||
breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale);
|
||||
breaker.Finalize(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace pdfengine::text
|
||||
@@ -9,6 +9,7 @@ add_executable(pdfengine_smoke
|
||||
document_load_test.cpp
|
||||
page_render_test.cpp
|
||||
document_edit_test.cpp
|
||||
edit_session_test.cpp
|
||||
font_diagnostics_test.cpp
|
||||
text_encoding_test.cpp
|
||||
skia_renderer_test.cpp
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#include "document_test_helpers.hpp"
|
||||
#include "pdfengine/pdf_document.hpp"
|
||||
#include "pdfengine/edit_session.hpp"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
TEST(EditSessionTest, StartEditSessionAndBasics) {
|
||||
SKIP_IF_NO_PDFIUM();
|
||||
|
||||
auto path = getCorpusPath("basic", "hello_world.pdf");
|
||||
if (!std::filesystem::exists(path)) {
|
||||
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
|
||||
}
|
||||
|
||||
auto docRes = PdfDocument::loadFromFile(path.string());
|
||||
ASSERT_TRUE(docRes.has_value());
|
||||
auto doc = *docRes;
|
||||
|
||||
auto session = EditSession::StartEditSession(doc, 0, "para_1");
|
||||
ASSERT_NE(session, nullptr);
|
||||
|
||||
// Initial Layout check (Mocked initial text: "Mock initial text for paragraph.")
|
||||
auto layout = session->GetLayoutView();
|
||||
EXPECT_FALSE(layout.lines.empty());
|
||||
EXPECT_FALSE(layout.glyphs.empty());
|
||||
|
||||
// HitTest against mocked coordinates
|
||||
// We mocked columnLeft at 100.0, advance 6.0 per char
|
||||
// Char 0 at x=100.0
|
||||
int hit = session->HitTest(102.0, 700.0);
|
||||
EXPECT_EQ(hit, 0); // Closest to first cluster
|
||||
|
||||
// Char 1 at x=106.0
|
||||
hit = session->HitTest(108.0, 700.0);
|
||||
EXPECT_EQ(hit, 1); // Closest to second cluster
|
||||
|
||||
// GetCaretRect
|
||||
Rect caret0 = session->GetCaretRect(0);
|
||||
EXPECT_EQ(caret0.x, 100.0);
|
||||
EXPECT_EQ(caret0.y, 690.0); // 700 - 10(ascent)
|
||||
|
||||
Rect caret1 = session->GetCaretRect(1);
|
||||
EXPECT_EQ(caret1.x, 106.0);
|
||||
|
||||
// ApplyEdit sets dirty flag
|
||||
session->ApplyEdit("append:!");
|
||||
|
||||
auto layout2 = session->GetLayoutView();
|
||||
EXPECT_GT(layout2.glyphs.size(), layout.glyphs.size()); // Appended '!'
|
||||
|
||||
bool committed = session->CommitEdit();
|
||||
EXPECT_TRUE(committed);
|
||||
}
|
||||
|
||||
} // namespace pdfengine
|
||||
@@ -438,7 +438,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 +452,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}`;
|
||||
|
||||
@@ -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 || '{}';
|
||||
}
|
||||
|
||||
@@ -158,7 +158,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 +176,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;
|
||||
|
||||
@@ -33,7 +33,10 @@ function lineAdvances(line: any): { perRun: Record<number, number[]>; anchorX: n
|
||||
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
|
||||
for (let k = 0; k < seq.length; k++) {
|
||||
const { ri, ci, ox } = seq[k];
|
||||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : (line.x + line.w) - ox;
|
||||
const gs = runs[ri]?.glyphs ?? [];
|
||||
const g = gs[ci];
|
||||
const charW = (g?.bbox_w && g.bbox_w > 0) ? g.bbox_w : (runs[ri]?.font_size ?? 12) * 0.5;
|
||||
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : charW;
|
||||
}
|
||||
for (const k of Object.keys(perRun)) {
|
||||
const ri = Number(k);
|
||||
@@ -68,21 +71,17 @@ function median(xs: number[]): number {
|
||||
return s[Math.floor(s.length / 2)];
|
||||
}
|
||||
function paraEffSize(lines: any[]): number {
|
||||
const heights: number[] = [];
|
||||
let nominal = 0;
|
||||
for (const line of lines) for (const r of (line.runs ?? [])) {
|
||||
nominal = Math.max(nominal, r.font_size ?? 0);
|
||||
for (const g of (r.glyphs ?? [])) if ((g.bbox_h ?? 0) > 0.1) heights.push(g.bbox_h);
|
||||
const sz = Math.max(r.font_size ?? 0, r.h ?? 0);
|
||||
if (sz > nominal) nominal = sz;
|
||||
}
|
||||
if (!heights.length) return nominal || 12;
|
||||
heights.sort((a, b) => a - b);
|
||||
const p75 = heights[Math.floor(heights.length * 0.75)];
|
||||
return Math.max(nominal, p75 / 0.7);
|
||||
return nominal || 12;
|
||||
}
|
||||
function computeLayout(para: any): ParagraphLayout {
|
||||
const lines = para?.lines ?? [];
|
||||
const effSize = paraEffSize(lines);
|
||||
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
|
||||
let columnLeft = Infinity, firstBaselineY = -Infinity;
|
||||
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
|
||||
const seedRuns: SeedRun[] = [];
|
||||
const origLines: OrigLine[] = [];
|
||||
@@ -90,7 +89,6 @@ function computeLayout(para: any): ParagraphLayout {
|
||||
const line = lines[li];
|
||||
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
|
||||
columnLeft = Math.min(columnLeft, line.x);
|
||||
columnRight = Math.max(columnRight, line.x + line.w);
|
||||
rightEdges.push(line.x + line.w);
|
||||
const lineRuns = line.runs ?? [];
|
||||
const { perRun, anchorX } = lineAdvances(line);
|
||||
@@ -106,11 +104,14 @@ function computeLayout(para: any): ParagraphLayout {
|
||||
}
|
||||
const adv = perRun[ri];
|
||||
const safeColor = sanitizeTextColor(r.color);
|
||||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: effSize, color: safeColor, advances: adv });
|
||||
const rSize = Math.max(r.font_size ?? 0, r.h ?? 0) || effSize;
|
||||
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, fontName: r.font_name ?? '', advances: adv });
|
||||
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, advances: adv });
|
||||
}
|
||||
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
|
||||
}
|
||||
const maxRightEdge = rightEdges.length ? Math.max(...rightEdges) : columnLeft + 250;
|
||||
const columnRight = isFinite(maxRightEdge) ? Math.max(maxRightEdge, columnLeft + 250) : columnLeft + 250;
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
|
||||
const domSize = seedRuns.find((r) => r.text.trim())?.size ?? 12;
|
||||
@@ -264,37 +265,55 @@ function applyListMarkers(
|
||||
return { runs: out, hangingIndent, marker: lastMarker };
|
||||
}
|
||||
|
||||
function globalCaretOffset(el: HTMLElement): number {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return 0;
|
||||
const pre = document.createRange();
|
||||
pre.selectNodeContents(el);
|
||||
pre.setEnd(range.startContainer, range.startOffset);
|
||||
return pre.toString().length;
|
||||
|
||||
|
||||
/**
|
||||
* Defensive guard for the pre-compiled WASM engine which may still contain the
|
||||
* matrix-override bug (using horizontal scale instead of vertical scale to
|
||||
* compute font size). If any line's fontSize is more than 20% smaller than the
|
||||
* expected domSize, clamp all lines back to domSize so that:
|
||||
* (a) the custom caret renders at the correct vertical position, and
|
||||
* (b) the editing bounding box does not visually shrink.
|
||||
*
|
||||
* The authoritative fix lives in pdfium_edit_reflow.cpp (matrix-override block
|
||||
* removed). This guard will become a no-op once a rebuilt WASM is deployed.
|
||||
*/
|
||||
function sanitizeEngineLayout(
|
||||
lay: import('../lib/pdfiumEngine').ReflowLayout | null,
|
||||
expectedFontSize: number,
|
||||
): import('../lib/pdfiumEngine').ReflowLayout | null {
|
||||
if (!lay || !lay.lines) return lay;
|
||||
for (const line of lay.lines) {
|
||||
console.log('[STAGE_6_SANITIZE_LAYOUT]', { lineFontSize: line.fontSize, expectedFontSize });
|
||||
if (line.fontSize && expectedFontSize > 0 && line.fontSize < expectedFontSize * 0.8) {
|
||||
line.fontSize = expectedFontSize;
|
||||
}
|
||||
}
|
||||
return lay;
|
||||
}
|
||||
|
||||
function globalCaretOffset(el: HTMLElement, caretRefVal?: number): number {
|
||||
if (typeof caretRefVal === 'number') return caretRefVal;
|
||||
return (el.textContent ?? '').length;
|
||||
}
|
||||
|
||||
function setGlobalCaretOffset(el: HTMLElement, target: number): void {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let acc = 0;
|
||||
let node = walker.nextNode() as Text | null;
|
||||
const sel = window.getSelection();
|
||||
while (node) {
|
||||
const len = node.textContent?.length ?? 0;
|
||||
if (acc + len >= target) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, Math.max(0, Math.min(target - acc, len)));
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges(); sel?.addRange(range);
|
||||
return;
|
||||
}
|
||||
acc += len;
|
||||
node = walker.nextNode() as Text | null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el); range.collapse(false);
|
||||
sel?.removeAllRanges(); sel?.addRange(range);
|
||||
}
|
||||
|
||||
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||||
@@ -310,7 +329,7 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] {
|
||||
|
||||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||||
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
}) => {
|
||||
const layout = useMemo(() => computeLayout(para), [para]);
|
||||
const leading = leadingOverride ?? layout.leading;
|
||||
@@ -356,8 +375,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const leadingPx = leading * zoom;
|
||||
const colLeftPx = columnLeft * zoom;
|
||||
const colWidthPx = (columnRight - columnLeft) * zoom;
|
||||
const colHeightPx = layout.oldLineCount * leadingPx;
|
||||
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
|
||||
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
|
||||
const editorTop = firstBaselineScreen - fontPx * 0.8;
|
||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||||
|
||||
const measureFamily = /times|serif/i.test(domFontName) ? 'Times New Roman, serif'
|
||||
@@ -375,17 +395,20 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
||||
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
||||
}
|
||||
const linePositionData = (!listActive && layout.origLines && layout.origLines.length) ? {
|
||||
lineX: layout.origLines.map((l) => l.x),
|
||||
lineBaselineY: layout.origLines.map((l) => l.baselineY),
|
||||
} : {};
|
||||
const linesData = (!listActive && origLines && origLines.length) ? {
|
||||
lines: origLines.map((l) => l.frags.map((f) => ({
|
||||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||||
...(f.advances ? { advances: f.advances } : {}),
|
||||
}))),
|
||||
lineX: origLines.map((l) => l.x),
|
||||
lineBaselineY: origLines.map((l) => l.baselineY),
|
||||
} : {};
|
||||
return {
|
||||
objectIndices: layout.objectIndices,
|
||||
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||||
...linePositionData,
|
||||
...linesData,
|
||||
columnLeft: effColumnLeft, columnRight,
|
||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||
@@ -403,6 +426,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }],
|
||||
});
|
||||
|
||||
const caretIndexRef = useRef<number | null>(null);
|
||||
|
||||
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
|
||||
if (!lay.lines.length) return null;
|
||||
const starts = lineStarts(lay, fullText);
|
||||
@@ -448,7 +473,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const positionCaret = () => {
|
||||
const el = editRef.current, lay = engineLayoutRef.current;
|
||||
if (!el || !lay) return;
|
||||
const box = caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? '');
|
||||
const fullText = el.textContent ?? '';
|
||||
const idx = typeof caretIndexRef.current === 'number' ? caretIndexRef.current : fullText.length;
|
||||
const box = caretBoxFor(idx, lay, fullText);
|
||||
if (box && box.pageIndex !== pageIndex) {
|
||||
setCaretBox(null);
|
||||
onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height });
|
||||
@@ -470,25 +497,127 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
if (!el) return;
|
||||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||
const dpi = Math.round(72 * zoom);
|
||||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||
const dpi = Math.round(96 * zoom * dpr);
|
||||
const origLines = editedRef.current ? undefined : layout.origLines;
|
||||
const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines);
|
||||
|
||||
console.log('[STAGE_1_EDITABLE_RUN]', { dominantFid, domSize, fontPx, domFontName, edited: editedRef.current });
|
||||
console.log('[STAGE_2_RENDER_PREVIEW_PAYLOAD]', opJson);
|
||||
|
||||
const yTopPt = bandTop / zoom;
|
||||
const { regions, layout: lay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||||
const { regions, layout: rawLay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt);
|
||||
|
||||
console.log('[STAGE_7_PREVIEW_DRAW]', { fontSize: domSize, fontPx, regionsCount: regions?.length ?? 0 });
|
||||
|
||||
console.log('[KEYSTROKE_FONT_METRICS_DEBUG]', {
|
||||
fontName: domFontName,
|
||||
internalFontId: dominantFid,
|
||||
fontSize: domSize,
|
||||
fontSizePx: fontPx,
|
||||
lineHeight: leading,
|
||||
lineHeightPx: leadingPx,
|
||||
edited: editedRef.current,
|
||||
origLinesProvided: !editedRef.current,
|
||||
});
|
||||
|
||||
console.log('[KEYSTROKE_LAYOUT_DEBUG]', {
|
||||
columnLeft,
|
||||
columnRight,
|
||||
columnWidth: columnRight - columnLeft,
|
||||
paragraphWidth: colWidthPx / zoom,
|
||||
firstBaselineY: layout.firstBaselineY,
|
||||
lineCount: rawLay?.lines?.length ?? 0,
|
||||
wrapPosition: rawLay?.lines?.map((l, idx) => ({
|
||||
lineIndex: idx,
|
||||
text: l.text,
|
||||
x0: l.x0,
|
||||
advanceWidthSum: l.adv?.reduce((a, b) => a + b, 0) ?? 0,
|
||||
lineRightX: l.x0 + (l.adv?.reduce((a, b) => a + b, 0) ?? 0),
|
||||
})),
|
||||
paragraphBounds: {
|
||||
columnLeft,
|
||||
columnRight,
|
||||
firstBaselineY: layout.firstBaselineY,
|
||||
leading,
|
||||
oldLineCount: layout.oldLineCount,
|
||||
},
|
||||
});
|
||||
|
||||
if (regions.length === 0) { return; }
|
||||
// Clamp font sizes in the returned layout to guard against the WASM matrix-override bug
|
||||
// (see sanitizeEngineLayout for full explanation). This keeps the caret at the correct
|
||||
// position and prevents the bounding box from visually shrinking on click or first keystroke.
|
||||
const lay = sanitizeEngineLayout(rawLay, domSize);
|
||||
engineLayoutRef.current = lay;
|
||||
const r0 = regions[0];
|
||||
const { rgba, width, height } = r0;
|
||||
if (!rgba || width <= 0 || height <= 0) { return; }
|
||||
const cv = previewCanvasRef.current;
|
||||
if (cv) {
|
||||
if (cv.width !== width) cv.width = width;
|
||||
if (cv.height !== height) cv.height = height;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const displayW = pageWidthPx;
|
||||
const displayH = Math.max(0, pageHeightPx - bandTop);
|
||||
|
||||
console.log('[OVERLAY_CANVAS_VERIFICATION]', {
|
||||
canvasWidth: cv.width,
|
||||
canvasHeight: cv.height,
|
||||
cssClientWidth: rect.width,
|
||||
cssClientHeight: rect.height,
|
||||
styleWidth: cv.style.width,
|
||||
styleHeight: cv.style.height,
|
||||
devicePixelRatio: dpr,
|
||||
regionBitmapWidth: width,
|
||||
regionBitmapHeight: height,
|
||||
displayW,
|
||||
displayH,
|
||||
});
|
||||
|
||||
console.log('[ALL_DOM_CANVASES_INSPECTION]', Array.from(document.querySelectorAll('canvas')).map((c, i) => {
|
||||
const r = c.getBoundingClientRect();
|
||||
const cs = getComputedStyle(c);
|
||||
return {
|
||||
index: i,
|
||||
pageIdx: c.getAttribute('data-page-index'),
|
||||
width: c.width,
|
||||
height: c.height,
|
||||
clientWidth: r.width,
|
||||
clientHeight: r.height,
|
||||
top: Math.round(r.top),
|
||||
left: Math.round(r.left),
|
||||
zIndex: cs.zIndex,
|
||||
opacity: cs.opacity,
|
||||
visibility: cs.visibility,
|
||||
display: cs.display,
|
||||
pointerEvents: cs.pointerEvents,
|
||||
};
|
||||
}));
|
||||
|
||||
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(width, height);
|
||||
img.data.set(rgba);
|
||||
ctx.putImageData(img, 0, 0);
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = width;
|
||||
offscreen.height = height;
|
||||
const offCtx = offscreen.getContext('2d');
|
||||
if (offCtx) {
|
||||
const imgData = offCtx.createImageData(width, height);
|
||||
imgData.data.set(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
onOverflowPreview?.(regions.slice(1).map((rg) => ({
|
||||
@@ -594,7 +723,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const onClickEditor = (e: React.MouseEvent) => {
|
||||
const lay = engineLayoutRef.current, el = editRef.current;
|
||||
if (!lay || !el || fallbackVisible) return;
|
||||
setGlobalCaretOffset(el, globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? ''));
|
||||
const targetIdx = globalFromPoint(e.clientX, e.clientY, lay, el.textContent ?? '');
|
||||
caretIndexRef.current = targetIdx;
|
||||
setGlobalCaretOffset(el, targetIdx);
|
||||
positionCaret();
|
||||
};
|
||||
|
||||
@@ -657,7 +788,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
{fallbackVisible && (
|
||||
<div
|
||||
className="absolute z-[36] bg-white"
|
||||
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: layout.oldLineCount * leadingPx + 8 }}
|
||||
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: colHeightPx + 4 }}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
@@ -736,12 +867,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||||
className="absolute z-[38] outline-none"
|
||||
style={{
|
||||
left: colLeftPx, top: editorTop, width: colWidthPx, minHeight: layout.oldLineCount * leadingPx,
|
||||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`,
|
||||
left: colLeftPx, top: editorTop, width: colWidthPx, height: colHeightPx,
|
||||
fontSize: `${fontPx}px`, lineHeight: `${leadingPx}px`, fontFamily: measureFamily,
|
||||
caretColor: fallbackVisible ? '#2563eb' : 'transparent',
|
||||
color: fallbackVisible ? undefined : 'transparent',
|
||||
background: fallbackVisible ? '#ffffff' : 'transparent',
|
||||
whiteSpace: 'normal', overflowWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -102,8 +102,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 +227,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 +257,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 +279,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 {
|
||||
@@ -473,40 +455,36 @@ 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);
|
||||
@@ -575,16 +553,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);
|
||||
};
|
||||
|
||||
@@ -713,8 +715,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`,
|
||||
|
||||
@@ -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: 6.1 KiB |
@@ -0,0 +1,42 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</Filter/FlateDecode/Length 237>>stream
|
||||
xœÅ“=kÃ0EwýŠ;6Cä+É–-0†|Ø…@! ¡Sj')mMœ!¿¤…:¨*Â[îô÷<ÞI×Yßã3{(\@,@<‹¤ÙÔ
|
||||
û3’å¼YNÖ”er|ºëú¡};wã®víørè_ÚQUa:Ÿ‰Ó÷ª¯åSõ‘”ÑÒºÌ9‡\þI³i”†ï@øÛ2O«Gøj�•¨fâùÚþ�_DäSR§¤ðm�¯®þ5©ü,¢ZIM2½Íý�“&-nãÞ™_ع‰Æ.¬´¹%M¨ºŽ‡§$�sÁöñÌç¡oË~Z_‰w‘UAÆ
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
[ 8 0 R ]
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
6 4
|
||||
0000000269 00000 n
|
||||
0000000312 00000 n
|
||||
0000000403 00000 n
|
||||
0000000712 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||
startxref
|
||||
740
|
||||
%%EOF
|
||||
@@ -0,0 +1,42 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</Filter/FlateDecode/Length 169>>stream
|
||||
xœŽK‚@E÷ó+î²æ|ãc
|
||||
Dð5�¨ÌBˆV¦fT¢.üûQA-‚Vq7guîÇcÅ/[fp¤à83S• ¡�`f±Ê‚"€ç™ÝqÑôc}›ºÊhú±ª�ùÔ_ê¥ï#Œ#6|Toy¨AO&‰•»œ¤àÐW˜ªTÐ
8ôˆ½çØ®#×ÒöÐ)�œ%»ˆý¯BŠ_ï–t\!-×ù*ÈÙ,·J
|
||||
endstream
|
||||
endobj
|
||||
9 0 obj
|
||||
[ 8 0 R ]
|
||||
endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
6 4
|
||||
0000000269 00000 n
|
||||
0000000312 00000 n
|
||||
0000000403 00000 n
|
||||
0000000644 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
|
||||
startxref
|
||||
672
|
||||
%%EOF
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Filter/FlateDecode/Length 832>>stream
|
||||
xœÅšMkTQ†÷ó+ÎR¦ù:9 ”B?fAP™EA\ˆ_(¨Xþ}¹Ó:-Âäº0séfV÷áMrNòžôÇ
|
||||
Ûô÷êi»ýqó©QûÕ°=kؾ¬N6×kjŸ~¶“W›ç¯ÎÛééÉç÷�>~¿ùðíççwOÞ
|
||||
Žw6äñÙY»¸º\ý¸ÿÎþËÛF»ßÔÈ",¢Ùà¶ýÚN6×jDmû±aÛÞ´×§¼>{Ó¶ÏÚzÛ^®ÖÏ/Wÿ�-àN ÙFyÖ3r•æÑAX2rT‘ Ìðà"°u誈)«à8—"x'PïœÁ«ò1¯Ü.«àÄ1b,QpÀ¦‘^jFUpÒ@Ìrn½Î1úLΫ®6&è"ˆé½ZUíÄ =ÓvR¥
X9"
û¦
|
||||
Ž@l3Õ^$š)ö¢¨‚¨/p¿¸/sÂ�€†ä•6´†=ˆz~§›±Ó`Ýå,ê< v;rûfA³”]u²˜¸cølr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3�GÏ7¹
ÌÙUsqξ¨bh,sûéüøÅf´ŸÎë¬A×½=|Ä«àê 숩ò*k 1EÝ›÷Fø°ò*k Îùµ^•s`wvðø9ß92ž9ç^åÈtèLØ‹FU"‚Ñe¡œc“%ytfDY ØB@±ç¯jhÙavÑ[òWtôf¤‚‹ÎÃdA£
Äc»¢>€çØçUlb OØeNTÌ)g½å²t9»Ê‘‰€ÄŒî¢¶sà’绪1Ë�3IØEí‹iÜ9“òMt·ŽLt
©Œ}·ŽLTWùл¥`¢¹jCŒ##W-æœUò*»ª[Â�¼ÊªfòÉ÷OV(Ó]ÔÁÈôV·f«ü@wp¢ééz�Öû?�ÀËܯAwœÆÓ^õÖ#¾3#I«þÉBø�Y áìàÓ>p‰JÌ0n÷�‰ò*8)�ëL©W½jâ4§ÎÁ«šÙý6�W½2ݯa�ßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚0ò?¦¦ÝtÕ>]§z©¡k}Ñ—«ßVn$
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
0000000269 00000 n
|
||||
6 2
|
||||
0000001173 00000 n
|
||||
0000001216 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||
startxref
|
||||
1307
|
||||
%%EOF
|
||||
@@ -0,0 +1,37 @@
|
||||
%PDF-1.4
|
||||
%¡³Å×
|
||||
1 0 obj
|
||||
<</Pages 2 0 R /Type/Catalog>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Filter/FlateDecode/Length 417>>stream
|
||||
xœµ”;k\A…ûû+T&EfGéhËÂ>îÛÜÂ`R¿p ^ùûacHRÄCŠÓ¨ÒùF::OS¦Ó»|O/Åñ�˜¾S¦3ÊôyZµ«™éá™Vç‡v¾½ÜÒz½z¼}sÿíx÷õùñæÝ§’ý^Þn6´;ì§§ß}~uÞ-Ä?k&©H�p*hùB«vÕ˜˜i¹§LË‘®×^<» 0ÃeÞ|¤åŒæ….¦ùÃ~ú_ɳw8``ì=1hMÌEK�!0h5™¤§Î€+Ì3Ú0 1PÐ[‡7̘Gƒ
)B¬ÔîD‰+'Ï9jôÄÇ-€5y°s÷ïÆŒÝ„È©¨ÃºÉ ÇÉ�c¼w®¨°AQ5‰d
òœ_ÿüHƒ©2‹õ
>.Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬v�h£lÀ"É‚.€¸ ¹�JäД9Wéz‘Ý(£"Ù¥#n;„ÈWÖW*´i3U³Sî5tÖ?.¦Ïê}=
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<</BM/Normal/CA 1/ca 1>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000017 00000 n
|
||||
0000000066 00000 n
|
||||
0000000122 00000 n
|
||||
0000000269 00000 n
|
||||
6 2
|
||||
0000000758 00000 n
|
||||
0000000801 00000 n
|
||||
trailer
|
||||
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
|
||||
startxref
|
||||
892
|
||||
%%EOF
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
"""Compare forensic snapshots and print diff tables."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def load(p):
|
||||
return json.loads(Path(p).read_text(encoding="utf-8-sig"))
|
||||
|
||||
def diff_table(a, b, keys):
|
||||
rows = []
|
||||
for k in keys:
|
||||
va, vb = a.get(k), b.get(k)
|
||||
changed = va != vb
|
||||
rows.append((k, va, vb, "CHANGED" if changed else "same"))
|
||||
return rows
|
||||
|
||||
def print_table(title, rows):
|
||||
print(f"\n=== {title} ===")
|
||||
print(f"{'Property':<22} {'Before':<40} {'After':<40} {'Status'}")
|
||||
for k, va, vb, st in rows:
|
||||
sa = json.dumps(va) if not isinstance(va, str) else va
|
||||
sb = json.dumps(vb) if not isinstance(vb, str) else vb
|
||||
if len(sa) > 38: sa = sa[:35] + "..."
|
||||
if len(sb) > 38: sb = sb[:35] + "..."
|
||||
print(f"{k:<22} {sa:<40} {sb:<40} {st}")
|
||||
|
||||
def compare_advances(orig, new):
|
||||
oa, na = orig or [], new or []
|
||||
diffs = []
|
||||
for i in range(max(len(oa), len(na))):
|
||||
ov = oa[i] if i < len(oa) else None
|
||||
nv = na[i] if i < len(na) else None
|
||||
if ov != nv:
|
||||
diffs.append((i, ov, nv))
|
||||
return diffs
|
||||
|
||||
def main():
|
||||
extract_path = Path(sys.argv[1] if len(sys.argv) > 1 else "forensic_extract_out.json")
|
||||
wasm_path = Path(sys.argv[2] if len(sys.argv) > 2 else "forensic_wasm_out.json")
|
||||
ex = load(extract_path)
|
||||
wasm = load(wasm_path)
|
||||
|
||||
keys = [
|
||||
"text", "fontId", "fontName", "fontSize", "color",
|
||||
"glyphCount", "advanceCount", "paragraphWidth", "baseline", "lineHeight",
|
||||
"origLinesProvided",
|
||||
]
|
||||
|
||||
orig = ex["original"]
|
||||
entry_payload = ex["editEntry"]
|
||||
one_payload = ex["editOne"]
|
||||
|
||||
# WASM layout snapshots
|
||||
from forensic_extract import snapshot_from_wasm_layout, para_signature # type: ignore
|
||||
seed_fn = orig.get("fontName", "")
|
||||
entry_wasm = snapshot_from_wasm_layout("ENTRY_WASM", wasm["editEntryWasm"]["layout"], seed_fn)
|
||||
one_wasm = snapshot_from_wasm_layout("ONE_WASM", wasm["editOneWasm"]["layout"], seed_fn)
|
||||
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (payload)", diff_table(orig, entry_payload, keys))
|
||||
print_table("ORIGINAL vs EDIT_MODE_ENTRY (WASM layout)", diff_table(orig, entry_wasm, keys))
|
||||
|
||||
adv_diff_entry = compare_advances(orig.get("advances"), entry_wasm.get("advances"))
|
||||
if adv_diff_entry:
|
||||
print(f"\nAdvance diffs ORIGINAL vs ENTRY_WASM: {len(adv_diff_entry)} positions")
|
||||
for i, ov, nv in adv_diff_entry[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
else:
|
||||
print("\nAdvance arrays IDENTICAL: ORIGINAL vs ENTRY_WASM")
|
||||
|
||||
print_table("EDIT_ENTRY vs AFTER_ONE_CHAR (payload)", diff_table(entry_payload, one_payload, keys))
|
||||
print_table("ENTRY_WASM vs ONE_WASM", diff_table(entry_wasm, one_wasm, keys))
|
||||
|
||||
adv_diff_one = compare_advances(entry_wasm.get("advances"), one_wasm.get("advances"))
|
||||
if adv_diff_one:
|
||||
print(f"\nAdvance diffs ENTRY_WASM vs ONE_WASM: {len(adv_diff_one)} positions")
|
||||
for i, ov, nv in adv_diff_one[:20]:
|
||||
print(f" [{i}] {ov} -> {nv}")
|
||||
|
||||
# Line widths
|
||||
print("\n=== LINE WIDTHS ===")
|
||||
print(f"Original lineWidths: {orig.get('lineWidths')}")
|
||||
print(f"Entry WASM lineWidths: {entry_wasm.get('lineWidths')}")
|
||||
print(f"One WASM lineWidths: {one_wasm.get('lineWidths')}")
|
||||
|
||||
# Signatures chain
|
||||
sigs = {
|
||||
"original": para_signature(orig),
|
||||
"entry_payload": para_signature(entry_payload),
|
||||
"entry_wasm": para_signature(entry_wasm),
|
||||
"one_payload": para_signature(one_payload),
|
||||
"one_wasm": para_signature(one_wasm),
|
||||
}
|
||||
print("\n=== SIGNATURE CHAIN ===")
|
||||
prev = None
|
||||
for name, sig in sigs.items():
|
||||
changed = prev and sig != prev
|
||||
print(f" {name}: {sig}" + (" <-- FIRST CHANGE from " + prev_name if changed else ""))
|
||||
if changed and prev:
|
||||
pass
|
||||
prev_name = name
|
||||
prev = sig
|
||||
|
||||
# First mutation
|
||||
chain = list(sigs.items())
|
||||
base = chain[0][1]
|
||||
for i in range(1, len(chain)):
|
||||
if chain[i][1] != base:
|
||||
print(f"\nFIRST SIGNATURE CHANGE: {chain[i][0]} (vs original)")
|
||||
break
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,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,138 @@
|
||||
"""Step 2 experiment: force runPerChar=false for UNCHANGED paragraph.
|
||||
|
||||
Requires rebuilt pdfengine with forensicForceWholeRun support.
|
||||
Compares:
|
||||
A) default entry (runPerChar as decided by diverges)
|
||||
B) forensicForceWholeRun=true (runPerChar forced 0)
|
||||
C) original PDF
|
||||
|
||||
Emits object dumps + content streams + optional region bitmap SHA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
# Prefer freshly rebuilt engine when present (must be AFTER gateway insert so it wins).
|
||||
_winlocal = Path(r"C:\Users\Maskan\pdfeng-build\win-local\lib")
|
||||
if _winlocal.exists():
|
||||
sys.path.insert(0, str(_winlocal))
|
||||
import pdfengine # type: ignore
|
||||
print(f"[engine] {pdfengine.__file__}", flush=True)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
|
||||
|
||||
def inflate_content(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(pdf_bytes: bytes) -> list[str]:
|
||||
found = []
|
||||
for m in re.finditer(rb"<<[^>]*?/Type\s*/Font[^>]*?>>", pdf_bytes, re.DOTALL):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
# also compact form
|
||||
if not found:
|
||||
for m in re.finditer(rb"<</BaseFont/[^>]+>>", pdf_bytes):
|
||||
found.append(m.group(0).decode("latin-1", errors="replace"))
|
||||
return found
|
||||
|
||||
|
||||
def region_sha(doc, page_index=0, dpi=144, y_top=200, height=80) -> str:
|
||||
page = doc.get_page(page_index)
|
||||
w, h, raw = page.render_region_raw(dpi, y_top, height)
|
||||
return hashlib.sha256(raw).hexdigest()[:16], w, h
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
|
||||
doc0 = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc0.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-force-whole")
|
||||
|
||||
variants = {
|
||||
"A_default_entry": {**data},
|
||||
"B_force_whole_run": {**data, "forensicForceWholeRun": True},
|
||||
}
|
||||
|
||||
orig_bytes = pdf_path.read_bytes()
|
||||
print("ORIGINAL fonts:", font_dicts(orig_bytes))
|
||||
print("ORIGINAL streams:")
|
||||
for s in inflate_content(orig_bytes):
|
||||
print(s)
|
||||
try:
|
||||
sha, w, h = region_sha(doc0, y_top=120, height=40)
|
||||
print(f"ORIGINAL region sha={sha} {w}x{h}")
|
||||
except Exception as e:
|
||||
print("ORIGINAL region render failed:", e)
|
||||
|
||||
results = {}
|
||||
for name, d in variants.items():
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": name, "type": "reflow_paragraph", "pageIndex": 0, "data": d,
|
||||
}]}
|
||||
print(f"\n===== {name} =====")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
out = doc.save_full()
|
||||
path = out_dir / f"_forensic_{name}.pdf"
|
||||
path.write_bytes(out)
|
||||
print("fonts:", font_dicts(out))
|
||||
streams = inflate_content(out)
|
||||
for s in streams:
|
||||
# only show streams that mention Goodbye / FXF2 / 100 Tm
|
||||
if "100 Tm" in s or "Goodbye" in s or "476F6F64" in s:
|
||||
print("--- content ---")
|
||||
print(s[:2000])
|
||||
# count TJ
|
||||
tj_count = sum(s.count(" TJ") for s in streams)
|
||||
print(f"TJ operator count across streams: {tj_count}")
|
||||
try:
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(out, "")
|
||||
sha, w, h = region_sha(d2, y_top=120, height=40)
|
||||
print(f"region sha={sha} {w}x{h}")
|
||||
results[name] = {"sha": sha, "tj": tj_count, "fonts": font_dicts(out)}
|
||||
except Exception as e:
|
||||
print("region render failed:", e)
|
||||
results[name] = {"error": str(e), "tj": tj_count}
|
||||
|
||||
print("\n===== VERDICT =====")
|
||||
if "A_default_entry" in results and "B_force_whole_run" in results:
|
||||
a, b = results["A_default_entry"], results["B_force_whole_run"]
|
||||
print(f"A (default runPerChar) TJ={a.get('tj')} sha={a.get('sha')}")
|
||||
print(f"B (force whole run) TJ={b.get('tj')} sha={b.get('sha')}")
|
||||
if a.get("sha") and b.get("sha"):
|
||||
if a["sha"] == b["sha"]:
|
||||
print("RESULT: A and B IDENTICAL bitmaps → runPerChar does NOT change pixels")
|
||||
else:
|
||||
print("RESULT: A and B DIFFER → runPerChar DOES change pixels")
|
||||
if a.get("tj") == 1 and b.get("tj") and b["tj"] < a.get("tj", 99):
|
||||
print("NOTE: B emitted fewer TJ (whole-run path active)")
|
||||
if a.get("tj", 0) > 5 and b.get("tj", 0) <= 3:
|
||||
print("CONFIRMED: forensicForceWholeRun reduced emission to whole-run")
|
||||
elif a.get("tj") == b.get("tj"):
|
||||
print("WARNING: TJ counts equal — rebuild may not include forensicForceWholeRun yet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Compare full-page render hashes + PDF objects across corpus files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
|
||||
from forensic_obj_compare import apply_and_save # type: ignore
|
||||
|
||||
|
||||
def inflate(pdf_bytes: bytes) -> list[str]:
|
||||
out = []
|
||||
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
|
||||
try:
|
||||
out.append(zlib.decompress(m.group(1)).decode("latin-1", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def font_dicts(b: bytes) -> list[str]:
|
||||
return [m.group(0).decode("latin-1", "replace") for m in re.finditer(rb"<</BaseFont/[^>]+>>", b)]
|
||||
|
||||
|
||||
def sha_full(doc, dpi=144) -> tuple[str, int]:
|
||||
img = doc.get_page(0).render(dpi)
|
||||
png = bytes(img.data)
|
||||
return hashlib.sha256(png).hexdigest()[:24], len(png)
|
||||
|
||||
|
||||
def run(pdf: Path):
|
||||
print(f"\n######## {pdf}")
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf), "")
|
||||
s0, n0 = sha_full(doc)
|
||||
print(f"ORIG sha={s0} pngbytes={n0}")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
if not model.paragraphs:
|
||||
print("no paragraphs")
|
||||
return
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
for f in doc.get_fonts(0, 0)[:6]:
|
||||
print(f" FontInfo name={f.font_name} emb={f.is_embedded} type={f.type} "
|
||||
f"subset={getattr(f, 'is_subset', None)} id={f.internal_font_id}")
|
||||
print(" seedRuns:", [(r["fontName"], r["fid"], round(r["size"], 2),
|
||||
len(r["advances"]) if r.get("advances") else 0)
|
||||
for r in layout["seedRuns"][:4]])
|
||||
|
||||
fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], fid, dom["size"], dom["color"])
|
||||
|
||||
data = build_reflow_data(layout, flat, layout["origLines"], "cmp")
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
|
||||
}]}
|
||||
outp = Path(__file__).resolve().parent / "_tmp_entry.pdf"
|
||||
apply_and_save(pdf, op, outp)
|
||||
d2 = pdfengine.PdfDocument.load_from_memory(outp.read_bytes(), "")
|
||||
s1, n1 = sha_full(d2)
|
||||
print(f"ENTRY sha={s1} pngbytes={n1} {'IDENTICAL' if s0 == s1 else '*** DIFFERENT ***'}")
|
||||
b = outp.read_bytes()
|
||||
print(" entry fonts:", font_dicts(b))
|
||||
for s in inflate(b):
|
||||
if "Tm" in s or "TJ" in s or "Tj" in s:
|
||||
one_line = s.replace("\n", " | ")
|
||||
print(" stream:", one_line[:700])
|
||||
tj = s.count(" TJ")
|
||||
print(f" TJ count={tj}")
|
||||
break
|
||||
|
||||
flat1 = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
|
||||
data1 = build_reflow_data(layout, flat1, None, "cmp")
|
||||
# also try force whole if supported
|
||||
data1_force = {**build_reflow_data(layout, flat, layout["origLines"], "cmp"),
|
||||
"forensicForceWholeRun": True}
|
||||
op1 = {"version": "1.0", "operations": [{
|
||||
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": data1,
|
||||
}]}
|
||||
out1 = Path(__file__).resolve().parent / "_tmp_one.pdf"
|
||||
apply_and_save(pdf, op1, out1)
|
||||
d3 = pdfengine.PdfDocument.load_from_memory(out1.read_bytes(), "")
|
||||
s2, n2 = sha_full(d3)
|
||||
print(f"ONE sha={s2} pngbytes={n2} vsORIG={'SAME' if s0 == s2 else 'DIFF'} vsENTRY={'SAME' if s1 == s2 else 'DIFF'}")
|
||||
|
||||
# force whole on unchanged
|
||||
opf = {"version": "1.0", "operations": [{
|
||||
"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data1_force,
|
||||
}]}
|
||||
outf = Path(__file__).resolve().parent / "_tmp_force.pdf"
|
||||
apply_and_save(pdf, opf, outf)
|
||||
df = pdfengine.PdfDocument.load_from_memory(outf.read_bytes(), "")
|
||||
sf, nf = sha_full(df)
|
||||
bf = outf.read_bytes()
|
||||
tjf = sum(s.count(" TJ") for s in inflate(bf))
|
||||
tje = sum(s.count(" TJ") for s in inflate(b))
|
||||
print(f"FORCE sha={sf} pngbytes={nf} vsORIG={'SAME' if s0 == sf else 'DIFF'} "
|
||||
f"vsENTRY={'SAME' if s1 == sf else 'DIFF'} TJ_entry={tje} TJ_force={tjf}")
|
||||
if "forensicForceWholeRun" in json.dumps(opf):
|
||||
if tje != tjf:
|
||||
print(" => forensicForceWholeRun ACTIVE (TJ count changed)")
|
||||
else:
|
||||
print(" => forensicForceWholeRun NOT in binary yet (TJ unchanged) OR no-op")
|
||||
|
||||
|
||||
def main():
|
||||
for p in [
|
||||
ROOT / "corpus" / "basic" / "hello_world.pdf",
|
||||
ROOT / "tests" / "edits" / "forensic_multiline.pdf",
|
||||
ROOT / "corpus" / "fonts" / "embedded_truetype.pdf",
|
||||
ROOT / "corpus" / "fonts" / "subset_font.pdf",
|
||||
]:
|
||||
if p.exists():
|
||||
try:
|
||||
run(p)
|
||||
except Exception as e:
|
||||
print(f"FAILED {p}: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
%PDF-1.7
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Length 238 >>
|
||||
stream
|
||||
BT /F1 11 Tf 72 700 Td (The quick brown fox jumps over the lazy) Tj ET
|
||||
BT /F1 11 Tf 72 686 Td (dog near the river bank on a sunny) Tj ET
|
||||
BT /F1 11 Tf 72 672 Td (afternoon in early spring.) Tj ET
|
||||
BT /F1 11 Tf 72 600 Td (FOOTER LINE) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000241 00000 n
|
||||
0000000529 00000 n
|
||||
trailer
|
||||
<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
599
|
||||
%%EOF
|
||||
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"pdf": "corpus\\basic\\hello_world.pdf",
|
||||
"fonts": [
|
||||
{
|
||||
"id": "Helvetica_Type1_32",
|
||||
"name": "Helvetica",
|
||||
"embedded": false,
|
||||
"type": "Type1",
|
||||
"subset": false,
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0
|
||||
},
|
||||
{
|
||||
"id": "Times-Roman_Type1_32",
|
||||
"name": "Times-Roman",
|
||||
"embedded": false,
|
||||
"type": "Type1",
|
||||
"subset": false,
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0
|
||||
}
|
||||
],
|
||||
"orig": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye, world!",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 114.31999969482422,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
1
|
||||
],
|
||||
"glyphCount": 15,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
93.824,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.272,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.6,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.152,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.048,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"entry": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye,",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 67.10400390625,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
14,
|
||||
13,
|
||||
12,
|
||||
11,
|
||||
10,
|
||||
9,
|
||||
8,
|
||||
7
|
||||
],
|
||||
"glyphCount": 8,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 1,
|
||||
"text": " ",
|
||||
"fontName": "",
|
||||
"fontId": "",
|
||||
"fontSize": 1.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "",
|
||||
"fontFidelity": "exact",
|
||||
"x": 89.3759994506836,
|
||||
"y": 100.0,
|
||||
"w": 0.0,
|
||||
"h": 0.0,
|
||||
"objectIndices": [],
|
||||
"glyphCount": 1,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
32
|
||||
],
|
||||
"fontMeta": null
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 2,
|
||||
"text": "world!",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 93.87199401855469,
|
||||
"y": 99.8239974975586,
|
||||
"w": 41.29600524902344,
|
||||
"h": 11.632003784179688,
|
||||
"objectIndices": [
|
||||
6,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1
|
||||
],
|
||||
"glyphCount": 6,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
93.824,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.376,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.272,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.6,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.152,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.048,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"one": [
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Goodbye,",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.847999572753906,
|
||||
"y": 96.65599822998047,
|
||||
"w": 67.10400390625,
|
||||
"h": 14.99200439453125,
|
||||
"objectIndices": [
|
||||
2
|
||||
],
|
||||
"glyphCount": 8,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
32.448,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
41.344,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
50.24,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
59.136,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
68.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
76.032,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
84.928,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
71,
|
||||
111,
|
||||
111,
|
||||
100,
|
||||
98,
|
||||
121,
|
||||
101,
|
||||
44
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 1,
|
||||
"text": " ",
|
||||
"fontName": "",
|
||||
"fontId": "",
|
||||
"fontSize": 1.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "",
|
||||
"fontFidelity": "exact",
|
||||
"x": 89.3759994506836,
|
||||
"y": 100.0,
|
||||
"w": 0.0,
|
||||
"h": 0.0,
|
||||
"objectIndices": [],
|
||||
"glyphCount": 1,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
89.376,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
32
|
||||
],
|
||||
"fontMeta": null
|
||||
},
|
||||
{
|
||||
"para": 0,
|
||||
"line": 0,
|
||||
"run": 2,
|
||||
"text": "world!x",
|
||||
"fontName": "Helvetica",
|
||||
"fontId": "Helvetica_Type1_32",
|
||||
"fontSize": 16.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 93.8762435913086,
|
||||
"y": 99.8239974975586,
|
||||
"w": 50.512001037597656,
|
||||
"h": 11.632003784179688,
|
||||
"objectIndices": [
|
||||
1
|
||||
],
|
||||
"glyphCount": 7,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
93.828,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
105.38,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
114.276,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
119.604,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
123.156,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
132.052,
|
||||
100.0
|
||||
],
|
||||
[
|
||||
136.5,
|
||||
100.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33,
|
||||
120
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 905.0,
|
||||
"descent": -211.0,
|
||||
"capHeight": 728.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Helvetica"
|
||||
}
|
||||
},
|
||||
{
|
||||
"para": 1,
|
||||
"line": 0,
|
||||
"run": 0,
|
||||
"text": "Hello, world!",
|
||||
"fontName": "Times-Roman",
|
||||
"fontId": "Times-Roman_Type1_32",
|
||||
"fontSize": 12.0,
|
||||
"isEmbedded": false,
|
||||
"fontType": "Type1",
|
||||
"fontFidelity": "exact",
|
||||
"x": 20.20400047302246,
|
||||
"y": 48.007999420166016,
|
||||
"w": 63.120004653930664,
|
||||
"h": 10.319999694824219,
|
||||
"objectIndices": [
|
||||
0
|
||||
],
|
||||
"glyphCount": 13,
|
||||
"glyphOrigins": [
|
||||
[
|
||||
20.0,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
28.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
33.992,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
37.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
40.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
46.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
49.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
52.664,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
61.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
67.328,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
71.324,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
74.66,
|
||||
50.0
|
||||
],
|
||||
[
|
||||
80.66,
|
||||
50.0
|
||||
]
|
||||
],
|
||||
"glyphUnicodes": [
|
||||
72,
|
||||
101,
|
||||
108,
|
||||
108,
|
||||
111,
|
||||
44,
|
||||
32,
|
||||
119,
|
||||
111,
|
||||
114,
|
||||
108,
|
||||
100,
|
||||
33
|
||||
],
|
||||
"fontMeta": {
|
||||
"ascent": 891.0,
|
||||
"descent": -215.0,
|
||||
"capHeight": 662.0,
|
||||
"isEmbedded": false,
|
||||
"isSubset": false,
|
||||
"type": "Type1",
|
||||
"fontName": "Times-Roman"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": {
|
||||
"orig": {
|
||||
"textOps": [
|
||||
{
|
||||
"op": "Tf",
|
||||
"fontRes": "F1",
|
||||
"size": 12.0
|
||||
},
|
||||
{
|
||||
"op": "Tf",
|
||||
"fontRes": "F2",
|
||||
"size": 16.0
|
||||
},
|
||||
{
|
||||
"op": "Td",
|
||||
"tx": 20.0,
|
||||
"ty": 50.0
|
||||
},
|
||||
{
|
||||
"op": "Td",
|
||||
"tx": 0.0,
|
||||
"ty": 50.0
|
||||
},
|
||||
{
|
||||
"op": "Tj",
|
||||
"raw": "(Hello, world!) Tj"
|
||||
},
|
||||
{
|
||||
"op": "Tj",
|
||||
"raw": "(Goodbye, world!) Tj"
|
||||
}
|
||||
],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "Type1",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "Type1",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 1
|
||||
},
|
||||
"entry": {
|
||||
"textOps": [],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 2
|
||||
},
|
||||
"one": {
|
||||
"textOps": [],
|
||||
"fontDicts": [
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Times-Roman"
|
||||
},
|
||||
{
|
||||
"subtype": "?",
|
||||
"baseFont": "Helvetica"
|
||||
}
|
||||
],
|
||||
"streamCount": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Forensic: dump & compare PDF text objects — original vs edit-entry vs one-char.
|
||||
|
||||
Does NOT compare images. Compares:
|
||||
font base name, font size, matrix, bbox, unicode text, object count,
|
||||
and raw content-stream snippets around Tj/TJ.
|
||||
|
||||
Also answers Step 4 from static path + runtime FontInfo:
|
||||
why measureFace is nullptr for this paragraph's font.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
|
||||
def dump_page_text_objects(doc, page_index: int = 0) -> list[dict]:
|
||||
"""Re-extract model and also pull flat glyph list as proxy for text objects."""
|
||||
page = doc.get_page(page_index)
|
||||
model = page.extract_document_model()
|
||||
fonts = doc.get_fonts(page_index, page_index)
|
||||
font_by_id = {f.internal_font_id: f for f in fonts}
|
||||
|
||||
objs = []
|
||||
for pi, para in enumerate(model.paragraphs):
|
||||
for li, line in enumerate(para.lines):
|
||||
for ri, run in enumerate(line.runs):
|
||||
fi = font_by_id.get(run.internal_font_id)
|
||||
objs.append({
|
||||
"para": pi, "line": li, "run": ri,
|
||||
"text": run.text,
|
||||
"fontName": run.font_name,
|
||||
"fontId": run.internal_font_id,
|
||||
"fontSize": run.font_size,
|
||||
"isEmbedded": run.is_embedded,
|
||||
"fontType": run.type,
|
||||
"fontFidelity": getattr(run, "font_fidelity", None),
|
||||
"x": run.x, "y": run.y, "w": run.w, "h": run.h,
|
||||
"objectIndices": list(run.object_indices or []),
|
||||
"glyphCount": len(run.glyphs),
|
||||
"glyphOrigins": [(round(g.origin_x, 3), round(g.origin_y, 3)) for g in run.glyphs[:40]],
|
||||
"glyphUnicodes": [g.unicode for g in run.glyphs[:40]],
|
||||
"fontMeta": None if not fi else {
|
||||
"ascent": fi.ascent, "descent": fi.descent,
|
||||
"capHeight": getattr(fi, "cap_height", None),
|
||||
"isEmbedded": fi.is_embedded,
|
||||
"isSubset": getattr(fi, "is_subset", None),
|
||||
"type": fi.type,
|
||||
"fontName": fi.font_name,
|
||||
},
|
||||
})
|
||||
return objs
|
||||
|
||||
|
||||
def extract_content_stream_text_ops(pdf_bytes: bytes) -> dict:
|
||||
"""Best-effort parse of content stream for font/text operators."""
|
||||
# Find stream ... endstream blocks
|
||||
streams = re.findall(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL)
|
||||
ops = []
|
||||
for s in streams:
|
||||
# Decode latin-1 so binary stays 1:1
|
||||
try:
|
||||
text = s.decode("latin-1")
|
||||
except Exception:
|
||||
continue
|
||||
# Font select: /F1 16 Tf or /Helv 11 Tf
|
||||
for m in re.finditer(r"/([A-Za-z0-9\+\,\-]+)\s+([\d.]+)\s+Tf", text):
|
||||
ops.append({"op": "Tf", "fontRes": m.group(1), "size": float(m.group(2))})
|
||||
# Text matrix: a b c d e f Tm
|
||||
for m in re.finditer(
|
||||
r"([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+Tm",
|
||||
text,
|
||||
):
|
||||
ops.append({"op": "Tm", "matrix": [float(x) for x in m.groups()]})
|
||||
# Td
|
||||
for m in re.finditer(r"([\d.\-]+)\s+([\d.\-]+)\s+Td", text):
|
||||
ops.append({"op": "Td", "tx": float(m.group(1)), "ty": float(m.group(2))})
|
||||
# Literal strings before Tj
|
||||
for m in re.finditer(r"\((?:\\.|[^\\)])*\)\s*Tj", text):
|
||||
raw = m.group(0)
|
||||
ops.append({"op": "Tj", "raw": raw[:120]})
|
||||
for m in re.finditer(r"\[.*?\]\s*TJ", text, re.DOTALL):
|
||||
ops.append({"op": "TJ", "raw": m.group(0)[:200]})
|
||||
# Font dictionaries
|
||||
fonts = []
|
||||
for m in re.finditer(
|
||||
rb"/Type\s*/Font\s*/Subtype\s*/(\w+)\s*/BaseFont\s*/([^\s/]+)",
|
||||
pdf_bytes,
|
||||
):
|
||||
fonts.append({"subtype": m.group(1).decode(), "baseFont": m.group(2).decode()})
|
||||
# Also looser BaseFont
|
||||
if not fonts:
|
||||
for m in re.finditer(rb"/BaseFont\s*/([^\s/]+)", pdf_bytes):
|
||||
fonts.append({"subtype": "?", "baseFont": m.group(1).decode()})
|
||||
return {"textOps": ops, "fontDicts": fonts, "streamCount": len(streams)}
|
||||
|
||||
|
||||
def load_layout(path: Path):
|
||||
sys.path.insert(0, str(path.parent))
|
||||
from forensic_extract import ( # type: ignore
|
||||
compute_layout, extract_flat_runs, build_reflow_data,
|
||||
)
|
||||
return compute_layout, extract_flat_runs, build_reflow_data
|
||||
|
||||
|
||||
def build_payloads(pdf_path: Path):
|
||||
compute_layout, extract_flat_runs, build_reflow_data = load_layout(
|
||||
Path(__file__).resolve().parent
|
||||
)
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
layout = compute_layout(para)
|
||||
dominant_fid = next(
|
||||
(r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), ""
|
||||
)
|
||||
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
|
||||
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
|
||||
para_id = "forensic-obj-cmp"
|
||||
|
||||
entry_data = build_reflow_data(layout, flat, layout["origLines"], para_id)
|
||||
# one char: append x, drop origLines, keep stale advances (mirrors real editor)
|
||||
flat_one = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
|
||||
one_data = build_reflow_data(layout, flat_one, None, para_id)
|
||||
|
||||
return {
|
||||
"entry": {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": entry_data,
|
||||
}]},
|
||||
"one": {"version": "1.0", "operations": [{
|
||||
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": one_data,
|
||||
}]},
|
||||
"layout": layout,
|
||||
"fonts": [
|
||||
{
|
||||
"id": f.internal_font_id, "name": f.font_name,
|
||||
"embedded": f.is_embedded, "type": f.type,
|
||||
"subset": getattr(f, "is_subset", None),
|
||||
"ascent": f.ascent, "descent": f.descent,
|
||||
}
|
||||
for f in doc.get_fonts(0, 0)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def apply_and_save(pdf_path: Path, op: dict, out_path: Path) -> bytes:
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
doc.apply_edits(json.dumps(op))
|
||||
data = doc.save_full()
|
||||
out_path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
def compare_objs(label_a: str, a: list[dict], label_b: str, b: list[dict]):
|
||||
print(f"\n{'='*60}\nCOMPARE {label_a} vs {label_b}\n{'='*60}")
|
||||
print(f" object/run count: {len(a)} -> {len(b)}")
|
||||
keys = ["text", "fontName", "fontId", "fontSize", "isEmbedded", "fontType",
|
||||
"w", "glyphCount"]
|
||||
n = max(len(a), len(b))
|
||||
for i in range(min(n, 12)):
|
||||
oa = a[i] if i < len(a) else None
|
||||
ob = b[i] if i < len(b) else None
|
||||
if oa is None:
|
||||
print(f" [{i}] ADDED: {ob['text']!r} font={ob['fontName']} size={ob['fontSize']}")
|
||||
continue
|
||||
if ob is None:
|
||||
print(f" [{i}] REMOVED: {oa['text']!r}")
|
||||
continue
|
||||
diffs = []
|
||||
for k in keys:
|
||||
if oa.get(k) != ob.get(k):
|
||||
diffs.append(f"{k}: {oa.get(k)!r} -> {ob.get(k)!r}")
|
||||
# origin of first glyph
|
||||
goa = oa["glyphOrigins"][0] if oa["glyphOrigins"] else None
|
||||
gob = ob["glyphOrigins"][0] if ob["glyphOrigins"] else None
|
||||
if goa != gob:
|
||||
diffs.append(f"firstOrigin: {goa} -> {gob}")
|
||||
status = "CHANGED" if diffs else "same"
|
||||
print(f" [{i}] {oa['text']!r:30} {status}")
|
||||
for d in diffs:
|
||||
print(f" {d}")
|
||||
|
||||
|
||||
def explain_measureface(fonts: list[dict]):
|
||||
print(f"\n{'='*60}\nSTEP 4 — why measureFace is nullptr\n{'='*60}")
|
||||
for f in fonts:
|
||||
print(f" FontInfo: id={f['id']} name={f['name']} embedded={f['embedded']} "
|
||||
f"type={f['type']} subset={f['subset']}")
|
||||
if not f["embedded"]:
|
||||
print(" PATH TAKEN in loadEmissionFont():")
|
||||
print(" matchedFontInfo && !isEmbedded")
|
||||
print(" -> cacheKey = \"standard_\" + fontName")
|
||||
print(" -> useEmbedded = false")
|
||||
print(" -> useSystem = false")
|
||||
print(" skip embedded/system load branches")
|
||||
print(" fall through to FPDFText_LoadStandardFont()")
|
||||
print(" measureFace ONLY set from loadedFontDataBuffers_ (TTF bytes)")
|
||||
print(" standard fonts never put bytes in that buffer")
|
||||
print(" => measureFace remains nullptr BY DESIGN for non-embedded fonts")
|
||||
else:
|
||||
print(" Embedded path: measureFace set only if getFontData*/LoadFromMemory succeeds")
|
||||
print(" or via useSystem branch FONT_METRICS_FIX preserving orig face")
|
||||
|
||||
|
||||
def main():
|
||||
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
|
||||
out_dir = Path(__file__).resolve().parent
|
||||
|
||||
print(f"PDF: {pdf_path}")
|
||||
payloads = build_payloads(pdf_path)
|
||||
explain_measureface(payloads["fonts"])
|
||||
|
||||
# Original
|
||||
orig_doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
orig_objs = dump_page_text_objects(orig_doc)
|
||||
orig_bytes = pdf_path.read_bytes()
|
||||
orig_stream = extract_content_stream_text_ops(orig_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nORIGINAL PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in orig_objs:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
if o["fontMeta"]:
|
||||
print(f" meta: ascent={o['fontMeta']['ascent']} descent={o['fontMeta']['descent']} "
|
||||
f"subset={o['fontMeta']['isSubset']}")
|
||||
print(f" content fontDicts: {orig_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(orig_stream['textOps'])}):")
|
||||
for op in orig_stream["textOps"][:20]:
|
||||
print(f" {op}")
|
||||
|
||||
# Entry preview (unchanged text)
|
||||
print(f"\n--- applying EDIT ENTRY reflow (unchanged text) ---")
|
||||
entry_path = out_dir / "_forensic_entry.pdf"
|
||||
entry_bytes = apply_and_save(pdf_path, payloads["entry"], entry_path)
|
||||
entry_doc = pdfengine.PdfDocument.load_from_memory(entry_bytes, "")
|
||||
entry_objs = dump_page_text_objects(entry_doc)
|
||||
entry_stream = extract_content_stream_text_ops(entry_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nEDIT-ENTRY PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in entry_objs[:20]:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
print(f" content fontDicts: {entry_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(entry_stream['textOps'])}):")
|
||||
for op in entry_stream["textOps"][:30]:
|
||||
print(f" {op}")
|
||||
|
||||
compare_objs("ORIGINAL", orig_objs, "EDIT-ENTRY", entry_objs)
|
||||
|
||||
# One char
|
||||
print(f"\n--- applying ONE-CHAR reflow ---")
|
||||
one_path = out_dir / "_forensic_one.pdf"
|
||||
one_bytes = apply_and_save(pdf_path, payloads["one"], one_path)
|
||||
one_doc = pdfengine.PdfDocument.load_from_memory(one_bytes, "")
|
||||
one_objs = dump_page_text_objects(one_doc)
|
||||
one_stream = extract_content_stream_text_ops(one_bytes)
|
||||
|
||||
print(f"\n{'='*60}\nONE-CHAR PDF TEXT OBJECTS\n{'='*60}")
|
||||
for o in one_objs[:20]:
|
||||
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
|
||||
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
|
||||
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
|
||||
print(f" content fontDicts: {one_stream['fontDicts']}")
|
||||
print(f" content Tf/Tj ops ({len(one_stream['textOps'])}):")
|
||||
for op in one_stream["textOps"][:30]:
|
||||
print(f" {op}")
|
||||
|
||||
compare_objs("EDIT-ENTRY", entry_objs, "ONE-CHAR", one_objs)
|
||||
compare_objs("ORIGINAL", orig_objs, "ONE-CHAR", one_objs)
|
||||
|
||||
# Summarize first differences
|
||||
print(f"\n{'='*60}\nFIRST DIFFERENCES SUMMARY\n{'='*60}")
|
||||
print(f" Original run count: {len(orig_objs)}")
|
||||
print(f" Edit-entry run count: {len(entry_objs)}")
|
||||
print(f" One-char run count: {len(one_objs)}")
|
||||
print(f" Original Tj/TJ ops: {sum(1 for o in orig_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" Entry Tj/TJ ops: {sum(1 for o in entry_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" One-char Tj/TJ ops: {sum(1 for o in one_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
|
||||
print(f" Original fontDicts: {orig_stream['fontDicts']}")
|
||||
print(f" Entry fontDicts: {entry_stream['fontDicts']}")
|
||||
print(f" One-char fontDicts: {one_stream['fontDicts']}")
|
||||
|
||||
report = {
|
||||
"pdf": str(pdf_path),
|
||||
"fonts": payloads["fonts"],
|
||||
"orig": orig_objs,
|
||||
"entry": entry_objs,
|
||||
"one": one_objs,
|
||||
"streams": {
|
||||
"orig": orig_stream,
|
||||
"entry": entry_stream,
|
||||
"one": one_stream,
|
||||
},
|
||||
}
|
||||
out_json = out_dir / "forensic_obj_compare.json"
|
||||
out_json.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote {out_json}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
"""Post-reflow PDF object inspection via Python pdfengine."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # type: ignore
|
||||
|
||||
|
||||
def inspect_pdf(path: Path, label: str):
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(path), "")
|
||||
model = doc.get_page(0).extract_document_model()
|
||||
para = model.paragraphs[0]
|
||||
print(f"\n=== {label} ({path.name}) ===")
|
||||
for li, ln in enumerate(para.lines):
|
||||
for ri, r in enumerate(ln.runs):
|
||||
adv_sum = 0.0
|
||||
if r.glyphs:
|
||||
# derive advances from origin_x
|
||||
gs = r.glyphs
|
||||
for i in range(len(gs)):
|
||||
if i + 1 < len(gs):
|
||||
adv_sum += gs[i + 1].origin_x - gs[i].origin_x
|
||||
else:
|
||||
adv_sum += gs[i].bbox_w or r.font_size * 0.5
|
||||
print(f" line{li} run{ri}: text={repr(r.text[:40])} font={r.font_name} size={r.font_size:.3f} w={r.w:.3f} advSum={adv_sum:.3f} glyphs={len(r.glyphs)}")
|
||||
|
||||
|
||||
def main():
|
||||
extract_path = ROOT / "tests" / "edits" / "forensic_extract_out.json"
|
||||
ex = json.loads(extract_path.read_text(encoding="utf-8"))
|
||||
pdf_path = Path(ex["pdf"])
|
||||
inspect_pdf(pdf_path, "ORIGINAL")
|
||||
|
||||
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
for label, key in [("ENTRY", "editEntry"), ("ONE_CHAR", "editOne")]:
|
||||
op = ex["payloads"][key]
|
||||
d = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
|
||||
d.apply_edits(json.dumps(op))
|
||||
out = d.save_full()
|
||||
tmp = ROOT / "tests" / "edits" / f"_forensic_tmp_{key}.pdf"
|
||||
tmp.write_bytes(out)
|
||||
inspect_pdf(tmp, label)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* WASM reflow forensic: run payloads and capture lastLayoutJson.
|
||||
* Usage: node forensic_wasm.mjs <extract.json> [mjsPath]
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "../../");
|
||||
|
||||
const extractPath = process.argv[2] || resolve(here, "forensic_extract_out.json");
|
||||
const mjsPath = process.argv[3] || resolve(repoRoot, "frontend/public/pdfium-engine.mjs");
|
||||
|
||||
if (!existsSync(extractPath)) {
|
||||
console.error(`Missing extract JSON: ${extractPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(mjsPath)) {
|
||||
console.error(`Missing WASM module: ${mjsPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const extract = JSON.parse(readFileSync(extractPath, "utf8"));
|
||||
const pdfBytes = readFileSync(extract.pdf);
|
||||
|
||||
const { default: createModule } = await import(pathToFileURL(mjsPath).href);
|
||||
const wasmPath = mjsPath.replace(/\.mjs$/, ".wasm");
|
||||
const Module = await createModule({
|
||||
locateFile: (path) => (path.endsWith(".wasm") ? wasmPath : path),
|
||||
});
|
||||
|
||||
const ptr = Module._malloc(pdfBytes.length);
|
||||
Module.HEAPU8.set(pdfBytes, ptr);
|
||||
const handle = Module.ccall("loadDocument", "number", ["number", "number"], [ptr, pdfBytes.length]);
|
||||
Module._free(ptr);
|
||||
if (handle < 0) {
|
||||
console.error("loadDocument failed");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function runReflow(label, opJson) {
|
||||
const editsStr = JSON.stringify(opJson);
|
||||
const len = Module.ccall(
|
||||
"previewRender",
|
||||
"number",
|
||||
["number", "number", "number", "string"],
|
||||
[handle, 0, 144, editsStr],
|
||||
);
|
||||
const layoutJson = Module.ccall("lastLayoutJson", "string", [], []);
|
||||
let layout = null;
|
||||
try {
|
||||
layout = JSON.parse(layoutJson || "{}");
|
||||
} catch {
|
||||
layout = { raw: layoutJson };
|
||||
}
|
||||
return { label, renderBytes: len, layout };
|
||||
}
|
||||
|
||||
const results = {
|
||||
editEntryWasm: await runReflow("EDIT_MODE_ENTRY_WASM", extract.payloads.editEntry),
|
||||
editOneWasm: await runReflow("AFTER_ONE_CHAR_WASM", extract.payloads.editOne),
|
||||
};
|
||||
|
||||
writeFileSync(resolve(here, "forensic_wasm_out.json"), JSON.stringify(results, null, 2));
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
|
||||
Module.ccall("freeDocument", null, ["number"], [handle]);
|
||||
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"editEntryWasm": {
|
||||
"label": "EDIT_MODE_ENTRY_WASM",
|
||||
"renderBytes": 10936,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 20.847999572753906,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
12.447998046875,
|
||||
8.896003723144531,
|
||||
8.89599609375,
|
||||
8.896003723144531,
|
||||
8.89599609375,
|
||||
8,
|
||||
8.896003723144531,
|
||||
4.447998046875,
|
||||
4.447998046875,
|
||||
11.552001953125,
|
||||
8.896003723144531,
|
||||
5.3280029296875,
|
||||
3.552001953125,
|
||||
8.89599609375,
|
||||
1.743988037109375
|
||||
],
|
||||
"baselineY": 100,
|
||||
"fontSize": 16,
|
||||
"pageIndex": 0,
|
||||
"text": "Goodbye, world!",
|
||||
"x0": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"editOneWasm": {
|
||||
"label": "AFTER_ONE_CHAR_WASM",
|
||||
"renderBytes": 11454,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 20.847999572753906,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
10.09375,
|
||||
8.4375,
|
||||
8.4375,
|
||||
8.40625,
|
||||
8.32825,
|
||||
7.047,
|
||||
7.961,
|
||||
3.9922500000000003,
|
||||
3.61725,
|
||||
11.289,
|
||||
8.4375,
|
||||
5.57825,
|
||||
3.672,
|
||||
8.40625,
|
||||
5.211,
|
||||
6.92975
|
||||
],
|
||||
"baselineY": 100,
|
||||
"fontSize": 16,
|
||||
"pageIndex": 0,
|
||||
"text": "Goodbye, world!x",
|
||||
"x0": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"editEntryWasm": {
|
||||
"label": "EDIT_MODE_ENTRY_WASM",
|
||||
"renderBytes": 36617,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 72.25299835205078,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
6.721000671386719,
|
||||
6.115997314453125,
|
||||
6.116004943847656,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
2.4420013427734375,
|
||||
5.5,
|
||||
5.5,
|
||||
3.0579986572265625,
|
||||
6.1160125732421875,
|
||||
3.662994384765625,
|
||||
6.115997314453125,
|
||||
7.9420013427734375,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
5.5,
|
||||
3.0579986572265625,
|
||||
2.4420013427734375,
|
||||
6.115997314453125,
|
||||
9.163009643554688,
|
||||
6.115997314453125,
|
||||
5.5,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
5.5,
|
||||
6.115997314453125,
|
||||
3.662994384765625,
|
||||
3.0579986572265625,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
2.4420013427734375,
|
||||
6.1160125732421875,
|
||||
5.5,
|
||||
5.22503662109375
|
||||
],
|
||||
"baselineY": 700,
|
||||
"fontSize": 11,
|
||||
"pageIndex": 0,
|
||||
"text": "The quick brown fox jumps over the lazy",
|
||||
"x0": 72
|
||||
},
|
||||
{
|
||||
"adv": [
|
||||
6.115997314453125,
|
||||
6.116004943847656,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
6.116004943847656,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
3.6630020141601562,
|
||||
3.0579986572265625,
|
||||
3.0579986572265625,
|
||||
6.116004943847656,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
3.662994384765625,
|
||||
2.4420166015625,
|
||||
5.5,
|
||||
6.115997314453125,
|
||||
3.662994384765625,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
5.5,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
5.5,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
5.225006103515625
|
||||
],
|
||||
"baselineY": 686,
|
||||
"fontSize": 11,
|
||||
"pageIndex": 0,
|
||||
"text": "dog near the river bank on a sunny",
|
||||
"x0": 72
|
||||
},
|
||||
{
|
||||
"adv": [
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
3.0580062866210938,
|
||||
6.115997314453125,
|
||||
3.6630020141601562,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
6.116004943847656,
|
||||
6.116004943847656,
|
||||
3.0579986572265625,
|
||||
2.4420013427734375,
|
||||
6.115997314453125,
|
||||
3.0579986572265625,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
3.6630096435546875,
|
||||
2.441986083984375,
|
||||
5.5,
|
||||
3.058013916015625,
|
||||
5.5,
|
||||
6.115997314453125,
|
||||
3.662994384765625,
|
||||
2.4420013427734375,
|
||||
6.115997314453125,
|
||||
6.115997314453125,
|
||||
1.100006103515625
|
||||
],
|
||||
"baselineY": 672,
|
||||
"fontSize": 11,
|
||||
"pageIndex": 0,
|
||||
"text": "afternoon in early spring.",
|
||||
"x0": 72
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"editOneWasm": {
|
||||
"label": "AFTER_ONE_CHAR_WASM",
|
||||
"renderBytes": 33262,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 72.25299835205078,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
5.3604375,
|
||||
5.779296875,
|
||||
5.4731875,
|
||||
2.486859375,
|
||||
5.779296875,
|
||||
5.779296875,
|
||||
2.5244999999999997,
|
||||
4.651453125,
|
||||
5.00053125,
|
||||
2.486859375,
|
||||
5.779296875,
|
||||
3.6578437499999996,
|
||||
5.7578125,
|
||||
7.863281249999999,
|
||||
5.779296875,
|
||||
2.486859375,
|
||||
3.125890625,
|
||||
5.5859375,
|
||||
4.764203125,
|
||||
2.486859375,
|
||||
2.6319218749999997,
|
||||
5.779296875,
|
||||
8.787109375,
|
||||
5.725671875,
|
||||
4.302203125,
|
||||
2.486859375,
|
||||
5.752484375,
|
||||
4.860796875,
|
||||
5.4731875,
|
||||
3.8350468749999997,
|
||||
2.486859375,
|
||||
3.6846562499999997,
|
||||
5.779296875,
|
||||
5.4731875,
|
||||
2.486859375,
|
||||
2.5244999999999997,
|
||||
5.269,
|
||||
4.248578125,
|
||||
4.979046875,
|
||||
4.764203125,
|
||||
2.486859375,
|
||||
5.779296875,
|
||||
5.80078125,
|
||||
5.177734375,
|
||||
2.486859375,
|
||||
5.779296875,
|
||||
5.4731875,
|
||||
5.269,
|
||||
3.8350468749999997,
|
||||
2.486859375,
|
||||
3.6846562499999997,
|
||||
5.779296875,
|
||||
5.4731875
|
||||
],
|
||||
"baselineY": 700,
|
||||
"fontSize": 11,
|
||||
"pageIndex": 0,
|
||||
"text": "The quick brown fox jumps over the lazyx dog near the",
|
||||
"x0": 72
|
||||
},
|
||||
{
|
||||
"adv": [
|
||||
3.8350468749999997,
|
||||
2.5244999999999997,
|
||||
4.860796875,
|
||||
5.4731875,
|
||||
3.8350468749999997,
|
||||
2.486859375,
|
||||
5.779296875,
|
||||
5.269,
|
||||
5.779296875,
|
||||
5.00053125,
|
||||
2.486859375,
|
||||
5.80078125,
|
||||
5.779296875,
|
||||
2.486859375,
|
||||
5.269,
|
||||
2.486859375,
|
||||
4.302203125,
|
||||
5.779296875,
|
||||
5.779296875,
|
||||
5.57528125,
|
||||
4.979046875,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705,
|
||||
4.2531359953703705
|
||||
],
|
||||
"baselineY": 686,
|
||||
"fontSize": 11,
|
||||
"pageIndex": 0,
|
||||
"text": "river bank on a sunny afternoon in early spring.",
|
||||
"x0": 72
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"editEntryWasm": {
|
||||
"label": "EDIT_MODE_ENTRY_WASM",
|
||||
"renderBytes": 10936,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 20.847999572753906,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
12.447998046875,
|
||||
8.896003723144531,
|
||||
8.89599609375,
|
||||
8.896003723144531,
|
||||
8.89599609375,
|
||||
8,
|
||||
8.896003723144531,
|
||||
4.447998046875,
|
||||
4.447998046875,
|
||||
11.552001953125,
|
||||
8.896003723144531,
|
||||
5.3280029296875,
|
||||
3.552001953125,
|
||||
8.89599609375,
|
||||
1.743988037109375
|
||||
],
|
||||
"baselineY": 100,
|
||||
"fontSize": 16,
|
||||
"pageIndex": 0,
|
||||
"text": "Goodbye, world!",
|
||||
"x0": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"editOneWasm": {
|
||||
"label": "AFTER_ONE_CHAR_WASM",
|
||||
"renderBytes": 11454,
|
||||
"layout": {
|
||||
"anchorPage": 0,
|
||||
"columnLeft": 20.847999572753906,
|
||||
"lines": [
|
||||
{
|
||||
"adv": [
|
||||
10.09375,
|
||||
8.4375,
|
||||
8.4375,
|
||||
8.40625,
|
||||
8.32825,
|
||||
7.047,
|
||||
7.961,
|
||||
3.9922500000000003,
|
||||
3.61725,
|
||||
11.289,
|
||||
8.4375,
|
||||
5.57825,
|
||||
3.672,
|
||||
8.40625,
|
||||
5.211,
|
||||
6.92975
|
||||
],
|
||||
"baselineY": 100,
|
||||
"fontSize": 16,
|
||||
"pageIndex": 0,
|
||||
"text": "Goodbye, world!x",
|
||||
"x0": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -59,7 +59,7 @@ target_link_options(pdfengine_wasm PRIVATE
|
||||
"-sALLOW_MEMORY_GROWTH=1"
|
||||
"-sWASM_BIGINT"
|
||||
"-sSTACK_SIZE=5MB"
|
||||
"-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_previewRenderPaginated','_lastRegionCount','_lastRegionPtr','_lastRegionW','_lastRegionH','_lastRegionPage','_lastRegionYTop','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_registerAuxFont','_freeDocument','_engineBuildInfo','_malloc','_free']"
|
||||
"-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_previewRenderPaginated','_lastRegionCount','_lastRegionPtr','_lastRegionW','_lastRegionH','_lastRegionPage','_lastRegionYTop','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_registerAuxFont','_freeDocument','_engineBuildInfo','_debugValidateLayout','_malloc','_free']"
|
||||
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']"
|
||||
)
|
||||
|
||||
|
||||
@@ -164,4 +164,13 @@ EMSCRIPTEN_KEEPALIVE void freeDocument(int handle) { g_docs.erase(handle); }
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }
|
||||
|
||||
std::string g_lastValidation;
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE const char* debugValidateLayout(int handle, int pageIndex, const char* jsonStr) {
|
||||
auto it = g_docs.find(handle);
|
||||
if (it == g_docs.end() || !jsonStr) return "{}";
|
||||
g_lastValidation = it->second.doc->validateLayout(pageIndex, jsonStr);
|
||||
return g_lastValidation.c_str();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user