diff --git a/CMakeUserPresets.json.template b/CMakeUserPresets.json.template index 3dea9bb..5354427 100644 --- a/CMakeUserPresets.json.template +++ b/CMakeUserPresets.json.template @@ -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", diff --git a/Testing/Temporary/CTestCostData.txt b/Testing/Temporary/CTestCostData.txt new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/Testing/Temporary/CTestCostData.txt @@ -0,0 +1 @@ +--- diff --git a/background_region.png b/background_region.png new file mode 100644 index 0000000..57c2879 Binary files /dev/null and b/background_region.png differ diff --git a/bindings/CMakeLists.txt b/bindings/CMakeLists.txt index d95d266..6c9ac89 100644 --- a/bindings/CMakeLists.txt +++ b/bindings/CMakeLists.txt @@ -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 $) set_target_properties(pdfengine_py PROPERTIES OUTPUT_NAME "pdfengine" diff --git a/debug_preview_region.png b/debug_preview_region.png new file mode 100644 index 0000000..97b28af Binary files /dev/null and b/debug_preview_region.png differ diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 512b7de..587962c 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -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() diff --git a/engine/include/pdfengine/edit_session.hpp b/engine/include/pdfengine/edit_session.hpp new file mode 100644 index 0000000..c01c2fd --- /dev/null +++ b/engine/include/pdfengine/edit_session.hpp @@ -0,0 +1,93 @@ +#pragma once +#include +#include +#include +#include +#include +#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; + +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 lines; + std::vector glyphs; + std::vector selectionRects; + CaretState caret; + RectList dirtyRects; +}; + +// Stable, lightweight view for WASM export +struct LayoutView { + std::vector lines; + std::vector glyphs; + CaretState caret; + RectList dirtyRects; +}; + +class EditSession { +public: + virtual ~EditSession() = default; + + static std::shared_ptr StartEditSession( + std::shared_ptr 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 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 RenderDirtyRegion(int dpi, const Rect& region) const = 0; + virtual bool CommitEdit() = 0; + virtual void CancelEdit() = 0; +}; + +} // namespace pdfengine diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 9b668b9..866eed8 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -1,12 +1,21 @@ #pragma once -#include #include #include #include #include #include +#if __has_include() +#include +#elif __has_include() +#include +namespace std { +using tl::expected; +using tl::unexpected; +} +#endif + namespace pdfengine { enum class EngineError { @@ -276,6 +285,7 @@ public: virtual std::expected, 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; } diff --git a/engine/include/pdfengine/text_layout_engine.hpp b/engine/include/pdfengine/text_layout_engine.hpp new file mode 100644 index 0000000..5f3bea4 --- /dev/null +++ b/engine/include/pdfengine/text_layout_engine.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "pdfengine/edit_session.hpp" +#include "fonts/shaping/hb_shaper.hpp" +#include "fonts/face/font_face.hpp" +#include +#include +#include + +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& 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 currentLineGlyphs_; + std::vector finishedLines_; + std::vector 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 diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index d710919..a4914e0 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -103,6 +103,7 @@ public: void invalidateCaches(); std::expected, EngineError> applyEdits(const std::string& editsJson) override; + std::string validateLayout(int pageIndex, const std::string& jsonStr) override; std::expected, EngineError> saveIncremental() const override; std::expected, EngineError> saveFull() const override; std::expected, EngineError> saveFullForExport() const override; @@ -151,13 +152,14 @@ private: double fontSize, const std::vector& codepoints, const std::vector& srcObjects = {}, FPDF_FONT reuseFont = nullptr); +#endif mutable std::unordered_map reconstructedFonts_; mutable std::mutex reconstructedFontsMutex_; void registerAuxFont(const std::string& internalFontId, const std::vector& sfnt) override; std::expected, 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 applyOp_pageReorder(const nlohmann::json& op, int pageIndex); std::expected applyOp_deleteAnnotation(const nlohmann::json& op, int pageIndex); std::expected applyOp_updateAnnotation(const nlohmann::json& op, int pageIndex); -#endif }; std::vector utf8_to_utf16le(const std::string& utf8); diff --git a/engine/src/parser/pdfium_edit_pages.cpp b/engine/src/parser/pdfium_edit_pages.cpp index 0e255b1..7b6fe8a 100644 --- a/engine/src/parser/pdfium_edit_pages.cpp +++ b/engine/src/parser/pdfium_edit_pages.cpp @@ -133,19 +133,23 @@ std::expected PdfiumDocument::applyOp_pageRotation(const nloh #endif } -#ifdef PDFENGINE_WITH_PDFIUM std::expected 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 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 PdfiumDocument::applyOp_pageReorder(const nlohm return std::unexpected(EngineError::Unknown); } return {}; -} +#else + (void)op; (void)pageIndex; + return std::unexpected(EngineError::Unknown); #endif +} } \ No newline at end of file diff --git a/engine/src/parser/pdfium_edit_reflow.cpp b/engine/src/parser/pdfium_edit_reflow.cpp index 5dc3ea5..acf807a 100644 --- a/engine/src/parser/pdfium_edit_reflow.cpp +++ b/engine/src/parser/pdfium_edit_reflow.cpp @@ -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 PdfiumDocument::applyOp_reflow(const nlohmann::json& op, int pageIndex) { @@ -30,7 +32,7 @@ std::expected 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()); @@ -142,46 +144,43 @@ std::expected PdfiumDocument::applyOp_reflow(const nlohmann:: spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById); } - { - std::unordered_map exactByBaseName; - std::vector 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(mtx.a) * mtx.a + - static_cast(mtx.b) * mtx.b); - double exact = static_cast(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 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(mtx.a) * mtx.a + static_cast(mtx.b) * mtx.b); + exactScaleY = std::sqrt(static_cast(mtx.c) * mtx.c + static_cast(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 runFonts(runs.size()); auto toCodepoints = [](const std::string& s) { auto u16 = utf8_to_utf16le(s); @@ -241,7 +240,7 @@ std::expected 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(kRefSize); + double scale = (size * textAspect) / static_cast(kRefSize); fonts::FontFace* face = rf.measureFace ? rf.measureFace.get() : (rf.resolved ? &rf.resolved->getFontFace() : nullptr); if (face) { @@ -259,6 +258,9 @@ std::expected 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> runCharAdv(runs.size()); std::vector runPerChar(runs.size(), 0); for (size_t ri = 0; ri < runs.size(); ++ri) { @@ -268,9 +270,17 @@ std::expected 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 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 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 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(runs[seg.runIdx].fontSize)); + std::string baseFontName; + { + size_t nl = FPDFFont_GetBaseFontName(font, nullptr, 0); + if (nl > 0) { + std::vector 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(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(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 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 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 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(); + 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 +} + } \ No newline at end of file diff --git a/engine/src/parser/pdfium_edit_session.cpp b/engine/src/parser/pdfium_edit_session.cpp new file mode 100644 index 0000000..88f2b00 --- /dev/null +++ b/engine/src/parser/pdfium_edit_session.cpp @@ -0,0 +1,180 @@ +#include "pdfengine/edit_session.hpp" +#include "parser/pdfium_edit_session.hpp" +#include "parser/pdfium_document.hpp" +#include +#include + +namespace pdfengine { + +std::shared_ptr EditSession::StartEditSession( + std::shared_ptr doc, + int pageIndex, + const std::string& paraId +) { + if (!doc) { + spdlog::error("StartEditSession: null document"); + return nullptr; + } + return std::make_shared(doc, pageIndex, paraId); +} + +} // namespace pdfengine + +namespace pdfengine::parser { + +PdfiumEditSession::PdfiumEditSession(std::shared_ptr 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(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(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 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 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 diff --git a/engine/src/parser/pdfium_edit_session.hpp b/engine/src/parser/pdfium_edit_session.hpp new file mode 100644 index 0000000..8bb8536 --- /dev/null +++ b/engine/src/parser/pdfium_edit_session.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "pdfengine/edit_session.hpp" +#include "pdfengine/text_layout_engine.hpp" +#include "parser/pdfium_internal.hpp" +#include +#include + +namespace pdfengine::parser { + +class PdfiumEditSession : public EditSession { +public: + PdfiumEditSession(std::shared_ptr 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 GetSelectionRects(int startOffset, int endOffset) const override; + + void ApplyEdit(const std::string& editOpJson) override; + + std::vector RenderDirtyRegion(int dpi, const Rect& region) const override; + bool CommitEdit() override; + void CancelEdit() override; + +private: + void RebuildLayoutIfNeeded() const; + + std::shared_ptr doc_; + int pageIndex_; + std::string paraId_; + + // Live editing state + std::string currentText_; + text::LayoutConstraints constraints_; + std::shared_ptr currentFace_; + double currentFontSize_ = 12.0; + + // Layout cache and engine + mutable text::TextLayoutEngine layoutEngine_; + mutable LayoutResult cachedLayout_; + mutable bool isDirty_ = true; +}; + +} // namespace pdfengine::parser diff --git a/engine/src/parser/pdfium_fonts.cpp b/engine/src/parser/pdfium_fonts.cpp index 785ede4..8a1c583 100644 --- a/engine/src/parser/pdfium_fonts.cpp +++ b/engine/src/parser/pdfium_fonts.cpp @@ -2,8 +2,8 @@ namespace pdfengine::parser { -#ifdef PDFENGINE_WITH_PDFIUM void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector& 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 lock(reconstructedFontsMutex_); reconstructedFonts_[internalFontId] = std::move(rf); +#else + (void)internalFontId; (void)sfnt; +#endif } std::expected, 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(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 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(); + 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 lock(loadedFontsMutex_); @@ -220,8 +281,19 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) { auto mf = std::make_shared(); 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 diff --git a/engine/src/parser/pdfium_page.cpp b/engine/src/parser/pdfium_page.cpp index 448a4fc..b0f0739 100644 --- a/engine/src/parser/pdfium_page.cpp +++ b/engine/src/parser/pdfium_page.cpp @@ -136,6 +136,9 @@ std::expected PdfiumPage::renderRegionRaw(int dpi, doubl const auto* bgra = static_cast(FPDFBitmap_GetBuffer(bitmap)); const int stride = FPDFBitmap_GetStride(bitmap); std::vector rgba(static_cast(w) * regionH * 4); + + bool allAlpha255 = true; + size_t non255Count = 0; for (int y = 0; y < regionH; ++y) { const uint8_t* src = bgra + static_cast(y) * stride; uint8_t* dst = rgba.data() + static_cast(y) * w * 4; @@ -144,8 +147,31 @@ std::expected 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 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 diff --git a/engine/src/parser/pdfium_page_model.cpp b/engine/src/parser/pdfium_page_model.cpp index 1d15c26..b38a51f 100644 --- a/engine/src/parser/pdfium_page_model.cpp +++ b/engine/src/parser/pdfium_page_model.cpp @@ -87,6 +87,19 @@ std::expected 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(mtx.c), static_cast(mtx.d)); + double scaleY2 = std::hypot(static_cast(mtx.a), static_cast(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); diff --git a/engine/src/text/text_layout_engine.cpp b/engine/src/text/text_layout_engine.cpp new file mode 100644 index 0000000..206b018 --- /dev/null +++ b/engine/src/text/text_layout_engine.cpp @@ -0,0 +1,135 @@ +#include "pdfengine/text_layout_engine.hpp" +#include + +namespace pdfengine::text { + +LineBreaker::LineBreaker(const LayoutConstraints& constraints) + : constraints_(constraints), + currentX_(constraints.columnLeft), + currentY_(constraints.firstBaselineY) {} + +void LineBreaker::ProcessRun( + const std::vector& 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(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(kRefSize); + + LineBreaker breaker(constraints); + breaker.ProcessRun(shapedGlyphs, text, fontSize, fontFace, scale); + breaker.Finalize(result); + + return result; +} + +} // namespace pdfengine::text diff --git a/engine/tests/CMakeLists.txt b/engine/tests/CMakeLists.txt index e1b053d..faee519 100644 --- a/engine/tests/CMakeLists.txt +++ b/engine/tests/CMakeLists.txt @@ -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 diff --git a/engine/tests/edit_session_test.cpp b/engine/tests/edit_session_test.cpp new file mode 100644 index 0000000..581e8a0 --- /dev/null +++ b/engine/tests/edit_session_test.cpp @@ -0,0 +1,57 @@ +#include "document_test_helpers.hpp" +#include "pdfengine/pdf_document.hpp" +#include "pdfengine/edit_session.hpp" +#include +#include + +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 diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 17f26df..853316f 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -438,7 +438,11 @@ class GatewayService { private renderCache = new Map(); async renderPage(params: RenderParams): Promise { - 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}`; diff --git a/frontend/src/lib/pdfiumEngine.ts b/frontend/src/lib/pdfiumEngine.ts index 661ee89..edf9c33 100644 --- a/frontend/src/lib/pdfiumEngine.ts +++ b/frontend/src/lib/pdfiumEngine.ts @@ -181,3 +181,12 @@ export async function wasmFreeDocument(documentId: string): Promise { const M = await getModule(); if (M) M.ccall('freeDocument', null, ['number'], [h]); } + +export async function wasmDebugValidateLayout(documentId: string, pageIndex: number, jsonStr: string): Promise { + 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 || '{}'; +} diff --git a/frontend/src/viewer/AnnotationLayer.tsx b/frontend/src/viewer/AnnotationLayer.tsx index 360c39c..60f2d6f 100644 --- a/frontend/src/viewer/AnnotationLayer.tsx +++ b/frontend/src/viewer/AnnotationLayer.tsx @@ -158,7 +158,6 @@ export const AnnotationLayer: React.FC = ({ {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 = ({ {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; diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index 4eba4d0..3841c6f 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -33,7 +33,10 @@ function lineAdvances(line: any): { perRun: Record; 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; @@ -237,9 +238,9 @@ let markerMeasureCanvas: HTMLCanvasElement | null = null; function measureTextWidth(text: string, sizePt: number, family: string): number { if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas'); const ctx = markerMeasureCanvas.getContext('2d'); - if (!ctx) return sizePt; + if (!ctx) return sizePt; ctx.font = `${sizePt}px ${family}`; - return ctx.measureText(text).width; + return ctx.measureText(text).width; } function applyListMarkers( @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }], }); + const caretIndexRef = useRef(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 = ({ 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 = ({ if (!el) return; const fids = new Set([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 = ({ 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 = ({ {fallbackVisible && (
)} = ({ if (e.nativeEvent.isComposing || composingRef.current) return; if (e.key === 'Enter') { e.preventDefault(); - if (e.shiftKey) commit(); - else insertHardBreak(); + if (e.shiftKey) commit(); + else insertHardBreak(); } else if (e.key === 'Escape') { e.preventDefault(); cancel(); } else if (e.key === 'Tab' && listKind !== null) { @@ -736,12 +867,12 @@ export const ParagraphEditor: React.FC = ({ 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', }} /> diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index b267a96..c10d9f4 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -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 = ({ 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 = ({ }, }], }); - 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 = ({ 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`, diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index 2fdb0cc..1d4a77b 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -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) diff --git a/preview_region.png b/preview_region.png new file mode 100644 index 0000000..c783706 Binary files /dev/null and b/preview_region.png differ diff --git a/tests/edits/_forensic_A_default_entry.pdf b/tests/edits/_forensic_A_default_entry.pdf new file mode 100644 index 0000000..3440621 --- /dev/null +++ b/tests/edits/_forensic_A_default_entry.pdf @@ -0,0 +1,42 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Type/Page>> +endobj +6 0 obj +<> +endobj +7 0 obj +<> +endobj +8 0 obj +<>stream +xœ=k0Ew;6C+ɖ-0|؅@! Sj')mM!:*[<3B9542415021229C68B8A7743652C304>]>> +startxref +740 +%%EOF diff --git a/tests/edits/_forensic_B_force_whole_run.pdf b/tests/edits/_forensic_B_force_whole_run.pdf new file mode 100644 index 0000000..c43484a --- /dev/null +++ b/tests/edits/_forensic_B_force_whole_run.pdf @@ -0,0 +1,42 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Type/Page>> +endobj +6 0 obj +<> +endobj +7 0 obj +<> +endobj +8 0 obj +<>stream +xK @E+|c +D5BVfT.QA-Vq7guc/[fp83S `fʂ"qc}h_##6|ToyAO&WT 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 +<<3B9542415021229C68B8A7743652C304>]>> +startxref +672 +%%EOF diff --git a/tests/edits/_forensic_entry.pdf b/tests/edits/_forensic_entry.pdf new file mode 100644 index 0000000..3394e98 Binary files /dev/null and b/tests/edits/_forensic_entry.pdf differ diff --git a/tests/edits/_forensic_one.pdf b/tests/edits/_forensic_one.pdf new file mode 100644 index 0000000..c1ca5b4 Binary files /dev/null and b/tests/edits/_forensic_one.pdf differ diff --git a/tests/edits/_forensic_tmp_editEntry.pdf b/tests/edits/_forensic_tmp_editEntry.pdf new file mode 100644 index 0000000..5405831 --- /dev/null +++ b/tests/edits/_forensic_tmp_editEntry.pdf @@ -0,0 +1,40 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Type/Page>> +endobj +4 0 obj +<>stream +xŚMkTQ +R:9 B?fAPEA\_(X}:-0sfVMrN +iqQհ=kؾN6kj~W>~wO +w6Y\F",N6jDma޴ק>{Ӷz^/W-N Fy 3rAX2rT "u誈)8 "x'P1.1b,Qp^jFUp@rn1LΫ6&"ZU =vR X9" +@l3^$)/p /s6=z~`,< v;rfA]uclr є=ͱfEFW,3G7 Usqξb h,sfëA׽=|ī숩*k 1EݛF*k ^s`wv9929^tL؋FU"ec%ytfDYB@jhav[WtfdA c>Ulb OeNT)gt9ʑČs绪1˝3IEi9MtLt }LTW`jC##W-U*[ʪfOV(]Vf«@wpz?ܯAw^#3#I«BY >pJ0n*8)LWj46W2ݯaTװ ȚdHpL`d0?t>]zk}їVn$ +endstream +endobj +6 0 obj +<> +endobj +7 0 obj +<> +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 +<<9C526D432005A510CFC4B813E5B68C5B>]>> +startxref +1307 +%%EOF diff --git a/tests/edits/_forensic_tmp_editOne.pdf b/tests/edits/_forensic_tmp_editOne.pdf new file mode 100644 index 0000000..6e38f32 --- /dev/null +++ b/tests/edits/_forensic_tmp_editOne.pdf @@ -0,0 +1,37 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Type/Page>> +endobj +4 0 obj +<>stream +x;k\A +T&EfGh> `Rp ^acHRC ӨF::OSӻ|O/񁘾S3yZVvzz}sxݧ^n6;짧}~u-?k&Hp*hBv՘iLˑ^< 0e|.~_ w8``=1hMEK!0h5΀+30 1P[7̘G )BD+'9j-5ysƌȩú ɏcw AQ5d _H2 >. Ÿ rUt [^X`놃g/rؒCvhl".JД9Wz("٥#n;WW*i3US5t?.}= +endstream +endobj +6 0 obj +<> +endobj +7 0 obj +<> +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 +<<9C526D432005A510CFC4B813E5B68C5B>]>> +startxref +892 +%%EOF diff --git a/tests/edits/_tmp_entry.pdf b/tests/edits/_tmp_entry.pdf new file mode 100644 index 0000000..bda4a8d Binary files /dev/null and b/tests/edits/_tmp_entry.pdf differ diff --git a/tests/edits/_tmp_force.pdf b/tests/edits/_tmp_force.pdf new file mode 100644 index 0000000..bda4a8d Binary files /dev/null and b/tests/edits/_tmp_force.pdf differ diff --git a/tests/edits/_tmp_one.pdf b/tests/edits/_tmp_one.pdf new file mode 100644 index 0000000..777728f Binary files /dev/null and b/tests/edits/_tmp_one.pdf differ diff --git a/tests/edits/build_log.txt b/tests/edits/build_log.txt new file mode 100644 index 0000000..0be82a7 Binary files /dev/null and b/tests/edits/build_log.txt differ diff --git a/tests/edits/build_winlocal.txt b/tests/edits/build_winlocal.txt new file mode 100644 index 0000000..8753f9e Binary files /dev/null and b/tests/edits/build_winlocal.txt differ diff --git a/tests/edits/forensic_compare.py b/tests/edits/forensic_compare.py new file mode 100644 index 0000000..aa76f33 --- /dev/null +++ b/tests/edits/forensic_compare.py @@ -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() diff --git a/tests/edits/forensic_extract.py b/tests/edits/forensic_extract.py new file mode 100644 index 0000000..fa05c04 --- /dev/null +++ b/tests/edits/forensic_extract.py @@ -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() diff --git a/tests/edits/forensic_extract_out.json b/tests/edits/forensic_extract_out.json new file mode 100644 index 0000000..3c2c8eb --- /dev/null +++ b/tests/edits/forensic_extract_out.json @@ -0,0 +1,1178 @@ +{ + "pdf": "tests\\edits\\forensic_multiline.pdf", + "fonts": [ + { + "internalFontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "ascent": 905.0, + "descent": -211.0, + "capHeight": 728.0, + "unitsPerEm": null + } + ], + "layout": { + "columnLeft": 72.25299835205078, + "columnRight": 322.2529983520508, + "firstBaselineY": 700.0, + "leading": 14.0, + "oldLineCount": 3, + "align": "left", + "objectIndices": [ + 0, + 1, + 2 + ], + "seedRuns": [ + { + "text": "The quick brown fox jumps over the lazy", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "fontName": "Helvetica", + "advances": [ + 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 + ] + }, + { + "text": " dog near the river bank on a sunny", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "fontName": "Helvetica", + "advances": [ + 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 + ] + }, + { + "text": " afternoon in early spring.", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "fontName": "Helvetica", + "advances": [ + 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 + ] + } + ], + "origLines": [ + { + "frags": [ + { + "text": "The quick brown fox jumps over the lazy", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ], + "x": 72.0, + "baselineY": 700.0 + }, + { + "frags": [ + { + "text": "dog near the river bank on a sunny", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ], + "x": 72.0, + "baselineY": 686.0 + }, + { + "frags": [ + { + "text": "afternoon in early spring.", + "fid": "Helvetica_Type1_32", + "size": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ], + "x": 72.0, + "baselineY": 672.0 + } + ], + "domSize": 11.0 + }, + "original": { + "stage": "ORIGINAL_PDF_EXTRACTION", + "text": "The quick brown fox jumps over the lazydog near the river bank on a sunnyafternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 99, + "advanceCount": 99, + "advances": [ + 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, + 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, + 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 + ], + "boundingBox": [ + 72.25299835205078, + 669.7009887695312, + 195.88799285888672, + 38.3070068359375 + ], + "paragraphWidth": 250.0, + "lineWidths": [ + 195.88799285888672, + 169.50997161865234, + 119.70198822021484 + ], + "baseline": 700.0, + "ascent": null, + "descent": null, + "lineHeight": 14.0, + "origLines": [ + "The quick brown fox jumps over the lazy", + "dog near the river bank on a sunny", + "afternoon in early spring." + ], + "runs": [ + { + "text": "The quick brown fox jumps over the lazy", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 39, + "advanceCount": 39, + "advances": [ + 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 + ], + "bbox": [ + 72.25299835205078, + 697.7009887695312, + 195.88799285888672, + 10.3070068359375 + ], + "lineWidth": 195.96502685546875 + }, + { + "text": "dog near the river bank on a sunny", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 34, + "advanceCount": 34, + "advances": [ + 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 + ], + "bbox": [ + 72.3740005493164, + 683.7009887695312, + 169.50997161865234, + 10.17498779296875 + ], + "lineWidth": 169.70797729492188 + }, + { + "text": "afternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 26, + "advanceCount": 26, + "advances": [ + 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 + ], + "bbox": [ + 72.39600372314453, + 669.7009887695312, + 119.70198822021484, + 10.3070068359375 + ], + "lineWidth": 119.09700012207031 + } + ], + "origLinesProvided": false + }, + "editEntry": { + "stage": "EDIT_MODE_ENTRY_PAYLOAD", + "text": "The quick brown fox jumps over the lazy dog near the river bank on a sunny afternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 101, + "advanceCount": 39, + "advances": [ + 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 + ], + "boundingBox": null, + "paragraphWidth": 250.0, + "lineWidths": null, + "baseline": 700.0, + "ascent": null, + "descent": null, + "lineHeight": 14.0, + "origLines": [ + "The quick brown fox jumps over the lazy", + "dog near the river bank on a sunny", + "afternoon in early spring." + ], + "runs": [ + { + "text": "The quick brown fox jumps over the lazy", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 39, + "advances": [ + 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 + ], + "advanceSource": "frontend_origin_x_derived" + }, + { + "text": " dog near the river bank on a sunny", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 0, + "advances": null, + "advanceSource": "missing" + }, + { + "text": " afternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 0, + "advances": null, + "advanceSource": "missing" + } + ], + "origLinesProvided": true + }, + "editOne": { + "stage": "AFTER_ONE_CHAR_PAYLOAD", + "text": "The quick brown fox jumps over the lazyx dog near the river bank on a sunny afternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontName": "Helvetica", + "fontSize": 11.0, + "color": "#000000", + "glyphCount": 102, + "advanceCount": 39, + "advances": [ + 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 + ], + "boundingBox": null, + "paragraphWidth": 250.0, + "lineWidths": null, + "baseline": 700.0, + "ascent": null, + "descent": null, + "lineHeight": 14.0, + "origLines": null, + "runs": [ + { + "text": "The quick brown fox jumps over the lazyx", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 39, + "advances": [ + 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 + ], + "advanceSource": "frontend_origin_x_derived" + }, + { + "text": " dog near the river bank on a sunny", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 0, + "advances": null, + "advanceSource": "missing" + }, + { + "text": " afternoon in early spring.", + "fontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advanceCount": 0, + "advances": null, + "advanceSource": "missing" + } + ], + "origLinesProvided": false + }, + "payloads": { + "editEntry": { + "version": "1.0", + "operations": [ + { + "id": "forensic-entry", + "type": "reflow_paragraph", + "pageIndex": 0, + "data": { + "objectIndices": [ + 0, + 1, + 2 + ], + "runs": [ + { + "text": "The quick brown fox jumps over the lazy", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + }, + { + "text": " dog near the river bank on a sunny", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000" + }, + { + "text": " afternoon in early spring.", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000" + } + ], + "lineX": [ + 72.0, + 72.0, + 72.0 + ], + "lineBaselineY": [ + 700.0, + 686.0, + 672.0 + ], + "lines": [ + [ + { + "text": "The quick brown fox jumps over the lazy", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ], + [ + { + "text": "dog near the river bank on a sunny", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ], + [ + { + "text": "afternoon in early spring.", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + } + ] + ], + "columnLeft": 72.25299835205078, + "columnRight": 322.2529983520508, + "pushColumnLeft": 72.25299835205078, + "firstBaselineY": 700.0, + "leading": 14.0, + "oldLineCount": 3, + "align": "left", + "paraId": "forensic-a307c673" + } + } + ] + }, + "editOne": { + "version": "1.0", + "operations": [ + { + "id": "forensic-one", + "type": "reflow_paragraph", + "pageIndex": 0, + "data": { + "objectIndices": [ + 0, + 1, + 2 + ], + "runs": [ + { + "text": "The quick brown fox jumps over the lazyx", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000", + "advances": [ + 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 + ] + }, + { + "text": " dog near the river bank on a sunny", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000" + }, + { + "text": " afternoon in early spring.", + "internalFontId": "Helvetica_Type1_32", + "fontSize": 11.0, + "color": "#000000" + } + ], + "lineX": [ + 72.0, + 72.0, + 72.0 + ], + "lineBaselineY": [ + 700.0, + 686.0, + 672.0 + ], + "columnLeft": 72.25299835205078, + "columnRight": 322.2529983520508, + "pushColumnLeft": 72.25299835205078, + "firstBaselineY": 700.0, + "leading": 14.0, + "oldLineCount": 3, + "align": "left", + "paraId": "forensic-a307c673" + } + } + ] + } + }, + "signatures": { + "original": "580b9da36ba5218bd84c55fed1c2c96b3dacdd32bea5e3883a8d0e9683810f5e", + "editEntryPayload": "bd8bbd8ff6023485685cd56f3e41f304af4b68f92f4a61a1c286427580a63aa4", + "editOnePayload": "6e6b671e052106608b3f9c920565e6fb568c49f1fb67c5be7665f66eec172443" + } +} \ No newline at end of file diff --git a/tests/edits/forensic_force_whole.py b/tests/edits/forensic_force_whole.py new file mode 100644 index 0000000..289e9fe --- /dev/null +++ b/tests/edits/forensic_force_whole.py @@ -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"<]+>>", 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() diff --git a/tests/edits/forensic_hash_compare.py b/tests/edits/forensic_hash_compare.py new file mode 100644 index 0000000..08de358 --- /dev/null +++ b/tests/edits/forensic_hash_compare.py @@ -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"<]+>>", 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() diff --git a/tests/edits/forensic_multiline.pdf b/tests/edits/forensic_multiline.pdf new file mode 100644 index 0000000..b76ac2e --- /dev/null +++ b/tests/edits/forensic_multiline.pdf @@ -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 diff --git a/tests/edits/forensic_obj_compare.json b/tests/edits/forensic_obj_compare.json new file mode 100644 index 0000000..0b8b3a9 --- /dev/null +++ b/tests/edits/forensic_obj_compare.json @@ -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 + } + } +} \ No newline at end of file diff --git a/tests/edits/forensic_obj_compare.py b/tests/edits/forensic_obj_compare.py new file mode 100644 index 0000000..e6d8ca9 --- /dev/null +++ b/tests/edits/forensic_obj_compare.py @@ -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() diff --git a/tests/edits/forensic_obj_compare_out.txt b/tests/edits/forensic_obj_compare_out.txt new file mode 100644 index 0000000..1f82a1d Binary files /dev/null and b/tests/edits/forensic_obj_compare_out.txt differ diff --git a/tests/edits/forensic_pdf_objects.py b/tests/edits/forensic_pdf_objects.py new file mode 100644 index 0000000..cfbc85d --- /dev/null +++ b/tests/edits/forensic_pdf_objects.py @@ -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() diff --git a/tests/edits/forensic_wasm.mjs b/tests/edits/forensic_wasm.mjs new file mode 100644 index 0000000..14e9db5 --- /dev/null +++ b/tests/edits/forensic_wasm.mjs @@ -0,0 +1,68 @@ +/** + * WASM reflow forensic: run payloads and capture lastLayoutJson. + * Usage: node forensic_wasm.mjs [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]); diff --git a/tests/edits/forensic_wasm_embedded.json b/tests/edits/forensic_wasm_embedded.json new file mode 100644 index 0000000..c1e6241 Binary files /dev/null and b/tests/edits/forensic_wasm_embedded.json differ diff --git a/tests/edits/forensic_wasm_hello.json b/tests/edits/forensic_wasm_hello.json new file mode 100644 index 0000000..7c08582 --- /dev/null +++ b/tests/edits/forensic_wasm_hello.json @@ -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 + } + ] + } + } +} diff --git a/tests/edits/forensic_wasm_multiline.json b/tests/edits/forensic_wasm_multiline.json new file mode 100644 index 0000000..26133fb --- /dev/null +++ b/tests/edits/forensic_wasm_multiline.json @@ -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 + } + ] + } + } +} diff --git a/tests/edits/forensic_wasm_out.json b/tests/edits/forensic_wasm_out.json new file mode 100644 index 0000000..507d776 --- /dev/null +++ b/tests/edits/forensic_wasm_out.json @@ -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 + } + ] + } + } +} \ No newline at end of file diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 57056bf..f0574b6 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -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']" ) diff --git a/wasm/bindings/wasm_engine.cpp b/wasm/bindings/wasm_engine.cpp index 51f75b4..a4b3570 100644 --- a/wasm/bindings/wasm_engine.cpp +++ b/wasm/bindings/wasm_engine.cpp @@ -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(); +} + } \ No newline at end of file