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("h", &pdfengine::TextRun::h)
|
||||||
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices)
|
.def_readonly("object_indices", &pdfengine::TextRun::objectIndices)
|
||||||
.def_readonly("fill_color", &pdfengine::TextRun::fillColor)
|
.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")
|
py::class_<pdfengine::TextLine>(m, "TextLine")
|
||||||
.def_readonly("runs", &pdfengine::TextLine::runs)
|
.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());
|
return py::bytes(reinterpret_cast<const char*>(res->data()), res->size());
|
||||||
}, py::arg("internal_font_id"))
|
}, 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) {
|
.def("apply_edits", [](pdfengine::PdfDocument& self, const std::string& editsJson) {
|
||||||
get_or_throw(self.applyEdits(editsJson));
|
get_or_throw(self.applyEdits(editsJson));
|
||||||
}, py::arg("edits_json"))
|
}, 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(
|
configure_file(
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/include/pdfengine/version.hpp.in"
|
"${CMAKE_CURRENT_SOURCE_DIR}/include/pdfengine/version.hpp.in"
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/pdfengine/version.hpp"
|
"${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_descriptor.cpp
|
||||||
src/fonts/pdf_fonts/font_fallback.cpp
|
src/fonts/pdf_fonts/font_fallback.cpp
|
||||||
src/fonts/pdf_fonts/font_subset.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/encoding.cpp
|
||||||
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
|
src/fonts/pdf_fonts/encoding/tounicode_parser.cpp
|
||||||
src/fonts/pdf_fonts/encoding/cjk_collection_db.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)
|
target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM)
|
||||||
endif()
|
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)
|
if(EMSCRIPTEN)
|
||||||
target_compile_definitions(pdfengine PRIVATE PDFENGINE_FONT_DIR="/fonts")
|
target_compile_definitions(pdfengine PRIVATE PDFENGINE_FONT_DIR="/fonts")
|
||||||
else()
|
else()
|
||||||
@@ -89,6 +84,7 @@ if(PDFENGINE_WITH_QPDF)
|
|||||||
endif()
|
endif()
|
||||||
target_sources(pdfengine PRIVATE
|
target_sources(pdfengine PRIVATE
|
||||||
src/qpdf/qpdf_extractor.cpp
|
src/qpdf/qpdf_extractor.cpp
|
||||||
|
src/qpdf/qpdf_font_extractor.cpp
|
||||||
src/qpdf/qpdf_writer.cpp
|
src/qpdf/qpdf_writer.cpp
|
||||||
src/qpdf/qpdf_resource_resolver.cpp
|
src/qpdf/qpdf_resource_resolver.cpp
|
||||||
src/core/image_decoder.cpp
|
src/core/image_decoder.cpp
|
||||||
@@ -109,17 +105,10 @@ if(PDFENGINE_BUILD_TESTS)
|
|||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
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)
|
if(PDFENGINE_FUZZING)
|
||||||
set(PDFENGINE_FUZZ_SANITIZERS "fuzzer,address,undefined" CACHE STRING
|
set(PDFENGINE_FUZZ_SANITIZERS "fuzzer,address,undefined" CACHE STRING
|
||||||
"Sanitizer set for fuzzing (e.g. 'fuzzer,address,undefined' or just 'fuzzer')")
|
"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}")
|
string(REPLACE "," ";" _fuzz_sans "${PDFENGINE_FUZZ_SANITIZERS}")
|
||||||
set(_fuzz_runtime_sans "")
|
set(_fuzz_runtime_sans "")
|
||||||
foreach(_s IN LISTS _fuzz_sans)
|
foreach(_s IN LISTS _fuzz_sans)
|
||||||
@@ -129,8 +118,6 @@ if(PDFENGINE_FUZZING)
|
|||||||
endforeach()
|
endforeach()
|
||||||
list(JOIN _fuzz_runtime_sans "," _fuzz_runtime_str)
|
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)
|
target_compile_options(pdfengine PRIVATE -fsanitize=fuzzer-no-link -fno-omit-frame-pointer)
|
||||||
if(_fuzz_runtime_str)
|
if(_fuzz_runtime_str)
|
||||||
target_compile_options(pdfengine PRIVATE -fsanitize=${_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;
|
double x = 0.0, y = 0.0, w = 0.0, h = 0.0;
|
||||||
std::string fillColor = "#000000";
|
std::string fillColor = "#000000";
|
||||||
std::string paraId;
|
std::string paraId;
|
||||||
|
std::string fontFidelity = "exact";
|
||||||
};
|
};
|
||||||
|
|
||||||
struct TextLine {
|
struct TextLine {
|
||||||
@@ -247,6 +248,11 @@ public:
|
|||||||
[[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;
|
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>
|
[[nodiscard]] virtual std::expected<std::shared_ptr<fonts::pdf_fonts::Font>, std::string>
|
||||||
getResolvedFont(const FontInfo& fontInfo) = 0;
|
getResolvedFont(const FontInfo& fontInfo) = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ bool FontFace::coversUnicode(uint32_t codepoint) const {
|
|||||||
return gid != 0;
|
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 {
|
uint64_t FontFace::getId() const {
|
||||||
return font_id_;
|
return font_id_;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ public:
|
|||||||
|
|
||||||
bool coversUnicode(uint32_t codepoint) const;
|
bool coversUnicode(uint32_t codepoint) const;
|
||||||
|
|
||||||
|
std::vector<uint32_t> coveredCodepoints() const;
|
||||||
|
|
||||||
std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
|
std::optional<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
|
||||||
|
|
||||||
private:
|
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 "fonts/face/font_face.hpp"
|
||||||
#include "decoration_builder.hpp"
|
#include "decoration_builder.hpp"
|
||||||
#include "qpdf/qpdf_extractor.hpp"
|
#include "qpdf/qpdf_extractor.hpp"
|
||||||
|
#include "qpdf/qpdf_font_extractor.hpp"
|
||||||
#include "parser/lexer.hpp"
|
#include "parser/lexer.hpp"
|
||||||
#include "parser/parser.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);
|
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 baseNameFromInternalFontId(const std::string& internalFontId) {
|
||||||
std::string expected = internalFontId;
|
std::string expected = internalFontId;
|
||||||
size_t lastUnderscore = expected.rfind('_');
|
size_t lastUnderscore = expected.rfind('_');
|
||||||
@@ -1156,6 +1173,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|||||||
currentRun.internalFontId = it->second.internalFontId;
|
currentRun.internalFontId = it->second.internalFontId;
|
||||||
currentRun.isEmbedded = it->second.isEmbedded;
|
currentRun.isEmbedded = it->second.isEmbedded;
|
||||||
currentRun.type = it->second.type;
|
currentRun.type = it->second.type;
|
||||||
|
currentRun.fontFidelity = classifyFontFidelity(it->second);
|
||||||
}
|
}
|
||||||
currentRun.glyphs.push_back(*firstG);
|
currentRun.glyphs.push_back(*firstG);
|
||||||
currentRun.text += firstG->text;
|
currentRun.text += firstG->text;
|
||||||
@@ -1206,6 +1224,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|||||||
currentRun.internalFontId = it->second.internalFontId;
|
currentRun.internalFontId = it->second.internalFontId;
|
||||||
currentRun.isEmbedded = it->second.isEmbedded;
|
currentRun.isEmbedded = it->second.isEmbedded;
|
||||||
currentRun.type = it->second.type;
|
currentRun.type = it->second.type;
|
||||||
|
currentRun.fontFidelity = classifyFontFidelity(it->second);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentRun.glyphs.push_back(spaceGlyph);
|
currentRun.glyphs.push_back(spaceGlyph);
|
||||||
@@ -1225,6 +1244,7 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|||||||
currentRun.internalFontId = it->second.internalFontId;
|
currentRun.internalFontId = it->second.internalFontId;
|
||||||
currentRun.isEmbedded = it->second.isEmbedded;
|
currentRun.isEmbedded = it->second.isEmbedded;
|
||||||
currentRun.type = it->second.type;
|
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
|
#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
|
#ifdef PDFENGINE_WITH_PDFIUM
|
||||||
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||||
int pageIndex, const std::string& internalFontId, double fontSize,
|
int pageIndex, const std::string& internalFontId, double fontSize,
|
||||||
@@ -2197,6 +2280,35 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
}
|
}
|
||||||
if (out.font) return out;
|
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) {
|
if (useEmbedded) {
|
||||||
std::vector<uint8_t> sourceBytes;
|
std::vector<uint8_t> sourceBytes;
|
||||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
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 newText = "";
|
||||||
std::string internalFontId = "";
|
std::string internalFontId = "";
|
||||||
double fontSize = -1.0;
|
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()) {
|
if (op.contains("data") && op["data"].is_object()) {
|
||||||
auto data = op["data"];
|
auto data = op["data"];
|
||||||
@@ -2288,6 +2401,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
if (data.contains("fontSize")) {
|
if (data.contains("fontSize")) {
|
||||||
fontSize = data["fontSize"].get<double>();
|
fontSize = data["fontSize"].get<double>();
|
||||||
}
|
}
|
||||||
|
disableJustify = data.value("disableJustify", false);
|
||||||
} else {
|
} else {
|
||||||
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
|
if (op.contains("objectIndices") && op["objectIndices"].is_array()) {
|
||||||
for (auto& idx : op["objectIndices"]) {
|
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);
|
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);
|
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
|
||||||
|
|
||||||
double deltaX = 0.0;
|
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);
|
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);
|
int pageObjCount = FPDFPage_CountObjects(page);
|
||||||
double tolerance = (std::max)(5.0, fontSize * 0.5);
|
double tolerance = (std::max)(5.0, fontSize * 0.5);
|
||||||
int reflowedCount = 0;
|
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 (!font) {
|
||||||
if (useEmbedded) {
|
if (useEmbedded) {
|
||||||
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
||||||
@@ -2643,18 +2783,20 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
if (fs) {
|
if (fs) {
|
||||||
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
||||||
if (!fileBytes.empty()) {
|
if (!fileBytes.empty()) {
|
||||||
std::vector<uint8_t> subsetBytes =
|
// Embed the FULL font (no per-edit subset). A subset renumbers GIDs
|
||||||
fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fileBytes, unicodeCodepoints);
|
// 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();
|
const size_t fullSize = fileBytes.size();
|
||||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||||
loadedFontDataBuffers_[cacheKey] =
|
loadedFontDataBuffers_[cacheKey] = std::move(fileBytes);
|
||||||
(!subsetBytes.empty()) ? std::move(subsetBytes) : std::move(fileBytes);
|
|
||||||
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
||||||
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||||
if (font) {
|
if (font) {
|
||||||
spdlog::info("Font Engine: Embedded {} CID system font '{}' ({} -> {} bytes) from '{}'",
|
spdlog::info("Font Engine: Embedded FULL CID system font '{}' ({} bytes) from '{}'",
|
||||||
bytes.size() < fullSize ? "SUBSET" : "FULL",
|
matchedFontInfo->fontName, fullSize, fontPath);
|
||||||
matchedFontInfo->fontName, fullSize, bytes.size(), fontPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2742,12 +2884,93 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
penX += (wpx[wi] + estSpace) * k + extraPerGap;
|
penX += (wpx[wi] + estSpace) * k + extraPerGap;
|
||||||
}
|
}
|
||||||
} else {
|
} 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));
|
FPDF_PAGEOBJECT newTextObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(fontSize));
|
||||||
if (newTextObj) {
|
if (newTextObj) {
|
||||||
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
|
FPDFPageObj_SetFillColor(newTextObj, r, g, b_color, a_color);
|
||||||
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
|
FPDFTextObj_SetTextRenderMode(newTextObj, renderMode);
|
||||||
FPDFText_SetText(newTextObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
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);
|
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
||||||
} else {
|
} else {
|
||||||
@@ -2755,6 +2978,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!FPDFPage_GenerateContent(page)) {
|
if (!FPDFPage_GenerateContent(page)) {
|
||||||
spdlog::error("Failed to generate page content after replace_text");
|
spdlog::error("Failed to generate page content after replace_text");
|
||||||
@@ -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);
|
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());
|
std::vector<EmissionFont> runFonts(runs.size());
|
||||||
auto toCodepoints = [](const std::string& s) {
|
auto toCodepoints = [](const std::string& s) {
|
||||||
auto u16 = utf8_to_utf16le(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 {
|
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);
|
if (!ownerDoc_) return std::unexpected(EngineError::Unknown);
|
||||||
const auto& buffer = ownerDoc_->getMemoryBuffer();
|
const auto& buffer = ownerDoc_->getMemoryBuffer();
|
||||||
if (buffer.empty()) return std::unexpected(EngineError::FileNotFound);
|
if (buffer.empty()) return std::unexpected(EngineError::FileNotFound);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include "fonts/pdf_fonts/embedded_font_reconstructor.hpp"
|
||||||
namespace pdfengine::fonts::loader { class FontResolver; }
|
namespace pdfengine::fonts::loader { class FontResolver; }
|
||||||
namespace pdfengine::fonts { class FontFace; }
|
namespace pdfengine::fonts { class FontFace; }
|
||||||
|
|
||||||
@@ -146,6 +147,15 @@ private:
|
|||||||
double fontSize, const std::vector<uint32_t>& codepoints,
|
double fontSize, const std::vector<uint32_t>& codepoints,
|
||||||
const std::vector<int>& srcObjects = {},
|
const std::vector<int>& srcObjects = {},
|
||||||
FPDF_FONT reuseFont = nullptr);
|
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
|
#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');
|
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; }
|
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
|
||||||
applyOps([{
|
applyOps([{
|
||||||
id: rid('edit'), type: 'replace_text', pageIndex,
|
id: rid('edit'), type: 'replace_text', pageIndex,
|
||||||
@@ -304,6 +304,7 @@ function App() {
|
|||||||
text: newText,
|
text: newText,
|
||||||
internalFontId: run.internalFontId,
|
internalFontId: run.internalFontId,
|
||||||
fontSize: run.fontSize,
|
fontSize: run.fontSize,
|
||||||
|
disableJustify: !!disableJustify,
|
||||||
},
|
},
|
||||||
}], 'Text updated');
|
}], 'Text updated');
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export interface ReplaceTextData {
|
|||||||
text: string;
|
text: string;
|
||||||
internalFontId: string;
|
internalFontId: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
disableJustify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflowFragment {
|
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 }> {
|
async applyEdits(documentId: string, operations: EditOperation[]): Promise<{ success: boolean; newDocumentId: string }> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { gatewayService } from './gatewayService';
|
||||||
|
|
||||||
interface PdfiumModule {
|
interface PdfiumModule {
|
||||||
_malloc(n: number): number;
|
_malloc(n: number): number;
|
||||||
_free(p: number): void;
|
_free(p: number): void;
|
||||||
@@ -16,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
|||||||
if (!modulePromise) {
|
if (!modulePromise) {
|
||||||
modulePromise = (async () => {
|
modulePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const V = '20260623-tier1b';
|
const V = '20260625-tier2b';
|
||||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
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);
|
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 {
|
function lastRenderBlob(M: PdfiumModule, len: number): Blob | null {
|
||||||
if (len <= 0) return null;
|
if (len <= 0) return null;
|
||||||
const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number;
|
const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface PDFViewerProps {
|
|||||||
onPageVisible?: (pageIndex: number) => void;
|
onPageVisible?: (pageIndex: number) => void;
|
||||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => 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;
|
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||||
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
||||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { gatewayService } from '../lib/gatewayService';
|
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';
|
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
||||||
|
|
||||||
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
||||||
@@ -364,6 +364,10 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const renderPreview = async () => {
|
const renderPreview = async () => {
|
||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) return;
|
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 dpi = Math.round(72 * zoom);
|
||||||
const origLines = editedRef.current ? undefined : layout.origLines;
|
const origLines = editedRef.current ? undefined : layout.origLines;
|
||||||
const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), 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 { gatewayService } from '../lib/gatewayService';
|
||||||
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
|
import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader';
|
||||||
import { sanitizeTextColor } from '../lib/colorUtils';
|
import { sanitizeTextColor } from '../lib/colorUtils';
|
||||||
|
import { wasmEnsureAuxFont, wasmHasDocument, wasmLoadDocument, wasmPreviewRenderRegion } from '../lib/pdfiumEngine';
|
||||||
import { ParagraphEditor } from './ParagraphEditor';
|
import { ParagraphEditor } from './ParagraphEditor';
|
||||||
import type { ReflowAlign } from './ParagraphEditor';
|
import type { ReflowAlign } from './ParagraphEditor';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export interface EditableRun {
|
|||||||
paraIndex: number;
|
paraIndex: number;
|
||||||
lineIndex: number;
|
lineIndex: number;
|
||||||
runIndex: number;
|
runIndex: number;
|
||||||
|
fontFidelity: string; // "exact" | "partial" | "substituted"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflowFragment {
|
export interface ReflowFragment {
|
||||||
@@ -163,6 +165,49 @@ function isFlowingParagraph(para: any): boolean {
|
|||||||
return filled && consistentLeft;
|
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 {
|
interface TextEditLayerProps {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
@@ -171,7 +216,7 @@ interface TextEditLayerProps {
|
|||||||
height: number;
|
height: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
pageImageUrl?: string;
|
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;
|
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||||
onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void;
|
onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void;
|
||||||
onOverflowCaret?: (caret: import('./ParagraphEditor').OverflowCaret | null) => 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 {
|
function fallbackFamily(fontName: string): string {
|
||||||
const n = (fontName || '').toLowerCase();
|
const n = (fontName || '').toLowerCase();
|
||||||
if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) {
|
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')) {
|
if (n.includes('courier') || n.includes('mono')) {
|
||||||
return '"Courier New", Courier, monospace';
|
return '"Courier New", Courier, monospace';
|
||||||
@@ -228,6 +273,7 @@ function flattenRuns(model: any): EditableRun[] {
|
|||||||
fontName: r.font_name ?? '',
|
fontName: r.font_name ?? '',
|
||||||
color: sanitizeTextColor(r.color),
|
color: sanitizeTextColor(r.color),
|
||||||
paraIndex: pi, lineIndex: li, runIndex: ri,
|
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 {
|
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 {
|
function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null {
|
||||||
@@ -332,6 +382,11 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
anchorPageIndex?: number;
|
anchorPageIndex?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [caretClick, setCaretClick] = useState<{ x: number; y: 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -418,7 +473,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
|
const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => {
|
||||||
const run = runs[i];
|
const run = runs[i];
|
||||||
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
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;
|
const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null;
|
||||||
if (para.lines.length > 1) {
|
if (para.lines.length > 1) {
|
||||||
if (isFlowingParagraph(para)) {
|
if (isFlowingParagraph(para)) {
|
||||||
@@ -456,7 +511,8 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
setEditing(i);
|
setEditing(i);
|
||||||
setValue(run.text);
|
setValue(run.text);
|
||||||
setFontFamily(fb);
|
setFontFamily(fb);
|
||||||
if (run.internalFontId) {
|
const isSubsetFont = /^[A-Z]{6}\+/.test(run.internalFontId || '');
|
||||||
|
if (run.internalFontId && !isSubsetFont) {
|
||||||
loadPdfFont(documentId, run.internalFontId).then((family) => {
|
loadPdfFont(documentId, run.internalFontId).then((family) => {
|
||||||
if (family) setFontFamily(`'${family}', ${fb}`);
|
if (family) setFontFamily(`'${family}', ${fb}`);
|
||||||
});
|
});
|
||||||
@@ -467,39 +523,17 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
if (editing === null || committedRef.current) return;
|
if (editing === null || committedRef.current) return;
|
||||||
const run = runs[editing];
|
const run = runs[editing];
|
||||||
const next = value;
|
const next = value;
|
||||||
|
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
||||||
if (next !== run.text) {
|
const isTable = isTableParagraph(para, modelRef.current);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
committedRef.current = true;
|
committedRef.current = true;
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
if (next === run.text) return;
|
if (next === run.text) return;
|
||||||
|
if (onReflowParagraph && !isTable && isFlowingParagraph(para)) {
|
||||||
const para = modelRef.current?.paragraphs?.[run.paraIndex];
|
|
||||||
if (onReflowParagraph && isFlowingParagraph(para)) {
|
|
||||||
const payload = buildReflowPayload(modelRef.current, run, next);
|
const payload = buildReflowPayload(modelRef.current, run, next);
|
||||||
if (payload) { onReflowParagraph(pageIndex, payload); return; }
|
if (payload) { onReflowParagraph(pageIndex, payload); return; }
|
||||||
}
|
}
|
||||||
onEditText?.(pageIndex, run, next);
|
onEditText?.(pageIndex, run, next, shouldDisableJustify(para));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -507,6 +541,84 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
setEditing(null);
|
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;
|
const run = editing !== null ? runs[editing] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -552,12 +664,33 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
const baselineScreen = (heightPts - run.baselineY) * zoom;
|
const baselineScreen = (heightPts - run.baselineY) * zoom;
|
||||||
const inputTop = baselineScreen - 0.8 * fpx;
|
const inputTop = baselineScreen - 0.8 * fpx;
|
||||||
const box = rectOf(run);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* White cover for the original cell until the engine preview is ready. */}
|
||||||
<div
|
<div
|
||||||
className="absolute z-[36] bg-white"
|
className="absolute z-[36] bg-white"
|
||||||
style={{ left: box.left - 2, top: box.top - 2, width: box.width + 4, height: box.height + 4 }}
|
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
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -586,7 +719,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
|||||||
lineHeight: `${fpx}px`,
|
lineHeight: `${fpx}px`,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize: `${fpx}px`,
|
fontSize: `${fpx}px`,
|
||||||
color: run.color,
|
color: cellPreviewReady ? 'transparent' : run.color,
|
||||||
caretColor: 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},
|
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")
|
@router.get("/{document_id}/raw")
|
||||||
def get_document_raw(document_id: str) -> Response:
|
def get_document_raw(document_id: str) -> Response:
|
||||||
@@ -463,6 +488,7 @@ class TextRunModel(BaseModel):
|
|||||||
h: float
|
h: float
|
||||||
object_indices: list[int] = []
|
object_indices: list[int] = []
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
|
font_fidelity: str = "exact"
|
||||||
|
|
||||||
|
|
||||||
class TextLineModel(BaseModel):
|
class TextLineModel(BaseModel):
|
||||||
@@ -547,6 +573,7 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
|
|||||||
h=r.h,
|
h=r.h,
|
||||||
object_indices=r.object_indices,
|
object_indices=r.object_indices,
|
||||||
color=getattr(r, "fill_color", "#000000") or "#000000",
|
color=getattr(r, "fill_color", "#000000") or "#000000",
|
||||||
|
font_fidelity=getattr(r, "font_fidelity", "exact") or "exact",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
lines.append(
|
lines.append(
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ class ReplaceTextData(BaseModel):
|
|||||||
text: str
|
text: str
|
||||||
internalFontId: str
|
internalFontId: str
|
||||||
fontSize: float
|
fontSize: float
|
||||||
|
disableJustify: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ReplaceTextOperation(BaseModel):
|
class ReplaceTextOperation(BaseModel):
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ target_link_options(pdfengine_wasm PRIVATE
|
|||||||
"-sALLOW_MEMORY_GROWTH=1"
|
"-sALLOW_MEMORY_GROWTH=1"
|
||||||
"-sWASM_BIGINT"
|
"-sWASM_BIGINT"
|
||||||
"-sSTACK_SIZE=5MB"
|
"-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']"
|
"-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 int lastRenderH() { return g_lastH; }
|
||||||
EMSCRIPTEN_KEEPALIVE const char* lastLayoutJson() { return g_lastLayout.c_str(); }
|
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 void freeDocument(int handle) { g_docs.erase(handle); }
|
||||||
|
|
||||||
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }
|
EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { return "pdfengine-wasm+pdfium"; }
|
||||||
|
|||||||
Reference in New Issue
Block a user