feat: implemented real flow text editor for paragraphs

This commit is contained in:
Furqan-14
2026-06-16 10:56:08 +05:30
parent 6b78a72f68
commit ec509400e7
22 changed files with 1821 additions and 72 deletions
+1
View File
@@ -79,3 +79,4 @@ timeout-*
PDF Editor Timeline.xlsx
# Local environment config / secrets
/third_party/pdfium-wasm/
+2 -1
View File
@@ -53,8 +53,9 @@ endif()
if(PDFENGINE_WASM)
set(PDFENGINE_BUILD_TESTS OFF CACHE BOOL "Build engine unit/smoke tests" FORCE)
set(PDFENGINE_WITH_PDFIUM OFF CACHE BOOL "Link the PDFium static lib" FORCE)
set(PDFENGINE_ENABLE_SANITIZERS OFF CACHE BOOL "ASan/UBSan" FORCE)
# PDFium under WASM is opt-in via the wasm-pdfium preset (links the prebuilt wasm32
# libpdfium.a). The plain 'wasm' preset leaves PDFENGINE_WITH_PDFIUM=OFF (mock engine).
endif()
include(CompilerWarnings)
+14
View File
@@ -110,6 +110,19 @@
}
},
{
"name": "wasm-pdfium",
"displayName": "WASM • Emscripten + PDFium (live-preview engine)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/out/build/${presetName}",
"toolchainFile": "${sourceDir}/cmake/toolchains/vcpkg-wasm.cmake",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"PDFENGINE_BUILD_TESTS": "OFF",
"PDFENGINE_WITH_PDFIUM": "ON"
}
},
{
"name": "fuzz-linux",
"displayName": "Linux • libFuzzer (Clang + ASan)",
@@ -143,6 +156,7 @@
{ "name": "macos-release", "configurePreset": "macos-release" },
{ "name": "macos-asan", "configurePreset": "macos-asan" },
{ "name": "wasm", "configurePreset": "wasm" },
{ "name": "wasm-pdfium", "configurePreset": "wasm-pdfium" },
{ "name": "fuzz-linux", "configurePreset": "fuzz-linux" },
{ "name": "fuzz-linux-nosan", "configurePreset": "fuzz-linux-nosan" }
],
+31 -10
View File
@@ -7,18 +7,39 @@ if(NOT PDFENGINE_WITH_PDFIUM)
return()
endif()
set(PDFIUM_INSTALL_DIR "${CMAKE_SOURCE_DIR}/third_party/pdfium/install"
CACHE PATH "Root of the PDFium install tree produced by build_pdfium.*")
# Desktop uses the GN-built tree; WASM uses the prebuilt Emscripten static lib
# (third_party/pdfium-wasm, fetched by get_pdfium_wasm.ps1 — a relinkable libpdfium.a).
if(EMSCRIPTEN)
set(PDFIUM_INSTALL_DIR "${CMAKE_SOURCE_DIR}/third_party/pdfium-wasm"
CACHE PATH "Root of the wasm32 PDFium tree (prebuilt libpdfium.a + include)")
else()
set(PDFIUM_INSTALL_DIR "${CMAKE_SOURCE_DIR}/third_party/pdfium/install"
CACHE PATH "Root of the PDFium install tree produced by build_pdfium.*")
endif()
find_path(PDFIUM_INCLUDE_DIR
NAMES fpdfview.h
PATHS "${PDFIUM_INSTALL_DIR}/include"
NO_DEFAULT_PATH)
if(EMSCRIPTEN)
# Emscripten's toolchain sets CMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY, which makes
# find_library ignore custom PATHS (it only searches the emsdk sysroot). Set the
# prebuilt wasm32 lib + headers directly instead.
set(PDFIUM_INCLUDE_DIR "${PDFIUM_INSTALL_DIR}/include")
set(PDFIUM_LIBRARY "${PDFIUM_INSTALL_DIR}/lib/libpdfium.a")
if(NOT EXISTS "${PDFIUM_INCLUDE_DIR}/fpdfview.h")
set(PDFIUM_INCLUDE_DIR "")
endif()
if(NOT EXISTS "${PDFIUM_LIBRARY}")
set(PDFIUM_LIBRARY "")
endif()
else()
find_path(PDFIUM_INCLUDE_DIR
NAMES fpdfview.h
PATHS "${PDFIUM_INSTALL_DIR}/include"
NO_DEFAULT_PATH)
find_library(PDFIUM_LIBRARY
NAMES pdfium libpdfium
PATHS "${PDFIUM_INSTALL_DIR}/lib"
NO_DEFAULT_PATH)
find_library(PDFIUM_LIBRARY
NAMES pdfium libpdfium
PATHS "${PDFIUM_INSTALL_DIR}/lib"
NO_DEFAULT_PATH)
endif()
if(NOT PDFIUM_INCLUDE_DIR OR NOT PDFIUM_LIBRARY)
message(FATAL_ERROR
@@ -258,6 +258,10 @@ public:
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
// Per-character caret layout (JSON, PDF coords) of the most recent reflow_paragraph op, or
// empty if none. Lets the WASM live preview align a caret exactly to the rendered glyphs.
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveIncremental() const = 0;
@@ -81,6 +81,40 @@ std::vector<uint8_t> FontSubset::buildSubset(const std::vector<uint8_t>& origina
return result;
}
// Overwrite a few bytes of the sfnt 'name' table strings (family/full/PostScript/typographic
// names) with a deterministic seed so the subset has a UNIQUE name. Two reflow subsets of the
// same base font (e.g. Calibri) otherwise share the name "Calibri", and PDFium merges them —
// making the second subset's text map to the first's glyphs (scrambled output). Identical inputs
// hash to the same seed → same name → safe to share. The name is only used for de-dup; rendering
// uses the embedded glyphs + cmap, so a "garbled" name is harmless.
static void makeFontNameUnique(std::vector<uint8_t>& font, uint64_t seed) {
auto rd16 = [&](size_t o) -> uint32_t { return (o + 1 < font.size()) ? (uint32_t(font[o]) << 8) | font[o + 1] : 0; };
auto rd32 = [&](size_t o) -> uint32_t { return (o + 3 < font.size()) ? (uint32_t(font[o]) << 24) | (uint32_t(font[o + 1]) << 16) | (uint32_t(font[o + 2]) << 8) | font[o + 3] : 0; };
if (font.size() < 12) return;
uint32_t numTables = rd16(4);
size_t nameOff = 0;
for (uint32_t i = 0; i < numTables; ++i) {
size_t rec = 12 + size_t(i) * 16;
if (rec + 16 > font.size()) break;
if (font[rec] == 'n' && font[rec + 1] == 'a' && font[rec + 2] == 'm' && font[rec + 3] == 'e') { nameOff = rd32(rec + 8); break; }
}
if (nameOff == 0 || nameOff + 6 > font.size()) return;
uint32_t count = rd16(nameOff + 2);
size_t storageOff = nameOff + rd16(nameOff + 4);
for (uint32_t i = 0; i < count; ++i) {
size_t rec = nameOff + 6 + size_t(i) * 12;
if (rec + 12 > font.size()) break;
uint32_t nameID = rd16(rec + 6);
if (nameID == 1 || nameID == 4 || nameID == 6 || nameID == 16) {
uint32_t len = rd16(rec + 8);
size_t s = storageOff + rd16(rec + 10);
for (uint32_t b = 0; b < len && b < 8; ++b) {
if (s + b < font.size()) font[s + b] = static_cast<uint8_t>('A' + ((seed >> ((b % 8) * 4)) & 0x0F)); // 'A'..'P'
}
}
}
}
std::vector<uint8_t> FontSubset::buildSubsetByUnicode(const std::vector<uint8_t>& originalStream,
const std::vector<uint32_t>& codepoints) {
// Subset a font down to only the glyphs needed for `codepoints`, for embedding
+569 -3
View File
@@ -17,6 +17,7 @@
#include "fonts/pdf_fonts/font_fallback.hpp"
#include "fonts/pdf_fonts/font_subset.hpp"
#include "fonts/shaping/hb_shaper.hpp"
#include "fonts/face/font_face.hpp"
#include "decoration_builder.hpp"
#include <nlohmann/json.hpp>
@@ -117,6 +118,93 @@ std::string code_point_to_utf8(unsigned int cp) {
}
return utf8;
}
// Reverse of the Windows-1252 (cp1252) byte→codepoint map: a unicode codepoint back to its
// single cp1252 byte, or -1 if it isn't representable in cp1252. Used to undo "double-encoded"
// ToUnicode maps (see repairMojibake).
static int cp1252ToByte(unsigned int cp) {
if (cp <= 0x7F) return static_cast<int>(cp);
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<int>(cp); // Latin-1 high range is identity
switch (cp) {
case 0x20AC: return 0x80; case 0x201A: return 0x82; case 0x0192: return 0x83;
case 0x201E: return 0x84; case 0x2026: return 0x85; case 0x2020: return 0x86;
case 0x2021: return 0x87; case 0x02C6: return 0x88; case 0x2030: return 0x89;
case 0x0160: return 0x8A; case 0x2039: return 0x8B; case 0x0152: return 0x8C;
case 0x017D: return 0x8E; case 0x2018: return 0x91; case 0x2019: return 0x92;
case 0x201C: return 0x93; case 0x201D: return 0x94; case 0x2022: return 0x95;
case 0x2013: return 0x96; case 0x2014: return 0x97; case 0x02DC: return 0x98;
case 0x2122: return 0x99; case 0x0161: return 0x9A; case 0x203A: return 0x9B;
case 0x0153: return 0x9C; case 0x017E: return 0x9E; case 0x0178: return 0x9F;
default: return -1;
}
}
// Decode a UTF-8 string into codepoints.
static std::vector<unsigned int> utf8_to_codepoints(const std::string& s) {
std::vector<unsigned int> cps;
for (size_t i = 0; i < s.size();) {
unsigned char c = static_cast<unsigned char>(s[i]);
unsigned int cp = c; int extra = 0;
if (c < 0x80) { cp = c; extra = 0; }
else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; extra = 1; }
else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; extra = 2; }
else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; extra = 3; }
else { cp = c; extra = 0; }
if (i + extra >= s.size()) { cps.push_back(c); i++; continue; }
bool ok = true;
for (int j = 1; j <= extra; ++j) { unsigned char n = static_cast<unsigned char>(s[i + j]); if ((n & 0xC0) != 0x80) { ok = false; break; } cp = (cp << 6) | (n & 0x3F); }
if (!ok) { cps.push_back(c); i++; continue; }
cps.push_back(cp); i += extra + 1;
}
return cps;
}
// Repair "double-encoded" text. Some PDFs ship a ToUnicode CMap whose multi-byte UTF-8 sequences
// were themselves decoded through cp1252, so a single char like the en-dash (U+2013, UTF-8 bytes
// E2 80 93) is extracted as three garbage chars (â € " = U+00E2 U+20AC U+201C). We map each char
// back to its cp1252 byte and, where those bytes form a valid UTF-8 lead+continuation sequence,
// re-decode them to the intended codepoint. Plain text is left untouched.
static std::string repairMojibake(const std::string& s) {
auto cps = utf8_to_codepoints(s);
std::string out;
auto emit = [&](unsigned int cp) { out += code_point_to_utf8(cp); };
for (size_t i = 0; i < cps.size();) {
int b0 = cp1252ToByte(cps[i]);
int need = 0;
if (b0 >= 0xC0 && b0 <= 0xDF) need = 1;
else if (b0 >= 0xE0 && b0 <= 0xEF) need = 2;
else if (b0 >= 0xF0 && b0 <= 0xF7) need = 3;
if (need > 0 && i + need < cps.size()) {
std::string bytes; bytes.push_back(static_cast<char>(b0));
bool ok = true;
for (int j = 1; j <= need; ++j) { int b = cp1252ToByte(cps[i + j]); if (b < 0x80 || b > 0xBF) { ok = false; break; } bytes.push_back(static_cast<char>(b)); }
if (ok) { auto rd = utf8_to_codepoints(bytes); if (rd.size() == 1 && rd[0] > 0x7F) { emit(rd[0]); i += need + 1; continue; } }
}
emit(cps[i]); i++;
}
return out;
}
// Normalize "smart" punctuation to ASCII for reflow re-emission. Office subset fonts often map
// en/em dashes, curly quotes, and ellipses at custom codes (not their Unicode), so re-emitting
// them via FPDFText_SetText(unicode) drops them to a notdef glyph (the en-dash came out as "B").
// Substituting the ASCII equivalent renders reliably in every font (embedded, base-14, or system)
// and keeps preview == saved. Only reflowed text is affected; untouched text keeps its originals.
std::string asciiizePunctuation(const std::string& s) {
auto cps = utf8_to_codepoints(s);
std::string out;
for (unsigned int cp : cps) {
switch (cp) {
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: out += '-'; break;
case 0x2018: case 0x2019: case 0x201B: out += '\''; break;
case 0x201C: case 0x201D: case 0x201F: out += '"'; break;
case 0x2026: out += "..."; break;
case 0x00A0: case 0x2002: case 0x2003: case 0x2009: case 0x202F: out += ' '; break;
default: out += code_point_to_utf8(cp); break;
}
}
return out;
}
} // namespace pdfengine::parser
namespace {
@@ -1024,10 +1112,15 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
gaps.push_back(gap);
}
}
double medianGap = 0.0;
// Robust intra-word gap estimate. Use a LOW percentile (25th), NOT the median: on lines
// with many short words (or justified text), positive gaps are dominated by word-spaces,
// which inflates the median and makes the space threshold below swallow real spaces —
// gluing whole words together ("backend systems for" -> "backendsystemsfor"). The 25th
// percentile stays inside the small intra-word gap cluster even when spaces dominate.
double p25Gap = 0.0;
if (!gaps.empty()) {
std::sort(gaps.begin(), gaps.end());
medianGap = gaps[gaps.size() / 2];
p25Gap = gaps[gaps.size() / 4];
}
// Phase 5F: Run Builder
@@ -1050,7 +1143,14 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
const auto& currG = line.glyphs[i];
double gap = currG.bboxX - (prevG.bboxX + prevG.bboxW);
double spaceThreshold = (std::max)(currG.fontSize * 0.25, medianGap * 2.0);
// A word space is a clear outlier above intra-word tracking. Anchor on a
// fontSize-relative floor (reliable for normal AND justified body text), and only
// raise it for unusually loose tracking — capped so it can never grow large
// enough to drop real word-spaces (the previous medianGap*2.0 bug).
double spaceThreshold = currG.fontSize * 0.2;
if (p25Gap > spaceThreshold) {
spaceThreshold = (std::min)(p25Gap * 1.5, currG.fontSize * 0.38);
}
bool addSpace = gap > spaceThreshold && prevG.text != " " && currG.text != " ";
bool breakRun = currG.fontName != currentRun.fontName ||
@@ -1123,6 +1223,15 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
}
}
// Phase 5F.5: Repair double-encoded text (e.g. an en-dash extracted as "â€"" from a malformed
// ToUnicode CMap) so both display and reflow re-emission use the correct codepoints.
for (auto& line : lines) {
for (auto& run : line.runs) {
std::string repaired = pdfengine::parser::repairMojibake(run.text);
if (repaired != run.text) run.text = std::move(repaired);
}
}
// Phase 5G: Paragraph Builder
std::vector<Paragraph> paragraphs;
if (!lines.empty()) {
@@ -1765,12 +1874,155 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
int pageIndex, const std::string& internalFontId, double fontSize,
const std::vector<uint32_t>& codepoints) {
EmissionFont out;
(void)fontSize;
// Derive a base-14 name + bold/italic from the internalFontId (mirrors replace_text).
std::string fontName = "Helvetica";
bool bold = false, italic = false;
{
std::string lowerId = internalFontId;
std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; }
else if (lowerId.find("bold") != std::string::npos) { bold = true; }
else if (lowerId.find("italic") != std::string::npos) { italic = true; }
else if (lowerId.find("oblique") != std::string::npos) { italic = true; }
if (lowerId.find("times") != std::string::npos) {
fontName = bold && italic ? "Times-BoldItalic" : bold ? "Times-Bold" : italic ? "Times-Italic" : "Times-Roman";
} else if (lowerId.find("courier") != std::string::npos) {
fontName = bold && italic ? "Courier-BoldOblique" : bold ? "Courier-Bold" : italic ? "Courier-Oblique" : "Courier";
} else {
fontName = bold && italic ? "Helvetica-BoldOblique" : bold ? "Helvetica-Bold" : italic ? "Helvetica-Oblique" : "Helvetica";
}
}
// Match the FontInfo by internalFontId + resolve a FontFace (for shaping/width).
std::optional<FontInfo> matchedFontInfo;
auto fontsRes = getFonts(pageIndex, pageIndex);
if (fontsRes.has_value()) {
for (const auto& fi : *fontsRes) {
if (!internalFontId.empty() && fi.internalFontId == internalFontId) { matchedFontInfo = fi; break; }
}
}
if (matchedFontInfo.has_value()) {
auto r = getResolvedFont(*matchedFontInfo);
if (r.has_value()) out.resolved = *r;
}
// Glyph coverage → pick the embedding tier.
bool fontSupportsAll = true, subsetLacksGlyphs = false;
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
if (out.resolved) {
for (uint32_t cp : codepoints) {
if (!out.resolved->hasGlyph(cp)) {
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
}
}
} else {
fontSupportsAll = false;
}
std::string cacheKey;
bool useEmbedded = false, useSystem = false;
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
// Re-embed from the PDF's OWN font bytes (getFontData below). This path does NOT need the
// resolver's FontFace, so it works under WASM's no-filesystem build where getResolvedFont
// can return null — the reflowed text then keeps the document's real font (e.g. Calibri)
// instead of falling back to a generic Helvetica/Liberation. We only divert to a system
// substitute when we have a resolved face that PROVES the subset lacks a needed glyph.
bool subsetProvenLacking = out.resolved && isSubsetFont && subsetLacksGlyphs;
bool fullProvenLacking = out.resolved && !isSubsetFont && !fontSupportsAll;
if (!subsetProvenLacking && !fullProvenLacking) { cacheKey = matchedFontInfo->internalFontId; useEmbedded = true; }
else { cacheKey = "system_embed_" + matchedFontInfo->fontName + std::string("_") + (bold ? "B" : "") + (italic ? "I" : ""); useSystem = true; }
} else if (matchedFontInfo && !matchedFontInfo->isEmbedded) {
cacheKey = "standard_" + fontName;
} else {
cacheKey = "standard_" + fontName;
}
// Make the cache key CODEPOINT-AWARE. A subset is built for an exact glyph set, so a font
// reused with different characters (e.g. the Summary's text cached, then a bullet reflowed)
// must NOT reuse the prior subset — its cmap would map the new chars to the wrong glyphs,
// producing scrambled text ("Core Java" -> "Cuwi Le,e"). Hashing the codepoints fixes it.
{
uint64_t h = 1469598103934665603ull;
for (uint32_t cp : codepoints) { h ^= cp; h *= 1099511628211ull; }
cacheKey += "#" + std::to_string(h);
}
{
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
if (loadedFontsCache_.count(cacheKey)) {
out.font = loadedFontsCache_[cacheKey];
auto it = loadedMeasureFaces_.find(cacheKey);
if (it != loadedMeasureFaces_.end()) out.measureFace = it->second;
}
}
if (out.font) return out;
if (useEmbedded) {
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
if (fontDataRes.has_value() && !fontDataRes.value().empty()) {
// Re-subset the embedded font over the glyphs we'll emit. HarfBuzz rebuilds a
// clean unicode cmap (no RETAIN_GIDS), so FPDFText_SetText(unicode) maps every
// char — loading the raw embedded bytes cid=false drops glyphs whose original
// cmap subtable PDFium can't follow (e.g. Office's embedded Calibri). Smart
// punctuation is normalized to ASCII upstream (asciiizePunctuation), so the rare
// "glyph at a custom code" case (the en-dash) never reaches here.
auto subset = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fontDataRes.value(), codepoints);
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
loadedFontDataBuffers_[cacheKey] = !subset.empty() ? std::move(subset) : fontDataRes.value();
const auto& bytes = loadedFontDataBuffers_[cacheKey];
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
}
} else if (useSystem) {
std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath(
matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily, bold, italic);
std::ifstream fs(fontPath, std::ios::binary);
if (fs) {
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
if (!fileBytes.empty()) {
std::vector<uint8_t> subsetBytes = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fileBytes, codepoints);
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
loadedFontDataBuffers_[cacheKey] = !subsetBytes.empty() ? std::move(subsetBytes) : std::move(fileBytes);
const auto& bytes = loadedFontDataBuffers_[cacheKey];
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
}
}
}
if (!out.font) {
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
}
if (out.font) {
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
loadedFontsCache_[cacheKey] = out.font;
// Build a measuring face from the exact bytes we loaded (embedded/system paths) so
// width measurement matches what PDFium renders. Standard base-14 fonts have no bytes
// here → measurement falls back to the resolver face.
auto bit = loadedFontDataBuffers_.find(cacheKey);
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
auto mf = std::make_shared<fonts::FontFace>();
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
}
}
return out;
}
#endif
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
}
lastReflowLayout_.clear(); // repopulated only if a reflow_paragraph op runs
try {
auto root = nlohmann::json::parse(editsJson);
if (!root.contains("version") || root["version"] != "1.0") {
@@ -2227,6 +2479,320 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
spdlog::error("Failed to generate page content after replace_text");
}
FPDF_ClosePage(page);
} else if (type == "reflow_paragraph") {
// Word/Adobe-style paragraph reflow: re-wrap the paragraph's styled runs
// within its column width, re-justify, emit the new lines, and push the
// following in-column content down/up by the line-count delta.
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("reflow_paragraph missing 'data'");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; };
std::vector<RunStyle> runs;
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
r = 0; g = 0; b = 0;
if (hex.size() >= 7 && hex[0] == '#') {
auto hv = [](char ch) -> int {
if (ch >= '0' && ch <= '9') return ch - '0';
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
return 0;
};
r = static_cast<unsigned int>(hv(hex[1]) * 16 + hv(hex[2]));
g = static_cast<unsigned int>(hv(hex[3]) * 16 + hv(hex[4]));
b = static_cast<unsigned int>(hv(hex[5]) * 16 + hv(hex[6]));
}
};
auto parseRun = [&](const nlohmann::json& rj) {
RunStyle rs;
rs.text = pdfengine::parser::asciiizePunctuation(rj.value("text", ""));
rs.internalFontId = rj.value("internalFontId", "");
rs.fontSize = rj.value("fontSize", 12.0);
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
runs.push_back(std::move(rs));
};
// Optional WYSIWYG mode: the frontend provides the exact visual line breaks
// (one inner array of styled fragments per line). When present we emit those
// breaks verbatim and skip our greedy line-breaker, so the saved page matches
// the live preview. Otherwise we break the flat `runs` ourselves.
std::vector<std::vector<int>> providedLines; // run indices per provided line
bool hasProvidedLines = data.contains("lines") && data["lines"].is_array() && !data["lines"].empty();
if (hasProvidedLines) {
for (const auto& lineJson : data["lines"]) {
std::vector<int> lineRunIdxs;
if (lineJson.is_array()) {
for (const auto& rj : lineJson) { lineRunIdxs.push_back(static_cast<int>(runs.size())); parseRun(rj); }
}
providedLines.push_back(std::move(lineRunIdxs));
}
} else if (data.contains("runs") && data["runs"].is_array()) {
for (const auto& rj : data["runs"]) parseRun(rj);
}
std::vector<int> objectIndices;
if (data.contains("objectIndices") && data["objectIndices"].is_array()) {
for (auto& idx : data["objectIndices"]) objectIndices.push_back(idx.get<int>());
}
double columnLeft = data.value("columnLeft", 0.0);
double columnRight = data.value("columnRight", 0.0);
double firstBaselineY = data.value("firstBaselineY", 0.0);
double leading = data.value("leading", 0.0);
int oldLineCount = data.value("oldLineCount", 1);
std::string align = data.value("align", std::string("left"));
double columnWidth = columnRight - columnLeft;
// For a bullet/list item the TEXT reflows from a hanging indent (columnLeft), but
// the push-down of content below must span the FULL block width so the bullet
// markers (which sit left of the text indent) and other items move together.
// Defaults to columnLeft for ordinary paragraphs.
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
if (runs.empty() || objectIndices.empty() || columnWidth <= 1.0 || leading <= 0.0) {
spdlog::warn("reflow_paragraph: insufficient layout data, skipping");
continue;
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page {} for reflow_paragraph", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// Resolve + load each run's emission font (subset over that run's codepoints).
std::vector<EmissionFont> runFonts(runs.size());
auto toCodepoints = [](const std::string& s) {
auto u16 = utf8_to_utf16le(s);
std::vector<uint32_t> cps;
for (size_t i = 0; i < u16.size();) {
uint32_t cp = u16[i];
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < u16.size()) {
uint32_t low = u16[i + 1];
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
else i += 1;
} else i += 1;
cps.push_back(cp);
}
return cps;
};
// Load each unique font ONCE, subset over the UNION of all its runs' glyphs
// (a font shared by several runs must cover every char it will emit).
{
std::unordered_map<std::string, std::vector<uint32_t>> fontCps;
for (const auto& rs : runs) {
auto cps = toCodepoints(rs.text);
auto& dst = fontCps[rs.internalFontId];
dst.insert(dst.end(), cps.begin(), cps.end());
}
std::unordered_map<std::string, EmissionFont> fontByFid;
for (const auto& rs : runs) {
if (!fontByFid.count(rs.internalFontId))
fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId]);
}
for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId];
}
// Measure a substring's width in a run's resolved font. HbShaper takes an
// INTEGER pixel size, so shape at a large reference size and scale to the real
// (fractional) font size — otherwise 9.96px rounds to 9px, every word measures
// ~10% narrow, and PDFium then renders them wider, collapsing the spaces.
fonts::HbShaper shaper;
constexpr unsigned int kRefSize = 1000;
auto measure = [&](size_t runIdx, const std::string& text) -> double {
if (text.empty()) return 0.0;
auto& rf = runFonts[runIdx];
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
// Prefer the face built from the EMITTED bytes (exact width parity); fall
// back to the resolver face (base-14 standard fonts have no emitted bytes).
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
if (face) {
auto glyphs = shaper.shapeRun(text, *face, kRefSize);
if (!glyphs.empty()) {
double w = 0.0; for (auto& gph : glyphs) w += gph.advanceX;
return w * (size / static_cast<double>(kRefSize));
}
}
return size * 0.5 * static_cast<double>(text.size()); // rough fallback
};
// Per-character advances (PDF units) for a run substring — used to build the
// caret layout so the live preview can place a caret exactly on each glyph. For
// ASCII (one glyph per byte) this is exact; otherwise the width is spread evenly.
auto perCharAdvances = [&](size_t runIdx, const std::string& text) -> std::vector<double> {
std::vector<double> out(text.size(), 0.0);
if (text.empty()) return out;
auto& rf = runFonts[runIdx];
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
double scale = size / static_cast<double>(kRefSize);
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
if (face) {
auto glyphs = shaper.shapeRun(text, *face, kRefSize);
if (glyphs.size() == text.size()) {
for (size_t i = 0; i < text.size(); ++i) out[i] = glyphs[i].advanceX * scale;
return out;
}
double w = 0.0; for (auto& gph : glyphs) w += gph.advanceX;
double per = w * scale / static_cast<double>(text.size());
for (auto& v : out) v = per;
return out;
}
for (auto& v : out) v = size * 0.5;
return out;
};
auto isSpace = [](char ch) { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; };
struct Seg { int runIdx; std::string text; double width; };
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
std::vector<Word> words;
// Tokenize a sequence of runs into words (non-space runs split into same-style
// segments), append to `words`, return the new word indices. Space width is
// measured in the word's own font (the model emits zero-size empty-fid space runs).
auto tokenize = [&](const std::vector<int>& runIdxs) -> std::vector<size_t> {
std::string ft; std::vector<int> sof;
for (int ri : runIdxs) for (char ch : runs[ri].text) { ft.push_back(ch); sof.push_back(ri); }
std::vector<size_t> out;
size_t i = 0;
while (i < ft.size()) {
if (isSpace(ft[i])) { i++; continue; }
Word w; w.width = 0.0; w.spaceAfter = 0.0;
while (i < ft.size() && !isSpace(ft[i])) {
int st = sof[i]; std::string frag;
while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); i++; }
double fw = measure(static_cast<size_t>(st), frag);
w.segs.push_back({st, frag, fw}); w.width += fw;
}
if (i < ft.size() && isSpace(ft[i])) {
int styleForSpace = w.segs.empty() ? sof[i] : w.segs.back().runIdx;
w.spaceAfter = measure(static_cast<size_t>(styleForSpace), " ");
}
out.push_back(words.size());
words.push_back(std::move(w));
}
return out;
};
std::vector<std::vector<size_t>> lines;
if (hasProvidedLines) {
// WYSIWYG: emit the frontend's exact visual line breaks.
for (const auto& lineRunIdxs : providedLines) {
auto wi = tokenize(lineRunIdxs);
if (!wi.empty()) lines.push_back(std::move(wi));
}
} else {
// Greedy-break the whole paragraph within the column.
std::vector<int> allRuns(runs.size());
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
auto allWords = tokenize(allRuns);
std::vector<size_t> cur; double curW = 0.0;
for (size_t k = 0; k < allWords.size(); ++k) {
size_t wi = allWords[k];
double gap = cur.empty() ? 0.0 : words[allWords[k - 1]].spaceAfter;
if (!cur.empty() && curW + gap + words[wi].width > columnWidth) {
lines.push_back(cur); cur.clear();
cur.push_back(wi); curW = words[wi].width;
} else {
cur.push_back(wi); curW += gap + words[wi].width;
}
}
if (!cur.empty()) lines.push_back(cur);
}
if (words.empty() || lines.empty()) { FPDF_ClosePage(page); continue; }
int newLineCount = static_cast<int>(lines.size());
// Push following in-column content down/up by the height delta (do this BEFORE
// deleting the old objects so indices stay valid and new objects aren't moved).
double deltaH = (newLineCount - oldLineCount) * leading;
double paragraphBottomBaseline = firstBaselineY - (oldLineCount - 1) * leading;
if (std::abs(deltaH) > 0.01) {
double threshold = paragraphBottomBaseline - 0.5 * leading;
int nObjs = FPDFPage_CountObjects(page);
int pushed = 0;
for (int k = 0; k < nObjs; ++k) {
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
if (!o) continue;
float l = 0, bo = 0, rr = 0, tt = 0;
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
double cY = (bo + tt) / 2.0;
if (cY < threshold && rr > pushColumnLeft && l < columnRight) {
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -deltaH);
pushed++;
}
}
spdlog::info("reflow_paragraph: lines {}->{}, deltaH={}, pushed {} objects", oldLineCount, newLineCount, deltaH, pushed);
}
// Delete the old paragraph objects (descending so indices stay valid).
std::sort(objectIndices.begin(), objectIndices.end(), std::greater<int>());
int minIndex = objectIndices.back();
for (int idx : objectIndices) {
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); }
}
// Emit the new lines (one text object per styled segment, positioned explicitly).
// While positioning, also capture a per-character caret layout (PDF coords) so the
// live preview can place a caret exactly on the rendered glyphs.
nlohmann::json layoutLines = nlohmann::json::array();
for (size_t li = 0; li < lines.size(); ++li) {
double baselineY = firstBaselineY - static_cast<double>(li) * leading;
auto& lw = lines[li];
double naturalW = 0.0;
for (size_t k = 0; k < lw.size(); ++k) {
naturalW += words[lw[k]].width;
if (k > 0) naturalW += words[lw[k - 1]].spaceAfter;
}
bool justifyThis = (align == "justify") && (li + 1 < lines.size()) && lw.size() > 1;
double extraPerGap = 0.0;
if (justifyThis) { double slack = columnWidth - naturalW; if (slack > 0) extraPerGap = slack / static_cast<double>(lw.size() - 1); }
std::string lineText;
std::vector<double> adv; // advance (PDF units) of each char in lineText
double lineFontSize = 0.0;
double x = columnLeft;
for (size_t k = 0; k < lw.size(); ++k) {
size_t wi = lw[k];
if (k > 0) {
double gap = words[lw[k - 1]].spaceAfter + (justifyThis ? extraPerGap : 0.0);
x += gap;
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;
FPDF_FONT font = runFonts[seg.runIdx].font;
if (font) {
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(runs[seg.runIdx].fontSize));
if (obj) {
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
auto u16 = utf8_to_utf16le(seg.text);
u16.push_back(0);
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, segX, baselineY);
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
}
}
auto ca = perCharAdvances(seg.runIdx, seg.text);
for (size_t c = 0; c < seg.text.size(); ++c) { lineText.push_back(seg.text[c]); adv.push_back(ca[c]); }
segX += seg.width;
}
x += words[wi].width;
}
layoutLines.push_back({
{"baselineY", baselineY}, {"x0", columnLeft},
{"fontSize", lineFontSize > 0 ? lineFontSize : leading / 1.2},
{"text", lineText}, {"adv", adv},
});
}
lastReflowLayout_ = nlohmann::json{{"columnLeft", columnLeft}, {"lines", layoutLines}}.dump();
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after reflow_paragraph");
}
FPDF_ClosePage(page);
} else if (type == "text_overlay" || type == "add_text") {
if (!op.contains("data") || !op["data"].is_object()) {
+22
View File
@@ -13,6 +13,7 @@
#include <mutex>
#include <unordered_map>
namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::fonts { class FontFace; }
namespace pdfengine::parser {
@@ -99,7 +100,13 @@ public:
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
// Per-character layout (PDF coords) of the LAST reflow_paragraph op, as JSON. The WASM
// preview uses it to align a custom caret exactly to the rendered glyphs. Empty if the
// most recent applyEdits contained no reflow_paragraph operation.
std::string lastReflowLayout() const { return lastReflowLayout_; }
private:
mutable std::string lastReflowLayout_;
NativeDocHandle doc_ = nullptr;
std::vector<uint8_t> memoryBuffer_;
@@ -126,7 +133,22 @@ private:
#ifdef PDFENGINE_WITH_PDFIUM
mutable std::unordered_map<std::string, FPDF_FONT> loadedFontsCache_;
mutable std::unordered_map<std::string, std::vector<uint8_t>> loadedFontDataBuffers_;
mutable std::unordered_map<std::string, std::shared_ptr<fonts::FontFace>> loadedMeasureFaces_;
mutable std::mutex loadedFontsMutex_;
// Resolve + load a PDFium font for emitting NEW text (mirrors the replace_text tiers:
// embedded full/subset reuse, base-14 standard, or system-font subset embed). Returns
// the FPDF_FONT (for FPDFPageObj_CreateTextObj) and the resolved font (FontFace for
// HarfBuzz width measurement during line-breaking). Either field may be null on failure.
struct EmissionFont {
FPDF_FONT font = nullptr;
std::shared_ptr<fonts::pdf_fonts::Font> resolved;
// A FontFace built from the EXACT bytes loaded into PDFium, so width measurement
// matches what PDFium renders (the resolver's face can have different advances).
std::shared_ptr<fonts::FontFace> measureFace;
};
EmissionFont loadEmissionFont(int pageIndex, const std::string& internalFontId,
double fontSize, const std::vector<uint32_t>& codepoints);
#endif
};
File diff suppressed because one or more lines are too long
Binary file not shown.
+9 -6
View File
@@ -12,13 +12,12 @@ import type { CustomConfirmationOptions } from './components/custom/CustomConfir
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import type { EditableRun } from './viewer/TextEditLayer';
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
import { gatewayService, PasswordError } from './lib/gatewayService';
import { PasswordModal } from './components/PasswordModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { wasmLoader } from './lib/wasmLoader';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools';
@@ -126,10 +125,6 @@ function App() {
})();
}, [openDocument]);
useEffect(() => {
wasmLoader.loadEngine().then((i) => console.log(`[WASM] ${i.engineBuildInfo()}`)).catch(() => {});
}, []);
// Load metadata, annotations, fonts when the active document version changes.
useEffect(() => {
if (!selectedDocId) return;
@@ -333,6 +328,13 @@ function App() {
setActiveTool('select');
};
// Whole-paragraph reflow (Word-style wrap + push-down) for multi-line paragraph edits.
const handleReflowParagraph = (pageIndex: number, payload: ReflowParagraphPayload) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{ id: rid('reflow'), type: 'reflow_paragraph', pageIndex, data: payload }], 'Text reflowed');
setActiveTool('select');
};
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
if (!activeStamp) return;
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
@@ -613,6 +615,7 @@ function App() {
onRedactArea={handleRedactArea}
onPlaceText={handlePlaceText}
onEditText={handleEditText}
onReflowParagraph={handleReflowParagraph}
onPlaceStamp={handlePlaceStamp}
onPlaceSignature={handlePlaceSignature}
onDecorateText={handleDecorateText}
+38
View File
@@ -220,6 +220,7 @@ export type EditOperationDataMap = {
delete_annotation: DeleteAnnotationData;
update_annotation: UpdateAnnotationData;
replace_text: ReplaceTextData;
reflow_paragraph: ReflowParagraphData;
underline: DecorationData;
strikeout: DecorationData;
squiggly: DecorationData;
@@ -244,6 +245,32 @@ export interface ReplaceTextData {
fontSize: number;
}
// Whole-paragraph re-layout (Word-style wrap + push-down). The engine line-breaks the
// styled runs within [columnLeft, columnRight], re-justifies, and shifts following
// in-column content by the line-count delta.
export interface ReflowFragment {
text: string;
internalFontId: string;
fontSize: number;
color: string;
}
export interface ReflowParagraphData {
objectIndices: number[];
runs: ReflowFragment[];
columnLeft: number;
columnRight: number;
firstBaselineY: number;
leading: number;
oldLineCount: number;
align: 'left' | 'justify';
// Push-down column left (defaults to columnLeft). Lets a bullet item reflow its text from a
// hanging indent while the push-down still moves markers + items below by the full width.
pushColumnLeft?: number;
// WYSIWYG mode: exact visual line breaks from the live editor (one inner array per line).
lines?: ReflowFragment[][];
}
export interface DeleteAnnotationData {
annotationId: string;
}
@@ -423,6 +450,17 @@ class GatewayService {
return response.json();
}
// Raw PDF bytes of the current document version (for the in-browser WASM preview engine).
async getDocumentRaw(documentId: string): Promise<ArrayBuffer | null> {
try {
const r = await fetch(`${this.baseUrl}/documents/${documentId}/raw`);
if (!r.ok) return null;
return await r.arrayBuffer();
} catch {
return null;
}
}
// Raw embedded/substitute font bytes for an in-place-editing preview. Returns null
// when the font isn't browser-loadable (Type1/non-sfnt → 404) so callers fall back
// to a CSS font.
+122
View File
@@ -0,0 +1,122 @@
// Lazy-loaded in-browser PDFium engine (WASM). Renders pages + edit previews PIXEL-IDENTICAL
// to the gateway (same C++ engine compiled to WASM). Used only for the live-edit preview;
// the gateway stays authoritative for commit/save. Loaded on demand (the .wasm is ~6.7MB).
interface PdfiumModule {
_malloc(n: number): number;
_free(p: number): void;
HEAPU8: Uint8Array;
ccall(name: string, ret: string | null, argTypes: string[], args: unknown[]): number | string;
}
// Per-character caret layout the engine emits for the reflowed paragraph (PDF coords). Lets the
// live preview place a caret EXACTLY on the rendered glyphs (same wrap + metrics as the image).
export interface ReflowLayoutLine { baselineY: number; x0: number; fontSize: number; text: string; adv: number[]; }
export interface ReflowLayout { columnLeft: number; lines: ReflowLayoutLine[]; }
export interface PreviewResult { blob: Blob | null; layout: ReflowLayout | null; }
let modulePromise: Promise<PdfiumModule | null> | null = null;
const docHandles = new Map<string, number>(); // documentId -> wasm doc handle
// Load the Emscripten module once (mirrors the existing wasmLoader mechanism).
function getModule(): Promise<PdfiumModule | null> {
if (!modulePromise) {
modulePromise = (async () => {
try {
// Vite refuses to import /public files as modules ("can only be referenced via HTML
// tags"). So fetch the Emscripten module as TEXT and import it via a Blob URL — Vite
// never sees it as an import, and the browser loads it as a normal ES module.
// Cache-buster: bump V whenever the engine is rebuilt so the browser can never serve a
// stale .mjs/.wasm (a normal hard-reload sometimes keeps the multi-MB .wasm cached).
const V = '20260615e';
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
let createModule: (opts: object) => Promise<PdfiumModule>;
try {
createModule = (await import(/* @vite-ignore */ blobUrl)).default;
} finally {
URL.revokeObjectURL(blobUrl);
}
const M = await createModule({
locateFile: (path: string) => (path.endsWith('.wasm') ? `/pdfium-engine.wasm?v=${V}` : path),
});
console.log(`[pdfiumEngine] ✅ WASM engine v${V} loaded & instantiated`);
return M as PdfiumModule;
} catch (e) {
console.error('[pdfiumEngine] failed to load WASM engine', e);
return null;
}
})();
}
return modulePromise;
}
export async function isReady(): Promise<boolean> {
return (await getModule()) !== null;
}
// Load a document into the WASM engine (keyed by documentId). Idempotent per id.
export async function wasmLoadDocument(documentId: string, bytes: ArrayBuffer): Promise<boolean> {
const M = await getModule();
if (!M) return false;
if (docHandles.has(documentId)) return true;
const u8 = new Uint8Array(bytes);
const ptr = M._malloc(u8.length);
M.HEAPU8.set(u8, ptr);
const h = M.ccall('loadDocument', 'number', ['number', 'number'], [ptr, u8.length]) as number;
M._free(ptr);
console.log(`[pdfiumEngine] loadDocument(${u8.length} bytes) -> handle ${h}`);
if (h < 0) return false;
docHandles.set(documentId, h);
return true;
}
export function wasmHasDocument(documentId: string): boolean {
return docHandles.has(documentId);
}
function lastRenderBlob(M: PdfiumModule, len: number): Blob | null {
if (len <= 0) return null;
const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number;
// Copy out of the WASM heap before it can move (ALLOW_MEMORY_GROWTH).
const bytes = M.HEAPU8.slice(ptr, ptr + len);
return new Blob([bytes], { type: 'image/png' });
}
function lastLayout(M: PdfiumModule): ReflowLayout | null {
const json = M.ccall('lastLayoutJson', 'string', [], []) as string;
if (!json) return null;
try { return JSON.parse(json) as ReflowLayout; } catch { return null; }
}
// Render the page as-is (PNG blob).
export async function wasmRenderPage(documentId: string, pageIndex: number, dpi: number): Promise<Blob | null> {
const M = await getModule();
if (!M) return null;
const h = docHandles.get(documentId);
if (h === undefined) return null;
const len = M.ccall('renderPagePng', 'number', ['number', 'number', 'number'], [h, pageIndex, dpi]) as number;
return lastRenderBlob(M, len);
}
// Render a PREVIEW of an edit (e.g. a reflow_paragraph op) without mutating the loaded doc.
// Returns the PNG blob AND the per-character caret layout the engine computed for the reflow.
export async function wasmPreviewRender(
documentId: string, pageIndex: number, dpi: number, editsJson: string,
): Promise<PreviewResult> {
const M = await getModule();
if (!M) return { blob: null, layout: null };
const h = docHandles.get(documentId);
if (h === undefined) return { blob: null, layout: null };
const len = M.ccall('previewRender', 'number', ['number', 'number', 'number', 'string'], [h, pageIndex, dpi, editsJson]) as number;
return { blob: lastRenderBlob(M, len), layout: lastLayout(M) };
}
export async function wasmFreeDocument(documentId: string): Promise<void> {
const h = docHandles.get(documentId);
if (h === undefined) return;
docHandles.delete(documentId);
const M = await getModule();
if (M) M.ccall('freeDocument', null, ['number'], [h]);
}
+11 -4
View File
@@ -25,10 +25,17 @@ class WasmLoader {
}
try {
// Dynamic import from the public folder / static route
// @ts-ignore
const moduleUrl = (await import('/pdfengine.mjs?url')).default;
const createModule = (await import(/* @vite-ignore */ moduleUrl)).default;
// Vite won't import /public files as modules, so fetch the module text and import it
// via a Blob URL (the browser loads it as an ES module; Vite never sees the import).
const resp = await fetch('/pdfengine.mjs');
if (!resp.ok) throw new Error(`pdfengine.mjs ${resp.status}`);
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
let createModule: (opts: object) => Promise<any>;
try {
createModule = (await import(/* @vite-ignore */ blobUrl)).default;
} finally {
URL.revokeObjectURL(blobUrl);
}
const Module = await createModule({
locateFile: (path: string) => {
if (path.endsWith('.wasm')) {
+5 -1
View File
@@ -5,7 +5,7 @@ import { AnnotationLayer } from './AnnotationLayer';
import type { Annotation } from './AnnotationLayer';
import { OverlayLayer } from './OverlayLayer';
import { TextEditLayer } from './TextEditLayer';
import type { EditableRun } from './TextEditLayer';
import type { EditableRun, ReflowParagraphPayload } from './TextEditLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
@@ -36,6 +36,7 @@ interface PDFViewerProps {
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
@@ -78,6 +79,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onRedactArea,
onPlaceText,
onEditText,
onReflowParagraph,
onPlaceStamp,
onPlaceSignature,
onDecorateText,
@@ -482,7 +484,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width={page.width}
height={page.height}
zoom={zoom}
pageImageUrl={imageUrl}
onEditText={onEditText}
onReflowParagraph={onReflowParagraph}
/>
)}
+445
View File
@@ -0,0 +1,445 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRender } from '../lib/pdfiumEngine';
import type { ReflowLayout } from '../lib/pdfiumEngine';
import type { ReflowParagraphPayload } from './TextEditLayer';
import type { ReflowFragment } from '../lib/gatewayService';
// Live paragraph editor with a PIXEL-PERFECT preview. A transparent contentEditable handles
// input + caret (native), while the in-browser WASM PDFium engine renders the edited paragraph
// band IDENTICALLY to the page (same C++ engine) and paints it underneath. So clicking in
// doesn't change the look, and typing reflows in the document's true rendering. Commit goes to
// the gateway (authoritative). Falls back to plain browser text if the WASM engine is unavailable.
interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; }
interface ParagraphLayout {
columnLeft: number; columnRight: number; firstBaselineY: number; leading: number;
oldLineCount: number; align: 'left' | 'justify'; objectIndices: number[]; seedRuns: SeedRun[];
}
interface ParagraphEditorProps {
documentId: string;
pageIndex: number;
para: any;
// Bullet-item reflow: full-width push-down left, and overrides for spacing/alignment that the
// sub-paragraph can't infer on its own (a single-line item has no leading; bullets aren't justified).
pushColumnLeft?: number;
leadingOverride?: number;
alignOverride?: 'left' | 'justify';
caretClick?: { x: number; y: number } | null;
heightPts: number;
zoom: number;
pageWidthPx: number;
pageHeightPx: number;
onCommit: (payload: ReflowParagraphPayload) => void;
onCancel: () => void;
}
function median(xs: number[]): number {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
function computeLayout(para: any): ParagraphLayout {
const lines = para?.lines ?? [];
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
const seedRuns: SeedRun[] = [];
for (let li = 0; li < lines.length; li++) {
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 ?? [];
for (let ri = 0; ri < lineRuns.length; ri++) {
const r = lineRuns[ri];
(Array.isArray(r.object_indices) ? r.object_indices : []).forEach((o: number) => objectIndices.push(o));
let text = r.text ?? '';
if (li > 0 && ri === 0 && seedRuns.length > 0) {
const prev = seedRuns[seedRuns.length - 1].text;
if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text;
}
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', fontName: r.font_name ?? '' });
}
}
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;
const leading = deltas.length ? Math.abs(median(deltas)) : domSize * 1.2;
const colW = columnRight - columnLeft;
let align: 'left' | 'justify' = 'left';
if (lines.length >= 2) {
let reaching = 0;
for (let i = 0; i < rightEdges.length - 1; i++) if (rightEdges[i] >= columnRight - colW * 0.04) reaching++;
if (reaching >= (lines.length - 1) * 0.7) align = 'justify';
}
return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns };
}
// Flat styled runs from the editable's current text (each text node → a fragment). The engine
// wraps these into lines itself (greedy break within the column), so the wrap + justification of
// the rendered image and the exported caret layout always agree — no browser layout involved.
function extractFlatRuns(editable: HTMLElement, dominantFid: string): ReflowFragment[] {
const out: ReflowFragment[] = [];
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
let node = walker.nextNode() as Text | null;
while (node) {
const el = node.parentElement;
const fid = el?.getAttribute('data-fid') || dominantFid;
const size = parseFloat(el?.getAttribute('data-size') ?? '12') || 12;
const color = el?.getAttribute('data-color') ?? '#000000';
const text = node.textContent ?? '';
if (text) out.push({ text, internalFontId: fid, fontSize: size, color });
node = walker.nextNode() as Text | null;
}
return out;
}
// The paragraph's ORIGINAL line breaks as fragments (one inner array per line). Used for the
// initial render so opening the editor shows the document's existing wrapping verbatim — the
// engine only re-wraps once the user actually edits.
function buildOriginalLines(para: any, dominantFid: string): ReflowFragment[][] {
const out: ReflowFragment[][] = [];
for (const line of para?.lines ?? []) {
const frags: ReflowFragment[] = [];
for (const r of line.runs ?? []) {
const text = r.text ?? '';
if (text) frags.push({ text, internalFontId: r.internal_font_id || dominantFid, fontSize: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000' });
}
if (frags.length) out.push(frags);
}
return out;
}
// Global character offset of the DOM caret within the editable.
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;
}
// Move the DOM caret to a global character offset (so typing inserts at the right place).
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);
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);
}
// Global char index where each engine line starts in the full text (continuation lines skip the
// whitespace the engine consumed at the wrap point).
function lineStarts(layout: ReflowLayout, fullText: string): number[] {
const starts: number[] = [];
let pos = 0;
for (let i = 0; i < layout.lines.length; i++) {
if (i > 0) { while (pos < fullText.length && /\s/.test(fullText[pos])) pos++; }
starts.push(pos);
pos += layout.lines[i].text.length;
}
return starts;
}
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride,
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCancel,
}) => {
const layout = useMemo(() => computeLayout(para), [para]);
// A bullet item supplies the paragraph's true leading + forces left alignment; a flowing
// paragraph uses what computeLayout inferred.
const leading = leadingOverride ?? layout.leading;
const align = alignOverride ?? layout.align;
const editRef = useRef<HTMLDivElement>(null);
const committedRef = useRef(false);
const initialTextRef = useRef('');
const debounceRef = useRef<number | null>(null);
const blobUrlRef = useRef<string | null>(null);
const engineLayoutRef = useRef<ReflowLayout | null>(null);
const initialCaretApplied = useRef(false);
// Until the user actually edits, the preview keeps the document's ORIGINAL line breaks (no
// re-wrap), so merely opening the editor doesn't shift anything. Re-wrap kicks in on first edit.
const editedRef = useRef(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null);
// True once the user actually edits. Until then we show the UNTOUCHED original page (pixel-
// perfect) rather than the reflow render, so merely opening the editor changes nothing.
const [edited, setEdited] = useState(false);
// Falls back to the plain (visible) browser editor only if the WASM engine fails to produce
// a render in a few seconds — so the user is never stuck editing invisible text.
const [wasmFailed, setWasmFailed] = useState(false);
// The font most of the paragraph uses — empty-fid runs (spaces, freshly typed text) adopt it
// so the engine emits them in the real document font instead of a generic fallback.
const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid
?? layout.seedRuns.find((r) => r.fid)?.fid ?? '';
const domSize = layout.seedRuns.find((r) => r.text.trim())?.size ?? layout.seedRuns[0]?.size ?? 12;
const fontPx = domSize * zoom;
const leadingPx = leading * zoom;
const colLeftPx = layout.columnLeft * zoom;
const colWidthPx = (layout.columnRight - layout.columnLeft) * zoom;
const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom;
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
// The WASM preview repaints the page from the paragraph top down (reflow pushes content below).
const bandTop = Math.max(0, editorTop - leadingPx * 0.5);
// Reflow op JSON for the engine. With `lines` (initial render) the engine emits those exact
// breaks — opening the editor changes nothing. Without `lines` (after an edit) it greedy-wraps
// within the column, and its render + exported caret layout agree exactly (no browser layout).
const buildOpJson = (runs: ReflowFragment[], originalLines?: ReflowFragment[][]): string => JSON.stringify({
version: '1.0',
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: {
objectIndices: layout.objectIndices,
runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
...(originalLines && originalLines.length ? { lines: originalLines } : {}),
columnLeft: layout.columnLeft, columnRight: layout.columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
} }],
});
// Caret screen box (px in the page container) for a global char offset, from the engine layout.
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
if (!lay.lines.length) return null;
const starts = lineStarts(lay, fullText);
let li = lay.lines.length - 1;
for (let i = 0; i < lay.lines.length; i++) {
if (global <= starts[i] + lay.lines[i].text.length) { li = i; break; }
}
const line = lay.lines[li];
const offset = Math.max(0, Math.min(global - starts[li], line.adv.length));
let x = line.x0;
for (let k = 0; k < offset; k++) x += line.adv[k] ?? 0;
const fpx = line.fontSize * zoom;
return { left: x * zoom, top: (heightPts - line.baselineY) * zoom - fpx * 0.82, height: fpx * 1.04 };
};
// Map a screen click to a global char offset via the engine layout (line by baseline, char by adv).
const globalFromPoint = (clientX: number, clientY: number, lay: ReflowLayout, fullText: string) => {
const el = editRef.current;
const container = el?.offsetParent as HTMLElement | null;
if (!container || !lay.lines.length) return 0;
const rect = container.getBoundingClientRect();
const pdfX = (clientX - rect.left) / zoom;
const localY = clientY - rect.top;
let li = 0, bestD = Infinity;
for (let i = 0; i < lay.lines.length; i++) {
const fpx = lay.lines[i].fontSize * zoom;
const mid = (heightPts - lay.lines[i].baselineY) * zoom - fpx * 0.35;
const d = Math.abs(localY - mid);
if (d < bestD) { bestD = d; li = i; }
}
const line = lay.lines[li];
let x = line.x0, offset = 0;
for (let k = 0; k < line.adv.length; k++) {
const next = x + line.adv[k];
if (pdfX < (x + next) / 2) break;
x = next; offset = k + 1;
}
return lineStarts(lay, fullText)[li] + offset;
};
const positionCaret = () => {
const el = editRef.current, lay = engineLayoutRef.current;
if (!el || !lay) return;
setCaretBox(caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? ''));
};
// Render the current edit state via the WASM engine (pixel-identical to the page) + reposition
// the caret from the engine's own glyph layout.
const renderPreview = async () => {
const el = editRef.current;
if (!el) return;
const dpi = Math.round(72 * zoom);
// Before the first edit, keep the document's original line breaks (no re-wrap on open).
const originalLines = editedRef.current ? undefined : buildOriginalLines(para, dominantFid);
const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid), originalLines));
if (!blob) { console.warn('[ParagraphEditor] WASM preview returned null'); return; }
engineLayoutRef.current = lay;
const url = URL.createObjectURL(blob);
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = url;
setPreviewUrl(url);
// On the very first render, place the caret where the user clicked (mapped via engine layout).
if (!initialCaretApplied.current) {
initialCaretApplied.current = true;
if (caretClick && lay) setGlobalCaretOffset(el, globalFromPoint(caretClick.x, caretClick.y, lay, el.textContent ?? ''));
}
positionCaret();
};
// Load the document into the WASM engine, then do the initial render.
useEffect(() => {
let cancelled = false;
(async () => {
if (!wasmHasDocument(documentId)) {
const bytes = await gatewayService.getDocumentRaw(documentId);
if (bytes) await wasmLoadDocument(documentId, bytes);
}
if (cancelled) return;
renderPreview();
})();
return () => {
cancelled = true;
if (debounceRef.current) window.clearTimeout(debounceRef.current);
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [documentId]);
// Seed the contentEditable ONCE (one span per run; spaces/empty-fid adopt the dominant font).
useEffect(() => {
const el = editRef.current;
if (!el) return;
el.innerHTML = '';
for (const r of layout.seedRuns) {
if (!r.text) continue;
const effSize = !r.text.trim() || r.size <= 1 ? domSize : r.size;
const span = document.createElement('span');
span.setAttribute('data-fid', r.fid || dominantFid);
span.setAttribute('data-size', String(effSize));
span.setAttribute('data-color', r.color);
span.setAttribute('data-fontname', r.fontName);
span.style.fontSize = `${effSize * zoom}px`;
span.style.color = 'transparent';
span.textContent = r.text;
el.appendChild(span);
}
el.focus();
initialTextRef.current = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const imageReady = previewUrl !== null;
const fallbackVisible = wasmFailed && !imageReady;
// In the fallback (WASM unavailable) the plain glyphs become visible and we use the native caret.
useEffect(() => {
const el = editRef.current;
if (!el) return;
el.querySelectorAll<HTMLElement>('span[data-fid]').forEach((span) => {
span.style.color = fallbackVisible ? (span.getAttribute('data-color') ?? '#000000') : 'transparent';
});
}, [fallbackVisible]);
// Fall back to the visible browser editor only if no render arrives within ~4s.
useEffect(() => {
if (previewUrl) return;
const t = window.setTimeout(() => setWasmFailed(true), 4000);
return () => window.clearTimeout(t);
}, [previewUrl]);
// Re-render shortly after each keystroke (the caret repositions from the new layout).
const onInput = () => {
editedRef.current = true; // now the engine may re-wrap (the user is actually editing)
if (!edited) setEdited(true); // swap from the untouched original page to the reflow render
positionCaret(); // immediate (approximate, from the prior layout) for responsiveness
if (debounceRef.current) window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(renderPreview, 110);
};
// Place the caret on click via the engine layout. Using onClick (not onMouseDown+preventDefault)
// means focus is already established, so a SINGLE click lands the caret correctly.
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 ?? ''));
positionCaret();
};
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
const el = editRef.current;
if (!el) { onCancel(); return; }
const nowText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
if (nowText === initialTextRef.current) { onCancel(); return; }
const flat = extractFlatRuns(el, dominantFid);
if (flat.length === 0) { onCancel(); return; }
// Commit with FLAT runs (no lines) so the gateway wraps identically to the live preview.
onCommit({
objectIndices: layout.objectIndices, runs: flat,
columnLeft: layout.columnLeft, columnRight: layout.columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
});
};
const cancel = () => { committedRef.current = true; onCancel(); };
return (
<>
<style>{`@keyframes pe-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
{/* White cover ONLY in the fallback case (WASM failed). */}
{fallbackVisible && (
<div
className="absolute z-[36] bg-white"
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: layout.oldLineCount * leadingPx + 8 }}
/>
)}
{/* Reflow render — shown ONLY after the user edits. Before that, the untouched original
page (the canvas below this layer) shows through, so opening the editor changes nothing. */}
{edited && previewUrl && (
<div
className="absolute z-[37] overflow-hidden"
style={{ left: 0, top: bandTop, width: pageWidthPx, height: Math.max(0, pageHeightPx - bandTop), pointerEvents: 'none' }}
>
<img src={previewUrl} alt="" style={{ position: 'absolute', left: 0, top: -bandTop, width: pageWidthPx, height: pageHeightPx, maxWidth: 'none' }} />
</div>
)}
{/* Custom caret, positioned from the ENGINE's glyph layout so it lands exactly on the
rendered text (the browser's own layout is never used for positioning). */}
{!fallbackVisible && caretBox && (
<div
className="absolute z-[39]"
style={{ left: caretBox.left, top: caretBox.top, height: caretBox.height, width: 1.6, background: '#2563eb', animation: 'pe-caret-blink 1s step-end infinite', pointerEvents: 'none' }}
/>
)}
{/* Transparent input layer: holds the text + receives keys; its OWN layout is ignored. */}
<div
ref={editRef}
contentEditable
suppressContentEditableWarning
spellCheck={false}
onInput={onInput}
onClick={onClickEditor}
onKeyUp={positionCaret}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
}}
onBlur={commit}
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`,
caretColor: fallbackVisible ? '#2563eb' : 'transparent',
color: fallbackVisible ? undefined : 'transparent',
background: fallbackVisible ? '#ffffff' : 'transparent',
whiteSpace: 'normal', overflowWrap: 'break-word',
}}
/>
</>
);
};
+246 -11
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
import { ParagraphEditor } from './ParagraphEditor';
// A single editable run. Geometry (x/y/w/h, baselineY) is PDF BOTTOM-LEFT (as
// getPageModel returns) and is used only to position the inline editor. The
@@ -17,6 +18,104 @@ export interface EditableRun {
internalFontId: string;
fontName: string;
color: string;
// Position of this run within the raw page model, so a commit can reconstruct its paragraph.
paraIndex: number;
lineIndex: number;
runIndex: number;
}
// A whole-paragraph re-layout request (Word-style wrap + push-down), built on commit when the
// edited run's paragraph spans multiple lines. The engine owns the actual line-breaking.
export interface ReflowFragment {
text: string;
internalFontId: string;
fontSize: number;
color: string;
}
export interface ReflowParagraphPayload {
objectIndices: number[];
runs: ReflowFragment[];
columnLeft: number;
columnRight: number;
firstBaselineY: number;
leading: number;
oldLineCount: number;
align: 'left' | 'justify';
pushColumnLeft?: number; // full-width push-down left for bullet items (see engine)
// WYSIWYG: exact visual line breaks from the live editor (one inner array per line).
lines?: ReflowFragment[][];
}
// A bullet marker glyph at the start of a list item (•, -, ▪, etc.).
export function isBulletMarker(text?: string): boolean {
if (!text) return false;
const t = text.trim();
return t.length <= 2 && /^[•▪◦‣·●○■□*‒–—\-]$/.test(t);
}
// One logical item to reflow inside a non-flowing paragraph (a single bullet + its wrapped lines,
// or a leading non-bullet block). Returns a sub-paragraph whose lines exclude the bullet marker
// (so it stays put) with the text column starting at the hanging indent, plus the full-width
// pushColumnLeft and the paragraph's true leading.
export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number } | null {
const lines = para?.lines ?? [];
if (!lines.length) return null;
const colLeft = Math.min(...lines.map((l: any) => l.x));
const isStart = (l: any) => isBulletMarker((l.runs ?? [])[0]?.text);
// Item = from the nearest marker line at/above the click (or paragraph start) to before the next.
let start = runLineIndex;
while (start > 0 && !isStart(lines[start])) start--;
let end = runLineIndex + 1;
while (end < lines.length && !isStart(lines[end])) end++;
const itemLines = lines.slice(start, end);
if (!itemLines.length) return null;
// Paragraph leading (line-spacing) from all baselines — used so a newly-wrapped line gets the
// document's true spacing, not a guess.
const baselines = lines.map((l: any) => l.baseline_y).filter((b: any) => typeof b === 'number');
const deltas: number[] = [];
for (let i = 0; i < baselines.length - 1; i++) deltas.push(Math.abs(baselines[i] - baselines[i + 1]));
const leading = deltas.length ? median(deltas) : (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2;
// Strip a leading bullet marker (+ any leading space) from the first line; the text indent is
// where the real text begins (which the wrapped lines already hang to).
const firstRuns = itemLines[0].runs ?? [];
let subLines = itemLines;
if (isBulletMarker(firstRuns[0]?.text)) {
let ti = 1;
while (ti < firstRuns.length && !(firstRuns[ti].text ?? '').trim()) ti++;
const textRuns = firstRuns.slice(ti);
if (!textRuns.length) return null;
const textIndent = textRuns[0].x;
subLines = itemLines.map((l: any, idx: number) => idx === 0
? { ...l, runs: textRuns, x: textIndent, w: (l.x + l.w) - textIndent }
: l);
}
return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading };
}
// True only for a genuine FLOWING paragraph — multiple lines that fill the column from a common
// left edge (e.g. the Professional Summary). Bullet lists / structured blocks get grouped into a
// single "paragraph" by the model too, but they must NOT be reflowed (it would merge the bullets
// into one justified blob and mangle markers like • and ). Those fall back to per-line editing.
function isFlowingParagraph(para: any): boolean {
const lines = para?.lines ?? [];
if (lines.length < 2) return false;
const lefts = lines.map((l: any) => l.x);
const colLeft = Math.min(...lefts);
const colRight = Math.max(...lines.map((l: any) => l.x + l.w));
const colW = colRight - colLeft;
if (colW <= 0) return false;
// Most non-last lines must reach near the right edge (a filled column, not ragged list items).
let reaching = 0;
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].x + lines[i].w >= colRight - colW * 0.06) reaching++;
}
const filled = reaching >= (lines.length - 1) * 0.7;
// And every line must start at (roughly) the same left edge — bullets have hanging indents.
const consistentLeft = Math.max(...lefts.map((x: number) => Math.abs(x - colLeft))) < colW * 0.12;
return filled && consistentLeft;
}
interface TextEditLayerProps {
@@ -25,7 +124,9 @@ interface TextEditLayerProps {
width: number; // page width in ZOOMED px (= widthPts * zoom)
height: number; // page height in ZOOMED px (= heightPts * zoom)
zoom: number;
pageImageUrl?: string; // rendered page image, for the live push-down preview
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
}
// Nearest character index to an x-offset (px from the run's left edge), measured in the
@@ -58,14 +159,19 @@ function fallbackFamily(fontName: string): string {
return 'Arial, "Helvetica Neue", Helvetica, sans-serif';
}
// Flatten the dynamic page-model JSON into a flat list of editable runs.
// Flatten the dynamic page-model JSON into a flat list of editable runs, tagging each with its
// (paragraph, line, run) position in the raw model so a commit can reconstruct the paragraph.
function flattenRuns(model: any): EditableRun[] {
const runs: EditableRun[] = [];
const paragraphs = model?.paragraphs ?? [];
for (const p of paragraphs) {
for (const line of p.lines ?? []) {
for (let pi = 0; pi < paragraphs.length; pi++) {
const lines = paragraphs[pi].lines ?? [];
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
const baselineY = typeof line.baseline_y === 'number' ? line.baseline_y : 0;
for (const r of line.runs ?? []) {
const lineRuns = line.runs ?? [];
for (let ri = 0; ri < lineRuns.length; ri++) {
const r = lineRuns[ri];
const objectIndices: number[] = Array.isArray(r.object_indices) ? r.object_indices : [];
// Only runs backed by real page objects are editable (replace_text targets them).
if (typeof r.text === 'string' && r.text.trim() && r.w > 0 && r.h > 0 && objectIndices.length > 0) {
@@ -78,6 +184,7 @@ function flattenRuns(model: any): EditableRun[] {
internalFontId: r.internal_font_id ?? '',
fontName: r.font_name ?? '',
color: typeof r.color === 'string' ? r.color : '#000000',
paraIndex: pi, lineIndex: li, runIndex: ri,
});
}
}
@@ -86,6 +193,76 @@ function flattenRuns(model: any): EditableRun[] {
return runs;
}
// Median of an array (used for line leading).
function median(xs: number[]): number {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
// Reconstruct the edited run's paragraph as a reflow request: collect every run in reading
// order (substituting the edited run's new text), infer column bounds / leading / alignment.
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
const para = model?.paragraphs?.[run.paraIndex];
if (!para || !Array.isArray(para.lines)) return null;
const runs: ReflowParagraphPayload['runs'] = [];
const objectIndices: number[] = [];
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
const baselines: number[] = [];
const rightEdges: number[] = [];
for (let li = 0; li < para.lines.length; li++) {
const line = para.lines[li];
if (typeof line.baseline_y === 'number') baselines.push(line.baseline_y);
columnLeft = Math.min(columnLeft, line.x);
columnRight = Math.max(columnRight, line.x + line.w);
rightEdges.push(line.x + line.w);
if (typeof line.baseline_y === 'number') firstBaselineY = Math.max(firstBaselineY, line.baseline_y);
for (let ri = 0; ri < (line.runs ?? []).length; ri++) {
const r = line.runs[ri];
const oi: number[] = Array.isArray(r.object_indices) ? r.object_indices : [];
objectIndices.push(...oi);
const isEdited = li === run.lineIndex && ri === run.runIndex;
let text = isEdited ? newText : (r.text ?? '');
// A model line break carries no space character (the gap was positional), so insert
// one at line boundaries — otherwise "high-performance" + "backend" merge on reflow.
if (li > 0 && ri === 0 && runs.length > 0) {
const prev = runs[runs.length - 1].text;
if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text;
}
runs.push({
text,
internalFontId: r.internal_font_id ?? '',
fontSize: r.font_size ?? run.fontSize,
color: typeof r.color === 'string' ? r.color : '#000000',
});
}
}
if (runs.length === 0 || objectIndices.length === 0) return null;
// Leading = median baseline-to-baseline gap (bottom-left → positive going down).
const deltas: number[] = [];
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
const leading = deltas.length ? Math.abs(median(deltas)) : run.fontSize * 1.2;
// Alignment: if most non-last lines reach the right margin, it's justified.
const colW = columnRight - columnLeft;
let align: 'left' | 'justify' = 'left';
if (para.lines.length >= 2) {
let reaching = 0;
for (let i = 0; i < rightEdges.length - 1; i++) {
if (rightEdges[i] >= columnRight - colW * 0.04) reaching++;
}
if (reaching >= (para.lines.length - 1) * 0.7) align = 'justify';
}
return {
objectIndices, runs, columnLeft, columnRight, firstBaselineY,
leading, oldLineCount: para.lines.length, align,
};
}
export const TextEditLayer: React.FC<TextEditLayerProps> = ({
documentId,
pageIndex,
@@ -93,6 +270,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
height,
zoom,
onEditText,
onReflowParagraph,
}) => {
const [runs, setRuns] = useState<EditableRun[]>([]);
const [editing, setEditing] = useState<number | null>(null);
@@ -102,18 +280,28 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const committedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const caretIdxRef = useRef<number | null>(null);
const modelRef = useRef<any>(null);
// When set, the live reflow editor is open. `para` is the (sub-)paragraph to reflow — the whole
// paragraph for a flowing block, or a single bullet item (marker stripped) for a list.
const [paraEdit, setParaEdit] = useState<{
para: any; pushColumnLeft?: number; leading?: number; align?: 'left' | 'justify';
} | null>(null);
// Screen coords of the click that opened the paragraph editor, so the caret lands there
// (instead of jumping to the paragraph start).
const [caretClick, setCaretClick] = useState<{ x: number; y: number } | null>(null);
// Fetch the page model once per document/page.
useEffect(() => {
let cancelled = false;
setEditing(null);
setParaEdit(null);
gatewayService
.getPageModel(documentId, pageIndex)
.then((model) => {
if (!cancelled) setRuns(flattenRuns(model));
if (!cancelled) { modelRef.current = model; setRuns(flattenRuns(model)); }
})
.catch(() => {
if (!cancelled) setRuns([]);
if (!cancelled) { modelRef.current = null; setRuns([]); }
});
return () => {
cancelled = true;
@@ -135,9 +323,27 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
height: r.h * zoom,
});
const openEditor = (i: number, clickX: number) => {
committedRef.current = false;
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
const run = runs[i];
// Multi-line content → live reflow editor. A FLOWING paragraph reflows whole; a bullet/list
// reflows just the clicked ITEM (marker kept in place, hanging indent preserved).
const para = modelRef.current?.paragraphs?.[run.paraIndex];
if (Array.isArray(para?.lines) && para.lines.length > 1 && onReflowParagraph) {
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
if (isFlowingParagraph(para)) {
setCaretClick(click);
setParaEdit({ para });
return;
}
const item = buildBulletItem(para, run.lineIndex);
if (item) {
setCaretClick(click);
setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left' });
return;
}
// else: couldn't scope an item → fall through to per-line in-place editing
}
committedRef.current = false;
const fb = fallbackFamily(run.fontName);
// Caret lands at the clicked character (measured in the fallback font — close enough
// to pick the index; it stays correct after the real font swaps in).
@@ -158,7 +364,17 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const run = runs[editing];
const next = value;
setEditing(null);
if (next !== run.text) onEditText?.(pageIndex, run, next);
if (next === run.text) return;
// FLOWING multi-line paragraph → full reflow (re-wrap + push-down). Single lines, headings,
// labels, and bullet/list items → the precise, low-risk replace_text path (no re-wrapping,
// so list structure and markers stay intact).
const para = modelRef.current?.paragraphs?.[run.paraIndex];
if (onReflowParagraph && isFlowingParagraph(para)) {
const payload = buildReflowPayload(modelRef.current, run, next);
if (payload) { onReflowParagraph(pageIndex, payload); return; }
}
onEditText?.(pageIndex, run, next);
};
const cancel = () => {
@@ -170,14 +386,33 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
return (
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
{/* Live reflow editor (wraps + pushes down as you type) — whole paragraph or one bullet item. */}
{paraEdit !== null && (
<ParagraphEditor
documentId={documentId}
pageIndex={pageIndex}
para={paraEdit.para}
pushColumnLeft={paraEdit.pushColumnLeft}
leadingOverride={paraEdit.leading}
alignOverride={paraEdit.align}
caretClick={caretClick}
heightPts={heightPts}
zoom={zoom}
pageWidthPx={width}
pageHeightPx={height}
onCommit={(payload) => { setParaEdit(null); onReflowParagraph?.(pageIndex, payload); }}
onCancel={() => setParaEdit(null)}
/>
)}
{/* Per-run hit targets (visible hint on hover). */}
{editing === null &&
{editing === null && paraEdit === null &&
runs.map((r, i) => {
const box = rectOf(r);
return (
<div
key={i}
onClick={(e) => openEditor(i, e.nativeEvent.offsetX)}
onClick={(e) => openEditor(i, e.nativeEvent.offsetX, e.clientX, e.clientY)}
className="absolute cursor-text hover:bg-[rgba(37,99,235,0.06)]"
style={{ left: box.left, top: box.top, width: box.width, height: box.height }}
/>
+16
View File
@@ -298,6 +298,22 @@ def get_font_bytes(document_id: str, internal_font_id: str) -> Response:
)
@router.get("/{document_id}/raw")
def get_document_raw(document_id: str) -> Response:
"""Raw PDF bytes of the current version — loaded into the in-browser WASM engine for the
pixel-identical live-edit preview. Gated on copy permission (same as export)."""
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
perms = doc_info.get("permissions") or {}
if perms.get("canCopy", True) is False:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not permitted (canCopy).")
data = doc_info.get("bytes_data")
if not data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document bytes unavailable")
return Response(content=bytes(data), media_type="application/pdf")
class SearchRect(BaseModel):
x: float
y: float
+38 -2
View File
@@ -223,6 +223,41 @@ class ReplaceTextOperation(BaseModel):
data: ReplaceTextData
class ReflowRun(BaseModel):
text: str
internalFontId: str
fontSize: float
color: str = "#000000"
class ReflowParagraphData(BaseModel):
objectIndices: list[int]
runs: list[ReflowRun]
columnLeft: float
columnRight: float
firstBaselineY: float
leading: float
oldLineCount: int = 1
align: Literal["left", "justify"] = "left"
# Push-down column left edge (defaults to columnLeft). For a bullet item the text reflows from
# a hanging indent (columnLeft) but the push-down spans the full width from pushColumnLeft so
# bullet markers and items below move together.
pushColumnLeft: float | None = None
# Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of
# styled fragments per line). When present the engine emits these breaks verbatim.
lines: list[list[ReflowRun]] | None = None
# Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of
# styled fragments per line). When present the engine emits these breaks verbatim.
lines: list[list[ReflowRun]] | None = None
class ReflowParagraphOperation(BaseModel):
id: str
type: Literal["reflow_paragraph"]
pageIndex: int = Field(..., ge=0)
data: ReflowParagraphData
class DecorationData(BaseModel):
# Text-markup annotation (underline/strikeout/squiggly) over one or more text
# lines — quadpoints in PDF top-down space, mirroring HighlightData.
@@ -264,6 +299,7 @@ EditOperation = Annotated[
| DeleteAnnotationOperation
| UpdateAnnotationOperation
| ReplaceTextOperation
| ReflowParagraphOperation
| UnderlineOperation
| StrikeoutOperation
| SquigglyOperation,
@@ -283,7 +319,7 @@ _OP_PERMISSION = {
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"replace_text": "canModify", "redaction": "canModify",
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
"update_field": "canFillForms",
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
}
@@ -366,7 +402,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
# Redaction and in-place text rewrites mutate existing objects, which do
# not round-trip cleanly through an incremental save — force a full save.
full_save_types = {"redaction", "replace_text"}
full_save_types = {"redaction", "replace_text", "reflow_paragraph"}
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
new_bytes = doc.save_full() if needs_full else doc.save_incremental()
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Tests for `reflow_paragraph` — Word/Adobe-style paragraph re-layout.
Editing paragraph text re-wraps it within the column width, re-justifies, and pushes the
following content down/up by the line-count delta. This is what fixes the justified-text
"word collision" that the old line-level shift produced.
Run: gateway/.venv/Scripts/python.exe tests/edits/test_reflow_paragraph.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # noqa: E402
def _build_pdf() -> bytes:
# A 3-line paragraph (Helvetica 11, baselines 700/686/672) + a footer line at y=600.
content = (
b"BT /F1 11 Tf 72 700 Td (The quick brown fox jumps over the lazy) Tj ET\n"
b"BT /F1 11 Tf 72 686 Td (dog near the river bank on a sunny) Tj ET\n"
b"BT /F1 11 Tf 72 672 Td (afternoon in early spring.) Tj ET\n"
b"BT /F1 11 Tf 72 600 Td (FOOTER LINE) Tj ET\n"
)
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
pdf = b"%PDF-1.7\n"
offs = []
for i, o in enumerate(objs, 1):
offs.append(len(pdf))
pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n"
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for o in offs:
pdf += b"%010d 00000 n \n" % o
pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
return pdf
def _para_text(p) -> str:
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
def _footer_baseline(model):
for p in model.paragraphs:
for ln in p.lines:
if "FOOTER" in "".join(r.text for r in ln.runs):
return ln.baseline_y
return None
def _reflow(longer: bool):
doc = pdfengine.PdfDocument.load_from_memory(_build_pdf(), "")
m = doc.get_page(0).extract_document_model()
para = m.paragraphs[0]
obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices]
col_left = min(ln.x for ln in para.lines)
col_right = max(ln.x + ln.w for ln in para.lines)
first_baseline = max(ln.baseline_y for ln in para.lines)
bls = sorted((ln.baseline_y for ln in para.lines), reverse=True)
leading = abs(bls[0] - bls[1])
if longer:
new_text = ("The quick brown fox jumps over the lazy dog near the river bank on a sunny "
"afternoon in early spring while birds sing softly and the gentle breeze "
"carries the scent of fresh blossoms across the meadow.")
else:
new_text = "The quick brown fox."
op = {"version": "1.0", "operations": [{
"id": "r1", "type": "reflow_paragraph", "pageIndex": 0, "data": {
"objectIndices": obj_idxs,
"runs": [{"text": new_text, "internalFontId": para.lines[0].runs[0].internal_font_id,
"fontSize": 11.0, "color": "#000000"}],
"columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline,
"leading": leading, "oldLineCount": len(para.lines), "align": "justify"}}]}
doc.apply_edits(json.dumps(op))
out = doc.save_full()
m2 = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).extract_document_model()
return m2, leading, len(para.lines)
def test_grow_wraps_and_pushes_down():
m2, leading, old_lines = _reflow(longer=True)
new_lines = len(m2.paragraphs[0].lines)
assert new_lines > old_lines, f"expected more lines after growing text, got {new_lines} (was {old_lines})"
footer = _footer_baseline(m2)
expected = 600 - (new_lines - old_lines) * leading
assert footer is not None and abs(footer - expected) < 2.0, \
f"footer should be pushed to ~{expected:.0f}, got {footer}"
print(f" ok grow: {old_lines}->{new_lines} lines, footer 600->{footer:.0f} (pushed {(new_lines-old_lines)*leading:.0f})")
def test_shrink_pulls_up():
m2, leading, old_lines = _reflow(longer=False)
new_lines = len(m2.paragraphs[0].lines)
assert new_lines <= old_lines
footer = _footer_baseline(m2)
expected = 600 - (new_lines - old_lines) * leading # new<old -> negative delta -> footer moves UP
assert footer is not None and abs(footer - expected) < 2.0, \
f"footer should pull up to ~{expected:.0f}, got {footer}"
print(f" ok shrink: {old_lines}->{new_lines} lines, footer 600->{footer:.0f}")
def test_no_word_overlap():
# After a justified reflow, words on a line must not overlap (the old bug collided them).
m2, _, _ = _reflow(longer=True)
for ln in m2.paragraphs[0].lines:
runs = sorted(ln.runs, key=lambda r: r.x)
for a, b in zip(runs, runs[1:]):
assert a.x + a.w <= b.x + 1.0, f"overlap: run ends {a.x + a.w:.1f} > next start {b.x:.1f}"
print(" ok no-overlap: justified lines have monotonic, non-colliding runs")
def main() -> int:
try:
test_grow_wraps_and_pushes_down()
test_shrink_pulls_up()
test_no_word_overlap()
except AssertionError as exc:
print(f" FAIL: {exc}")
return 1
print("\n3/3 passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -43,7 +43,6 @@ message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/hello.mjs (+ he
# Phase 1/2 WASM Engine target
add_executable(pdfengine_wasm
bindings/wasm_engine.cpp
bindings/pdf_engine_facade.cpp
)
set_target_properties(pdfengine_wasm PROPERTIES
@@ -62,9 +61,10 @@ target_link_options(pdfengine_wasm PRIVATE
"-sMODULARIZE=1"
"-sEXPORT_ES6=1"
"-sENVIRONMENT=node,web"
"-sFILESYSTEM=0"
"-sALLOW_MEMORY_GROWTH=1"
"-sEXPORTED_FUNCTIONS=['_loadDocument','_renderPage','_freeDocument','_engineBuildInfo','_engineHasSkia','_malloc','_free','_getPageTextJson']"
"-sWASM_BIGINT" # PDFium uses i64
"-sSTACK_SIZE=5MB" # PDFium render is stack-heavy
"-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_freeDocument','_engineBuildInfo','_malloc','_free']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']"
)
+69 -30
View File
@@ -1,49 +1,88 @@
// Real engine bindings for WASM: load a PDF, render a page (PNG), and render a PREVIEW of
// an edit (e.g. reflow_paragraph) without mutating the loaded document. Rendering uses the
// SAME C++ engine path as the gateway (PdfDocument::render), so the browser preview is
// pixel-identical to the server-rendered page.
#include <emscripten/emscripten.h>
#include <cstdint>
#include <pdfengine/pdf_document.hpp>
#include <unordered_map>
#include <memory>
#include <vector>
#include <string>
#include "pdf_engine_facade.hpp"
#include <cstdint>
// Global facade instance
static PdfEngineFacade g_facade;
namespace {
struct DocEntry {
std::shared_ptr<pdfengine::PdfDocument> doc;
std::vector<uint8_t> bytes; // kept so a preview can re-load a clean copy
};
std::unordered_map<int, DocEntry> g_docs;
int g_nextHandle = 1;
// Holds the most recent render output (PNG) so JS can read it via lastRender* accessors.
std::vector<uint8_t> g_lastPng;
int g_lastW = 0, g_lastH = 0;
// Per-character caret layout (JSON) from the most recent preview reflow, read via lastLayoutJson().
std::string g_lastLayout;
int renderInto(pdfengine::PdfDocument& doc, int pageIndex, int dpi) {
auto page = doc.getPage(pageIndex);
if (!page) return -1;
auto img = (*page)->render(dpi);
if (!img) return -1;
g_lastPng = std::move(img->data);
g_lastW = img->width;
g_lastH = img->height;
return static_cast<int>(g_lastPng.size());
}
} // namespace
extern "C" {
EMSCRIPTEN_KEEPALIVE int loadDocument(const uint8_t* buffer, int size) {
return g_facade.loadDocument(buffer, size);
std::vector<uint8_t> bytes(buffer, buffer + size);
auto res = pdfengine::PdfDocument::loadFromMemory(bytes, "");
if (!res) return -1;
int h = g_nextHandle++;
g_docs[h] = DocEntry{*res, std::move(bytes)};
return h;
}
EMSCRIPTEN_KEEPALIVE int renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) {
return g_facade.renderPage(docHandle, pageIndex, scale, outputBuffer, width, height) ? 1 : 0;
EMSCRIPTEN_KEEPALIVE int pageCount(int handle) {
auto it = g_docs.find(handle);
return it == g_docs.end() ? -1 : it->second.doc->pageCount();
}
EMSCRIPTEN_KEEPALIVE void freeDocument(int docHandle) {
g_facade.freeDocument(docHandle);
// Render the loaded page as-is (PNG). Returns byte length (read via lastRenderPtr()).
EMSCRIPTEN_KEEPALIVE int renderPagePng(int handle, int pageIndex, int dpi) {
auto it = g_docs.find(handle);
if (it == g_docs.end()) return -1;
return renderInto(*it->second.doc, pageIndex, dpi);
}
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() {
return PdfEngineFacade::buildInfo();
// Render a PREVIEW of an edit: load a fresh copy from the original bytes, apply the edits
// (e.g. a reflow_paragraph op), render the page — the loaded document is untouched.
EMSCRIPTEN_KEEPALIVE int previewRender(int handle, int pageIndex, int dpi, const char* editsJson) {
auto it = g_docs.find(handle);
if (it == g_docs.end()) return -1;
auto fresh = pdfengine::PdfDocument::loadFromMemory(it->second.bytes, "");
if (!fresh) return -1;
g_lastLayout.clear();
if (editsJson && editsJson[0]) {
auto r = (*fresh)->applyEdits(editsJson);
if (!r) return -1;
g_lastLayout = (*fresh)->lastReflowLayout();
}
return renderInto(**fresh, pageIndex, dpi);
}
EMSCRIPTEN_KEEPALIVE int engineHasSkia() {
return PdfEngineFacade::hasSkia() ? 1 : 0;
}
EMSCRIPTEN_KEEPALIVE const uint8_t* lastRenderPtr() { return g_lastPng.data(); }
EMSCRIPTEN_KEEPALIVE int lastRenderW() { return g_lastW; }
EMSCRIPTEN_KEEPALIVE int lastRenderH() { return g_lastH; }
// Caret layout JSON for the most recent previewRender (empty if it had no reflow).
EMSCRIPTEN_KEEPALIVE const char* lastLayoutJson() { return g_lastLayout.c_str(); }
EMSCRIPTEN_KEEPALIVE const char* getDocumentFonts(int docHandle, int startPage, int endPage) {
static std::string s_buf;
s_buf = g_facade.getDocumentFonts(docHandle, startPage, endPage);
return s_buf.c_str();
}
EMSCRIPTEN_KEEPALIVE void freeDocument(int handle) { g_docs.erase(handle); }
EMSCRIPTEN_KEEPALIVE const char* getPageFonts(int docHandle, int pageIndex) {
static std::string s_buf;
s_buf = g_facade.getPageFonts(docHandle, pageIndex);
return s_buf.c_str();
}
EMSCRIPTEN_KEEPALIVE const char* getPageTextJson(int docHandle, int pageIndex) {
static std::string s_buf;
s_buf = g_facade.getPageTextJson(docHandle, pageIndex);
return s_buf.c_str();
}
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }
}