This commit is contained in:
saqib mir
2026-07-30 16:48:46 +05:30
parent 94b7f28d9c
commit ca3d124b0d
55 changed files with 5015 additions and 182 deletions
+5 -1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

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

After

Width:  |  Height:  |  Size: 646 B

+25 -27
View File
@@ -14,6 +14,7 @@ add_library(pdfengine OBJECT
src/parser/content_stream_parser.cpp
src/parser/decoration_builder.cpp
src/text/selection.cpp
src/text/text_layout_engine.cpp
src/fonts/face/font_face.cpp
src/fonts/face/free_type_manager.cpp
src/fonts/loader/font_resolver.cpp
@@ -32,6 +33,30 @@ add_library(pdfengine OBJECT
src/fonts/pdf_fonts/encoding/encoding.cpp
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
src/fonts/pdf_fonts/encoding/cjk_collection_db.cpp
src/parser/pdfium_loader.cpp
src/parser/pdfium_document.cpp
src/parser/pdfium_internal.cpp
src/parser/pdfium_reflow.cpp
src/parser/pdfium_page.cpp
src/parser/pdfium_page_model.cpp
src/parser/pdfium_fonts.cpp
src/parser/pdfium_edit_session.cpp
src/parser/pdfium_edit.cpp
src/parser/pdfium_edit_replace.cpp
src/parser/pdfium_edit_reflow.cpp
src/parser/pdfium_edit_annotations.cpp
src/parser/pdfium_edit_pages.cpp
src/parser/pdfium_edit_images.cpp
src/qpdf/qpdf_extractor.cpp
src/qpdf/qpdf_font_extractor.cpp
src/qpdf/qpdf_writer.cpp
src/qpdf/qpdf_resource_resolver.cpp
src/core/image_decoder.cpp
src/parser/lexer.cpp
src/parser/parser.cpp
src/parser/content_builder.cpp
src/serializer/content_serializer.cpp
src/serializer/ast_serializer.cpp
)
add_library(pdfengine::pdfengine ALIAS pdfengine)
set_target_properties(pdfengine PROPERTIES POSITION_INDEPENDENT_CODE ON)
@@ -56,21 +81,6 @@ target_link_libraries(pdfengine
)
if(PDFENGINE_WITH_PDFIUM)
target_sources(pdfengine PRIVATE
src/parser/pdfium_loader.cpp
src/parser/pdfium_document.cpp
src/parser/pdfium_internal.cpp
src/parser/pdfium_reflow.cpp
src/parser/pdfium_page.cpp
src/parser/pdfium_page_model.cpp
src/parser/pdfium_fonts.cpp
src/parser/pdfium_edit.cpp
src/parser/pdfium_edit_replace.cpp
src/parser/pdfium_edit_reflow.cpp
src/parser/pdfium_edit_annotations.cpp
src/parser/pdfium_edit_pages.cpp
src/parser/pdfium_edit_images.cpp
)
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_PDFIUM)
# PDFium statically bundles its own libjpeg, zlib, etc. which conflicts with vcpkg.
@@ -98,18 +108,6 @@ if(PDFENGINE_WITH_QPDF)
if(NOT TARGET jpeg)
add_library(jpeg ALIAS JPEG::JPEG)
endif()
target_sources(pdfengine PRIVATE
src/qpdf/qpdf_extractor.cpp
src/qpdf/qpdf_font_extractor.cpp
src/qpdf/qpdf_writer.cpp
src/qpdf/qpdf_resource_resolver.cpp
src/core/image_decoder.cpp
src/parser/lexer.cpp
src/parser/parser.cpp
src/parser/content_builder.cpp
src/serializer/content_serializer.cpp
src/serializer/ast_serializer.cpp
)
target_link_libraries(pdfengine PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
target_compile_definitions(pdfengine PUBLIC PDFENGINE_WITH_QPDF)
endif()
+93
View File
@@ -0,0 +1,93 @@
#pragma once
#include <string>
#include <vector>
#include <memory>
#include <expected>
#include <cstdint>
#include "pdfengine/pdf_document.hpp"
namespace pdfengine {
struct Rect {
double x = 0.0;
double y = 0.0;
double width = 0.0;
double height = 0.0;
};
using RectList = std::vector<Rect>;
struct ParagraphBounds {
Rect rect;
};
struct GlyphInfo {
uint32_t glyphId = 0;
uint32_t cluster = 0;
double x = 0.0;
double y = 0.0;
double advance = 0.0;
double width = 0.0;
double ascent = 0.0;
double descent = 0.0;
std::string text;
};
struct LineInfo {
int id = 0;
Rect rect;
double baselineY = 0.0;
};
struct CaretState {
int offset = 0;
Rect rect;
};
// Internal comprehensive layout state
struct LayoutResult {
ParagraphBounds bounds;
std::vector<LineInfo> lines;
std::vector<GlyphInfo> glyphs;
std::vector<Rect> selectionRects;
CaretState caret;
RectList dirtyRects;
};
// Stable, lightweight view for WASM export
struct LayoutView {
std::vector<LineInfo> lines;
std::vector<GlyphInfo> glyphs;
CaretState caret;
RectList dirtyRects;
};
class EditSession {
public:
virtual ~EditSession() = default;
static std::shared_ptr<EditSession> StartEditSession(
std::shared_ptr<PdfDocument> doc,
int pageIndex,
const std::string& paraId
);
// Returns a stable, lightweight view of the layout
virtual LayoutView GetLayoutView() const = 0;
// Geometry queries against the cached layout
// offset represents the caret insertion point (between glyphs)
virtual int HitTest(double x, double y) const = 0;
virtual Rect GetCaretRect(int offset) const = 0;
virtual std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const = 0;
// Mutates paragraph, marks cache dirty, recalculates
virtual void ApplyEdit(const std::string& editOpJson) = 0;
// Rendering & Lifecycle
virtual std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const = 0;
virtual bool CommitEdit() = 0;
virtual void CancelEdit() = 0;
};
} // namespace pdfengine
+11 -1
View File
@@ -1,12 +1,21 @@
#pragma once
#include <expected>
#include <memory>
#include <string>
#include <vector>
#include <cstdint>
#include <array>
#if __has_include(<expected>)
#include <expected>
#elif __has_include(<tl/expected.hpp>)
#include <tl/expected.hpp>
namespace std {
using tl::expected;
using tl::unexpected;
}
#endif
namespace pdfengine {
enum class EngineError {
@@ -276,6 +285,7 @@ public:
virtual std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) = 0;
[[nodiscard]] virtual std::string validateLayout(int pageIndex, const std::string& jsonStr) { (void)pageIndex; (void)jsonStr; return "{}"; }
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
[[nodiscard]] virtual bool lastReflowOverflowed() const { return false; }
@@ -0,0 +1,66 @@
#pragma once
#include "pdfengine/edit_session.hpp"
#include "fonts/shaping/hb_shaper.hpp"
#include "fonts/face/font_face.hpp"
#include <string>
#include <vector>
#include <memory>
namespace pdfengine::text {
struct LayoutConstraints {
double columnLeft = 0.0;
double columnRight = 0.0;
double firstBaselineY = 0.0;
double leading = 0.0;
std::string align = "left";
double hangingIndent = 0.0;
};
class LineBreaker {
public:
LineBreaker(const LayoutConstraints& constraints);
// Processes a stream of shaped glyphs and applies line breaking
void ProcessRun(
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
const std::string& runText,
double fontSize,
const fonts::FontFace& face,
double scale
);
// Finalizes the layout and populates the LayoutResult
void Finalize(LayoutResult& outLayout);
private:
LayoutConstraints constraints_;
double currentX_;
double currentY_;
// Internal state for word wrapping
std::vector<GlyphInfo> currentLineGlyphs_;
std::vector<LineInfo> finishedLines_;
std::vector<GlyphInfo> allGlyphs_;
void CommitLine();
};
class TextLayoutEngine {
public:
TextLayoutEngine();
// Computes layout for a given text run (simplified for single font for now)
LayoutResult ComputeLayout(
const std::string& text,
const LayoutConstraints& constraints,
fonts::FontFace& fontFace,
double fontSize
);
private:
fonts::HbShaper shaper_;
};
} // namespace pdfengine::text
+3 -2
View File
@@ -103,6 +103,7 @@ public:
void invalidateCaches();
std::expected<std::vector<InvalidatedRegion>, EngineError> applyEdits(const std::string& editsJson) override;
std::string validateLayout(int pageIndex, const std::string& jsonStr) override;
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
@@ -151,13 +152,14 @@ private:
double fontSize, const std::vector<uint32_t>& codepoints,
const std::vector<int>& srcObjects = {},
FPDF_FONT reuseFont = nullptr);
#endif
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
mutable std::mutex reconstructedFontsMutex_;
void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) override;
std::expected<std::vector<uint8_t>, EngineError> getReconstructedFontData(const std::string& internalFontId) override;
const fonts::pdf_fonts::ReconstructedFont* lookupReconFont(const std::string& internalFontId);
#ifdef PDFENGINE_WITH_QPDF
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
#endif
@@ -180,7 +182,6 @@ private:
std::expected<void, EngineError> applyOp_pageReorder(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex);
#endif
};
std::vector<unsigned short> utf8_to_utf16le(const std::string& utf8);
+11 -4
View File
@@ -133,19 +133,23 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
(void)op;
if (pageCount() <= 1) {
spdlog::error("Cannot delete the only page in the document");
return std::unexpected(EngineError::Unknown);
}
FPDFPage_Delete(doc_, pageIndex);
return {};
}
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_reorder operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
@@ -167,7 +171,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
return std::unexpected(EngineError::Unknown);
}
return {};
}
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
}
+166 -43
View File
@@ -1,5 +1,7 @@
#include "parser/pdfium_internal.hpp"
#include "fonts/face/free_type_manager.hpp"
#include "fonts/face/font_face.hpp"
#include "pdfengine/text_layout_engine.hpp"
namespace pdfengine::parser {
std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) {
@@ -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
}
}
+180
View File
@@ -0,0 +1,180 @@
#include "pdfengine/edit_session.hpp"
#include "parser/pdfium_edit_session.hpp"
#include "parser/pdfium_document.hpp"
#include <spdlog/spdlog.h>
#include <cmath>
namespace pdfengine {
std::shared_ptr<EditSession> EditSession::StartEditSession(
std::shared_ptr<PdfDocument> doc,
int pageIndex,
const std::string& paraId
) {
if (!doc) {
spdlog::error("StartEditSession: null document");
return nullptr;
}
return std::make_shared<parser::PdfiumEditSession>(doc, pageIndex, paraId);
}
} // namespace pdfengine
namespace pdfengine::parser {
PdfiumEditSession::PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId)
: doc_(doc), pageIndex_(pageIndex), paraId_(paraId) {
spdlog::info("Started edit session on page {} for para {}", pageIndex_, paraId_);
// In a full implementation, we'd extract text, bounds, and fonts from doc_ using paraId_.
// For now, populate dummy state for unit testing the architecture.
currentText_ = "Mock initial text for paragraph.";
constraints_.columnLeft = 100.0;
constraints_.columnRight = 400.0;
constraints_.firstBaselineY = 700.0;
constraints_.leading = 14.0;
isDirty_ = true;
}
PdfiumEditSession::~PdfiumEditSession() {}
void PdfiumEditSession::RebuildLayoutIfNeeded() const {
if (!isDirty_) return;
if (currentFace_) {
cachedLayout_ = layoutEngine_.ComputeLayout(
currentText_, constraints_, *currentFace_, currentFontSize_
);
} else {
// Mock fallback if face not loaded yet
cachedLayout_.lines.clear();
cachedLayout_.glyphs.clear();
GlyphInfo g;
g.x = constraints_.columnLeft;
g.y = constraints_.firstBaselineY;
g.width = 5.0;
g.advance = 6.0;
g.ascent = 10.0;
g.descent = -2.0;
g.cluster = 0;
if (!currentText_.empty()) {
LineInfo line;
line.id = 0;
line.baselineY = constraints_.firstBaselineY;
for (size_t i = 0; i < currentText_.size(); ++i) {
g.cluster = static_cast<uint32_t>(i);
g.text = currentText_.substr(i, 1);
cachedLayout_.glyphs.push_back(g);
g.x += g.advance;
}
line.rect = Rect{constraints_.columnLeft, constraints_.firstBaselineY - g.ascent,
g.x - constraints_.columnLeft, g.ascent - g.descent};
cachedLayout_.lines.push_back(line);
}
}
isDirty_ = false;
}
LayoutView PdfiumEditSession::GetLayoutView() const {
RebuildLayoutIfNeeded();
return LayoutView{
cachedLayout_.lines,
cachedLayout_.glyphs,
cachedLayout_.caret,
cachedLayout_.dirtyRects
};
}
int PdfiumEditSession::HitTest(double x, double y) const {
RebuildLayoutIfNeeded();
// Find closest glyph by checking boundary/midpoint logic
int closestCluster = 0;
double minDistanceSq = 1e9;
for (const auto& glyph : cachedLayout_.glyphs) {
// Find distance to bounding box
double dx = 0.0;
if (x < glyph.x) dx = glyph.x - x;
else if (x > glyph.x + glyph.advance) dx = x - (glyph.x + glyph.advance);
double gy = glyph.y - glyph.ascent;
double gh = glyph.ascent - glyph.descent;
double dy = 0.0;
if (y < gy) dy = gy - y;
else if (y > gy + gh) dy = y - (gy + gh);
double distSq = dx * dx + dy * dy;
if (distSq < minDistanceSq) {
minDistanceSq = distSq;
// Midpoint logic: if past midpoint, insertion point is after this glyph
if (x > glyph.x + glyph.advance / 2.0) {
closestCluster = glyph.cluster + 1;
} else {
closestCluster = glyph.cluster;
}
}
}
return closestCluster;
}
Rect PdfiumEditSession::GetCaretRect(int offset) const {
RebuildLayoutIfNeeded();
if (cachedLayout_.glyphs.empty()) {
return Rect{constraints_.columnLeft, constraints_.firstBaselineY - 10.0, 1.0, 12.0};
}
// Caret is positioned *between* glyphs at the insertion point `offset`.
// If offset matches a cluster, place it at the left edge of that glyph.
for (const auto& glyph : cachedLayout_.glyphs) {
if (glyph.cluster == static_cast<uint32_t>(offset)) {
return Rect{glyph.x, glyph.y - glyph.ascent, 1.0, glyph.ascent - glyph.descent};
}
}
// If offset is beyond all glyphs, place it at the right edge of the last glyph.
const auto& lastGlyph = cachedLayout_.glyphs.back();
return Rect{lastGlyph.x + lastGlyph.advance, lastGlyph.y - lastGlyph.ascent, 1.0, lastGlyph.ascent - lastGlyph.descent};
}
std::vector<Rect> PdfiumEditSession::GetSelectionRects(int startOffset, int endOffset) const {
RebuildLayoutIfNeeded();
(void)startOffset; (void)endOffset;
return {};
}
void PdfiumEditSession::ApplyEdit(const std::string& editOpJson) {
// Phase 1.1: Modify state and mark dirty
spdlog::info("ApplyEdit called with {}", editOpJson);
// Very naive edit implementation for testing
// e.g. editOpJson = "append:A"
if (editOpJson.find("append:") == 0) {
currentText_ += editOpJson.substr(7);
}
isDirty_ = true;
}
std::vector<uint8_t> PdfiumEditSession::RenderDirtyRegion(int dpi, const Rect& region) const {
(void)dpi; (void)region;
return {};
}
bool PdfiumEditSession::CommitEdit() {
spdlog::info("CommitEdit called");
return true;
}
void PdfiumEditSession::CancelEdit() {
spdlog::info("CancelEdit called");
}
} // namespace pdfengine::parser
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "pdfengine/edit_session.hpp"
#include "pdfengine/text_layout_engine.hpp"
#include "parser/pdfium_internal.hpp"
#include <memory>
#include <string>
namespace pdfengine::parser {
class PdfiumEditSession : public EditSession {
public:
PdfiumEditSession(std::shared_ptr<PdfDocument> doc, int pageIndex, const std::string& paraId);
~PdfiumEditSession() override;
LayoutView GetLayoutView() const override;
int HitTest(double x, double y) const override;
Rect GetCaretRect(int offset) const override;
std::vector<Rect> GetSelectionRects(int startOffset, int endOffset) const override;
void ApplyEdit(const std::string& editOpJson) override;
std::vector<uint8_t> RenderDirtyRegion(int dpi, const Rect& region) const override;
bool CommitEdit() override;
void CancelEdit() override;
private:
void RebuildLayoutIfNeeded() const;
std::shared_ptr<PdfDocument> doc_;
int pageIndex_;
std::string paraId_;
// Live editing state
std::string currentText_;
text::LayoutConstraints constraints_;
std::shared_ptr<fonts::FontFace> currentFace_;
double currentFontSize_ = 12.0;
// Layout cache and engine
mutable text::TextLayoutEngine layoutEngine_;
mutable LayoutResult cachedLayout_;
mutable bool isDirty_ = true;
};
} // namespace pdfengine::parser
+75 -3
View File
@@ -2,8 +2,8 @@
namespace pdfengine::parser {
#ifdef PDFENGINE_WITH_PDFIUM
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
#ifdef PDFENGINE_WITH_PDFIUM
fonts::pdf_fonts::ReconstructedFont rf;
if (!sfnt.empty()) {
rf.ok = true;
@@ -14,16 +14,25 @@ void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const st
}
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
reconstructedFonts_[internalFontId] = std::move(rf);
#else
(void)internalFontId; (void)sfnt;
#endif
}
std::expected<std::vector<uint8_t>, EngineError>
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
#ifdef PDFENGINE_WITH_PDFIUM
const auto* rf = lookupReconFont(internalFontId);
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
return std::unexpected(EngineError::Unknown);
#else
(void)internalFontId;
return std::unexpected(EngineError::Unknown);
#endif
}
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
#ifdef PDFENGINE_WITH_PDFIUM
#ifdef PDFENGINE_WITH_QPDF
return getReconstructedEmbeddedFont(internalFontId);
#else
@@ -31,8 +40,11 @@ const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const
auto it = reconstructedFonts_.find(internalFontId);
return it != reconstructedFonts_.end() ? &it->second : nullptr;
#endif
}
#else
(void)internalFontId;
return nullptr;
#endif
}
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
const fonts::pdf_fonts::ReconstructedFont*
@@ -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
+26
View File
@@ -136,6 +136,9 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
const auto* bgra = static_cast<const uint8_t*>(FPDFBitmap_GetBuffer(bitmap));
const int stride = FPDFBitmap_GetStride(bitmap);
std::vector<uint8_t> rgba(static_cast<size_t>(w) * regionH * 4);
bool allAlpha255 = true;
size_t non255Count = 0;
for (int y = 0; y < regionH; ++y) {
const uint8_t* src = bgra + static_cast<size_t>(y) * stride;
uint8_t* dst = rgba.data() + static_cast<size_t>(y) * w * 4;
@@ -144,8 +147,31 @@ std::expected<PageImage, EngineError> PdfiumPage::renderRegionRaw(int dpi, doubl
dst[x * 4 + 1] = src[x * 4 + 1];
dst[x * 4 + 2] = src[x * 4 + 0];
dst[x * 4 + 3] = src[x * 4 + 3];
if (src[x * 4 + 3] != 255) {
allAlpha255 = false;
non255Count++;
}
}
}
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 1. Region width: {}, height: {} (dpi: {}, yTopPt: {:.2f}, heightPt: {:.2f}, pagePt: {:.2f}x{:.2f})",
w, regionH, dpi, yTopPt, heightPt, width(), height());
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 2. Initialized with opaque white fill (0xFFFFFFFF): TRUE (via FPDFBitmap_FillRect)");
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 3. Content scope: Entire page vertical slice from yTopPx={} to yTopPx+regionH={} (renders ALL page objects in slice, not just edited paragraph)",
yTopPx, yTopPx + regionH);
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] 4. Alpha transparency check: allAlpha255={}, non255Count={}",
allAlpha255 ? "TRUE (100% opaque, alpha=255 everywhere)" : "FALSE", non255Count);
std::vector<uint8_t> pngBytes = encodeBgraToPng(bgra, w, regionH, stride);
if (!pngBytes.empty()) {
FILE* f = fopen("debug_preview_region.png", "wb");
if (f) {
fwrite(pngBytes.data(), 1, pngBytes.size(), f);
fclose(f);
spdlog::error("[RENDER_REGION_RAW_INSTRUMENTATION] Saved preview bitmap to debug_preview_region.png (size: {} bytes)", pngBytes.size());
}
}
FPDFBitmap_Destroy(bitmap);
return PageImage{w, regionH, std::move(rgba)};
#else
+13
View File
@@ -87,6 +87,19 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
if (it != objToIndex.end()) {
g.pageObjectIndex = it->second;
}
FS_MATRIX mtx;
if (FPDFPageObj_GetMatrix(textObj, &mtx)) {
double scaleY1 = std::hypot(static_cast<double>(mtx.c), static_cast<double>(mtx.d));
double scaleY2 = std::hypot(static_cast<double>(mtx.a), static_cast<double>(mtx.b));
double scaleY = (std::max)(scaleY1, scaleY2);
if (scaleY > 0.001) {
double trueSize = g.fontSize * scaleY;
if (trueSize > 0.1) g.fontSize = trueSize;
}
}
}
if (g.bboxH > g.fontSize * 0.8) {
g.fontSize = (std::max)(g.fontSize, g.bboxH);
}
documentGlyphs.push_back(g);
+135
View File
@@ -0,0 +1,135 @@
#include "pdfengine/text_layout_engine.hpp"
#include <cmath>
namespace pdfengine::text {
LineBreaker::LineBreaker(const LayoutConstraints& constraints)
: constraints_(constraints),
currentX_(constraints.columnLeft),
currentY_(constraints.firstBaselineY) {}
void LineBreaker::ProcessRun(
const std::vector<fonts::ShapedGlyph>& shapedGlyphs,
const std::string& runText,
double fontSize,
const fonts::FontFace& face,
double scale
) {
// Basic line breaker mimicking the HarfBuzz scaling loop from pdfium_edit_reflow
// In a full implementation, we'd find spaces and wrap. For now, just lay out linearly
// and wrap aggressively at columnRight.
FT_Face ftFace = face.getFace();
double upm = ftFace->units_per_EM;
double fontAscent = (ftFace->ascender / upm) * 1000.0 * scale;
double fontDescent = (ftFace->descender / upm) * 1000.0 * scale;
for (size_t i = 0; i < shapedGlyphs.size(); ++i) {
const auto& glyph = shapedGlyphs[i];
// Very rudimentary word wrap for demonstration
if (currentX_ + glyph.advanceX * scale > constraints_.columnRight && !currentLineGlyphs_.empty()) {
CommitLine();
}
GlyphInfo info;
info.glyphId = glyph.glyphIndex;
info.cluster = glyph.clusterIndex;
info.x = currentX_ + glyph.offsetX * scale;
info.y = currentY_ + glyph.offsetY * scale;
info.advance = glyph.advanceX * scale;
info.width = info.advance; // Simplification
info.ascent = fontAscent;
info.descent = fontDescent;
// Extract substring for this cluster if possible
if (info.cluster < runText.size()) {
info.text = runText.substr(info.cluster, 1);
}
currentLineGlyphs_.push_back(info);
currentX_ += info.advance;
}
}
void LineBreaker::CommitLine() {
if (currentLineGlyphs_.empty()) return;
LineInfo line;
line.id = static_cast<int>(finishedLines_.size());
line.baselineY = currentY_;
if (!currentLineGlyphs_.empty()) {
line.rect.x = currentLineGlyphs_.front().x;
line.rect.y = currentY_ - currentLineGlyphs_.front().ascent;
line.rect.width = currentX_ - line.rect.x;
line.rect.height = currentLineGlyphs_.front().ascent - currentLineGlyphs_.front().descent;
}
finishedLines_.push_back(line);
for (auto& g : currentLineGlyphs_) {
allGlyphs_.push_back(g);
}
currentLineGlyphs_.clear();
// Advance to next line
currentX_ = constraints_.columnLeft;
currentY_ -= constraints_.leading; // Assuming standard PDF Y-up coordinates
}
void LineBreaker::Finalize(LayoutResult& outLayout) {
CommitLine(); // Commit any remaining glyphs
outLayout.lines = std::move(finishedLines_);
outLayout.glyphs = std::move(allGlyphs_);
// Compute bounding box
if (!outLayout.lines.empty()) {
double minX = outLayout.lines.front().rect.x;
double maxX = minX;
double minY = outLayout.lines.front().rect.y;
double maxY = minY + outLayout.lines.front().rect.height;
for (const auto& line : outLayout.lines) {
if (line.rect.x < minX) minX = line.rect.x;
if (line.rect.x + line.rect.width > maxX) maxX = line.rect.x + line.rect.width;
if (line.rect.y < minY) minY = line.rect.y;
if (line.rect.y + line.rect.height > maxY) maxY = line.rect.y + line.rect.height;
}
outLayout.bounds.rect.x = minX;
outLayout.bounds.rect.y = minY;
outLayout.bounds.rect.width = maxX - minX;
outLayout.bounds.rect.height = maxY - minY;
}
}
TextLayoutEngine::TextLayoutEngine() = default;
LayoutResult TextLayoutEngine::ComputeLayout(
const std::string& text,
const LayoutConstraints& constraints,
fonts::FontFace& fontFace,
double fontSize
) {
LayoutResult result;
if (text.empty() || constraints.columnRight <= constraints.columnLeft) {
return result;
}
// 1000 is the standard reference size used in HarfBuzz scaling in this engine
constexpr unsigned int kRefSize = 1000;
auto shapedGlyphs = shaper_.shapeRun(text, fontFace, kRefSize);
double scale = fontSize / static_cast<double>(kRefSize);
LineBreaker breaker(constraints);
breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale);
breaker.Finalize(result);
return result;
}
} // namespace pdfengine::text
+1
View File
@@ -9,6 +9,7 @@ add_executable(pdfengine_smoke
document_load_test.cpp
page_render_test.cpp
document_edit_test.cpp
edit_session_test.cpp
font_diagnostics_test.cpp
text_encoding_test.cpp
skia_renderer_test.cpp
+57
View File
@@ -0,0 +1,57 @@
#include "document_test_helpers.hpp"
#include "pdfengine/pdf_document.hpp"
#include "pdfengine/edit_session.hpp"
#include <string>
#include <vector>
namespace pdfengine {
TEST(EditSessionTest, StartEditSessionAndBasics) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
auto session = EditSession::StartEditSession(doc, 0, "para_1");
ASSERT_NE(session, nullptr);
// Initial Layout check (Mocked initial text: "Mock initial text for paragraph.")
auto layout = session->GetLayoutView();
EXPECT_FALSE(layout.lines.empty());
EXPECT_FALSE(layout.glyphs.empty());
// HitTest against mocked coordinates
// We mocked columnLeft at 100.0, advance 6.0 per char
// Char 0 at x=100.0
int hit = session->HitTest(102.0, 700.0);
EXPECT_EQ(hit, 0); // Closest to first cluster
// Char 1 at x=106.0
hit = session->HitTest(108.0, 700.0);
EXPECT_EQ(hit, 1); // Closest to second cluster
// GetCaretRect
Rect caret0 = session->GetCaretRect(0);
EXPECT_EQ(caret0.x, 100.0);
EXPECT_EQ(caret0.y, 690.0); // 700 - 10(ascent)
Rect caret1 = session->GetCaretRect(1);
EXPECT_EQ(caret1.x, 106.0);
// ApplyEdit sets dirty flag
session->ApplyEdit("append:!");
auto layout2 = session->GetLayoutView();
EXPECT_GT(layout2.glyphs.size(), layout.glyphs.size()); // Appended '!'
bool committed = session->CommitEdit();
EXPECT_TRUE(committed);
}
} // namespace pdfengine
+6 -1
View File
@@ -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}`;
+9
View File
@@ -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 || '{}';
}
-2
View File
@@ -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;
+173 -42
View File
@@ -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',
}}
/>
</>
+50 -48
View File
@@ -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`,
+5 -2
View File
@@ -26,6 +26,7 @@ def render_page(
rotation: int = 0,
render_mode: RenderMode = RenderMode.NORMAL
) -> Response:
print(f"[RENDER] document_id={document_id} page_index={page_index} zoom={zoom} dpi={dpi}")
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -202,9 +203,11 @@ def extract_page_text(document_id: str, page_index: Annotated[int, Path(ge=0)]):
@compat_router.get("/render/{document_id}")
def render_page_compat(
request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0
request: Request, document_id: str, page: int = 0, zoom: float = 1.0, rotation: int = 0, dpi: int | None = None
) -> Response:
dpi = int(96 * zoom)
if dpi is None:
dpi = int(96 * zoom)
print(f"[RENDER_COMPAT] document_id={document_id} page={page} zoom={zoom} dpi={dpi}")
return render_page(request, document_id, page, dpi, zoom, rotation, RenderMode.NORMAL)
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+42
View File
@@ -0,0 +1,42 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
8 0 obj
<</Filter/FlateDecode/Length 237>>stream
xœÅ“=kÃ0EwýŠ;6Cä+É–-0†|Ø…@! ¡Sj')mMœ!¿¤…*Â[îô÷<ÞI×Yßã3 {(\@,@<‹¤ÙÔ
û3’å¼YNÖ”er|ºëú¡};wã®víørè_ÚQUa:Ÿ‰Ó÷ª¯åSõ‘”ÑÒºÌ9‡\þI³i”†ï@øÛ2O«Gøj•¨ùÚþ_DäSR§¤ðm¯®þ5©ü,¢ZIM2½Íý“&-nãÞ™_ع‰Æ.¬´¹%M¨ºŽ‡§$sÁöñÌç¡oË~Z_‰w‘UAÆ
endstream
endobj
9 0 obj
[ 8 0 R ]
endobj
xref
0 4
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
6 4
0000000269 00000 n
0000000312 00000 n
0000000403 00000 n
0000000712 00000 n
trailer
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
startxref
740
%%EOF
@@ -0,0 +1,42 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 9 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
8 0 obj
<</Filter/FlateDecode/Length 169>>stream
xœ­ŽK ‚@E÷ó+î²æ|ãc
Dð5¨ÌBˆV¦fT¢.üûQA-Vq7guîÇcÅ/[fp¤à83S• ¡`f±Ê‚"€ç™ÝqÑôc}›ºÊhú±ªùÔ_ê¥ï#Œ#6|Toy¨AO&‰•»œ¤àÐW˜ªTÐ 8ôˆ½çØ®#×ÒöÐ)œ%»ˆý¯BŠ_ï–t\!-×ù*ÈÙ,·J
endstream
endobj
9 0 obj
[ 8 0 R ]
endobj
xref
0 4
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
6 4
0000000269 00000 n
0000000312 00000 n
0000000403 00000 n
0000000644 00000 n
trailer
<</Root 1 0 R /Size 10/ID[<3B9542415021229C68B8A7743652C304><3B9542415021229C68B8A7743652C304>]>>
startxref
672
%%EOF
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
4 0 obj
<</Filter/FlateDecode/Length 832>>stream
xœÅšMkTQ †÷ó+ÎR¦ù:9 ”B?fAP™EA\ˆ_(¨Xþ}¹Ó:-Âäº0séfV÷áMrNòžôÇ
Ûô÷êi»ýqó©QûÕ°=kؾ¬N6×kjŸ~¶“W›ç¯ÎÛééÉç÷>~¿ùðíççwOÞ
Žw6äñÙY»¸º\ý¸ÿÎþËÛF»ßÔÈ",¢Ùà¶ýÚN6×jDmû±aÛÞ´×§¼>{Ó¶ÏÚzÛ^®ÖÏ/Wÿ-àN ÙFy Ö3r•æÑAX2rT Ìðà"°u誈)«à8 —"x'PïœÁ«ò­1¯Ü.«àÄ1b,QpÀ¦‘^jFUpÒ@Ìrn½Î1úLΫ®6&è"ˆé½ZUíÄ =ÓvR¥ X9" û¦
Ž@l3Õ^$š)ö¢¨‚¨/p¿¸ /s€†ä•6´†=ˆz~§›±Ó`Ýå,ê< v;rûfA³”]u²˜¸lr Ñ”=ŠƒÍ±‹fEFµ™˜WÍ,¡À¾3GÏ7¹ ÌÙU­sqξ¨b h,sûéüøÅf´ŸÎë¬A×½=|Ä«àê ìˆ©ò*k  1EÝ›÷Fø°ò*k Îùµ^•s`wvðø9ß92ž9ç^åÈtèLØ‹FU"‚Ñe¡œc“%ytfDY ØB@±ç¯jhÙavÑ[òWtôf¤‚‹ÎÃdA£ Äc»¢>€çØçUlb OØeNTÌ)g½å²t9»Ê‘‰€ÄŒî¢¶sà’绪3IØEí‹iÜ9“òMt·ŽLt ©Œ}·ŽLTWùл¥`¢¹jCŒ##W-æœUò*»ª[¼ʪfòÉ÷OV(Ó]ÔÁÈôV·f«ü@wp¢éézÖû?ÀËܯAwœÆÓ^õÖ#¾3#I«þÉBøY áìàÓ>p‰JÌ0n÷‰ò*8)ëL©W½jâ4§ÎÁ«šÙý6W½2ݯaßTî×° »Èš¸‚‹Íd¼H÷pLÓÒÿøö`dÁîû‚?¦¦ÝtÕ>]§z©¡k}Ñ—«ßVn$
endstream
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
xref
0 5
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
0000000269 00000 n
6 2
0000001173 00000 n
0000001216 00000 n
trailer
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
startxref
1307
%%EOF
+37
View File
@@ -0,0 +1,37 @@
%PDF-1.4
%¡³Å×
1 0 obj
<</Pages 2 0 R /Type/Catalog>>
endobj
2 0 obj
<</Count 1/Kids[ 3 0 R ]/Type/Pages>>
endobj
3 0 obj
<</Contents 4 0 R /MediaBox[ 0 0 612 792]/Parent 2 0 R /Resources<</ExtGState<</FXE1 6 0 R >>/Font<</FXF1 7 0 R >>>>/Type/Page>>
endobj
4 0 obj
<</Filter/FlateDecode/Length 417>>stream
xœµ”;k\A …ûû+T&EfGéhËÂ>î ÛÜÂ`R¿p ^ùûacHRÄCŠ Ó¨ÒùF::OS¦Ó»|O/Åñ˜¾S¦3ÊôyZµ«™éá™Vç‡v¾½ÜÒz½z¼}sÿíx÷õùñæÝ§’ý^Þn6´;ì§§ß}~uÞ-Ä?k&©Hp*hùB«vÕ˜˜i¹§LË‘®×^<» 0ÃeÞ|¤åŒæ….¦ùÃ~ú_É ³w8``ì=1hMÌEK!0h5™¤§Î€+Ì3Ú0 1PÐ[‡7̘G­ƒ )B¬ÔîD‰+'Ï9jôÄÇ-€5y°s÷ïÆŒÝ„È©¨ÃºÉ ÇÉc¼w ®¨°AQ5‰d òœ_ÿü©2‹õ >. Ÿ§¿ûÖÃë †rò Ut†º [ªÁ¬½´‘À^’†X—`놃g/£rØ’C¬vh£lÀ"É‚.€¸ ¹JäД9Wéz‘Ý(£"Ù¥#n;„ÈWÖW*´i3U³Sî5tÖ?Ïê}=
endstream
endobj
6 0 obj
<</BM/Normal/CA 1/ca 1>>
endobj
7 0 obj
<</BaseFont/Helvetica/Encoding/WinAnsiEncoding/Subtype/Type1/Type/Font>>
endobj
xref
0 5
0000000000 65535 f
0000000017 00000 n
0000000066 00000 n
0000000122 00000 n
0000000269 00000 n
6 2
0000000758 00000 n
0000000801 00000 n
trailer
<</Root 1 0 R /Size 8/ID[<9C526D432005A510CFC4B813E5B68C5B><9C526D432005A510CFC4B813E5B68C5B>]>>
startxref
892
%%EOF
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+112
View File
@@ -0,0 +1,112 @@
"""Compare forensic snapshots and print diff tables."""
import json
import sys
from pathlib import Path
def load(p):
return json.loads(Path(p).read_text(encoding="utf-8-sig"))
def diff_table(a, b, keys):
rows = []
for k in keys:
va, vb = a.get(k), b.get(k)
changed = va != vb
rows.append((k, va, vb, "CHANGED" if changed else "same"))
return rows
def print_table(title, rows):
print(f"\n=== {title} ===")
print(f"{'Property':<22} {'Before':<40} {'After':<40} {'Status'}")
for k, va, vb, st in rows:
sa = json.dumps(va) if not isinstance(va, str) else va
sb = json.dumps(vb) if not isinstance(vb, str) else vb
if len(sa) > 38: sa = sa[:35] + "..."
if len(sb) > 38: sb = sb[:35] + "..."
print(f"{k:<22} {sa:<40} {sb:<40} {st}")
def compare_advances(orig, new):
oa, na = orig or [], new or []
diffs = []
for i in range(max(len(oa), len(na))):
ov = oa[i] if i < len(oa) else None
nv = na[i] if i < len(na) else None
if ov != nv:
diffs.append((i, ov, nv))
return diffs
def main():
extract_path = Path(sys.argv[1] if len(sys.argv) > 1 else "forensic_extract_out.json")
wasm_path = Path(sys.argv[2] if len(sys.argv) > 2 else "forensic_wasm_out.json")
ex = load(extract_path)
wasm = load(wasm_path)
keys = [
"text", "fontId", "fontName", "fontSize", "color",
"glyphCount", "advanceCount", "paragraphWidth", "baseline", "lineHeight",
"origLinesProvided",
]
orig = ex["original"]
entry_payload = ex["editEntry"]
one_payload = ex["editOne"]
# WASM layout snapshots
from forensic_extract import snapshot_from_wasm_layout, para_signature # type: ignore
seed_fn = orig.get("fontName", "")
entry_wasm = snapshot_from_wasm_layout("ENTRY_WASM", wasm["editEntryWasm"]["layout"], seed_fn)
one_wasm = snapshot_from_wasm_layout("ONE_WASM", wasm["editOneWasm"]["layout"], seed_fn)
print_table("ORIGINAL vs EDIT_MODE_ENTRY (payload)", diff_table(orig, entry_payload, keys))
print_table("ORIGINAL vs EDIT_MODE_ENTRY (WASM layout)", diff_table(orig, entry_wasm, keys))
adv_diff_entry = compare_advances(orig.get("advances"), entry_wasm.get("advances"))
if adv_diff_entry:
print(f"\nAdvance diffs ORIGINAL vs ENTRY_WASM: {len(adv_diff_entry)} positions")
for i, ov, nv in adv_diff_entry[:20]:
print(f" [{i}] {ov} -> {nv}")
else:
print("\nAdvance arrays IDENTICAL: ORIGINAL vs ENTRY_WASM")
print_table("EDIT_ENTRY vs AFTER_ONE_CHAR (payload)", diff_table(entry_payload, one_payload, keys))
print_table("ENTRY_WASM vs ONE_WASM", diff_table(entry_wasm, one_wasm, keys))
adv_diff_one = compare_advances(entry_wasm.get("advances"), one_wasm.get("advances"))
if adv_diff_one:
print(f"\nAdvance diffs ENTRY_WASM vs ONE_WASM: {len(adv_diff_one)} positions")
for i, ov, nv in adv_diff_one[:20]:
print(f" [{i}] {ov} -> {nv}")
# Line widths
print("\n=== LINE WIDTHS ===")
print(f"Original lineWidths: {orig.get('lineWidths')}")
print(f"Entry WASM lineWidths: {entry_wasm.get('lineWidths')}")
print(f"One WASM lineWidths: {one_wasm.get('lineWidths')}")
# Signatures chain
sigs = {
"original": para_signature(orig),
"entry_payload": para_signature(entry_payload),
"entry_wasm": para_signature(entry_wasm),
"one_payload": para_signature(one_payload),
"one_wasm": para_signature(one_wasm),
}
print("\n=== SIGNATURE CHAIN ===")
prev = None
for name, sig in sigs.items():
changed = prev and sig != prev
print(f" {name}: {sig}" + (" <-- FIRST CHANGE from " + prev_name if changed else ""))
if changed and prev:
pass
prev_name = name
prev = sig
# First mutation
chain = list(sigs.items())
base = chain[0][1]
for i in range(1, len(chain)):
if chain[i][1] != base:
print(f"\nFIRST SIGNATURE CHANGE: {chain[i][0]} (vs original)")
break
if __name__ == "__main__":
main()
+407
View File
@@ -0,0 +1,407 @@
"""Stage 1-4 extraction + frontend layout mirror (ParagraphEditor.tsx)."""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
def line_advances(line) -> tuple[dict[int, list[float]], float]:
runs = line.runs
seq: list[tuple[int, int, float]] = []
aligned: dict[int, bool] = {}
for ri, r in enumerate(runs):
gs = r.glyphs
ok = bool(r.text) and len(gs) == len(r.text)
aligned[ri] = ok
if ok:
for ci, g in enumerate(gs):
seq.append((ri, ci, g.origin_x))
per_run: dict[int, list[float]] = {}
for ri, r in enumerate(runs):
if aligned.get(ri):
per_run[ri] = [0.0] * len(r.text)
anchor_x = seq[0][2] if seq else line.x
for k, (ri, ci, ox) in enumerate(seq):
gs = runs[ri].glyphs
g = gs[ci]
char_w = g.bbox_w if g.bbox_w > 0 else (runs[ri].font_size or 12) * 0.5
per_run[ri][ci] = (seq[k + 1][2] - ox) if k + 1 < len(seq) else char_w
for ri in list(per_run.keys()):
if any(a <= 0 for a in per_run[ri]):
del per_run[ri]
return per_run, anchor_x
def para_eff_size(lines) -> float:
nominal = 0.0
for line in lines:
for r in line.runs:
sz = max(r.font_size or 0, r.h or 0)
if sz > nominal:
nominal = sz
return nominal or 12.0
def median(xs: list[float]) -> float:
if not xs:
return 0.0
s = sorted(xs)
return s[len(s) // 2]
def compute_layout(para) -> dict:
lines = para.lines
eff_size = para_eff_size(lines)
column_left = float("inf")
first_baseline_y = float("-inf")
baselines: list[float] = []
right_edges: list[float] = []
object_indices: list[int] = []
seed_runs: list[dict] = []
orig_lines: list[dict] = []
for li, line in enumerate(lines):
if line.baseline_y is not None:
baselines.append(line.baseline_y)
first_baseline_y = max(first_baseline_y, line.baseline_y)
column_left = min(column_left, line.x)
right_edges.append(line.x + line.w)
line_runs = line.runs
per_run, anchor_x = line_advances(line)
line_frags: list[dict] = []
for ri, r in enumerate(line_runs):
for oi in (r.object_indices or []):
object_indices.append(oi)
orig = r.text or ""
text = orig
if li > 0 and ri == 0 and seed_runs:
prev = seed_runs[-1]["text"]
if prev and not prev.endswith((" ", "\t")) and not text.startswith((" ", "\t")):
text = " " + text
adv = per_run.get(ri)
fc = getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000"
safe_color = fc if fc and fc != "#ffffff00" else "#000000"
r_size = max(r.font_size or 0, r.h or 0) or eff_size
seed_runs.append({
"text": text, "fid": r.internal_font_id or "", "size": r_size,
"color": safe_color, "fontName": r.font_name or "", "advances": adv,
})
if orig:
line_frags.append({
"text": orig, "fid": r.internal_font_id or "", "size": r_size,
"color": safe_color, "advances": adv,
})
if line_frags:
orig_lines.append({"frags": line_frags, "x": anchor_x, "baselineY": line.baseline_y or 0})
max_right = max(right_edges) if right_edges else column_left + 250
column_right = max(max_right, column_left + 250) if column_left != float("inf") else 250
deltas = [baselines[i] - baselines[i + 1] for i in range(len(baselines) - 1)]
dom_size = next((r["size"] for r in seed_runs if r["text"].strip()), 12.0)
leading = abs(median(deltas)) if deltas else dom_size * 1.2
col_w = column_right - column_left
align = "left"
if len(lines) >= 2:
reaching = sum(1 for i in range(len(right_edges) - 1) if right_edges[i] >= column_right - col_w * 0.04)
if reaching >= (len(lines) - 1) * 0.7:
align = "justify"
return {
"columnLeft": column_left, "columnRight": column_right,
"firstBaselineY": first_baseline_y, "leading": leading,
"oldLineCount": len(lines), "align": align,
"objectIndices": object_indices, "seedRuns": seed_runs, "origLines": orig_lines,
"domSize": dom_size,
}
def extract_flat_runs(seed_runs: list[dict], dominant_fid: str, dom_size: float, dom_color: str) -> list[dict]:
"""Mirror extractFlatRuns when DOM matches seedRuns exactly (edit mode entry)."""
out: list[dict] = []
for r in seed_runs:
if not r["text"]:
continue
frag = {
"text": r["text"], "internalFontId": r["fid"] or dominant_fid,
"fontSize": r["size"], "color": r["color"],
}
adv = r.get("advances")
if adv and len(adv) == len(r["text"]):
frag["advances"] = adv
out.append(frag)
return out
def build_reflow_data(layout: dict, runs: list[dict], orig_lines: list[dict] | None, para_id: str) -> dict:
line_position = {}
if layout["origLines"]:
line_position = {
"lineX": [l["x"] for l in layout["origLines"]],
"lineBaselineY": [l["baselineY"] for l in layout["origLines"]],
}
lines_data = {}
if orig_lines:
lines_data = {
"lines": [
[
{
"text": f["text"], "internalFontId": f["fid"],
"fontSize": f["size"], "color": f["color"],
**({"advances": f["advances"]} if f.get("advances") else {}),
}
for f in l["frags"]
]
for l in orig_lines
],
}
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip() and r["fid"] == dominant_fid), None)
dom_size = (dom_run or layout["seedRuns"][0])["size"] if layout["seedRuns"] else 12.0
dom_color = (dom_run or layout["seedRuns"][0])["color"] if layout["seedRuns"] else "#000000"
out_runs = runs if runs else [{"text": " ", "internalFontId": dominant_fid, "fontSize": dom_size, "color": "#000000"}]
return {
"objectIndices": layout["objectIndices"],
"runs": out_runs,
**line_position,
**lines_data,
"columnLeft": layout["columnLeft"],
"columnRight": layout["columnRight"],
"pushColumnLeft": layout["columnLeft"],
"firstBaselineY": layout["firstBaselineY"],
"leading": layout["leading"],
"oldLineCount": layout["oldLineCount"],
"align": layout["align"],
"paraId": para_id,
}
def paragraph_snapshot_from_extraction(para, layout: dict) -> dict:
"""Original PDF paragraph metrics from extraction + derived advances."""
lines = para.lines
all_text = "".join(r.text for ln in lines for r in ln.runs)
runs_detail = []
for ln in lines:
per_run, anchor_x = line_advances(ln)
for ri, r in enumerate(ln.runs):
adv = per_run.get(ri)
runs_detail.append({
"text": r.text,
"fontId": r.internal_font_id,
"fontName": r.font_name,
"fontSize": max(r.font_size or 0, r.h or 0),
"color": getattr(r, "fill_color", None) or getattr(r, "color", None) or "#000000",
"glyphCount": len(r.glyphs),
"advanceCount": len(adv) if adv else 0,
"advances": adv,
"bbox": [r.x, r.y, r.w, r.h],
"lineWidth": sum(adv) if adv else r.w,
})
line_widths = [ln.w for ln in lines]
orig_line_texts = ["".join(r.text for r in ln.runs) for ln in lines]
return {
"stage": "ORIGINAL_PDF_EXTRACTION",
"text": all_text,
"fontId": runs_detail[0]["fontId"] if runs_detail else "",
"fontName": runs_detail[0]["fontName"] if runs_detail else "",
"fontSize": layout["domSize"],
"color": runs_detail[0]["color"] if runs_detail else "#000000",
"glyphCount": sum(len(r.glyphs) for ln in lines for r in ln.runs),
"advanceCount": sum(rd["advanceCount"] for rd in runs_detail),
"advances": [a for rd in runs_detail for a in (rd["advances"] or [])],
"boundingBox": [para.x, para.y, para.w, para.h],
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
"lineWidths": line_widths,
"baseline": layout["firstBaselineY"],
"ascent": None,
"descent": None,
"lineHeight": layout["leading"],
"origLines": orig_line_texts,
"runs": runs_detail,
"origLinesProvided": False,
}
def snapshot_from_payload(stage: str, layout: dict, runs: list[dict], orig_lines: list[dict] | None) -> dict:
all_text = "".join(r["text"] for r in runs)
dom = runs[0] if runs else {}
adv_flat = []
for r in runs:
if r.get("advances"):
adv_flat.extend(r["advances"])
return {
"stage": stage,
"text": all_text,
"fontId": dom.get("internalFontId", ""),
"fontName": layout["seedRuns"][0]["fontName"] if layout["seedRuns"] else "",
"fontSize": dom.get("fontSize", layout["domSize"]),
"color": dom.get("color", "#000000"),
"glyphCount": len(all_text),
"advanceCount": len(adv_flat),
"advances": adv_flat,
"boundingBox": None,
"paragraphWidth": layout["columnRight"] - layout["columnLeft"],
"lineWidths": None,
"baseline": layout["firstBaselineY"],
"ascent": None,
"descent": None,
"lineHeight": layout["leading"],
"origLines": ["".join(f["text"] for f in l["frags"]) for l in orig_lines] if orig_lines else None,
"runs": [
{
"text": r["text"],
"fontId": r.get("internalFontId"),
"fontSize": r.get("fontSize"),
"color": r.get("color"),
"advanceCount": len(r.get("advances") or []),
"advances": r.get("advances"),
"advanceSource": "frontend_origin_x_derived" if r.get("advances") else "missing",
}
for r in runs
],
"origLinesProvided": orig_lines is not None,
}
def snapshot_from_wasm_layout(stage: str, layout_json: dict, seed_font_name: str) -> dict:
lines = layout_json.get("lines") or []
all_text = "".join(ln.get("text", "") for ln in lines)
adv_flat = []
line_widths = []
for ln in lines:
adv = ln.get("adv") or []
adv_flat.extend(adv)
line_widths.append(sum(adv))
dom_fs = lines[0].get("fontSize") if lines else None
return {
"stage": stage,
"text": all_text,
"fontId": "",
"fontName": seed_font_name,
"fontSize": dom_fs,
"color": None,
"glyphCount": len(all_text),
"advanceCount": len(adv_flat),
"advances": adv_flat,
"boundingBox": None,
"paragraphWidth": (layout_json.get("columnRight") or 0) - (layout_json.get("columnLeft") or 0),
"lineWidths": line_widths,
"baseline": lines[0].get("baselineY") if lines else None,
"ascent": None,
"descent": None,
"lineHeight": None,
"origLines": [ln.get("text", "") for ln in lines],
"runs": [
{
"text": ln.get("text"),
"lineX0": ln.get("x0"),
"fontSize": ln.get("fontSize"),
"advanceCount": len(ln.get("adv") or []),
"advances": ln.get("adv"),
"advanceSum": sum(ln.get("adv") or []),
"advanceSource": "wasm_reflow_output",
}
for ln in lines
],
"origLinesProvided": None,
}
def para_signature(snap: dict) -> str:
parts = [
snap.get("text") or "",
str(snap.get("fontId") or ""),
str(snap.get("fontSize") or ""),
json.dumps(snap.get("advances") or []),
json.dumps(snap.get("lineWidths") or []),
str(snap.get("baseline") or ""),
json.dumps(snap.get("boundingBox") or []),
]
return hashlib.sha256("".join(parts).encode()).hexdigest()
def main():
pdf_path = ROOT / "corpus" / "basic" / "hello_world.pdf"
if len(sys.argv) > 1:
pdf_path = Path(sys.argv[1])
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
model = doc.get_page(0).extract_document_model()
para = model.paragraphs[0]
layout = compute_layout(para)
fonts = doc.get_fonts(0, 0)
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom_run = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
dom_size = dom_run["size"]
dom_color = dom_run["color"]
para_id = f"forensic-{hashlib.md5(str(pdf_path).encode()).hexdigest()[:8]}"
# Stage snapshots
original = paragraph_snapshot_from_extraction(para, layout)
flat_entry = extract_flat_runs(layout["seedRuns"], dominant_fid, dom_size, dom_color)
payload_entry = build_reflow_data(layout, flat_entry, layout["origLines"], para_id)
edit_entry = snapshot_from_payload("EDIT_MODE_ENTRY_PAYLOAD", layout, flat_entry, layout["origLines"])
# After one character
flat_one = []
for i, r in enumerate(flat_entry):
if i == 0:
flat_one.append({**r, "text": r["text"] + "x"})
else:
flat_one.append(r)
payload_one = build_reflow_data(layout, flat_one, None, para_id)
edit_one = snapshot_from_payload("AFTER_ONE_CHAR_PAYLOAD", layout, flat_one, None)
out = {
"pdf": str(pdf_path),
"fonts": [
{
"internalFontId": f.internal_font_id,
"fontName": f.font_name,
"ascent": f.ascent,
"descent": f.descent,
"capHeight": getattr(f, "cap_height", None),
"unitsPerEm": getattr(f, "units_per_em", None),
}
for f in fonts
],
"layout": layout,
"original": original,
"editEntry": edit_entry,
"editOne": edit_one,
"payloads": {
"editEntry": {
"version": "1.0",
"operations": [{
"id": "forensic-entry", "type": "reflow_paragraph", "pageIndex": 0,
"data": payload_entry,
}],
},
"editOne": {
"version": "1.0",
"operations": [{
"id": "forensic-one", "type": "reflow_paragraph", "pageIndex": 0,
"data": payload_one,
}],
},
},
"signatures": {
"original": para_signature(original),
"editEntryPayload": para_signature(edit_entry),
"editOnePayload": para_signature(edit_one),
},
}
out_path = Path(__file__).resolve().parent / "forensic_extract_out.json"
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(json.dumps(out, indent=2))
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
"""Step 2 experiment: force runPerChar=false for UNCHANGED paragraph.
Requires rebuilt pdfengine with forensicForceWholeRun support.
Compares:
A) default entry (runPerChar as decided by diverges)
B) forensicForceWholeRun=true (runPerChar forced 0)
C) original PDF
Emits object dumps + content streams + optional region bitmap SHA.
"""
from __future__ import annotations
import hashlib
import json
import sys
import zlib
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
# Prefer freshly rebuilt engine when present (must be AFTER gateway insert so it wins).
_winlocal = Path(r"C:\Users\Maskan\pdfeng-build\win-local\lib")
if _winlocal.exists():
sys.path.insert(0, str(_winlocal))
import pdfengine # type: ignore
print(f"[engine] {pdfengine.__file__}", flush=True)
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
def inflate_content(pdf_bytes: bytes) -> list[str]:
out = []
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
try:
out.append(zlib.decompress(m.group(1)).decode("latin-1", errors="replace"))
except Exception:
pass
return out
def font_dicts(pdf_bytes: bytes) -> list[str]:
found = []
for m in re.finditer(rb"<<[^>]*?/Type\s*/Font[^>]*?>>", pdf_bytes, re.DOTALL):
found.append(m.group(0).decode("latin-1", errors="replace"))
# also compact form
if not found:
for m in re.finditer(rb"<</BaseFont/[^>]+>>", pdf_bytes):
found.append(m.group(0).decode("latin-1", errors="replace"))
return found
def region_sha(doc, page_index=0, dpi=144, y_top=200, height=80) -> str:
page = doc.get_page(page_index)
w, h, raw = page.render_region_raw(dpi, y_top, height)
return hashlib.sha256(raw).hexdigest()[:16], w, h
def main():
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
out_dir = Path(__file__).resolve().parent
doc0 = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
model = doc0.get_page(0).extract_document_model()
para = model.paragraphs[0]
layout = compute_layout(para)
dominant_fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
data = build_reflow_data(layout, flat, layout["origLines"], "forensic-force-whole")
variants = {
"A_default_entry": {**data},
"B_force_whole_run": {**data, "forensicForceWholeRun": True},
}
orig_bytes = pdf_path.read_bytes()
print("ORIGINAL fonts:", font_dicts(orig_bytes))
print("ORIGINAL streams:")
for s in inflate_content(orig_bytes):
print(s)
try:
sha, w, h = region_sha(doc0, y_top=120, height=40)
print(f"ORIGINAL region sha={sha} {w}x{h}")
except Exception as e:
print("ORIGINAL region render failed:", e)
results = {}
for name, d in variants.items():
op = {"version": "1.0", "operations": [{
"id": name, "type": "reflow_paragraph", "pageIndex": 0, "data": d,
}]}
print(f"\n===== {name} =====")
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
doc.apply_edits(json.dumps(op))
out = doc.save_full()
path = out_dir / f"_forensic_{name}.pdf"
path.write_bytes(out)
print("fonts:", font_dicts(out))
streams = inflate_content(out)
for s in streams:
# only show streams that mention Goodbye / FXF2 / 100 Tm
if "100 Tm" in s or "Goodbye" in s or "476F6F64" in s:
print("--- content ---")
print(s[:2000])
# count TJ
tj_count = sum(s.count(" TJ") for s in streams)
print(f"TJ operator count across streams: {tj_count}")
try:
d2 = pdfengine.PdfDocument.load_from_memory(out, "")
sha, w, h = region_sha(d2, y_top=120, height=40)
print(f"region sha={sha} {w}x{h}")
results[name] = {"sha": sha, "tj": tj_count, "fonts": font_dicts(out)}
except Exception as e:
print("region render failed:", e)
results[name] = {"error": str(e), "tj": tj_count}
print("\n===== VERDICT =====")
if "A_default_entry" in results and "B_force_whole_run" in results:
a, b = results["A_default_entry"], results["B_force_whole_run"]
print(f"A (default runPerChar) TJ={a.get('tj')} sha={a.get('sha')}")
print(f"B (force whole run) TJ={b.get('tj')} sha={b.get('sha')}")
if a.get("sha") and b.get("sha"):
if a["sha"] == b["sha"]:
print("RESULT: A and B IDENTICAL bitmaps → runPerChar does NOT change pixels")
else:
print("RESULT: A and B DIFFER → runPerChar DOES change pixels")
if a.get("tj") == 1 and b.get("tj") and b["tj"] < a.get("tj", 99):
print("NOTE: B emitted fewer TJ (whole-run path active)")
if a.get("tj", 0) > 5 and b.get("tj", 0) <= 3:
print("CONFIRMED: forensicForceWholeRun reduced emission to whole-run")
elif a.get("tj") == b.get("tj"):
print("WARNING: TJ counts equal — rebuild may not include forensicForceWholeRun yet")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""Compare full-page render hashes + PDF objects across corpus files."""
from __future__ import annotations
import hashlib
import json
import re
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
sys.path.insert(0, str(Path(__file__).resolve().parent))
from forensic_extract import compute_layout, extract_flat_runs, build_reflow_data # type: ignore
from forensic_obj_compare import apply_and_save # type: ignore
def inflate(pdf_bytes: bytes) -> list[str]:
out = []
for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL):
try:
out.append(zlib.decompress(m.group(1)).decode("latin-1", "replace"))
except Exception:
pass
return out
def font_dicts(b: bytes) -> list[str]:
return [m.group(0).decode("latin-1", "replace") for m in re.finditer(rb"<</BaseFont/[^>]+>>", b)]
def sha_full(doc, dpi=144) -> tuple[str, int]:
img = doc.get_page(0).render(dpi)
png = bytes(img.data)
return hashlib.sha256(png).hexdigest()[:24], len(png)
def run(pdf: Path):
print(f"\n######## {pdf}")
doc = pdfengine.PdfDocument.load_from_file(str(pdf), "")
s0, n0 = sha_full(doc)
print(f"ORIG sha={s0} pngbytes={n0}")
model = doc.get_page(0).extract_document_model()
if not model.paragraphs:
print("no paragraphs")
return
para = model.paragraphs[0]
layout = compute_layout(para)
for f in doc.get_fonts(0, 0)[:6]:
print(f" FontInfo name={f.font_name} emb={f.is_embedded} type={f.type} "
f"subset={getattr(f, 'is_subset', None)} id={f.internal_font_id}")
print(" seedRuns:", [(r["fontName"], r["fid"], round(r["size"], 2),
len(r["advances"]) if r.get("advances") else 0)
for r in layout["seedRuns"][:4]])
fid = next((r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), "")
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], fid, dom["size"], dom["color"])
data = build_reflow_data(layout, flat, layout["origLines"], "cmp")
op = {"version": "1.0", "operations": [{
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": data,
}]}
outp = Path(__file__).resolve().parent / "_tmp_entry.pdf"
apply_and_save(pdf, op, outp)
d2 = pdfengine.PdfDocument.load_from_memory(outp.read_bytes(), "")
s1, n1 = sha_full(d2)
print(f"ENTRY sha={s1} pngbytes={n1} {'IDENTICAL' if s0 == s1 else '*** DIFFERENT ***'}")
b = outp.read_bytes()
print(" entry fonts:", font_dicts(b))
for s in inflate(b):
if "Tm" in s or "TJ" in s or "Tj" in s:
one_line = s.replace("\n", " | ")
print(" stream:", one_line[:700])
tj = s.count(" TJ")
print(f" TJ count={tj}")
break
flat1 = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
data1 = build_reflow_data(layout, flat1, None, "cmp")
# also try force whole if supported
data1_force = {**build_reflow_data(layout, flat, layout["origLines"], "cmp"),
"forensicForceWholeRun": True}
op1 = {"version": "1.0", "operations": [{
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": data1,
}]}
out1 = Path(__file__).resolve().parent / "_tmp_one.pdf"
apply_and_save(pdf, op1, out1)
d3 = pdfengine.PdfDocument.load_from_memory(out1.read_bytes(), "")
s2, n2 = sha_full(d3)
print(f"ONE sha={s2} pngbytes={n2} vsORIG={'SAME' if s0 == s2 else 'DIFF'} vsENTRY={'SAME' if s1 == s2 else 'DIFF'}")
# force whole on unchanged
opf = {"version": "1.0", "operations": [{
"id": "f", "type": "reflow_paragraph", "pageIndex": 0, "data": data1_force,
}]}
outf = Path(__file__).resolve().parent / "_tmp_force.pdf"
apply_and_save(pdf, opf, outf)
df = pdfengine.PdfDocument.load_from_memory(outf.read_bytes(), "")
sf, nf = sha_full(df)
bf = outf.read_bytes()
tjf = sum(s.count(" TJ") for s in inflate(bf))
tje = sum(s.count(" TJ") for s in inflate(b))
print(f"FORCE sha={sf} pngbytes={nf} vsORIG={'SAME' if s0 == sf else 'DIFF'} "
f"vsENTRY={'SAME' if s1 == sf else 'DIFF'} TJ_entry={tje} TJ_force={tjf}")
if "forensicForceWholeRun" in json.dumps(opf):
if tje != tjf:
print(" => forensicForceWholeRun ACTIVE (TJ count changed)")
else:
print(" => forensicForceWholeRun NOT in binary yet (TJ unchanged) OR no-op")
def main():
for p in [
ROOT / "corpus" / "basic" / "hello_world.pdf",
ROOT / "tests" / "edits" / "forensic_multiline.pdf",
ROOT / "corpus" / "fonts" / "embedded_truetype.pdf",
ROOT / "corpus" / "fonts" / "subset_font.pdf",
]:
if p.exists():
try:
run(p)
except Exception as e:
print(f"FAILED {p}: {e}")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 238 >>
stream
BT /F1 11 Tf 72 700 Td (The quick brown fox jumps over the lazy) Tj ET
BT /F1 11 Tf 72 686 Td (dog near the river bank on a sunny) Tj ET
BT /F1 11 Tf 72 672 Td (afternoon in early spring.) Tj ET
BT /F1 11 Tf 72 600 Td (FOOTER LINE) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000529 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
599
%%EOF
+849
View File
@@ -0,0 +1,849 @@
{
"pdf": "corpus\\basic\\hello_world.pdf",
"fonts": [
{
"id": "Helvetica_Type1_32",
"name": "Helvetica",
"embedded": false,
"type": "Type1",
"subset": false,
"ascent": 905.0,
"descent": -211.0
},
{
"id": "Times-Roman_Type1_32",
"name": "Times-Roman",
"embedded": false,
"type": "Type1",
"subset": false,
"ascent": 891.0,
"descent": -215.0
}
],
"orig": [
{
"para": 0,
"line": 0,
"run": 0,
"text": "Goodbye, world!",
"fontName": "Helvetica",
"fontId": "Helvetica_Type1_32",
"fontSize": 16.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.847999572753906,
"y": 96.65599822998047,
"w": 114.31999969482422,
"h": 14.99200439453125,
"objectIndices": [
1
],
"glyphCount": 15,
"glyphOrigins": [
[
20.0,
100.0
],
[
32.448,
100.0
],
[
41.344,
100.0
],
[
50.24,
100.0
],
[
59.136,
100.0
],
[
68.032,
100.0
],
[
76.032,
100.0
],
[
84.928,
100.0
],
[
89.376,
100.0
],
[
93.824,
100.0
],
[
105.376,
100.0
],
[
114.272,
100.0
],
[
119.6,
100.0
],
[
123.152,
100.0
],
[
132.048,
100.0
]
],
"glyphUnicodes": [
71,
111,
111,
100,
98,
121,
101,
44,
32,
119,
111,
114,
108,
100,
33
],
"fontMeta": {
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Helvetica"
}
},
{
"para": 1,
"line": 0,
"run": 0,
"text": "Hello, world!",
"fontName": "Times-Roman",
"fontId": "Times-Roman_Type1_32",
"fontSize": 12.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.20400047302246,
"y": 48.007999420166016,
"w": 63.120004653930664,
"h": 10.319999694824219,
"objectIndices": [
0
],
"glyphCount": 13,
"glyphOrigins": [
[
20.0,
50.0
],
[
28.664,
50.0
],
[
33.992,
50.0
],
[
37.328,
50.0
],
[
40.664,
50.0
],
[
46.664,
50.0
],
[
49.664,
50.0
],
[
52.664,
50.0
],
[
61.328,
50.0
],
[
67.328,
50.0
],
[
71.324,
50.0
],
[
74.66,
50.0
],
[
80.66,
50.0
]
],
"glyphUnicodes": [
72,
101,
108,
108,
111,
44,
32,
119,
111,
114,
108,
100,
33
],
"fontMeta": {
"ascent": 891.0,
"descent": -215.0,
"capHeight": 662.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Times-Roman"
}
}
],
"entry": [
{
"para": 0,
"line": 0,
"run": 0,
"text": "Goodbye,",
"fontName": "Helvetica",
"fontId": "Helvetica_Type1_32",
"fontSize": 16.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.847999572753906,
"y": 96.65599822998047,
"w": 67.10400390625,
"h": 14.99200439453125,
"objectIndices": [
14,
13,
12,
11,
10,
9,
8,
7
],
"glyphCount": 8,
"glyphOrigins": [
[
20.0,
100.0
],
[
32.448,
100.0
],
[
41.344,
100.0
],
[
50.24,
100.0
],
[
59.136,
100.0
],
[
68.032,
100.0
],
[
76.032,
100.0
],
[
84.928,
100.0
]
],
"glyphUnicodes": [
71,
111,
111,
100,
98,
121,
101,
44
],
"fontMeta": {
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Helvetica"
}
},
{
"para": 0,
"line": 0,
"run": 1,
"text": " ",
"fontName": "",
"fontId": "",
"fontSize": 1.0,
"isEmbedded": false,
"fontType": "",
"fontFidelity": "exact",
"x": 89.3759994506836,
"y": 100.0,
"w": 0.0,
"h": 0.0,
"objectIndices": [],
"glyphCount": 1,
"glyphOrigins": [
[
89.376,
100.0
]
],
"glyphUnicodes": [
32
],
"fontMeta": null
},
{
"para": 0,
"line": 0,
"run": 2,
"text": "world!",
"fontName": "Helvetica",
"fontId": "Helvetica_Type1_32",
"fontSize": 16.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 93.87199401855469,
"y": 99.8239974975586,
"w": 41.29600524902344,
"h": 11.632003784179688,
"objectIndices": [
6,
5,
4,
3,
2,
1
],
"glyphCount": 6,
"glyphOrigins": [
[
93.824,
100.0
],
[
105.376,
100.0
],
[
114.272,
100.0
],
[
119.6,
100.0
],
[
123.152,
100.0
],
[
132.048,
100.0
]
],
"glyphUnicodes": [
119,
111,
114,
108,
100,
33
],
"fontMeta": {
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Helvetica"
}
},
{
"para": 1,
"line": 0,
"run": 0,
"text": "Hello, world!",
"fontName": "Times-Roman",
"fontId": "Times-Roman_Type1_32",
"fontSize": 12.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.20400047302246,
"y": 48.007999420166016,
"w": 63.120004653930664,
"h": 10.319999694824219,
"objectIndices": [
0
],
"glyphCount": 13,
"glyphOrigins": [
[
20.0,
50.0
],
[
28.664,
50.0
],
[
33.992,
50.0
],
[
37.328,
50.0
],
[
40.664,
50.0
],
[
46.664,
50.0
],
[
49.664,
50.0
],
[
52.664,
50.0
],
[
61.328,
50.0
],
[
67.328,
50.0
],
[
71.324,
50.0
],
[
74.66,
50.0
],
[
80.66,
50.0
]
],
"glyphUnicodes": [
72,
101,
108,
108,
111,
44,
32,
119,
111,
114,
108,
100,
33
],
"fontMeta": {
"ascent": 891.0,
"descent": -215.0,
"capHeight": 662.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Times-Roman"
}
}
],
"one": [
{
"para": 0,
"line": 0,
"run": 0,
"text": "Goodbye,",
"fontName": "Helvetica",
"fontId": "Helvetica_Type1_32",
"fontSize": 16.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.847999572753906,
"y": 96.65599822998047,
"w": 67.10400390625,
"h": 14.99200439453125,
"objectIndices": [
2
],
"glyphCount": 8,
"glyphOrigins": [
[
20.0,
100.0
],
[
32.448,
100.0
],
[
41.344,
100.0
],
[
50.24,
100.0
],
[
59.136,
100.0
],
[
68.032,
100.0
],
[
76.032,
100.0
],
[
84.928,
100.0
]
],
"glyphUnicodes": [
71,
111,
111,
100,
98,
121,
101,
44
],
"fontMeta": {
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Helvetica"
}
},
{
"para": 0,
"line": 0,
"run": 1,
"text": " ",
"fontName": "",
"fontId": "",
"fontSize": 1.0,
"isEmbedded": false,
"fontType": "",
"fontFidelity": "exact",
"x": 89.3759994506836,
"y": 100.0,
"w": 0.0,
"h": 0.0,
"objectIndices": [],
"glyphCount": 1,
"glyphOrigins": [
[
89.376,
100.0
]
],
"glyphUnicodes": [
32
],
"fontMeta": null
},
{
"para": 0,
"line": 0,
"run": 2,
"text": "world!x",
"fontName": "Helvetica",
"fontId": "Helvetica_Type1_32",
"fontSize": 16.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 93.8762435913086,
"y": 99.8239974975586,
"w": 50.512001037597656,
"h": 11.632003784179688,
"objectIndices": [
1
],
"glyphCount": 7,
"glyphOrigins": [
[
93.828,
100.0
],
[
105.38,
100.0
],
[
114.276,
100.0
],
[
119.604,
100.0
],
[
123.156,
100.0
],
[
132.052,
100.0
],
[
136.5,
100.0
]
],
"glyphUnicodes": [
119,
111,
114,
108,
100,
33,
120
],
"fontMeta": {
"ascent": 905.0,
"descent": -211.0,
"capHeight": 728.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Helvetica"
}
},
{
"para": 1,
"line": 0,
"run": 0,
"text": "Hello, world!",
"fontName": "Times-Roman",
"fontId": "Times-Roman_Type1_32",
"fontSize": 12.0,
"isEmbedded": false,
"fontType": "Type1",
"fontFidelity": "exact",
"x": 20.20400047302246,
"y": 48.007999420166016,
"w": 63.120004653930664,
"h": 10.319999694824219,
"objectIndices": [
0
],
"glyphCount": 13,
"glyphOrigins": [
[
20.0,
50.0
],
[
28.664,
50.0
],
[
33.992,
50.0
],
[
37.328,
50.0
],
[
40.664,
50.0
],
[
46.664,
50.0
],
[
49.664,
50.0
],
[
52.664,
50.0
],
[
61.328,
50.0
],
[
67.328,
50.0
],
[
71.324,
50.0
],
[
74.66,
50.0
],
[
80.66,
50.0
]
],
"glyphUnicodes": [
72,
101,
108,
108,
111,
44,
32,
119,
111,
114,
108,
100,
33
],
"fontMeta": {
"ascent": 891.0,
"descent": -215.0,
"capHeight": 662.0,
"isEmbedded": false,
"isSubset": false,
"type": "Type1",
"fontName": "Times-Roman"
}
}
],
"streams": {
"orig": {
"textOps": [
{
"op": "Tf",
"fontRes": "F1",
"size": 12.0
},
{
"op": "Tf",
"fontRes": "F2",
"size": 16.0
},
{
"op": "Td",
"tx": 20.0,
"ty": 50.0
},
{
"op": "Td",
"tx": 0.0,
"ty": 50.0
},
{
"op": "Tj",
"raw": "(Hello, world!) Tj"
},
{
"op": "Tj",
"raw": "(Goodbye, world!) Tj"
}
],
"fontDicts": [
{
"subtype": "Type1",
"baseFont": "Times-Roman"
},
{
"subtype": "Type1",
"baseFont": "Helvetica"
}
],
"streamCount": 1
},
"entry": {
"textOps": [],
"fontDicts": [
{
"subtype": "?",
"baseFont": "Times-Roman"
},
{
"subtype": "?",
"baseFont": "Helvetica"
}
],
"streamCount": 2
},
"one": {
"textOps": [],
"fontDicts": [
{
"subtype": "?",
"baseFont": "Times-Roman"
},
{
"subtype": "?",
"baseFont": "Helvetica"
}
],
"streamCount": 2
}
}
}
+308
View File
@@ -0,0 +1,308 @@
"""Forensic: dump & compare PDF text objects — original vs edit-entry vs one-char.
Does NOT compare images. Compares:
font base name, font size, matrix, bbox, unicode text, object count,
and raw content-stream snippets around Tj/TJ.
Also answers Step 4 from static path + runtime FontInfo:
why measureFace is nullptr for this paragraph's font.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
def dump_page_text_objects(doc, page_index: int = 0) -> list[dict]:
"""Re-extract model and also pull flat glyph list as proxy for text objects."""
page = doc.get_page(page_index)
model = page.extract_document_model()
fonts = doc.get_fonts(page_index, page_index)
font_by_id = {f.internal_font_id: f for f in fonts}
objs = []
for pi, para in enumerate(model.paragraphs):
for li, line in enumerate(para.lines):
for ri, run in enumerate(line.runs):
fi = font_by_id.get(run.internal_font_id)
objs.append({
"para": pi, "line": li, "run": ri,
"text": run.text,
"fontName": run.font_name,
"fontId": run.internal_font_id,
"fontSize": run.font_size,
"isEmbedded": run.is_embedded,
"fontType": run.type,
"fontFidelity": getattr(run, "font_fidelity", None),
"x": run.x, "y": run.y, "w": run.w, "h": run.h,
"objectIndices": list(run.object_indices or []),
"glyphCount": len(run.glyphs),
"glyphOrigins": [(round(g.origin_x, 3), round(g.origin_y, 3)) for g in run.glyphs[:40]],
"glyphUnicodes": [g.unicode for g in run.glyphs[:40]],
"fontMeta": None if not fi else {
"ascent": fi.ascent, "descent": fi.descent,
"capHeight": getattr(fi, "cap_height", None),
"isEmbedded": fi.is_embedded,
"isSubset": getattr(fi, "is_subset", None),
"type": fi.type,
"fontName": fi.font_name,
},
})
return objs
def extract_content_stream_text_ops(pdf_bytes: bytes) -> dict:
"""Best-effort parse of content stream for font/text operators."""
# Find stream ... endstream blocks
streams = re.findall(rb"stream\r?\n(.*?)\r?\nendstream", pdf_bytes, re.DOTALL)
ops = []
for s in streams:
# Decode latin-1 so binary stays 1:1
try:
text = s.decode("latin-1")
except Exception:
continue
# Font select: /F1 16 Tf or /Helv 11 Tf
for m in re.finditer(r"/([A-Za-z0-9\+\,\-]+)\s+([\d.]+)\s+Tf", text):
ops.append({"op": "Tf", "fontRes": m.group(1), "size": float(m.group(2))})
# Text matrix: a b c d e f Tm
for m in re.finditer(
r"([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+([\d.\-]+)\s+Tm",
text,
):
ops.append({"op": "Tm", "matrix": [float(x) for x in m.groups()]})
# Td
for m in re.finditer(r"([\d.\-]+)\s+([\d.\-]+)\s+Td", text):
ops.append({"op": "Td", "tx": float(m.group(1)), "ty": float(m.group(2))})
# Literal strings before Tj
for m in re.finditer(r"\((?:\\.|[^\\)])*\)\s*Tj", text):
raw = m.group(0)
ops.append({"op": "Tj", "raw": raw[:120]})
for m in re.finditer(r"\[.*?\]\s*TJ", text, re.DOTALL):
ops.append({"op": "TJ", "raw": m.group(0)[:200]})
# Font dictionaries
fonts = []
for m in re.finditer(
rb"/Type\s*/Font\s*/Subtype\s*/(\w+)\s*/BaseFont\s*/([^\s/]+)",
pdf_bytes,
):
fonts.append({"subtype": m.group(1).decode(), "baseFont": m.group(2).decode()})
# Also looser BaseFont
if not fonts:
for m in re.finditer(rb"/BaseFont\s*/([^\s/]+)", pdf_bytes):
fonts.append({"subtype": "?", "baseFont": m.group(1).decode()})
return {"textOps": ops, "fontDicts": fonts, "streamCount": len(streams)}
def load_layout(path: Path):
sys.path.insert(0, str(path.parent))
from forensic_extract import ( # type: ignore
compute_layout, extract_flat_runs, build_reflow_data,
)
return compute_layout, extract_flat_runs, build_reflow_data
def build_payloads(pdf_path: Path):
compute_layout, extract_flat_runs, build_reflow_data = load_layout(
Path(__file__).resolve().parent
)
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
model = doc.get_page(0).extract_document_model()
para = model.paragraphs[0]
layout = compute_layout(para)
dominant_fid = next(
(r["fid"] for r in layout["seedRuns"] if r["text"].strip() and r["fid"]), ""
)
dom = next((r for r in layout["seedRuns"] if r["text"].strip()), layout["seedRuns"][0])
flat = extract_flat_runs(layout["seedRuns"], dominant_fid, dom["size"], dom["color"])
para_id = "forensic-obj-cmp"
entry_data = build_reflow_data(layout, flat, layout["origLines"], para_id)
# one char: append x, drop origLines, keep stale advances (mirrors real editor)
flat_one = [{**flat[0], "text": flat[0]["text"] + "x"}] + flat[1:]
one_data = build_reflow_data(layout, flat_one, None, para_id)
return {
"entry": {"version": "1.0", "operations": [{
"id": "e", "type": "reflow_paragraph", "pageIndex": 0, "data": entry_data,
}]},
"one": {"version": "1.0", "operations": [{
"id": "o", "type": "reflow_paragraph", "pageIndex": 0, "data": one_data,
}]},
"layout": layout,
"fonts": [
{
"id": f.internal_font_id, "name": f.font_name,
"embedded": f.is_embedded, "type": f.type,
"subset": getattr(f, "is_subset", None),
"ascent": f.ascent, "descent": f.descent,
}
for f in doc.get_fonts(0, 0)
],
}
def apply_and_save(pdf_path: Path, op: dict, out_path: Path) -> bytes:
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
doc.apply_edits(json.dumps(op))
data = doc.save_full()
out_path.write_bytes(data)
return data
def compare_objs(label_a: str, a: list[dict], label_b: str, b: list[dict]):
print(f"\n{'='*60}\nCOMPARE {label_a} vs {label_b}\n{'='*60}")
print(f" object/run count: {len(a)} -> {len(b)}")
keys = ["text", "fontName", "fontId", "fontSize", "isEmbedded", "fontType",
"w", "glyphCount"]
n = max(len(a), len(b))
for i in range(min(n, 12)):
oa = a[i] if i < len(a) else None
ob = b[i] if i < len(b) else None
if oa is None:
print(f" [{i}] ADDED: {ob['text']!r} font={ob['fontName']} size={ob['fontSize']}")
continue
if ob is None:
print(f" [{i}] REMOVED: {oa['text']!r}")
continue
diffs = []
for k in keys:
if oa.get(k) != ob.get(k):
diffs.append(f"{k}: {oa.get(k)!r} -> {ob.get(k)!r}")
# origin of first glyph
goa = oa["glyphOrigins"][0] if oa["glyphOrigins"] else None
gob = ob["glyphOrigins"][0] if ob["glyphOrigins"] else None
if goa != gob:
diffs.append(f"firstOrigin: {goa} -> {gob}")
status = "CHANGED" if diffs else "same"
print(f" [{i}] {oa['text']!r:30} {status}")
for d in diffs:
print(f" {d}")
def explain_measureface(fonts: list[dict]):
print(f"\n{'='*60}\nSTEP 4 — why measureFace is nullptr\n{'='*60}")
for f in fonts:
print(f" FontInfo: id={f['id']} name={f['name']} embedded={f['embedded']} "
f"type={f['type']} subset={f['subset']}")
if not f["embedded"]:
print(" PATH TAKEN in loadEmissionFont():")
print(" matchedFontInfo && !isEmbedded")
print(" -> cacheKey = \"standard_\" + fontName")
print(" -> useEmbedded = false")
print(" -> useSystem = false")
print(" skip embedded/system load branches")
print(" fall through to FPDFText_LoadStandardFont()")
print(" measureFace ONLY set from loadedFontDataBuffers_ (TTF bytes)")
print(" standard fonts never put bytes in that buffer")
print(" => measureFace remains nullptr BY DESIGN for non-embedded fonts")
else:
print(" Embedded path: measureFace set only if getFontData*/LoadFromMemory succeeds")
print(" or via useSystem branch FONT_METRICS_FIX preserving orig face")
def main():
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "corpus" / "basic" / "hello_world.pdf"
out_dir = Path(__file__).resolve().parent
print(f"PDF: {pdf_path}")
payloads = build_payloads(pdf_path)
explain_measureface(payloads["fonts"])
# Original
orig_doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
orig_objs = dump_page_text_objects(orig_doc)
orig_bytes = pdf_path.read_bytes()
orig_stream = extract_content_stream_text_ops(orig_bytes)
print(f"\n{'='*60}\nORIGINAL PDF TEXT OBJECTS\n{'='*60}")
for o in orig_objs:
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
if o["fontMeta"]:
print(f" meta: ascent={o['fontMeta']['ascent']} descent={o['fontMeta']['descent']} "
f"subset={o['fontMeta']['isSubset']}")
print(f" content fontDicts: {orig_stream['fontDicts']}")
print(f" content Tf/Tj ops ({len(orig_stream['textOps'])}):")
for op in orig_stream["textOps"][:20]:
print(f" {op}")
# Entry preview (unchanged text)
print(f"\n--- applying EDIT ENTRY reflow (unchanged text) ---")
entry_path = out_dir / "_forensic_entry.pdf"
entry_bytes = apply_and_save(pdf_path, payloads["entry"], entry_path)
entry_doc = pdfengine.PdfDocument.load_from_memory(entry_bytes, "")
entry_objs = dump_page_text_objects(entry_doc)
entry_stream = extract_content_stream_text_ops(entry_bytes)
print(f"\n{'='*60}\nEDIT-ENTRY PDF TEXT OBJECTS\n{'='*60}")
for o in entry_objs[:20]:
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
print(f" content fontDicts: {entry_stream['fontDicts']}")
print(f" content Tf/Tj ops ({len(entry_stream['textOps'])}):")
for op in entry_stream["textOps"][:30]:
print(f" {op}")
compare_objs("ORIGINAL", orig_objs, "EDIT-ENTRY", entry_objs)
# One char
print(f"\n--- applying ONE-CHAR reflow ---")
one_path = out_dir / "_forensic_one.pdf"
one_bytes = apply_and_save(pdf_path, payloads["one"], one_path)
one_doc = pdfengine.PdfDocument.load_from_memory(one_bytes, "")
one_objs = dump_page_text_objects(one_doc)
one_stream = extract_content_stream_text_ops(one_bytes)
print(f"\n{'='*60}\nONE-CHAR PDF TEXT OBJECTS\n{'='*60}")
for o in one_objs[:20]:
print(f" text={o['text']!r} font={o['fontName']} id={o['fontId']} "
f"size={o['fontSize']:.2f} emb={o['isEmbedded']} type={o['fontType']} "
f"w={o['w']:.3f} glyphs={o['glyphCount']} objs={o['objectIndices']}")
print(f" content fontDicts: {one_stream['fontDicts']}")
print(f" content Tf/Tj ops ({len(one_stream['textOps'])}):")
for op in one_stream["textOps"][:30]:
print(f" {op}")
compare_objs("EDIT-ENTRY", entry_objs, "ONE-CHAR", one_objs)
compare_objs("ORIGINAL", orig_objs, "ONE-CHAR", one_objs)
# Summarize first differences
print(f"\n{'='*60}\nFIRST DIFFERENCES SUMMARY\n{'='*60}")
print(f" Original run count: {len(orig_objs)}")
print(f" Edit-entry run count: {len(entry_objs)}")
print(f" One-char run count: {len(one_objs)}")
print(f" Original Tj/TJ ops: {sum(1 for o in orig_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
print(f" Entry Tj/TJ ops: {sum(1 for o in entry_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
print(f" One-char Tj/TJ ops: {sum(1 for o in one_stream['textOps'] if o['op'] in ('Tj','TJ'))}")
print(f" Original fontDicts: {orig_stream['fontDicts']}")
print(f" Entry fontDicts: {entry_stream['fontDicts']}")
print(f" One-char fontDicts: {one_stream['fontDicts']}")
report = {
"pdf": str(pdf_path),
"fonts": payloads["fonts"],
"orig": orig_objs,
"entry": entry_objs,
"one": one_objs,
"streams": {
"orig": orig_stream,
"entry": entry_stream,
"one": one_stream,
},
}
out_json = out_dir / "forensic_obj_compare.json"
out_json.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\nWrote {out_json}")
if __name__ == "__main__":
main()
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
"""Post-reflow PDF object inspection via Python pdfengine."""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # type: ignore
def inspect_pdf(path: Path, label: str):
doc = pdfengine.PdfDocument.load_from_file(str(path), "")
model = doc.get_page(0).extract_document_model()
para = model.paragraphs[0]
print(f"\n=== {label} ({path.name}) ===")
for li, ln in enumerate(para.lines):
for ri, r in enumerate(ln.runs):
adv_sum = 0.0
if r.glyphs:
# derive advances from origin_x
gs = r.glyphs
for i in range(len(gs)):
if i + 1 < len(gs):
adv_sum += gs[i + 1].origin_x - gs[i].origin_x
else:
adv_sum += gs[i].bbox_w or r.font_size * 0.5
print(f" line{li} run{ri}: text={repr(r.text[:40])} font={r.font_name} size={r.font_size:.3f} w={r.w:.3f} advSum={adv_sum:.3f} glyphs={len(r.glyphs)}")
def main():
extract_path = ROOT / "tests" / "edits" / "forensic_extract_out.json"
ex = json.loads(extract_path.read_text(encoding="utf-8"))
pdf_path = Path(ex["pdf"])
inspect_pdf(pdf_path, "ORIGINAL")
doc = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
for label, key in [("ENTRY", "editEntry"), ("ONE_CHAR", "editOne")]:
op = ex["payloads"][key]
d = pdfengine.PdfDocument.load_from_file(str(pdf_path), "")
d.apply_edits(json.dumps(op))
out = d.save_full()
tmp = ROOT / "tests" / "edits" / f"_forensic_tmp_{key}.pdf"
tmp.write_bytes(out)
inspect_pdf(tmp, label)
if __name__ == "__main__":
main()
+68
View File
@@ -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.
+71
View File
@@ -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
}
]
}
}
}
+267
View File
@@ -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
}
]
}
}
}
+71
View File
@@ -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
View File
@@ -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']"
)
+9
View File
@@ -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();
}
}