Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into azeem
This commit is contained in:
@@ -79,3 +79,4 @@ timeout-*
|
||||
|
||||
PDF Editor Timeline.xlsx
|
||||
# Local environment config / secrets
|
||||
/third_party/pdfium-wasm/
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,8 +1143,15 @@ 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 ||
|
||||
std::abs(currG.fontSize - currentRun.fontSize) > 0.1 ||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
@@ -334,6 +329,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; }
|
||||
@@ -614,6 +616,7 @@ function App() {
|
||||
onRedactArea={handleRedactArea}
|
||||
onPlaceText={handlePlaceText}
|
||||
onEditText={handleEditText}
|
||||
onReflowParagraph={handleReflowParagraph}
|
||||
onPlaceStamp={handlePlaceStamp}
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
onDecorateText={handleDecorateText}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -367,7 +403,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_copy.save_full() if needs_full else doc_copy.save_incremental()
|
||||
|
||||
|
||||
@@ -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
@@ -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']"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user