feat: updated font handling in older pdfs, along with tables
This commit is contained in:
@@ -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
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -214,6 +214,7 @@ class ReplaceTextData(BaseModel):
|
||||
text: str
|
||||
internalFontId: str
|
||||
fontSize: float
|
||||
disableJustify: bool = False
|
||||
|
||||
|
||||
class ReplaceTextOperation(BaseModel):
|
||||
|
||||
+1
-1
@@ -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']"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
Reference in New Issue
Block a user