feat: updated font handling in older pdfs, along with tables

This commit is contained in:
Furqan-14
2026-06-26 11:33:28 +05:30
parent 52b45d82ea
commit e795eed63e
26 changed files with 995 additions and 67 deletions
+9 -1
View File
@@ -305,7 +305,8 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("h", &pdfengine::TextRun::h)
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices)
.def_readonly("fill_color", &pdfengine::TextRun::fillColor)
.def_readonly("para_id", &pdfengine::TextRun::paraId);
.def_readonly("para_id", &pdfengine::TextRun::paraId)
.def_readonly("font_fidelity", &pdfengine::TextRun::fontFidelity);
py::class_<pdfengine::TextLine>(m, "TextLine")
.def_readonly("runs", &pdfengine::TextLine::runs)
@@ -474,6 +475,13 @@ PYBIND11_MODULE(pdfengine, m) {
}
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
}, py::arg("internal_font_id"))
.def("get_reconstructed_font_data", [](pdfengine::PdfDocument& self, const std::string& internal_font_id) {
auto res = self.getReconstructedFontData(internal_font_id);
if (!res || res->empty()) {
return py::bytes();
}
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
}, py::arg("internal_font_id"))
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
get_or_throw(self.applyEdits(editsJson));
}, py::arg("edits_json"))
+3 -16
View File
@@ -1,7 +1,3 @@
# pdfengine — the C++23 PDF SDK core.
#
# Generate the version header from the project version.
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/include/pdfengine/version.hpp.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/pdfengine/version.hpp"
@@ -34,6 +30,8 @@ add_library(pdfengine STATIC
src/fonts/pdf_fonts/font_descriptor.cpp
src/fonts/pdf_fonts/font_fallback.cpp
src/fonts/pdf_fonts/font_subset.cpp
src/fonts/pdf_fonts/font_cmap_builder.cpp
src/fonts/pdf_fonts/embedded_font_reconstructor.cpp
src/fonts/pdf_fonts/encoding/encoding.cpp
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
src/fonts/pdf_fonts/encoding/cjk_collection_db.cpp
@@ -64,9 +62,6 @@ if(PDFENGINE_WITH_PDFIUM)
target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM)
endif()
# Directory of the bundled fallback fonts (Carlito/Tinos). Under WASM they're embedded into the
# module's in-memory FS at /fonts (see wasm/CMakeLists.txt --embed-file); natively the engine reads
# them straight from the source assets dir. font_fallback.cpp prefers these over OS fonts.
if(EMSCRIPTEN)
target_compile_definitions(pdfengine PRIVATE PDFENGINE_FONT_DIR="/fonts")
else()
@@ -89,6 +84,7 @@ if(PDFENGINE_WITH_QPDF)
endif()
target_sources(pdfengine PRIVATE
src/qpdf/qpdf_extractor.cpp
src/qpdf/qpdf_font_extractor.cpp
src/qpdf/qpdf_writer.cpp
src/qpdf/qpdf_resource_resolver.cpp
src/core/image_decoder.cpp
@@ -109,17 +105,10 @@ if(PDFENGINE_BUILD_TESTS)
add_subdirectory(tests)
endif()
# --- Fuzzing -----------------------------------------------------------------
# Coverage-instrument the engine and build the libFuzzer harness. Requires Clang
# (enforced at the top level). PDFENGINE_FUZZ_SANITIZERS lets callers drop ASan
# when the PDFium static lib isn't ASan-compatible (coverage-only fuzzing).
if(PDFENGINE_FUZZING)
set(PDFENGINE_FUZZ_SANITIZERS "fuzzer,address,undefined" CACHE STRING
"Sanitizer set for fuzzing (e.g. 'fuzzer,address,undefined' or just 'fuzzer')")
# Split the set into the libFuzzer driver ('fuzzer', linked only into the
# harness exe) and the runtime sanitizers (address/undefined/...), which must
# instrument the engine library itself to catch bugs in its code.
string(REPLACE "," ";" _fuzz_sans "${PDFENGINE_FUZZ_SANITIZERS}")
set(_fuzz_runtime_sans "")
foreach(_s IN LISTS _fuzz_sans)
@@ -129,8 +118,6 @@ if(PDFENGINE_FUZZING)
endforeach()
list(JOIN _fuzz_runtime_sans "," _fuzz_runtime_str)
# Coverage-instrument the engine; apply ASan/UBSan to it too so its own code
# is checked, not just the harness.
target_compile_options(pdfengine PRIVATE -fsanitize=fuzzer-no-link -fno-omit-frame-pointer)
if(_fuzz_runtime_str)
target_compile_options(pdfengine PRIVATE -fsanitize=${_fuzz_runtime_str})
+7 -1
View File
@@ -134,6 +134,7 @@ struct TextRun {
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
std::string fillColor = "#000000";
std::string paraId;
std::string fontFidelity = "exact";
};
struct TextLine {
@@ -244,9 +245,14 @@ public:
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError>
getFonts(int startPage = 0, int endPage = -1) const = 0;
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
getFontData(const std::string& internalFontId) const = 0;
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
getReconstructedFontData(const std::string& internalFontId) { (void)internalFontId; return std::unexpected(EngineError::Unknown); }
virtual void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) { (void)internalFontId; (void)sfnt; }
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
getResolvedFont(const FontInfo& fontInfo) = 0;
+14
View File
@@ -114,6 +114,20 @@ bool FontFace::coversUnicode(uint32_t codepoint) const {
return gid != 0;
}
std::vector<uint32_t> FontFace::coveredCodepoints() const {
std::vector<uint32_t> out;
if (!face_) return out;
std::lock_guard<std::mutex> lock(*mutex_);
FT_CharMap prev = face_->charmap;
if (FT_Select_Charmap(face_, FT_ENCODING_UNICODE) == 0) {
FT_UInt gid = 0;
FT_ULong cp = FT_Get_First_Char(face_, &gid);
while (gid != 0) { out.push_back(static_cast<uint32_t>(cp)); cp = FT_Get_Next_Char(face_, cp, &gid); }
}
if (prev) FT_Set_Charmap(face_, prev);
return out;
}
uint64_t FontFace::getId() const {
return font_id_;
}
+2
View File
@@ -32,6 +32,8 @@ public:
bool coversUnicode(uint32_t codepoint) const;
std::vector<uint32_t> coveredCodepoints() const;
std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
private:
@@ -0,0 +1,44 @@
#include "fonts/pdf_fonts/embedded_font_reconstructor.hpp"
#include <map>
#include "fonts/pdf_fonts/font_cmap_builder.hpp"
namespace pdfengine::fonts::pdf_fonts {
ReconstructedFont EmbeddedFontReconstructor::reconstruct(
const std::vector<uint8_t>& program,
const std::unordered_map<uint32_t, uint32_t>& codeToUnicode,
bool identityCidToGid,
const std::unordered_map<uint32_t, uint32_t>& codeToGid) {
ReconstructedFont out;
if (program.size() < 12 || codeToUnicode.empty()) return out;
std::map<uint32_t, uint16_t> unicodeToGid;
for (const auto& [code, uni] : codeToUnicode) {
if (uni == 0) continue;
uint32_t gid;
if (identityCidToGid) {
gid = code;
} else {
auto it = codeToGid.find(code);
if (it == codeToGid.end()) continue;
gid = it->second;
}
if (gid == 0 || gid > 0xFFFF) continue;
unicodeToGid.emplace(uni, static_cast<uint16_t>(gid));
}
if (unicodeToGid.empty()) return out;
std::vector<uint8_t> cmap = FontCmapBuilder::buildCmapTable(unicodeToGid);
if (cmap.empty()) return out;
std::vector<uint8_t> sfnt = FontCmapBuilder::spliceCmapIntoSfnt(program, cmap);
if (sfnt.empty()) return out;
out.sfnt = std::move(sfnt);
for (const auto& [uni, gid] : unicodeToGid) out.coveredUnicode.insert(uni);
out.ok = true;
return out;
}
}
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <set>
#include <unordered_map>
#include <vector>
namespace pdfengine::fonts::pdf_fonts {
struct ReconstructedFont {
bool ok = false;
std::vector<uint8_t> sfnt;
std::set<uint32_t> coveredUnicode;
};
class EmbeddedFontReconstructor {
public:
static ReconstructedFont reconstruct(const std::vector<uint8_t>& program,
const std::unordered_map<uint32_t, uint32_t>& codeToUnicode,
bool identityCidToGid,
const std::unordered_map<uint32_t, uint32_t>& codeToGid);
};
}
@@ -0,0 +1,202 @@
#include "fonts/pdf_fonts/font_cmap_builder.hpp"
#include <algorithm>
#include <cstring>
namespace pdfengine::fonts::pdf_fonts {
namespace {
void put16(std::vector<uint8_t>& b, uint16_t v) {
b.push_back(static_cast<uint8_t>(v >> 8));
b.push_back(static_cast<uint8_t>(v & 0xFF));
}
void put32(std::vector<uint8_t>& b, uint32_t v) {
b.push_back(static_cast<uint8_t>(v >> 24));
b.push_back(static_cast<uint8_t>((v >> 16) & 0xFF));
b.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
b.push_back(static_cast<uint8_t>(v & 0xFF));
}
uint16_t read16(const std::vector<uint8_t>& b, size_t o) {
return static_cast<uint16_t>((b[o] << 8) | b[o + 1]);
}
uint32_t read32(const std::vector<uint8_t>& b, size_t o) {
return (static_cast<uint32_t>(b[o]) << 24) | (static_cast<uint32_t>(b[o + 1]) << 16) |
(static_cast<uint32_t>(b[o + 2]) << 8) | static_cast<uint32_t>(b[o + 3]);
}
void pow2le(uint32_t n, uint32_t& powTimes16, uint32_t& sel) {
uint32_t p = 1, s = 0;
while (p * 2 <= n) { p *= 2; ++s; }
powTimes16 = p; sel = s;
}
uint32_t tableChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
size_t i = 0;
for (; i + 4 <= len; i += 4)
sum += (static_cast<uint32_t>(data[i]) << 24) | (static_cast<uint32_t>(data[i + 1]) << 16) |
(static_cast<uint32_t>(data[i + 2]) << 8) | static_cast<uint32_t>(data[i + 3]);
if (i < len) { // tail < 4 bytes, pad with zeros
uint32_t last = 0;
for (size_t j = 0; j < 4; ++j) last = (last << 8) | (i + j < len ? data[i + j] : 0);
sum += last;
}
return sum;
}
std::vector<uint8_t> buildFormat4(const std::vector<std::pair<uint16_t, uint16_t>>& bmp) {
std::vector<uint16_t> endC, startC, idDelta, idRange;
for (const auto& [u, g] : bmp) {
if (u == 0xFFFF) continue;
startC.push_back(u);
endC.push_back(u);
idDelta.push_back(static_cast<uint16_t>((static_cast<int32_t>(g) - static_cast<int32_t>(u)) & 0xFFFF));
idRange.push_back(0);
}
startC.push_back(0xFFFF); endC.push_back(0xFFFF); idDelta.push_back(1); idRange.push_back(0);
uint16_t segCount = static_cast<uint16_t>(endC.size());
uint32_t srPow, sel; pow2le(segCount, srPow, sel);
uint16_t segX2 = static_cast<uint16_t>(segCount * 2);
uint16_t searchRange = static_cast<uint16_t>(srPow * 2);
uint16_t rangeShift = static_cast<uint16_t>(segX2 - searchRange);
std::vector<uint8_t> t;
put16(t, 4);
put16(t, 0);
put16(t, 0);
put16(t, segX2);
put16(t, searchRange);
put16(t, static_cast<uint16_t>(sel));
put16(t, rangeShift);
for (auto v : endC) put16(t, v);
put16(t, 0);
for (auto v : startC) put16(t, v);
for (auto v : idDelta) put16(t, v);
for (auto v : idRange) put16(t, v);
t[2] = static_cast<uint8_t>(t.size() >> 8);
t[3] = static_cast<uint8_t>(t.size() & 0xFF);
return t;
}
std::vector<uint8_t> buildFormat12(const std::map<uint32_t, uint16_t>& all) {
std::vector<uint8_t> t;
put16(t, 12); put16(t, 0);
put32(t, 0);
put32(t, 0);
put32(t, static_cast<uint32_t>(all.size()));
for (const auto& [u, g] : all) {
put32(t, u); put32(t, u); put32(t, g);
}
uint32_t len = static_cast<uint32_t>(t.size());
t[4] = static_cast<uint8_t>(len >> 24); t[5] = static_cast<uint8_t>((len >> 16) & 0xFF);
t[6] = static_cast<uint8_t>((len >> 8) & 0xFF); t[7] = static_cast<uint8_t>(len & 0xFF);
return t;
}
}
std::vector<uint8_t> FontCmapBuilder::buildCmapTable(const std::map<uint32_t, uint16_t>& unicodeToGid) {
if (unicodeToGid.empty()) return {};
std::vector<std::pair<uint16_t, uint16_t>> bmp;
bool hasSupp = false;
for (const auto& [u, g] : unicodeToGid) {
if (u <= 0xFFFF) bmp.emplace_back(static_cast<uint16_t>(u), g);
else hasSupp = true;
}
std::vector<uint8_t> sub4 = bmp.empty() ? std::vector<uint8_t>{} : buildFormat4(bmp);
std::vector<uint8_t> sub12 = hasSupp ? buildFormat12(unicodeToGid) : std::vector<uint8_t>{};
uint16_t numTables = static_cast<uint16_t>((sub4.empty() ? 0 : 1) + (sub12.empty() ? 0 : 1));
if (numTables == 0) return {};
std::vector<uint8_t> out;
put16(out, 0);
put16(out, numTables);
uint32_t recBase = 4;
uint32_t dataBase = recBase + numTables * 8u;
uint32_t off4 = dataBase;
uint32_t off12 = dataBase + static_cast<uint32_t>(sub4.size());
if (!sub4.empty()) { put16(out, 3); put16(out, 1); put32(out, off4); }
if (!sub12.empty()) { put16(out, 3); put16(out, 10); put32(out, off12); }
out.insert(out.end(), sub4.begin(), sub4.end());
out.insert(out.end(), sub12.begin(), sub12.end());
return out;
}
std::vector<uint8_t> FontCmapBuilder::spliceCmapIntoSfnt(const std::vector<uint8_t>& sfnt,
const std::vector<uint8_t>& cmapTable) {
if (sfnt.size() < 12 || cmapTable.empty()) return {};
uint32_t sfntVersion = read32(sfnt, 0);
uint16_t numTables = read16(sfnt, 4);
if (12u + static_cast<size_t>(numTables) * 16u > sfnt.size()) return {};
struct Tab { uint32_t tag; std::vector<uint8_t> data; };
std::vector<Tab> tabs;
bool hadCmap = false;
for (uint16_t i = 0; i < numTables; ++i) {
size_t rec = 12 + static_cast<size_t>(i) * 16;
uint32_t tag = read32(sfnt, rec);
uint32_t off = read32(sfnt, rec + 8);
uint32_t len = read32(sfnt, rec + 12);
if (static_cast<size_t>(off) + len > sfnt.size()) return {};
Tab t; t.tag = tag;
if (tag == 0x636D6170u /* 'cmap' */) { t.data = cmapTable; hadCmap = true; }
else t.data.assign(sfnt.begin() + off, sfnt.begin() + off + len);
tabs.push_back(std::move(t));
}
if (!hadCmap) tabs.push_back({0x636D6170u, cmapTable});
std::sort(tabs.begin(), tabs.end(), [](const Tab& a, const Tab& b) { return a.tag < b.tag; });
const uint16_t n = static_cast<uint16_t>(tabs.size());
const uint32_t headerSize = 12u + static_cast<uint32_t>(n) * 16u;
std::vector<uint32_t> offsets(n), lengths(n), checksums(n);
uint32_t cursor = headerSize;
int headIdx = -1;
for (uint16_t i = 0; i < n; ++i) {
offsets[i] = cursor;
lengths[i] = static_cast<uint32_t>(tabs[i].data.size());
while (tabs[i].data.size() % 4 != 0) tabs[i].data.push_back(0);
if (tabs[i].tag == 0x68656164u /* 'head' */) {
headIdx = i;
if (tabs[i].data.size() >= 12) { // zero checkSumAdjustment (offset 8)
tabs[i].data[8] = tabs[i].data[9] = tabs[i].data[10] = tabs[i].data[11] = 0;
}
}
checksums[i] = tableChecksum(tabs[i].data.data(), tabs[i].data.size());
cursor += static_cast<uint32_t>(tabs[i].data.size());
}
uint32_t srPow, sel; pow2le(n, srPow, sel);
std::vector<uint8_t> out;
put32(out, sfntVersion);
put16(out, n);
put16(out, static_cast<uint16_t>(srPow * 16));
put16(out, static_cast<uint16_t>(sel));
put16(out, static_cast<uint16_t>(n * 16 - srPow * 16));
for (uint16_t i = 0; i < n; ++i) {
put32(out, tabs[i].tag);
put32(out, checksums[i]);
put32(out, offsets[i]);
put32(out, lengths[i]);
}
for (uint16_t i = 0; i < n; ++i)
out.insert(out.end(), tabs[i].data.begin(), tabs[i].data.end());
if (headIdx >= 0) {
uint32_t whole = tableChecksum(out.data(), out.size());
uint32_t adj = 0xB1B0AFBAu - whole;
size_t pos = offsets[headIdx] + 8;
out[pos] = static_cast<uint8_t>(adj >> 24);
out[pos + 1] = static_cast<uint8_t>((adj >> 16) & 0xFF);
out[pos + 2] = static_cast<uint8_t>((adj >> 8) & 0xFF);
out[pos + 3] = static_cast<uint8_t>(adj & 0xFF);
}
return out;
}
}
@@ -0,0 +1,17 @@
#pragma once
#include <cstdint>
#include <map>
#include <vector>
namespace pdfengine::fonts::pdf_fonts {
class FontCmapBuilder {
public:
static std::vector<uint8_t> buildCmapTable(const std::map<uint32_t, uint16_t>& unicodeToGid);
static std::vector<uint8_t> spliceCmapIntoSfnt(const std::vector<uint8_t>& sfnt,
const std::vector<uint8_t>& cmapTable);
};
}
+275 -11
View File
@@ -20,6 +20,7 @@
#include "fonts/face/font_face.hpp"
#include "decoration_builder.hpp"
#include "qpdf/qpdf_extractor.hpp"
#include "qpdf/qpdf_font_extractor.hpp"
#include "parser/lexer.hpp"
#include "parser/parser.hpp"
@@ -208,6 +209,22 @@ std::string makeInternalFontId(const pdfengine::FontInfo& f) {
return f.fontName + "_" + f.type + "_" + std::to_string(f.flags);
}
std::string classifyFontFidelity(const pdfengine::FontInfo& f) {
if (!f.isEmbedded) return "exact";
std::string fam = f.normalizedFamily;
std::transform(fam.begin(), fam.end(), fam.begin(), [](unsigned char c){ return std::tolower(c); });
auto has = [&](const char* s){ return fam.find(s) != std::string::npos; };
if (fam.empty() || has("times") || has("arial") || has("helvetica") || has("courier") ||
has("symbol") || has("zapf"))
return "exact";
const bool reconstructable =
f.hasToUnicode &&
(f.type.find("TrueType") != std::string::npos ||
f.type.find("Type0") != std::string::npos ||
f.type.find("CIDFontType2") != std::string::npos);
return reconstructable ? "partial" : "substituted";
}
std::string baseNameFromInternalFontId(const std::string& internalFontId) {
std::string expected = internalFontId;
size_t lastUnderscore = expected.rfind('_');
@@ -1156,6 +1173,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
currentRun.fontFidelity = classifyFontFidelity(it->second);
}
currentRun.glyphs.push_back(*firstG);
currentRun.text += firstG->text;
@@ -1206,6 +1224,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
currentRun.fontFidelity = classifyFontFidelity(it->second);
}
}
currentRun.glyphs.push_back(spaceGlyph);
@@ -1225,6 +1244,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
currentRun.internalFontId = it->second.internalFontId;
currentRun.isEmbedded = it->second.isEmbedded;
currentRun.type = it->second.type;
currentRun.fontFidelity = classifyFontFidelity(it->second);
}
}
@@ -2107,6 +2127,69 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
#endif
}
#ifdef PDFENGINE_WITH_PDFIUM
// Tier-2: register a pre-reconstructed (cmap-augmented) font + its coverage. Used by the WASM
// preview path: the frontend fetches gateway-reconstructed bytes and registers them so the
// preview emits the same glyphs the native save will.
void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) {
fonts::pdf_fonts::ReconstructedFont rf;
if (!sfnt.empty()) {
rf.ok = true;
// Derive coverage straight from the (cmap-bearing) sfnt so the WASM preview's
// exact/hybrid/fallback decision matches the native save.
fonts::FontFace face;
if (face.loadFromMemory(sfnt))
for (uint32_t u : face.coveredCodepoints()) rf.coveredUnicode.insert(u);
rf.sfnt = sfnt;
}
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
reconstructedFonts_[internalFontId] = std::move(rf);
}
std::expected<std::vector<uint8_t>, EngineError>
PdfiumDocument::getReconstructedFontData(const std::string& internalFontId) {
const auto* rf = lookupReconFont(internalFontId);
if (rf && rf->ok && !rf->sfnt.empty()) return rf->sfnt;
return std::unexpected(EngineError::Unknown);
}
const fonts::pdf_fonts::ReconstructedFont* PdfiumDocument::lookupReconFont(const std::string& internalFontId) {
#ifdef PDFENGINE_WITH_QPDF
return getReconstructedEmbeddedFont(internalFontId);
#else
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
auto it = reconstructedFonts_.find(internalFontId);
return it != reconstructedFonts_.end() ? &it->second : nullptr;
#endif
}
#endif
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
const fonts::pdf_fonts::ReconstructedFont*
PdfiumDocument::getReconstructedEmbeddedFont(const std::string& internalFontId) {
{
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
auto it = reconstructedFonts_.find(internalFontId);
if (it != reconstructedFonts_.end()) return &it->second;
}
fonts::pdf_fonts::ReconstructedFont rf;
auto progRes = getFontData(internalFontId);
if (progRes.has_value() && !progRes.value().empty() && !memoryBuffer_.empty()) {
std::string baseName = baseNameFromInternalFontId(internalFontId);
qpdf_layer::QpdfFontExtractor fx;
auto mapRes = fx.extractMapping(memoryBuffer_, baseName);
if (mapRes.has_value() && mapRes->ok) {
rf = fonts::pdf_fonts::EmbeddedFontReconstructor::reconstruct(
progRes.value(), mapRes->codeToUnicode, mapRes->identityCidToGid, mapRes->codeToGid);
spdlog::info("Tier-2: reconstruct '{}' ok={} covered={}", internalFontId, rf.ok, rf.coveredUnicode.size());
}
}
std::lock_guard<std::mutex> lock(reconstructedFontsMutex_);
auto res = reconstructedFonts_.emplace(internalFontId, std::move(rf));
return &res.first->second;
}
#endif
#ifdef PDFENGINE_WITH_PDFIUM
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
int pageIndex, const std::string& internalFontId, double fontSize,
@@ -2197,6 +2280,35 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
}
if (out.font) return out;
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
const auto* rf = lookupReconFont(internalFontId);
if (rf && rf->ok) {
bool covered = true;
for (uint32_t cp : codepoints) {
if (cp >= 0x20 && !rf->coveredUnicode.count(cp)) { covered = false; break; }
}
if (covered) {
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
const std::string rkey = "recon_" + internalFontId;
if (loadedFontsCache_.count(rkey)) {
out.font = loadedFontsCache_[rkey];
auto mit = loadedMeasureFaces_.find(rkey);
if (mit != loadedMeasureFaces_.end()) out.measureFace = mit->second;
} else {
loadedFontDataBuffers_[rkey] = rf->sfnt;
const auto& bytes = loadedFontDataBuffers_[rkey];
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
if (out.font) {
loadedFontsCache_[rkey] = out.font;
auto mf = std::make_shared<fonts::FontFace>();
if (mf->loadFromMemory(bytes)) { loadedMeasureFaces_[rkey] = mf; out.measureFace = mf; }
}
}
if (out.font) { spdlog::info("Tier-2: emit reconstructed embedded font '{}'", internalFontId); return out; }
}
}
}
if (useEmbedded) {
std::vector<uint8_t> sourceBytes;
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
@@ -2275,6 +2387,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
std::string newText = "";
std::string internalFontId = "";
double fontSize = -1.0;
bool disableJustify = false; // set for table cells: replace in place, never re-justify
if (op.contains("data") && op["data"].is_object()) {
auto data = op["data"];
@@ -2288,6 +2401,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
if (data.contains("fontSize")) {
fontSize = data["fontSize"].get<double>();
}
disableJustify = data.value("disableJustify", false);
} else {
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
for (auto& idx : op["objectIndices"]) {
@@ -2547,7 +2661,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
}
double justifyTol = (std::max)(4.0, (colRight - origLeft) * 0.02);
bool wasJustified = axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
bool wasJustified = !disableJustify && axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
double deltaX = 0.0;
@@ -2556,7 +2670,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
spdlog::info("Reflow Engine: origWidth = {}, newWidth = {}, deltaX = {}", origWidth, totalWidth, deltaX);
}
if (!wasJustified && hasOrigBounds && std::abs(deltaX) > 0.001) {
if (!wasJustified && !disableJustify && hasOrigBounds && std::abs(deltaX) > 0.001) {
int pageObjCount = FPDFPage_CountObjects(page);
double tolerance = (std::max)(5.0, fontSize * 0.5);
int reflowedCount = 0;
@@ -2621,6 +2735,32 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
}
FPDF_FONT reconFont = nullptr;
const fonts::pdf_fonts::ReconstructedFont* reconRf = nullptr;
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
reconRf = lookupReconFont(matchedFontInfo->internalFontId);
if (reconRf && reconRf->ok) {
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
const std::string rkey = "recon_" + matchedFontInfo->internalFontId;
if (loadedFontsCache_.count(rkey)) reconFont = loadedFontsCache_[rkey];
else {
loadedFontDataBuffers_[rkey] = reconRf->sfnt;
const auto& bytes = loadedFontDataBuffers_[rkey];
reconFont = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
if (reconFont) loadedFontsCache_[rkey] = reconFont;
}
if (reconFont) {
bool fullyCovered = true;
for (uint32_t cp : unicodeCodepoints)
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { fullyCovered = false; break; }
if (fullyCovered && !font) {
font = reconFont;
spdlog::info("Tier-2: replace_text exact reconstructed font '{}'", matchedFontInfo->internalFontId);
}
}
}
}
if (!font) {
if (useEmbedded) {
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
@@ -2643,18 +2783,20 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
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, unicodeCodepoints);
// Embed the FULL font (no per-edit subset). A subset renumbers GIDs
// per call, but every embed shares the same BaseFont name -> a second
// edit's text would reference the first edit's font with mismatched
// GIDs and render as scrambled glyphs. The full font has a stable,
// standard GID layout, so chained edits stay consistent (same reason
// the reflow path embeds full fonts; also avoids WASM subset glyph-drop).
const size_t fullSize = fileBytes.size();
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
loadedFontDataBuffers_[cacheKey] =
(!subsetBytes.empty()) ? std::move(subsetBytes) : std::move(fileBytes);
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
const auto& bytes = loadedFontDataBuffers_[cacheKey];
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
if (font) {
spdlog::info("Font Engine: Embedded {} CID system font '{}' ({} -> {} bytes) from '{}'",
bytes.size() < fullSize ? "SUBSET" : "FULL",
matchedFontInfo->fontName, fullSize, bytes.size(), fontPath);
spdlog::info("Font Engine: Embedded FULL CID system font '{}' ({} bytes) from '{}'",
matchedFontInfo->fontName, fullSize, fontPath);
}
}
} else {
@@ -2742,17 +2884,99 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
penX += (wpx[wi] + estSpace) * k + extraPerGap;
}
} else {
bool didHybrid = false;
if (reconFont && reconRf && font && font != reconFont && !unicodeCodepoints.empty()) {
fonts::FontFace reconFace; bool haveReconFace = reconFace.loadFromMemory(reconRf->sfnt);
fonts::FontFace subFace; bool haveSubFace = false;
{
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
auto it = loadedFontDataBuffers_.find(cacheKey);
if (it != loadedFontDataBuffers_.end() && !it->second.empty())
haveSubFace = subFace.loadFromMemory(it->second);
}
auto segWidthPage = [&](fonts::FontFace* face, const std::string& s) -> double {
if (!face || s.empty()) return 0.0;
double sum = 0.0;
try { fonts::HbShaper sh; for (const auto& gg : sh.shapeRun(s, *face, kRef)) sum += gg.advanceX; }
catch (...) { return 0.0; }
return sum * emToPage;
};
auto isCov = [&](uint32_t cp){ return cp < 0x20 || reconRf->coveredUnicode.count(cp) != 0; };
auto cpToU16 = [](uint32_t cp, std::vector<unsigned short>& dst){
if (cp <= 0xFFFF) dst.push_back(static_cast<unsigned short>(cp));
else { cp -= 0x10000; dst.push_back(static_cast<unsigned short>(0xD800 + (cp >> 10)));
dst.push_back(static_cast<unsigned short>(0xDC00 + (cp & 0x3FF))); }
};
auto cpToU8 = [](uint32_t cp, std::string& d){
if (cp < 0x80) d += static_cast<char>(cp);
else if (cp < 0x800) { d += static_cast<char>(0xC0 | (cp >> 6)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
else if (cp < 0x10000) { d += static_cast<char>(0xE0 | (cp >> 12)); d += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
else { d += static_cast<char>(0xF0 | (cp >> 18)); d += static_cast<char>(0x80 | ((cp >> 12) & 0x3F)); d += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); d += static_cast<char>(0x80 | (cp & 0x3F)); }
};
double penX = e;
size_t i = 0;
while (i < unicodeCodepoints.size()) {
bool cov = isCov(unicodeCodepoints[i]);
std::vector<unsigned short> seg; std::string seg8;
while (i < unicodeCodepoints.size() && isCov(unicodeCodepoints[i]) == cov) {
cpToU16(unicodeCodepoints[i], seg); cpToU8(unicodeCodepoints[i], seg8); ++i;
}
seg.push_back(0);
FPDF_FONT segFont = cov ? reconFont : font;
fonts::FontFace* segFace = cov ? (haveReconFace ? &reconFace : nullptr)
: (haveSubFace ? &subFace : nullptr);
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, segFont, static_cast<float>(fontSize));
if (obj) {
FPDFPageObj_SetFillColor(obj, r, g, b_color, a_color);
FPDFTextObj_SetTextRenderMode(obj, renderMode);
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(seg.data()));
FPDFPageObj_Transform(obj, a, b, c, d, penX, f);
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
}
double adv = segWidthPage(segFace, seg8);
if (adv <= 0.0) { // shaping unavailable -> fall back to ink bbox
if (obj) { float l=0,bo=0,rr=0,tt=0; if (FPDFPageObj_GetBounds(obj,&l,&bo,&rr,&tt)) adv = rr - l; }
}
penX += adv;
}
didHybrid = true;
spdlog::info("Tier-2: replace_text HYBRID emission for '{}' (mixed embedded/substitute)", internalFontId);
}
if (!didHybrid)
{
double aScale = a;
if (disableJustify && hasOrigBounds && axisAligned) {
double newW = measuredWidth(utf16);
double rowTol = (std::max)(5.0, (origTop - origBottom) * 0.5);
double nextLeft = 1e18;
int nObj = FPDFPage_CountObjects(page);
for (int k = 0; k < nObj; ++k) {
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
float l = 0, bo = 0, rr = 0, tt = 0;
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
if (std::abs((bo + tt) / 2.0 - origCenterY) <= rowTol && l > origRight + 1.0 && l < nextLeft) {
nextLeft = l;
}
}
if (nextLeft < 1e17) {
double avail = nextLeft - e - 2.0;
if (avail > 1.0 && newW > avail) aScale = a * (avail / newW);
}
}
FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
if (newTextObj) {
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
FPDFText_SetText(newTextObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
FPDFPageObj_Transform(newTextObj, a, b, c, d, e, f);
FPDFPageObj_Transform(newTextObj, aScale, b, c, d, e, f);
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
} else {
spdlog::error("Failed to create new text object");
}
}
}
}
@@ -2899,6 +3123,46 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById);
}
{
std::unordered_map<std::string, double> exactByBaseName;
std::vector<double> exactSizes;
for (int idx : paragraphSet) {
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
FS_MATRIX mtx;
if (!FPDFPageObj_GetMatrix(o, &mtx)) continue;
float nominal = 0.0f;
if (!FPDFTextObj_GetFontSize(o, &nominal)) continue;
double scale = std::sqrt(static_cast<double>(mtx.a) * mtx.a +
static_cast<double>(mtx.b) * mtx.b);
double exact = static_cast<double>(nominal) * scale;
if (exact <= 0.1) continue;
exactSizes.push_back(exact);
FPDF_FONT fo = FPDFTextObj_GetFont(o);
if (!fo) continue;
size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0);
if (nl == 0) continue;
std::vector<char> nb(nl);
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
std::string bn(nb.data());
if (!exactByBaseName.count(bn)) exactByBaseName[bn] = exact;
}
double paraExact = 0.0;
if (!exactSizes.empty()) {
std::sort(exactSizes.begin(), exactSizes.end());
paraExact = exactSizes[exactSizes.size() / 2];
}
if (paraExact > 0.1) {
for (auto& rs : runs) {
std::string bn = baseNameFromInternalFontId(rs.internalFontId);
auto it = exactByBaseName.find(bn);
rs.fontSize = (it != exactByBaseName.end() && it->second > 0.1) ? it->second : paraExact;
}
spdlog::info("reflow_paragraph: size-exact override paraExact={:.2f} ({} font(s))",
paraExact, exactByBaseName.size());
}
}
std::vector<EmissionFont> runFonts(runs.size());
auto toCodepoints = [](const std::string& s) {
auto u16 = utf8_to_utf16le(s);
@@ -4443,7 +4707,7 @@ std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string> PdfiumDocume
}
std::expected<std::string, EngineError> PdfiumPage::extractDisplayListJson() const {
#ifdef PDFENGINE_WITH_PDFIUM
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
if (!ownerDoc_) return std::unexpected(EngineError::Unknown);
const auto& buffer = ownerDoc_->getMemoryBuffer();
if (buffer.empty()) return std::unexpected(EngineError::FileNotFound);
+10
View File
@@ -13,6 +13,7 @@
#include <mutex>
#include <optional>
#include <unordered_map>
#include "fonts/pdf_fonts/embedded_font_reconstructor.hpp"
namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::fonts { class FontFace; }
@@ -146,6 +147,15 @@ private:
double fontSize, const std::vector<uint32_t>& codepoints,
const std::vector<int>& srcObjects = {},
FPDF_FONT reuseFont = nullptr);
mutable std::unordered_map<std::string, fonts::pdf_fonts::ReconstructedFont> reconstructedFonts_;
mutable std::mutex reconstructedFontsMutex_;
void registerAuxFont(const std::string& internalFontId, const std::vector<uint8_t>& sfnt) override;
std::expected<std::vector<uint8_t>, EngineError> getReconstructedFontData(const std::string& internalFontId) override;
const fonts::pdf_fonts::ReconstructedFont* lookupReconFont(const std::string& internalFontId);
#ifdef PDFENGINE_WITH_QPDF
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
#endif
#endif
};
+107
View File
@@ -0,0 +1,107 @@
#include "qpdf_font_extractor.hpp"
#include "fonts/pdf_fonts/encoding/tounicode_parser.hpp"
#ifdef PDFENGINE_WITH_QPDF
#include <qpdf/QPDF.hh>
#include <qpdf/QPDFObjectHandle.hh>
#include <qpdf/Buffer.hh>
#include <qpdf/Pl_Buffer.hh>
#include <qpdf/QPDFExc.hh>
#endif
#include <cstdio>
namespace pdfengine::qpdf_layer {
#ifdef PDFENGINE_WITH_QPDF
namespace {
std::string decodeStream(QPDFObjectHandle stream) {
try {
Pl_Buffer pl("tounicode");
stream.pipeStreamData(&pl, 0, qpdf_dl_all, false);
auto buf = pl.getBufferSharedPointer();
if (buf) return std::string(reinterpret_cast<const char*>(buf->getBuffer()), buf->getSize());
} catch (...) {}
return {};
}
bool baseFontMatches(QPDFObjectHandle font, const std::string& want) {
if (!font.isDictionary() || !font.hasKey("/BaseFont")) return false;
QPDFObjectHandle bf = font.getKey("/BaseFont");
if (!bf.isName()) return false;
std::string n = bf.getName();
if (!n.empty() && n[0] == '/') n.erase(0, 1);
return n == want;
}
}
#endif
std::expected<EmbeddedFontMapping, QpdfError>
QpdfFontExtractor::extractMapping(const std::vector<uint8_t>& pdf, const std::string& baseFontName) const {
#ifdef PDFENGINE_WITH_QPDF
try {
::QPDF qpdf;
qpdf.processMemoryFile("memory", reinterpret_cast<const char*>(pdf.data()), pdf.size());
for (QPDFObjectHandle obj : qpdf.getAllObjects()) {
if (!obj.isDictionary()) continue;
if (!obj.hasKey("/Type") || !obj.getKey("/Type").isName() ||
obj.getKey("/Type").getName() != "/Font")
continue;
if (!baseFontMatches(obj, baseFontName)) continue;
EmbeddedFontMapping m;
m.subtype = obj.hasKey("/Subtype") && obj.getKey("/Subtype").isName()
? obj.getKey("/Subtype").getName() : "";
if (obj.hasKey("/ToUnicode") && obj.getKey("/ToUnicode").isStream()) {
std::string s = decodeStream(obj.getKey("/ToUnicode"));
if (!s.empty() && fonts::pdf_fonts::ToUnicodeParser::parse(s, m.codeToUnicode))
m.hasToUnicode = !m.codeToUnicode.empty();
}
if (m.subtype == "/Type0" && obj.hasKey("/DescendantFonts")) {
QPDFObjectHandle df = obj.getKey("/DescendantFonts");
QPDFObjectHandle cid = df.isArray() && df.getArrayNItems() > 0 ? df.getArrayItem(0)
: QPDFObjectHandle();
if (cid.isDictionary() && cid.hasKey("/CIDToGIDMap")) {
QPDFObjectHandle c2g = cid.getKey("/CIDToGIDMap");
if (c2g.isStream()) {
std::string s = decodeStream(c2g);
m.identityCidToGid = false;
for (size_t i = 0; i + 1 < s.size(); i += 2) {
uint16_t gid = static_cast<uint16_t>((static_cast<uint8_t>(s[i]) << 8) |
static_cast<uint8_t>(s[i + 1]));
if (gid != 0) m.codeToGid[static_cast<uint32_t>(i / 2)] = gid;
}
} else {
m.identityCidToGid = true;
}
} else {
m.identityCidToGid = true;
}
} else {
m.identityCidToGid = true;
}
m.ok = m.hasToUnicode;
return m;
}
return std::unexpected(QpdfError::Unknown);
} catch (const QPDFExc& e) {
fprintf(stderr, "QpdfFontExtractor QPDFExc: %s\n", e.what());
return std::unexpected(QpdfError::InvalidFormat);
} catch (const std::exception& e) {
fprintf(stderr, "QpdfFontExtractor exception: %s\n", e.what());
return std::unexpected(QpdfError::Unknown);
}
#else
(void)pdf; (void)baseFontName;
return std::unexpected(QpdfError::NotSupported);
#endif
}
}
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <cstdint>
#include <expected>
#include <string>
#include <unordered_map>
#include <vector>
#include "qpdf_extractor.hpp"
namespace pdfengine::qpdf_layer {
struct EmbeddedFontMapping {
bool ok = false;
bool hasToUnicode = false;
std::unordered_map<uint32_t, uint32_t> codeToUnicode;
bool identityCidToGid = true;
std::unordered_map<uint32_t, uint32_t> codeToGid;
std::string subtype;
};
class QpdfFontExtractor {
public:
std::expected<EmbeddedFontMapping, QpdfError>
extractMapping(const std::vector<uint8_t>& pdf, const std::string& baseFontName) const;
};
}
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+2 -1
View File
@@ -295,7 +295,7 @@ function App() {
setActiveTool('select');
};
const handleEditText = (pageIndex: number, run: EditableRun, newText: string) => {
const handleEditText = (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{
id: rid('edit'), type: 'replace_text', pageIndex,
@@ -304,6 +304,7 @@ function App() {
text: newText,
internalFontId: run.internalFontId,
fontSize: run.fontSize,
disableJustify: !!disableJustify,
},
}], 'Text updated');
setActiveTool('select');
+13
View File
@@ -237,6 +237,7 @@ export interface ReplaceTextData {
text: string;
internalFontId: string;
fontSize: number;
disableJustify?: boolean;
}
export interface ReflowFragment {
@@ -465,6 +466,18 @@ class GatewayService {
}
}
async getReconstructedFontData(documentId: string, internalFontId: string): Promise<ArrayBuffer | null> {
try {
const url = `${this.baseUrl}/documents/${documentId}/font-reconstructed?internal_font_id=${encodeURIComponent(internalFontId)}`;
const response = await fetch(url);
if (!response.ok || response.status === 204) return null;
const buf = await response.arrayBuffer();
return buf.byteLength ? buf : null;
} catch {
return null;
}
}
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
let response: Response;
try {
+30 -1
View File
@@ -1,3 +1,5 @@
import { gatewayService } from './gatewayService';
interface PdfiumModule {
_malloc(n: number): number;
_free(p: number): void;
@@ -16,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
if (!modulePromise) {
modulePromise = (async () => {
try {
const V = '20260623-tier1b';
const V = '20260625-tier2b';
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' }));
@@ -63,6 +65,33 @@ export function wasmHasDocument(documentId: string): boolean {
return docHandles.has(documentId);
}
const auxFontEnsured = new Map<string, Promise<void>>();
export async function wasmRegisterAuxFont(documentId: string, internalFontId: string, bytes: ArrayBuffer): Promise<void> {
const M = await getModule();
if (!M) return;
const h = docHandles.get(documentId);
if (h === undefined) return;
const u8 = new Uint8Array(bytes);
const ptr = M._malloc(u8.length);
M.HEAPU8.set(u8, ptr);
M.ccall('registerAuxFont', null, ['number', 'string', 'number', 'number'], [h, internalFontId, ptr, u8.length]);
M._free(ptr);
}
export function wasmEnsureAuxFont(documentId: string, internalFontId: string): Promise<void> {
if (!internalFontId) return Promise.resolve();
const key = `${documentId}::${internalFontId}`;
let p = auxFontEnsured.get(key);
if (!p) {
p = (async () => {
const bytes = await gatewayService.getReconstructedFontData(documentId, internalFontId);
if (bytes && bytes.byteLength) await wasmRegisterAuxFont(documentId, internalFontId, bytes);
})().catch(() => {});
auxFontEnsured.set(key, p);
}
return p;
}
function lastRenderBlob(M: PdfiumModule, len: number): Blob | null {
if (len <= 0) return null;
const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number;
+1 -1
View File
@@ -38,7 +38,7 @@ interface PDFViewerProps {
onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onStreamDocumentChanged?: (newDocumentId: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
+5 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated } from '../lib/pdfiumEngine';
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated, wasmEnsureAuxFont } from '../lib/pdfiumEngine';
import type { ReflowLayout } from '../lib/pdfiumEngine';
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
@@ -364,6 +364,10 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
const renderPreview = async () => {
const el = editRef.current;
if (!el) return;
// Tier-2 parity: register cmap-augmented embedded fonts with WASM so the reflow preview
// reuses the document's real glyphs (matching the native save).
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
const dpi = Math.round(72 * zoom);
const origLines = editedRef.current ? undefined : layout.origLines;
const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines);
+165 -32
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
import { sanitizeTextColor } from '../lib/colorUtils';
import { wasmEnsureAuxFont, wasmHasDocument, wasmLoadDocument, wasmPreviewRenderRegion } from '../lib/pdfiumEngine';
import { ParagraphEditor } from './ParagraphEditor';
import type { ReflowAlign } from './ParagraphEditor';
@@ -20,6 +21,7 @@ export interface EditableRun {
paraIndex: number;
lineIndex: number;
runIndex: number;
fontFidelity: string; // "exact" | "partial" | "substituted"
}
export interface ReflowFragment {
@@ -163,6 +165,49 @@ function isFlowingParagraph(para: any): boolean {
return filled && consistentLeft;
}
function tableCells(line: any): any[] {
return (line?.runs ?? []).filter((r: any) => (r.text ?? '').trim());
}
function paraEm(para: any): number {
let capH = 0;
for (const l of (para?.lines ?? [])) for (const r of (l.runs ?? [])) for (const g of (r.glyphs ?? [])) {
if ((g.bbox_h ?? 0) > capH) capH = g.bbox_h;
}
return capH > 0 ? capH / 0.7 : 10;
}
function lineLargeGaps(line: any, em: number): number {
const cells = tableCells(line);
let n = 0;
for (let k = 0; k + 1 < cells.length; k++) {
if (cells[k + 1].x - (cells[k].x + cells[k].w) > em * 0.6) n++;
}
return n;
}
function isTableParagraph(para: any, model: any): boolean {
const lines = para?.lines ?? [];
if (!lines.length) return false;
const em = paraEm(para);
if (lines.length > 1) {
let columnar = 0;
for (const l of lines) if (tableCells(l).length >= 2 && lineLargeGaps(l, em) >= 1) columnar++;
return columnar >= Math.max(2, Math.ceil(lines.length * 0.6));
}
const cells = tableCells(lines[0]);
if (cells.length < 2) return false;
const gaps = lineLargeGaps(lines[0], em);
if (gaps >= 2) return true;
if (gaps < 1) return false;
const cols = cells.map((c: any) => c.x);
for (const p of (model?.paragraphs ?? [])) {
if (p === para) continue;
for (const l of (p.lines ?? [])) {
const oc = tableCells(l);
if (oc.length >= 2 && cols.filter((x: number) => oc.some((r: any) => Math.abs(r.x - x) <= em * 0.6)).length >= 2) return true;
}
}
return false;
}
interface TextEditLayerProps {
documentId: string;
pageIndex: number;
@@ -171,7 +216,7 @@ interface TextEditLayerProps {
height: number;
zoom: number;
pageImageUrl?: string;
onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void;
onOverflowCaret?: (caret: import('./ParagraphEditor').OverflowCaret | null) => void;
@@ -197,7 +242,7 @@ function caretIndexFromX(text: string, cssFont: string, x: number): number {
function fallbackFamily(fontName: string): string {
const n = (fontName || '').toLowerCase();
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
return 'Georgia, "Times New Roman", Times, serif';
return '"Times New Roman", Times, Georgia, serif';
}
if (n.includes('courier') || n.includes('mono')) {
return '"Courier New", Courier, monospace';
@@ -228,6 +273,7 @@ function flattenRuns(model: any): EditableRun[] {
fontName: r.font_name ?? '',
color: sanitizeTextColor(r.color),
paraIndex: pi, lineIndex: li, runIndex: ri,
fontFidelity: r.font_fidelity ?? 'exact',
});
}
}
@@ -243,7 +289,11 @@ function median(xs: number[]): number {
}
function displayFontSize(r: EditableRun): number {
return Math.max(r.fontSize, r.h / 0.92);
const t = r.text || '';
const hasDescender = /[gjpqy(),;\[\]{}₀-₉]/.test(t);
const hasAscender = /[bdfhklt]/.test(t);
const frac = hasDescender ? 0.92 : hasAscender ? 0.75 : 0.70;
return Math.max(r.fontSize, r.h / frac);
}
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
@@ -332,6 +382,11 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
anchorPageIndex?: number;
} | null>(null);
const [caretClick, setCaretClick] = useState<{ x: number; y: number } | null>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const [cellPreviewReady, setCellPreviewReady] = useState(false);
const cellRafRef = useRef<number | null>(null);
const cellRenderingRef = useRef(false);
const cellPendingRef = useRef<{ run: EditableRun; text: string } | null>(null);
useEffect(() => {
let cancelled = false;
@@ -418,7 +473,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
const run = runs[i];
const para = modelRef.current?.paragraphs?.[run.paraIndex];
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph) {
if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph && !isTableParagraph(para, modelRef.current)) {
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
if (para.lines.length > 1) {
if (isFlowingParagraph(para)) {
@@ -456,7 +511,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
setEditing(i);
setValue(run.text);
setFontFamily(fb);
if (run.internalFontId) {
const isSubsetFont = /^[A-Z]{6}\+/.test(run.internalFontId || '');
if (run.internalFontId && !isSubsetFont) {
loadPdfFont(documentId, run.internalFontId).then((family) => {
if (family) setFontFamily(`'${family}', ${fb}`);
});
@@ -467,39 +523,17 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
if (editing === null || committedRef.current) return;
const run = runs[editing];
const next = value;
if (next !== run.text) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let originalWidth = run.w * zoom;
let newWidth = originalWidth;
if (ctx) {
ctx.font = `${run.fontSize * zoom}px sans-serif`;
originalWidth = ctx.measureText(run.text).width;
newWidth = ctx.measureText(next).width;
} else {
originalWidth = run.text.length;
newWidth = next.length;
}
if (newWidth > originalWidth * 1.2) {
if (!window.confirm('Warning: The new text is significantly wider (> 120%) than the original. This might cause overlap or layout issues. Continue anyway?')) {
return;
}
}
}
const para = modelRef.current?.paragraphs?.[run.paraIndex];
const isTable = isTableParagraph(para, modelRef.current);
committedRef.current = true;
setEditing(null);
if (next === run.text) return;
const para = modelRef.current?.paragraphs?.[run.paraIndex];
if (onReflowParagraph && isFlowingParagraph(para)) {
if (onReflowParagraph && !isTable && isFlowingParagraph(para)) {
const payload = buildReflowPayload(modelRef.current, run, next);
if (payload) { onReflowParagraph(pageIndex, payload); return; }
}
onEditText?.(pageIndex, run, next);
onEditText?.(pageIndex, run, next, shouldDisableJustify(para));
};
const cancel = () => {
@@ -507,6 +541,84 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
setEditing(null);
};
const cellBand = (r: EditableRun) => {
const box = rectOf(r);
const pad = displayFontSize(r) * zoom * 0.45 + 4;
const topScreen = Math.max(0, box.top - pad);
const botScreen = Math.min(height, box.top + box.height + pad);
return { topScreen, heightScreen: Math.max(1, botScreen - topScreen) };
};
const shouldDisableJustify = (para: any): boolean => {
if (isTableParagraph(para, modelRef.current)) return true;
const lines = para?.lines ?? [];
if (lines.length === 1) {
const n = (lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length;
if (n > 1) return true;
}
return false;
};
const renderCellPreview = async (r: EditableRun, text: string) => {
if (!wasmHasDocument(documentId)) return;
if (r.internalFontId && r.fontFidelity !== 'exact') await wasmEnsureAuxFont(documentId, r.internalFontId);
const para = modelRef.current?.paragraphs?.[r.paraIndex];
const op = JSON.stringify({
version: '1.0',
operations: [{
id: 'cellpreview', type: 'replace_text', pageIndex,
data: {
objectIndices: r.objectIndices, text,
internalFontId: r.internalFontId || '', fontSize: -1.0, disableJustify: shouldDisableJustify(para),
},
}],
});
const dpi = Math.round(72 * zoom);
const { topScreen, heightScreen } = cellBand(r);
const res = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, op, topScreen / zoom, heightScreen / zoom);
if (!res.rgba || res.width <= 0 || res.height <= 0) return;
const cv = previewCanvasRef.current;
if (!cv) return;
if (cv.width !== res.width) cv.width = res.width;
if (cv.height !== res.height) cv.height = res.height;
const ctx = cv.getContext('2d');
if (ctx) { const img = ctx.createImageData(res.width, res.height); img.data.set(res.rgba); ctx.putImageData(img, 0, 0); }
setCellPreviewReady(true);
};
const scheduleCellRender = (r: EditableRun, text: string) => {
cellPendingRef.current = { run: r, text };
if (cellRafRef.current != null || cellRenderingRef.current) return;
cellRafRef.current = window.requestAnimationFrame(async () => {
cellRafRef.current = null;
cellRenderingRef.current = true;
let job = cellPendingRef.current;
while (job) {
cellPendingRef.current = null;
try { await renderCellPreview(job.run, job.text); } catch { /* keep DOM fallback */ }
job = cellPendingRef.current;
}
cellRenderingRef.current = false;
});
};
useEffect(() => {
if (editing === null) { setCellPreviewReady(false); return; }
const r = runs[editing];
if (!r) return;
let cancelled = false;
(async () => {
if (!wasmHasDocument(documentId)) {
const bytes = await gatewayService.getDocumentRaw(documentId);
if (bytes && !cancelled) await wasmLoadDocument(documentId, bytes);
}
if (!cancelled) scheduleCellRender(r, value);
})();
return () => { cancelled = true; };
}, [editing, value, documentId, pageIndex, zoom]);
useEffect(() => () => { if (cellRafRef.current != null) window.cancelAnimationFrame(cellRafRef.current); }, []);
const run = editing !== null ? runs[editing] : null;
return (
@@ -552,12 +664,33 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
const baselineScreen = (heightPts - run.baselineY) * zoom;
const inputTop = baselineScreen - 0.8 * fpx;
const box = rectOf(run);
const band = cellBand(run);
const fidelityNote = run.fontFidelity === 'substituted'
? "Original font isn't embedded for editing — using the closest match."
: run.fontFidelity === 'partial'
? 'Reusing the document font; brand-new characters may use a close match.'
: '';
return (
<>
{/* White cover for the original cell until the engine preview is ready. */}
<div
className="absolute z-[36] bg-white"
style={{ left: box.left - 2, top: box.top - 2, width: box.width + 4, height: box.height + 4 }}
/>
{fidelityNote && (
<div
className="absolute z-[38] rounded bg-amber-50 border border-amber-300 text-amber-800 text-[11px] px-1.5 py-0.5 shadow-sm pointer-events-none whitespace-nowrap"
style={{ left: box.left, top: Math.max(0, box.top - 22) }}
>
{fidelityNote}
</div>
)}
{/* Engine-rendered preview band (covers the whole row; only the edited cell changes). */}
<canvas
ref={previewCanvasRef}
className="absolute z-[36] pointer-events-none"
style={{ left: 0, top: band.topScreen, width: `${width}px`, height: `${band.heightScreen}px`, display: cellPreviewReady ? 'block' : 'none' }}
/>
<input
ref={inputRef}
autoFocus
@@ -586,7 +719,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
lineHeight: `${fpx}px`,
fontFamily,
fontSize: `${fpx}px`,
color: run.color,
color: cellPreviewReady ? 'transparent' : run.color,
caretColor: run.color,
}}
/>
+27
View File
@@ -294,6 +294,31 @@ def get_font_bytes(document_id: str, internal_font_id: str) -> Response:
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
)
@router.get("/{document_id}/font-reconstructed")
def get_reconstructed_font_bytes(document_id: str, internal_font_id: str) -> Response:
"""Tier-2: a cmap-augmented copy of an embedded font (original glyph program + synthesized
Unicode cmap) so the WASM live preview can reuse the document's real glyphs and match the
saved result. 204 when reconstruction isn't possible -> frontend falls back to Tier-1."""
if not engine.is_available():
return Response(status_code=status.HTTP_204_NO_CONTENT)
if not internal_font_id or len(internal_font_id) > 256:
return Response(status_code=status.HTTP_204_NO_CONTENT)
doc_info = document_store.get_document(document_id)
if not doc_info:
return Response(status_code=status.HTTP_204_NO_CONTENT)
try:
data = bytes(doc_info["doc_instance"].get_reconstructed_font_data(internal_font_id))
except Exception:
data = b""
if not data or data[:4] not in _SFNT_TTF_MAGIC:
return Response(status_code=status.HTTP_204_NO_CONTENT)
etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"'
return Response(
content=data,
media_type="font/ttf",
headers={"Cache-Control": "public, max-age=31536000, immutable", "ETag": etag},
)
@router.get("/{document_id}/raw")
def get_document_raw(document_id: str) -> Response:
@@ -463,6 +488,7 @@ class TextRunModel(BaseModel):
h: float
object_indices: list[int] = []
color: str = "#000000"
font_fidelity: str = "exact"
class TextLineModel(BaseModel):
@@ -547,6 +573,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
h=r.h,
object_indices=r.object_indices,
color=getattr(r, "fill_color", "#000000") or "#000000",
font_fidelity=getattr(r, "font_fidelity", "exact") or "exact",
)
)
lines.append(
+1
View File
@@ -214,6 +214,7 @@ class ReplaceTextData(BaseModel):
text: str
internalFontId: str
fontSize: float
disableJustify: bool = False
class ReplaceTextOperation(BaseModel):
+1 -1
View File
@@ -55,7 +55,7 @@ target_link_options(pdfengine_wasm PRIVATE
"-sALLOW_MEMORY_GROWTH=1"
"-sWASM_BIGINT"
"-sSTACK_SIZE=5MB"
"-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_previewRenderPaginated','_lastRegionCount','_lastRegionPtr','_lastRegionW','_lastRegionH','_lastRegionPage','_lastRegionYTop','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_freeDocument','_engineBuildInfo','_malloc','_free']"
"-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_previewRenderPaginated','_lastRegionCount','_lastRegionPtr','_lastRegionW','_lastRegionH','_lastRegionPage','_lastRegionYTop','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_registerAuxFont','_freeDocument','_engineBuildInfo','_malloc','_free']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']"
)
+7
View File
@@ -144,6 +144,13 @@ EMSCRIPTEN_KEEPALIVE int lastRenderW() { return g_lastW; }
EMSCRIPTEN_KEEPALIVE int lastRenderH() { return g_lastH; }
EMSCRIPTEN_KEEPALIVE const char* lastLayoutJson() { return g_lastLayout.c_str(); }
EMSCRIPTEN_KEEPALIVE void registerAuxFont(int handle, const char* fid, const uint8_t* data, int size) {
auto it = g_docs.find(handle);
if (it == g_docs.end() || !fid || !data || size <= 0) return;
std::vector<uint8_t> bytes(data, data + size);
it->second.doc->registerAuxFont(std::string(fid), bytes);
}
EMSCRIPTEN_KEEPALIVE void freeDocument(int handle) { g_docs.erase(handle); }
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }