|
|
|
@@ -17,6 +17,7 @@
|
|
|
|
|
#include "fonts/pdf_fonts/font_fallback.hpp"
|
|
|
|
|
#include "fonts/pdf_fonts/font_subset.hpp"
|
|
|
|
|
#include "fonts/shaping/hb_shaper.hpp"
|
|
|
|
|
#include "fonts/face/font_face.hpp"
|
|
|
|
|
#include "decoration_builder.hpp"
|
|
|
|
|
|
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
@@ -117,6 +118,93 @@ std::string code_point_to_utf8(unsigned int cp) {
|
|
|
|
|
}
|
|
|
|
|
return utf8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reverse of the Windows-1252 (cp1252) byte→codepoint map: a unicode codepoint back to its
|
|
|
|
|
// single cp1252 byte, or -1 if it isn't representable in cp1252. Used to undo "double-encoded"
|
|
|
|
|
// ToUnicode maps (see repairMojibake).
|
|
|
|
|
static int cp1252ToByte(unsigned int cp) {
|
|
|
|
|
if (cp <= 0x7F) return static_cast<int>(cp);
|
|
|
|
|
if (cp >= 0xA0 && cp <= 0xFF) return static_cast<int>(cp); // Latin-1 high range is identity
|
|
|
|
|
switch (cp) {
|
|
|
|
|
case 0x20AC: return 0x80; case 0x201A: return 0x82; case 0x0192: return 0x83;
|
|
|
|
|
case 0x201E: return 0x84; case 0x2026: return 0x85; case 0x2020: return 0x86;
|
|
|
|
|
case 0x2021: return 0x87; case 0x02C6: return 0x88; case 0x2030: return 0x89;
|
|
|
|
|
case 0x0160: return 0x8A; case 0x2039: return 0x8B; case 0x0152: return 0x8C;
|
|
|
|
|
case 0x017D: return 0x8E; case 0x2018: return 0x91; case 0x2019: return 0x92;
|
|
|
|
|
case 0x201C: return 0x93; case 0x201D: return 0x94; case 0x2022: return 0x95;
|
|
|
|
|
case 0x2013: return 0x96; case 0x2014: return 0x97; case 0x02DC: return 0x98;
|
|
|
|
|
case 0x2122: return 0x99; case 0x0161: return 0x9A; case 0x203A: return 0x9B;
|
|
|
|
|
case 0x0153: return 0x9C; case 0x017E: return 0x9E; case 0x0178: return 0x9F;
|
|
|
|
|
default: return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decode a UTF-8 string into codepoints.
|
|
|
|
|
static std::vector<unsigned int> utf8_to_codepoints(const std::string& s) {
|
|
|
|
|
std::vector<unsigned int> cps;
|
|
|
|
|
for (size_t i = 0; i < s.size();) {
|
|
|
|
|
unsigned char c = static_cast<unsigned char>(s[i]);
|
|
|
|
|
unsigned int cp = c; int extra = 0;
|
|
|
|
|
if (c < 0x80) { cp = c; extra = 0; }
|
|
|
|
|
else if ((c & 0xE0) == 0xC0) { cp = c & 0x1F; extra = 1; }
|
|
|
|
|
else if ((c & 0xF0) == 0xE0) { cp = c & 0x0F; extra = 2; }
|
|
|
|
|
else if ((c & 0xF8) == 0xF0) { cp = c & 0x07; extra = 3; }
|
|
|
|
|
else { cp = c; extra = 0; }
|
|
|
|
|
if (i + extra >= s.size()) { cps.push_back(c); i++; continue; }
|
|
|
|
|
bool ok = true;
|
|
|
|
|
for (int j = 1; j <= extra; ++j) { unsigned char n = static_cast<unsigned char>(s[i + j]); if ((n & 0xC0) != 0x80) { ok = false; break; } cp = (cp << 6) | (n & 0x3F); }
|
|
|
|
|
if (!ok) { cps.push_back(c); i++; continue; }
|
|
|
|
|
cps.push_back(cp); i += extra + 1;
|
|
|
|
|
}
|
|
|
|
|
return cps;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Repair "double-encoded" text. Some PDFs ship a ToUnicode CMap whose multi-byte UTF-8 sequences
|
|
|
|
|
// were themselves decoded through cp1252, so a single char like the en-dash (U+2013, UTF-8 bytes
|
|
|
|
|
// E2 80 93) is extracted as three garbage chars (â € " = U+00E2 U+20AC U+201C). We map each char
|
|
|
|
|
// back to its cp1252 byte and, where those bytes form a valid UTF-8 lead+continuation sequence,
|
|
|
|
|
// re-decode them to the intended codepoint. Plain text is left untouched.
|
|
|
|
|
static std::string repairMojibake(const std::string& s) {
|
|
|
|
|
auto cps = utf8_to_codepoints(s);
|
|
|
|
|
std::string out;
|
|
|
|
|
auto emit = [&](unsigned int cp) { out += code_point_to_utf8(cp); };
|
|
|
|
|
for (size_t i = 0; i < cps.size();) {
|
|
|
|
|
int b0 = cp1252ToByte(cps[i]);
|
|
|
|
|
int need = 0;
|
|
|
|
|
if (b0 >= 0xC0 && b0 <= 0xDF) need = 1;
|
|
|
|
|
else if (b0 >= 0xE0 && b0 <= 0xEF) need = 2;
|
|
|
|
|
else if (b0 >= 0xF0 && b0 <= 0xF7) need = 3;
|
|
|
|
|
if (need > 0 && i + need < cps.size()) {
|
|
|
|
|
std::string bytes; bytes.push_back(static_cast<char>(b0));
|
|
|
|
|
bool ok = true;
|
|
|
|
|
for (int j = 1; j <= need; ++j) { int b = cp1252ToByte(cps[i + j]); if (b < 0x80 || b > 0xBF) { ok = false; break; } bytes.push_back(static_cast<char>(b)); }
|
|
|
|
|
if (ok) { auto rd = utf8_to_codepoints(bytes); if (rd.size() == 1 && rd[0] > 0x7F) { emit(rd[0]); i += need + 1; continue; } }
|
|
|
|
|
}
|
|
|
|
|
emit(cps[i]); i++;
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normalize "smart" punctuation to ASCII for reflow re-emission. Office subset fonts often map
|
|
|
|
|
// en/em dashes, curly quotes, and ellipses at custom codes (not their Unicode), so re-emitting
|
|
|
|
|
// them via FPDFText_SetText(unicode) drops them to a notdef glyph (the en-dash came out as "B").
|
|
|
|
|
// Substituting the ASCII equivalent renders reliably in every font (embedded, base-14, or system)
|
|
|
|
|
// and keeps preview == saved. Only reflowed text is affected; untouched text keeps its originals.
|
|
|
|
|
std::string asciiizePunctuation(const std::string& s) {
|
|
|
|
|
auto cps = utf8_to_codepoints(s);
|
|
|
|
|
std::string out;
|
|
|
|
|
for (unsigned int cp : cps) {
|
|
|
|
|
switch (cp) {
|
|
|
|
|
case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: out += '-'; break;
|
|
|
|
|
case 0x2018: case 0x2019: case 0x201B: out += '\''; break;
|
|
|
|
|
case 0x201C: case 0x201D: case 0x201F: out += '"'; break;
|
|
|
|
|
case 0x2026: out += "..."; break;
|
|
|
|
|
case 0x00A0: case 0x2002: case 0x2003: case 0x2009: case 0x202F: out += ' '; break;
|
|
|
|
|
default: out += code_point_to_utf8(cp); break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
} // namespace pdfengine::parser
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
@@ -1024,10 +1112,15 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|
|
|
|
gaps.push_back(gap);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
double medianGap = 0.0;
|
|
|
|
|
// Robust intra-word gap estimate. Use a LOW percentile (25th), NOT the median: on lines
|
|
|
|
|
// with many short words (or justified text), positive gaps are dominated by word-spaces,
|
|
|
|
|
// which inflates the median and makes the space threshold below swallow real spaces —
|
|
|
|
|
// gluing whole words together ("backend systems for" -> "backendsystemsfor"). The 25th
|
|
|
|
|
// percentile stays inside the small intra-word gap cluster even when spaces dominate.
|
|
|
|
|
double p25Gap = 0.0;
|
|
|
|
|
if (!gaps.empty()) {
|
|
|
|
|
std::sort(gaps.begin(), gaps.end());
|
|
|
|
|
medianGap = gaps[gaps.size() / 2];
|
|
|
|
|
p25Gap = gaps[gaps.size() / 4];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 5F: Run Builder
|
|
|
|
@@ -1050,8 +1143,15 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|
|
|
|
const auto& currG = line.glyphs[i];
|
|
|
|
|
|
|
|
|
|
double gap = currG.bboxX - (prevG.bboxX + prevG.bboxW);
|
|
|
|
|
double spaceThreshold = (std::max)(currG.fontSize * 0.25, medianGap * 2.0);
|
|
|
|
|
|
|
|
|
|
// A word space is a clear outlier above intra-word tracking. Anchor on a
|
|
|
|
|
// fontSize-relative floor (reliable for normal AND justified body text), and only
|
|
|
|
|
// raise it for unusually loose tracking — capped so it can never grow large
|
|
|
|
|
// enough to drop real word-spaces (the previous medianGap*2.0 bug).
|
|
|
|
|
double spaceThreshold = currG.fontSize * 0.2;
|
|
|
|
|
if (p25Gap > spaceThreshold) {
|
|
|
|
|
spaceThreshold = (std::min)(p25Gap * 1.5, currG.fontSize * 0.38);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool addSpace = gap > spaceThreshold && prevG.text != " " && currG.text != " ";
|
|
|
|
|
bool breakRun = currG.fontName != currentRun.fontName ||
|
|
|
|
|
std::abs(currG.fontSize - currentRun.fontSize) > 0.1 ||
|
|
|
|
@@ -1123,6 +1223,15 @@ std::expected<PageModel, EngineError> PdfiumPage::extractDocumentModel() const {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 5F.5: Repair double-encoded text (e.g. an en-dash extracted as "â€"" from a malformed
|
|
|
|
|
// ToUnicode CMap) so both display and reflow re-emission use the correct codepoints.
|
|
|
|
|
for (auto& line : lines) {
|
|
|
|
|
for (auto& run : line.runs) {
|
|
|
|
|
std::string repaired = pdfengine::parser::repairMojibake(run.text);
|
|
|
|
|
if (repaired != run.text) run.text = std::move(repaired);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Phase 5G: Paragraph Builder
|
|
|
|
|
std::vector<Paragraph> paragraphs;
|
|
|
|
|
if (!lines.empty()) {
|
|
|
|
@@ -1765,12 +1874,155 @@ std::expected<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#ifdef PDFENGINE_WITH_PDFIUM
|
|
|
|
|
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|
|
|
|
int pageIndex, const std::string& internalFontId, double fontSize,
|
|
|
|
|
const std::vector<uint32_t>& codepoints) {
|
|
|
|
|
EmissionFont out;
|
|
|
|
|
(void)fontSize;
|
|
|
|
|
|
|
|
|
|
// Derive a base-14 name + bold/italic from the internalFontId (mirrors replace_text).
|
|
|
|
|
std::string fontName = "Helvetica";
|
|
|
|
|
bool bold = false, italic = false;
|
|
|
|
|
{
|
|
|
|
|
std::string lowerId = internalFontId;
|
|
|
|
|
std::transform(lowerId.begin(), lowerId.end(), lowerId.begin(),
|
|
|
|
|
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
|
|
|
|
if (lowerId.find("bolditalic") != std::string::npos) { bold = true; italic = true; }
|
|
|
|
|
else if (lowerId.find("bold") != std::string::npos) { bold = true; }
|
|
|
|
|
else if (lowerId.find("italic") != std::string::npos) { italic = true; }
|
|
|
|
|
else if (lowerId.find("oblique") != std::string::npos) { italic = true; }
|
|
|
|
|
if (lowerId.find("times") != std::string::npos) {
|
|
|
|
|
fontName = bold && italic ? "Times-BoldItalic" : bold ? "Times-Bold" : italic ? "Times-Italic" : "Times-Roman";
|
|
|
|
|
} else if (lowerId.find("courier") != std::string::npos) {
|
|
|
|
|
fontName = bold && italic ? "Courier-BoldOblique" : bold ? "Courier-Bold" : italic ? "Courier-Oblique" : "Courier";
|
|
|
|
|
} else {
|
|
|
|
|
fontName = bold && italic ? "Helvetica-BoldOblique" : bold ? "Helvetica-Bold" : italic ? "Helvetica-Oblique" : "Helvetica";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Match the FontInfo by internalFontId + resolve a FontFace (for shaping/width).
|
|
|
|
|
std::optional<FontInfo> matchedFontInfo;
|
|
|
|
|
auto fontsRes = getFonts(pageIndex, pageIndex);
|
|
|
|
|
if (fontsRes.has_value()) {
|
|
|
|
|
for (const auto& fi : *fontsRes) {
|
|
|
|
|
if (!internalFontId.empty() && fi.internalFontId == internalFontId) { matchedFontInfo = fi; break; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (matchedFontInfo.has_value()) {
|
|
|
|
|
auto r = getResolvedFont(*matchedFontInfo);
|
|
|
|
|
if (r.has_value()) out.resolved = *r;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Glyph coverage → pick the embedding tier.
|
|
|
|
|
bool fontSupportsAll = true, subsetLacksGlyphs = false;
|
|
|
|
|
bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset;
|
|
|
|
|
if (out.resolved) {
|
|
|
|
|
for (uint32_t cp : codepoints) {
|
|
|
|
|
if (!out.resolved->hasGlyph(cp)) {
|
|
|
|
|
if (isSubsetFont) subsetLacksGlyphs = true; else fontSupportsAll = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
fontSupportsAll = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string cacheKey;
|
|
|
|
|
bool useEmbedded = false, useSystem = false;
|
|
|
|
|
if (matchedFontInfo && matchedFontInfo->isEmbedded) {
|
|
|
|
|
// Re-embed from the PDF's OWN font bytes (getFontData below). This path does NOT need the
|
|
|
|
|
// resolver's FontFace, so it works under WASM's no-filesystem build where getResolvedFont
|
|
|
|
|
// can return null — the reflowed text then keeps the document's real font (e.g. Calibri)
|
|
|
|
|
// instead of falling back to a generic Helvetica/Liberation. We only divert to a system
|
|
|
|
|
// substitute when we have a resolved face that PROVES the subset lacks a needed glyph.
|
|
|
|
|
bool subsetProvenLacking = out.resolved && isSubsetFont && subsetLacksGlyphs;
|
|
|
|
|
bool fullProvenLacking = out.resolved && !isSubsetFont && !fontSupportsAll;
|
|
|
|
|
if (!subsetProvenLacking && !fullProvenLacking) { cacheKey = matchedFontInfo->internalFontId; useEmbedded = true; }
|
|
|
|
|
else { cacheKey = "system_embed_" + matchedFontInfo->fontName + std::string("_") + (bold ? "B" : "") + (italic ? "I" : ""); useSystem = true; }
|
|
|
|
|
} else if (matchedFontInfo && !matchedFontInfo->isEmbedded) {
|
|
|
|
|
cacheKey = "standard_" + fontName;
|
|
|
|
|
} else {
|
|
|
|
|
cacheKey = "standard_" + fontName;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Make the cache key CODEPOINT-AWARE. A subset is built for an exact glyph set, so a font
|
|
|
|
|
// reused with different characters (e.g. the Summary's text cached, then a bullet reflowed)
|
|
|
|
|
// must NOT reuse the prior subset — its cmap would map the new chars to the wrong glyphs,
|
|
|
|
|
// producing scrambled text ("Core Java" -> "Cuwi Le,e"). Hashing the codepoints fixes it.
|
|
|
|
|
{
|
|
|
|
|
uint64_t h = 1469598103934665603ull;
|
|
|
|
|
for (uint32_t cp : codepoints) { h ^= cp; h *= 1099511628211ull; }
|
|
|
|
|
cacheKey += "#" + std::to_string(h);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
|
|
|
|
if (loadedFontsCache_.count(cacheKey)) {
|
|
|
|
|
out.font = loadedFontsCache_[cacheKey];
|
|
|
|
|
auto it = loadedMeasureFaces_.find(cacheKey);
|
|
|
|
|
if (it != loadedMeasureFaces_.end()) out.measureFace = it->second;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (out.font) return out;
|
|
|
|
|
|
|
|
|
|
if (useEmbedded) {
|
|
|
|
|
auto fontDataRes = getFontData(matchedFontInfo->internalFontId);
|
|
|
|
|
if (fontDataRes.has_value() && !fontDataRes.value().empty()) {
|
|
|
|
|
// Re-subset the embedded font over the glyphs we'll emit. HarfBuzz rebuilds a
|
|
|
|
|
// clean unicode cmap (no RETAIN_GIDS), so FPDFText_SetText(unicode) maps every
|
|
|
|
|
// char — loading the raw embedded bytes cid=false drops glyphs whose original
|
|
|
|
|
// cmap subtable PDFium can't follow (e.g. Office's embedded Calibri). Smart
|
|
|
|
|
// punctuation is normalized to ASCII upstream (asciiizePunctuation), so the rare
|
|
|
|
|
// "glyph at a custom code" case (the en-dash) never reaches here.
|
|
|
|
|
auto subset = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fontDataRes.value(), codepoints);
|
|
|
|
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
|
|
|
|
loadedFontDataBuffers_[cacheKey] = !subset.empty() ? std::move(subset) : fontDataRes.value();
|
|
|
|
|
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
|
|
|
|
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
|
|
|
|
}
|
|
|
|
|
} else if (useSystem) {
|
|
|
|
|
std::string fontPath = fonts::pdf_fonts::FontFallback::getInstance().getFallbackFontPath(
|
|
|
|
|
matchedFontInfo->normalizedFamily.empty() ? matchedFontInfo->fontName : matchedFontInfo->normalizedFamily, bold, italic);
|
|
|
|
|
std::ifstream fs(fontPath, std::ios::binary);
|
|
|
|
|
if (fs) {
|
|
|
|
|
std::vector<uint8_t> fileBytes((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>());
|
|
|
|
|
if (!fileBytes.empty()) {
|
|
|
|
|
std::vector<uint8_t> subsetBytes = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fileBytes, codepoints);
|
|
|
|
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
|
|
|
|
loadedFontDataBuffers_[cacheKey] = !subsetBytes.empty() ? std::move(subsetBytes) : std::move(fileBytes);
|
|
|
|
|
const auto& bytes = loadedFontDataBuffers_[cacheKey];
|
|
|
|
|
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!out.font) {
|
|
|
|
|
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
|
|
|
|
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
|
|
|
|
}
|
|
|
|
|
if (out.font) {
|
|
|
|
|
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
|
|
|
|
loadedFontsCache_[cacheKey] = out.font;
|
|
|
|
|
// Build a measuring face from the exact bytes we loaded (embedded/system paths) so
|
|
|
|
|
// width measurement matches what PDFium renders. Standard base-14 fonts have no bytes
|
|
|
|
|
// here → measurement falls back to the resolver face.
|
|
|
|
|
auto bit = loadedFontDataBuffers_.find(cacheKey);
|
|
|
|
|
if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) {
|
|
|
|
|
auto mf = std::make_shared<fonts::FontFace>();
|
|
|
|
|
if (mf->loadFromMemory(bit->second)) { loadedMeasureFaces_[cacheKey] = mf; out.measureFace = mf; }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
|
|
|
|
#ifdef PDFENGINE_WITH_PDFIUM
|
|
|
|
|
if (!doc_) {
|
|
|
|
|
return std::unexpected(EngineError::Unknown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastReflowLayout_.clear(); // repopulated only if a reflow_paragraph op runs
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
auto root = nlohmann::json::parse(editsJson);
|
|
|
|
|
if (!root.contains("version") || root["version"] != "1.0") {
|
|
|
|
@@ -2227,6 +2479,320 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|
|
|
|
spdlog::error("Failed to generate page content after replace_text");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FPDF_ClosePage(page);
|
|
|
|
|
} else if (type == "reflow_paragraph") {
|
|
|
|
|
// Word/Adobe-style paragraph reflow: re-wrap the paragraph's styled runs
|
|
|
|
|
// within its column width, re-justify, emit the new lines, and push the
|
|
|
|
|
// following in-column content down/up by the line-count delta.
|
|
|
|
|
if (!op.contains("data") || !op["data"].is_object()) {
|
|
|
|
|
spdlog::error("reflow_paragraph missing 'data'");
|
|
|
|
|
return std::unexpected(EngineError::InvalidFormat);
|
|
|
|
|
}
|
|
|
|
|
auto data = op["data"];
|
|
|
|
|
|
|
|
|
|
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; };
|
|
|
|
|
std::vector<RunStyle> runs;
|
|
|
|
|
auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) {
|
|
|
|
|
r = 0; g = 0; b = 0;
|
|
|
|
|
if (hex.size() >= 7 && hex[0] == '#') {
|
|
|
|
|
auto hv = [](char ch) -> int {
|
|
|
|
|
if (ch >= '0' && ch <= '9') return ch - '0';
|
|
|
|
|
if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10;
|
|
|
|
|
if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10;
|
|
|
|
|
return 0;
|
|
|
|
|
};
|
|
|
|
|
r = static_cast<unsigned int>(hv(hex[1]) * 16 + hv(hex[2]));
|
|
|
|
|
g = static_cast<unsigned int>(hv(hex[3]) * 16 + hv(hex[4]));
|
|
|
|
|
b = static_cast<unsigned int>(hv(hex[5]) * 16 + hv(hex[6]));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
auto parseRun = [&](const nlohmann::json& rj) {
|
|
|
|
|
RunStyle rs;
|
|
|
|
|
rs.text = pdfengine::parser::asciiizePunctuation(rj.value("text", ""));
|
|
|
|
|
rs.internalFontId = rj.value("internalFontId", "");
|
|
|
|
|
rs.fontSize = rj.value("fontSize", 12.0);
|
|
|
|
|
parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b);
|
|
|
|
|
runs.push_back(std::move(rs));
|
|
|
|
|
};
|
|
|
|
|
// Optional WYSIWYG mode: the frontend provides the exact visual line breaks
|
|
|
|
|
// (one inner array of styled fragments per line). When present we emit those
|
|
|
|
|
// breaks verbatim and skip our greedy line-breaker, so the saved page matches
|
|
|
|
|
// the live preview. Otherwise we break the flat `runs` ourselves.
|
|
|
|
|
std::vector<std::vector<int>> providedLines; // run indices per provided line
|
|
|
|
|
bool hasProvidedLines = data.contains("lines") && data["lines"].is_array() && !data["lines"].empty();
|
|
|
|
|
if (hasProvidedLines) {
|
|
|
|
|
for (const auto& lineJson : data["lines"]) {
|
|
|
|
|
std::vector<int> lineRunIdxs;
|
|
|
|
|
if (lineJson.is_array()) {
|
|
|
|
|
for (const auto& rj : lineJson) { lineRunIdxs.push_back(static_cast<int>(runs.size())); parseRun(rj); }
|
|
|
|
|
}
|
|
|
|
|
providedLines.push_back(std::move(lineRunIdxs));
|
|
|
|
|
}
|
|
|
|
|
} else if (data.contains("runs") && data["runs"].is_array()) {
|
|
|
|
|
for (const auto& rj : data["runs"]) parseRun(rj);
|
|
|
|
|
}
|
|
|
|
|
std::vector<int> objectIndices;
|
|
|
|
|
if (data.contains("objectIndices") && data["objectIndices"].is_array()) {
|
|
|
|
|
for (auto& idx : data["objectIndices"]) objectIndices.push_back(idx.get<int>());
|
|
|
|
|
}
|
|
|
|
|
double columnLeft = data.value("columnLeft", 0.0);
|
|
|
|
|
double columnRight = data.value("columnRight", 0.0);
|
|
|
|
|
double firstBaselineY = data.value("firstBaselineY", 0.0);
|
|
|
|
|
double leading = data.value("leading", 0.0);
|
|
|
|
|
int oldLineCount = data.value("oldLineCount", 1);
|
|
|
|
|
std::string align = data.value("align", std::string("left"));
|
|
|
|
|
double columnWidth = columnRight - columnLeft;
|
|
|
|
|
// For a bullet/list item the TEXT reflows from a hanging indent (columnLeft), but
|
|
|
|
|
// the push-down of content below must span the FULL block width so the bullet
|
|
|
|
|
// markers (which sit left of the text indent) and other items move together.
|
|
|
|
|
// Defaults to columnLeft for ordinary paragraphs.
|
|
|
|
|
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
|
|
|
|
|
|
|
|
|
|
if (runs.empty() || objectIndices.empty() || columnWidth <= 1.0 || leading <= 0.0) {
|
|
|
|
|
spdlog::warn("reflow_paragraph: insufficient layout data, skipping");
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
|
|
|
|
if (!page) {
|
|
|
|
|
spdlog::error("Failed to load page {} for reflow_paragraph", pageIndex);
|
|
|
|
|
return std::unexpected(EngineError::Unknown);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve + load each run's emission font (subset over that run's codepoints).
|
|
|
|
|
std::vector<EmissionFont> runFonts(runs.size());
|
|
|
|
|
auto toCodepoints = [](const std::string& s) {
|
|
|
|
|
auto u16 = utf8_to_utf16le(s);
|
|
|
|
|
std::vector<uint32_t> cps;
|
|
|
|
|
for (size_t i = 0; i < u16.size();) {
|
|
|
|
|
uint32_t cp = u16[i];
|
|
|
|
|
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < u16.size()) {
|
|
|
|
|
uint32_t low = u16[i + 1];
|
|
|
|
|
if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); i += 2; }
|
|
|
|
|
else i += 1;
|
|
|
|
|
} else i += 1;
|
|
|
|
|
cps.push_back(cp);
|
|
|
|
|
}
|
|
|
|
|
return cps;
|
|
|
|
|
};
|
|
|
|
|
// Load each unique font ONCE, subset over the UNION of all its runs' glyphs
|
|
|
|
|
// (a font shared by several runs must cover every char it will emit).
|
|
|
|
|
{
|
|
|
|
|
std::unordered_map<std::string, std::vector<uint32_t>> fontCps;
|
|
|
|
|
for (const auto& rs : runs) {
|
|
|
|
|
auto cps = toCodepoints(rs.text);
|
|
|
|
|
auto& dst = fontCps[rs.internalFontId];
|
|
|
|
|
dst.insert(dst.end(), cps.begin(), cps.end());
|
|
|
|
|
}
|
|
|
|
|
std::unordered_map<std::string, EmissionFont> fontByFid;
|
|
|
|
|
for (const auto& rs : runs) {
|
|
|
|
|
if (!fontByFid.count(rs.internalFontId))
|
|
|
|
|
fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId]);
|
|
|
|
|
}
|
|
|
|
|
for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Measure a substring's width in a run's resolved font. HbShaper takes an
|
|
|
|
|
// INTEGER pixel size, so shape at a large reference size and scale to the real
|
|
|
|
|
// (fractional) font size — otherwise 9.96px rounds to 9px, every word measures
|
|
|
|
|
// ~10% narrow, and PDFium then renders them wider, collapsing the spaces.
|
|
|
|
|
fonts::HbShaper shaper;
|
|
|
|
|
constexpr unsigned int kRefSize = 1000;
|
|
|
|
|
auto measure = [&](size_t runIdx, const std::string& text) -> double {
|
|
|
|
|
if (text.empty()) return 0.0;
|
|
|
|
|
auto& rf = runFonts[runIdx];
|
|
|
|
|
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
|
|
|
|
// Prefer the face built from the EMITTED bytes (exact width parity); fall
|
|
|
|
|
// back to the resolver face (base-14 standard fonts have no emitted bytes).
|
|
|
|
|
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
|
|
|
|
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
|
|
|
|
if (face) {
|
|
|
|
|
auto glyphs = shaper.shapeRun(text, *face, kRefSize);
|
|
|
|
|
if (!glyphs.empty()) {
|
|
|
|
|
double w = 0.0; for (auto& gph : glyphs) w += gph.advanceX;
|
|
|
|
|
return w * (size / static_cast<double>(kRefSize));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return size * 0.5 * static_cast<double>(text.size()); // rough fallback
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Per-character advances (PDF units) for a run substring — used to build the
|
|
|
|
|
// caret layout so the live preview can place a caret exactly on each glyph. For
|
|
|
|
|
// ASCII (one glyph per byte) this is exact; otherwise the width is spread evenly.
|
|
|
|
|
auto perCharAdvances = [&](size_t runIdx, const std::string& text) -> std::vector<double> {
|
|
|
|
|
std::vector<double> out(text.size(), 0.0);
|
|
|
|
|
if (text.empty()) return out;
|
|
|
|
|
auto& rf = runFonts[runIdx];
|
|
|
|
|
double size = runs[runIdx].fontSize > 0 ? runs[runIdx].fontSize : 12.0;
|
|
|
|
|
double scale = size / static_cast<double>(kRefSize);
|
|
|
|
|
fonts::FontFace* face = rf.measureFace ? rf.measureFace.get()
|
|
|
|
|
: (rf.resolved ? &rf.resolved->getFontFace() : nullptr);
|
|
|
|
|
if (face) {
|
|
|
|
|
auto glyphs = shaper.shapeRun(text, *face, kRefSize);
|
|
|
|
|
if (glyphs.size() == text.size()) {
|
|
|
|
|
for (size_t i = 0; i < text.size(); ++i) out[i] = glyphs[i].advanceX * scale;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
double w = 0.0; for (auto& gph : glyphs) w += gph.advanceX;
|
|
|
|
|
double per = w * scale / static_cast<double>(text.size());
|
|
|
|
|
for (auto& v : out) v = per;
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
for (auto& v : out) v = size * 0.5;
|
|
|
|
|
return out;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
auto isSpace = [](char ch) { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; };
|
|
|
|
|
struct Seg { int runIdx; std::string text; double width; };
|
|
|
|
|
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
|
|
|
|
|
std::vector<Word> words;
|
|
|
|
|
|
|
|
|
|
// Tokenize a sequence of runs into words (non-space runs split into same-style
|
|
|
|
|
// segments), append to `words`, return the new word indices. Space width is
|
|
|
|
|
// measured in the word's own font (the model emits zero-size empty-fid space runs).
|
|
|
|
|
auto tokenize = [&](const std::vector<int>& runIdxs) -> std::vector<size_t> {
|
|
|
|
|
std::string ft; std::vector<int> sof;
|
|
|
|
|
for (int ri : runIdxs) for (char ch : runs[ri].text) { ft.push_back(ch); sof.push_back(ri); }
|
|
|
|
|
std::vector<size_t> out;
|
|
|
|
|
size_t i = 0;
|
|
|
|
|
while (i < ft.size()) {
|
|
|
|
|
if (isSpace(ft[i])) { i++; continue; }
|
|
|
|
|
Word w; w.width = 0.0; w.spaceAfter = 0.0;
|
|
|
|
|
while (i < ft.size() && !isSpace(ft[i])) {
|
|
|
|
|
int st = sof[i]; std::string frag;
|
|
|
|
|
while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); i++; }
|
|
|
|
|
double fw = measure(static_cast<size_t>(st), frag);
|
|
|
|
|
w.segs.push_back({st, frag, fw}); w.width += fw;
|
|
|
|
|
}
|
|
|
|
|
if (i < ft.size() && isSpace(ft[i])) {
|
|
|
|
|
int styleForSpace = w.segs.empty() ? sof[i] : w.segs.back().runIdx;
|
|
|
|
|
w.spaceAfter = measure(static_cast<size_t>(styleForSpace), " ");
|
|
|
|
|
}
|
|
|
|
|
out.push_back(words.size());
|
|
|
|
|
words.push_back(std::move(w));
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
std::vector<std::vector<size_t>> lines;
|
|
|
|
|
if (hasProvidedLines) {
|
|
|
|
|
// WYSIWYG: emit the frontend's exact visual line breaks.
|
|
|
|
|
for (const auto& lineRunIdxs : providedLines) {
|
|
|
|
|
auto wi = tokenize(lineRunIdxs);
|
|
|
|
|
if (!wi.empty()) lines.push_back(std::move(wi));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Greedy-break the whole paragraph within the column.
|
|
|
|
|
std::vector<int> allRuns(runs.size());
|
|
|
|
|
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
|
|
|
|
|
auto allWords = tokenize(allRuns);
|
|
|
|
|
std::vector<size_t> cur; double curW = 0.0;
|
|
|
|
|
for (size_t k = 0; k < allWords.size(); ++k) {
|
|
|
|
|
size_t wi = allWords[k];
|
|
|
|
|
double gap = cur.empty() ? 0.0 : words[allWords[k - 1]].spaceAfter;
|
|
|
|
|
if (!cur.empty() && curW + gap + words[wi].width > columnWidth) {
|
|
|
|
|
lines.push_back(cur); cur.clear();
|
|
|
|
|
cur.push_back(wi); curW = words[wi].width;
|
|
|
|
|
} else {
|
|
|
|
|
cur.push_back(wi); curW += gap + words[wi].width;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!cur.empty()) lines.push_back(cur);
|
|
|
|
|
}
|
|
|
|
|
if (words.empty() || lines.empty()) { FPDF_ClosePage(page); continue; }
|
|
|
|
|
int newLineCount = static_cast<int>(lines.size());
|
|
|
|
|
|
|
|
|
|
// Push following in-column content down/up by the height delta (do this BEFORE
|
|
|
|
|
// deleting the old objects so indices stay valid and new objects aren't moved).
|
|
|
|
|
double deltaH = (newLineCount - oldLineCount) * leading;
|
|
|
|
|
double paragraphBottomBaseline = firstBaselineY - (oldLineCount - 1) * leading;
|
|
|
|
|
if (std::abs(deltaH) > 0.01) {
|
|
|
|
|
double threshold = paragraphBottomBaseline - 0.5 * leading;
|
|
|
|
|
int nObjs = FPDFPage_CountObjects(page);
|
|
|
|
|
int pushed = 0;
|
|
|
|
|
for (int k = 0; k < nObjs; ++k) {
|
|
|
|
|
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue;
|
|
|
|
|
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
|
|
|
|
if (!o) continue;
|
|
|
|
|
float l = 0, bo = 0, rr = 0, tt = 0;
|
|
|
|
|
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
|
|
|
|
double cY = (bo + tt) / 2.0;
|
|
|
|
|
if (cY < threshold && rr > pushColumnLeft && l < columnRight) {
|
|
|
|
|
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -deltaH);
|
|
|
|
|
pushed++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spdlog::info("reflow_paragraph: lines {}->{}, deltaH={}, pushed {} objects", oldLineCount, newLineCount, deltaH, pushed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete the old paragraph objects (descending so indices stay valid).
|
|
|
|
|
std::sort(objectIndices.begin(), objectIndices.end(), std::greater<int>());
|
|
|
|
|
int minIndex = objectIndices.back();
|
|
|
|
|
for (int idx : objectIndices) {
|
|
|
|
|
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
|
|
|
|
|
if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Emit the new lines (one text object per styled segment, positioned explicitly).
|
|
|
|
|
// While positioning, also capture a per-character caret layout (PDF coords) so the
|
|
|
|
|
// live preview can place a caret exactly on the rendered glyphs.
|
|
|
|
|
nlohmann::json layoutLines = nlohmann::json::array();
|
|
|
|
|
for (size_t li = 0; li < lines.size(); ++li) {
|
|
|
|
|
double baselineY = firstBaselineY - static_cast<double>(li) * leading;
|
|
|
|
|
auto& lw = lines[li];
|
|
|
|
|
double naturalW = 0.0;
|
|
|
|
|
for (size_t k = 0; k < lw.size(); ++k) {
|
|
|
|
|
naturalW += words[lw[k]].width;
|
|
|
|
|
if (k > 0) naturalW += words[lw[k - 1]].spaceAfter;
|
|
|
|
|
}
|
|
|
|
|
bool justifyThis = (align == "justify") && (li + 1 < lines.size()) && lw.size() > 1;
|
|
|
|
|
double extraPerGap = 0.0;
|
|
|
|
|
if (justifyThis) { double slack = columnWidth - naturalW; if (slack > 0) extraPerGap = slack / static_cast<double>(lw.size() - 1); }
|
|
|
|
|
|
|
|
|
|
std::string lineText;
|
|
|
|
|
std::vector<double> adv; // advance (PDF units) of each char in lineText
|
|
|
|
|
double lineFontSize = 0.0;
|
|
|
|
|
double x = columnLeft;
|
|
|
|
|
for (size_t k = 0; k < lw.size(); ++k) {
|
|
|
|
|
size_t wi = lw[k];
|
|
|
|
|
if (k > 0) {
|
|
|
|
|
double gap = words[lw[k - 1]].spaceAfter + (justifyThis ? extraPerGap : 0.0);
|
|
|
|
|
x += gap;
|
|
|
|
|
lineText.push_back(' ');
|
|
|
|
|
adv.push_back(gap);
|
|
|
|
|
}
|
|
|
|
|
double segX = x;
|
|
|
|
|
for (auto& seg : words[wi].segs) {
|
|
|
|
|
if (lineFontSize <= 0.0) lineFontSize = runs[seg.runIdx].fontSize;
|
|
|
|
|
FPDF_FONT font = runFonts[seg.runIdx].font;
|
|
|
|
|
if (font) {
|
|
|
|
|
FPDF_PAGEOBJECT obj = FPDFPageObj_CreateTextObj(doc_, font, static_cast<float>(runs[seg.runIdx].fontSize));
|
|
|
|
|
if (obj) {
|
|
|
|
|
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
|
|
|
|
|
auto u16 = utf8_to_utf16le(seg.text);
|
|
|
|
|
u16.push_back(0);
|
|
|
|
|
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
|
|
|
|
|
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, segX, baselineY);
|
|
|
|
|
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
auto ca = perCharAdvances(seg.runIdx, seg.text);
|
|
|
|
|
for (size_t c = 0; c < seg.text.size(); ++c) { lineText.push_back(seg.text[c]); adv.push_back(ca[c]); }
|
|
|
|
|
segX += seg.width;
|
|
|
|
|
}
|
|
|
|
|
x += words[wi].width;
|
|
|
|
|
}
|
|
|
|
|
layoutLines.push_back({
|
|
|
|
|
{"baselineY", baselineY}, {"x0", columnLeft},
|
|
|
|
|
{"fontSize", lineFontSize > 0 ? lineFontSize : leading / 1.2},
|
|
|
|
|
{"text", lineText}, {"adv", adv},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
lastReflowLayout_ = nlohmann::json{{"columnLeft", columnLeft}, {"lines", layoutLines}}.dump();
|
|
|
|
|
|
|
|
|
|
if (!FPDFPage_GenerateContent(page)) {
|
|
|
|
|
spdlog::error("Failed to generate page content after reflow_paragraph");
|
|
|
|
|
}
|
|
|
|
|
FPDF_ClosePage(page);
|
|
|
|
|
} else if (type == "text_overlay" || type == "add_text") {
|
|
|
|
|
if (!op.contains("data") || !op["data"].is_object()) {
|
|
|
|
|