From ca3d124b0d546f69b8814123f1b59f842dfe2a35 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 30 Jul 2026 16:48:46 +0530 Subject: [PATCH] fix --- CMakeUserPresets.json.template | 6 +- Testing/Temporary/CTestCostData.txt | 1 + background_region.png | Bin 0 -> 6545 bytes bindings/CMakeLists.txt | 2 +- debug_preview_region.png | Bin 0 -> 646 bytes engine/CMakeLists.txt | 52 +- engine/include/pdfengine/edit_session.hpp | 93 ++ engine/include/pdfengine/pdf_document.hpp | 12 +- .../include/pdfengine/text_layout_engine.hpp | 66 + engine/src/parser/pdfium_document.hpp | 5 +- engine/src/parser/pdfium_edit_pages.cpp | 15 +- engine/src/parser/pdfium_edit_reflow.cpp | 209 ++- engine/src/parser/pdfium_edit_session.cpp | 180 +++ engine/src/parser/pdfium_edit_session.hpp | 46 + engine/src/parser/pdfium_fonts.cpp | 78 +- engine/src/parser/pdfium_page.cpp | 26 + engine/src/parser/pdfium_page_model.cpp | 13 + engine/src/text/text_layout_engine.cpp | 135 ++ engine/tests/CMakeLists.txt | 1 + engine/tests/edit_session_test.cpp | 57 + frontend/src/lib/gatewayService.ts | 7 +- frontend/src/lib/pdfiumEngine.ts | 9 + frontend/src/viewer/AnnotationLayer.tsx | 2 - frontend/src/viewer/ParagraphEditor.tsx | 223 +++- frontend/src/viewer/TextEditLayer.tsx | 98 +- gateway/app/routers/render.py | 7 +- preview_region.png | Bin 0 -> 6278 bytes tests/edits/_forensic_A_default_entry.pdf | 42 + tests/edits/_forensic_B_force_whole_run.pdf | 42 + tests/edits/_forensic_entry.pdf | Bin 0 -> 1404 bytes tests/edits/_forensic_one.pdf | Bin 0 -> 1297 bytes tests/edits/_forensic_tmp_editEntry.pdf | 40 + tests/edits/_forensic_tmp_editOne.pdf | 37 + tests/edits/_tmp_entry.pdf | Bin 0 -> 1030 bytes tests/edits/_tmp_force.pdf | Bin 0 -> 1030 bytes tests/edits/_tmp_one.pdf | Bin 0 -> 966 bytes tests/edits/build_log.txt | Bin 0 -> 9286 bytes tests/edits/build_winlocal.txt | Bin 0 -> 13436 bytes tests/edits/forensic_compare.py | 112 ++ tests/edits/forensic_extract.py | 407 ++++++ tests/edits/forensic_extract_out.json | 1178 +++++++++++++++++ tests/edits/forensic_force_whole.py | 138 ++ tests/edits/forensic_hash_compare.py | 130 ++ tests/edits/forensic_multiline.pdf | 35 + tests/edits/forensic_obj_compare.json | 849 ++++++++++++ tests/edits/forensic_obj_compare.py | 308 +++++ tests/edits/forensic_obj_compare_out.txt | Bin 0 -> 25086 bytes tests/edits/forensic_pdf_objects.py | 48 + tests/edits/forensic_wasm.mjs | 68 + tests/edits/forensic_wasm_embedded.json | Bin 0 -> 5910 bytes tests/edits/forensic_wasm_hello.json | 71 + tests/edits/forensic_wasm_multiline.json | 267 ++++ tests/edits/forensic_wasm_out.json | 71 + wasm/CMakeLists.txt | 2 +- wasm/bindings/wasm_engine.cpp | 9 + 55 files changed, 5015 insertions(+), 182 deletions(-) create mode 100644 Testing/Temporary/CTestCostData.txt create mode 100644 background_region.png create mode 100644 debug_preview_region.png create mode 100644 engine/include/pdfengine/edit_session.hpp create mode 100644 engine/include/pdfengine/text_layout_engine.hpp create mode 100644 engine/src/parser/pdfium_edit_session.cpp create mode 100644 engine/src/parser/pdfium_edit_session.hpp create mode 100644 engine/src/text/text_layout_engine.cpp create mode 100644 engine/tests/edit_session_test.cpp create mode 100644 preview_region.png create mode 100644 tests/edits/_forensic_A_default_entry.pdf create mode 100644 tests/edits/_forensic_B_force_whole_run.pdf create mode 100644 tests/edits/_forensic_entry.pdf create mode 100644 tests/edits/_forensic_one.pdf create mode 100644 tests/edits/_forensic_tmp_editEntry.pdf create mode 100644 tests/edits/_forensic_tmp_editOne.pdf create mode 100644 tests/edits/_tmp_entry.pdf create mode 100644 tests/edits/_tmp_force.pdf create mode 100644 tests/edits/_tmp_one.pdf create mode 100644 tests/edits/build_log.txt create mode 100644 tests/edits/build_winlocal.txt create mode 100644 tests/edits/forensic_compare.py create mode 100644 tests/edits/forensic_extract.py create mode 100644 tests/edits/forensic_extract_out.json create mode 100644 tests/edits/forensic_force_whole.py create mode 100644 tests/edits/forensic_hash_compare.py create mode 100644 tests/edits/forensic_multiline.pdf create mode 100644 tests/edits/forensic_obj_compare.json create mode 100644 tests/edits/forensic_obj_compare.py create mode 100644 tests/edits/forensic_obj_compare_out.txt create mode 100644 tests/edits/forensic_pdf_objects.py create mode 100644 tests/edits/forensic_wasm.mjs create mode 100644 tests/edits/forensic_wasm_embedded.json create mode 100644 tests/edits/forensic_wasm_hello.json create mode 100644 tests/edits/forensic_wasm_multiline.json create mode 100644 tests/edits/forensic_wasm_out.json 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 0000000000000000000000000000000000000000..57c28796b1949ac10afe06332c4d0418da22aa76 GIT binary patch literal 6545 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 NqM#xF`L5Z^{{jWZaWMb@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..97b28afb228939994e5eb831b23a33071a6d2aca GIT binary patch literal 646 zcmeAS@N?(olHy`uVBq!ia0y~yU^xL~2XL?f$v0a(jTji1d_7$pLn`LHy +#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 0000000000000000000000000000000000000000..c783706386495912f0120a66ee39c6c49ee288cd GIT binary patch literal 6278 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3394e98cefe2e923b4137a800fb3e5b670fde69f GIT binary patch literal 1404 zcmY!laBiE*l&DfW-9FVg)0hNRWblNM%8)zH?$p zVorX#ogFV%YF-Lh9Y`9c!8yM)uSCI6-#as%SkTQ-!31KATZEgDf(29oPzl&;AeSIJ1f&+`5U0fARFFCP zA(^?U#kxWHxrurD!KFzhpfCaHHiVb~QiE)U8Il=pnK>n?Mfz?zKu5ZyCg-Q5>ieYT zrI%zV7#bSc*%g-*r6%U`a#hT6?Kb2(5qg$LM&k}W zBMSrM(A#>&Kby%=fc3-gx~}pZ{ z==1J8lUcVXMQwT0j(JC!_jP|3YvLCO7Wlm=XS(pgZ%svJdFjnZ8GM7^#l15-{`vLo z)a~zN=YQY(_2J)r_m3CYX!-5Wt&`%nJ`t`fu)1xXnrw?pxx-Dp=+=r7X@R?vtKF9V zZ^`TOY_Bw|lo9=(vf4CAGg3Y0{ihp3z3QIxH@VNuNjK5R6fV19W2T+#ESl`|G;i~b zi?gC~dlW^jQx;~YFS@p5$ugVs+3fils>hy82#?H&zrW1+j8piQ|WOfev!kcSX61Dc5@W@uuHA!cL& zEJ0xE%#bWHHU;K-m;{(%U|?zt%$De4=9re7nE}%!syaghBP8=miV`z(Qj5UFTu^>K zu+RegTt7IoDixR^^*vppZCu>U99>NYw(0GV~e)Bpeg literal 0 HcmV?d00001 diff --git a/tests/edits/_forensic_one.pdf b/tests/edits/_forensic_one.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c1ca5b48e41fb3b734828d109f10fcb83039bc2b GIT binary patch literal 1297 zcma)6ZAcVB7!JaSjRWJ3(l8l+2ti(Zvv+$RWS4q(t7ev*Cs}sp=#AIb-LCD<RIX-?C062THt0n{963>8SB z>r_NRtjY0lhI99DeIgTW@GEq-7O*C&f$)RD498Lp=AjSw*SRqD(^y3COg4wlL ziju7WLeA?|iGoqw#3VZcM6OJ6+|6)mcUb|)AmQcl| z%#6m;fit5=ZgkrpnNh**u1o9gadO_o&y~jx?Ar`G&2I`TyRKed=sH+^XXNGC=Gy+` zt@`%9;aF`yziRW51Ky0MCu_~>(h2<{Q-`PSR16_uiQt%$li-3%S)jrxXk6gA8T6@$$Rx+WOZiy#J6UPIP|tHl-&~e23RopbA(KLS OcpH8Has%rH6Y~qxOO0p% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bda4a8d5c165d2d501d7cce90ee74a6691e7ae2d GIT binary patch literal 1030 zcmah|TSyd97!D+hp)G^LD9IyaJ=xrLXLn><+}mW>rmcpy%VM*;$8Bnz(V4Tg1u1lU zFfb5WQ+miQkOo~?Dh++;K`4+4r6MHE7ki0B5=sc`%&hA!zI2|>`Tqa^&iDW4JLPpA zp@QCTHWB3m>G4k{f(8^&!lz7x-A>ktF{l9sBYr?OoNI%mTSQ`9i8&l50?JV%PY-7c z+)6@5fF^6Cs1^jQQ5ecp8eI8xdNfxjBPb&c@R_@OFe-^IB^ks=3UD+7tUMFKdQ_a1 z737DSl29X1!%DqLbg&+Y2;#la;5IG#ApvcGo1sYgaKnxq*=$i5+=|x#dE+2VB zQCr10>2~6dA|jykQ+2Af@!(=AqkP%epQzy(1I$(RUMXih2VxvuVB* zBnzIdTZj*}vl}-*evtav&L6LRpZGBKV{YNMI{oYB+t}RVfu(1s`$6eg)o9we>zEgL zD(~#H-mNN`-0i81Opm|ptO}f{Zt9!uXj#6l&2AZfUecXr_9;l}h}p!r4P*1_fChQ<4Jtxw7dC%;`B37Z3x4_ngPr*>${%%6r~W`12#kM;|~ITTz(MB88WHD785}M>}hIs0nTD!EkF;fEE+$dzJQ#=IP8Em7|mdV zXE831l;N<5Y{1x16pNj@7Bf@W>1*;h-jc(RDoSyv8b8jjDER*v9g+3Yc?f8VJmd-5 zd6staW+%%q0!vvaFK2VvoK~xu<+}mW>rmcpy%VM*;$8Bnz(V4Tg1u1lU zFfb5WQ+miQkOo~?Dh++;K`4+4r6MHE7ki0B5=sc`%&hA!zI2|>`Tqa^&iDW4JLPpA zp@QCTHWB3m>G4k{f(8^&!lz7x-A>ktF{l9sBYr?OoNI%mTSQ`9i8&l50?JV%PY-7c z+)6@5fF^6Cs1^jQQ5ecp8eI8xdNfxjBPb&c@R_@OFe-^IB^ks=3UD+7tUMFKdQ_a1 z737DSl29X1!%DqLbg&+Y2;#la;5IG#ApvcGo1sYgaKnxq*=$i5+=|x#dE+2VB zQCr10>2~6dA|jykQ+2Af@!(=AqkP%epQzy(1I$(RUMXih2VxvuVB* zBnzIdTZj*}vl}-*evtav&L6LRpZGBKV{YNMI{oYB+t}RVfu(1s`$6eg)o9we>zEgL zD(~#H-mNN`-0i81Opm|ptO}f{Zt9!uXj#6l&2AZfUecXr_9;l}h}p!r4P*1_fChQ<4Jtxw7dC%;`B37Z3x4_ngPr*>${%%6r~W`12#kM;|~ITTz(MB88WHD785}M>}hIs0nTD!EkF;fEE+$dzJQ#=IP8Em7|mdV zXE831l;N<5Y{1x16pNj@7Bf@W>1*;h-jc(RDoSyv8b8jjDER*v9g+3Yc?f8VJmd-5 zd6staW+%%q0!vvaFK2VvoK~xuraghX{AC)Ws{8Q%62#IP9@cY z5Stz<1$(e4R8c4tq(!tBQR$&7p7h{FK|wrt(2GzCUZgm)>9(O4=QQ)Z_rCYNnQvOs zJ^E36EF=mokCxtk5(NwpaPpT$p|caFjS}&I#E=Yt17l?Z69zRbr=)43KeiCv<@04u7ddDJDwh$vKM?@x8@UO{`4fBsH({K~I> zY<`kxU2BWShI=Qfk=FgS>f+5C7YEYi^6i(iKOV@tYVTesLue29`Y@gN-ahxcZKYhZ z&U`E8CYq<`U*}H@&6ty~+TW~=E$7B|blvNo8=N@wrD4oEwQyyo zpdOs>7(Z~nyX|r6$ohw8>vwK_KK_1s$y%;1KHqtGwJYsAB@h~zhQG6aeObWA&4ApN zp(-x9Bnk*HPom8QR7H^$zyli#vnSvS*jt#&UT_b#@>H39lK`WxVOqrXe@w=4nE(BWV83~d0F2=2o@{4S#<6H9CQFhoBLyYZNIVh? iheNWeNC_E*wEt)1xJNu{xU_yjsuE?Nwk6r8i^4yF2^w|) literal 0 HcmV?d00001 diff --git a/tests/edits/build_log.txt b/tests/edits/build_log.txt new file mode 100644 index 0000000000000000000000000000000000000000..0be82a7700d38dc94e07d2470e845f314bbc2e7b GIT binary patch literal 9286 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1f82a1d65eb7c1283139c5bfd9ff3f79fb53e1d7 GIT binary patch literal 25086 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c1e62419ed9f21047679573303074677a0994d24 GIT binary patch literal 5910 zcmeHLZEF)j5T4J1{~_FGDR;T#Ui?;V#DZF>fl{PMO{x}>w4|w|O8>h0JUh2ZF1_UT za>gK(rFYxCnc11yd3iR!fBlrF@^#Bj zCX*XVVn+0UiyGX?mE3~9NJo`@4#_?F2D-7l2gg^)R%(AqqSE(3Ie^R|_}+l_%S?JK zq3lT@zC`%*r3t-!iSb{6XCP^zyaBvATCnG1&UWs}k@v1$<=cbKefgg0T+D6hQ|K3B z#Ly+hvxRviZD<;rc~YRE<>foT*aJrd4z;k2IeBR%=gkrcG5BNnB{Uk4Co!@@NN(c4 z{K+rw&AH`nncZ99phkSRjnv>D@z>M79ye{L)d}qOD|wI6nZWOi#ME$dPHB2_W!_J~ z-^QT+iJ7H%#*pB4daZ@nVMg%eIc(&V?Qc)Np8q!Eu4{Yj7@3=CXFap~*wD;mT(%LP zEkp`)loi@7v+LQv8F$^hsLO9-Yj?h5E$3h*U@c8dcBWWiMK8U!kKb%p~E!AsOR zU4sbcdl8jRXK0XkcW4lB-^l0#zjT2yZ$Z@sF1^OPA+Iw1_>Gu$g0%yz^$fx!>Lqqv zr|T3RP`pZS6#4i5!RlFVW&ZtsVW&CbKQj2VD%#(NZC&`_7|8wzD4%HmrMw2l=gOv7 zpLKzCD(?{wk@1N2Z6xYDLIx6NyVp`;d-iH~9@bA%Gt(n*mRD?Ql^VpS)VA}?-(}sV z`kvJ}cA+IJW?($yJ9fXe@ugN-i{R&eMC96Yc_uFBw+mnLG#Hn>cNRs>ay7{3 zK?7WR>y1q06-g-b(=M#89uKs$X?m{fjZW?2JuHET%aI=Gd1KRCpbLlI`FQKJ5t^^u z%V$;HYG#4n{#`L={p{wRyz|m?WwEn;5AgoL-second.doc->validateLayout(pageIndex, jsonStr); + return g_lastValidation.c_str(); +} + } \ No newline at end of file