feat: implemented real flow text editor for paragraphs

This commit is contained in:
Furqan-14
2026-06-16 10:56:08 +05:30
parent 6b78a72f68
commit ec509400e7
22 changed files with 1821 additions and 72 deletions
@@ -258,6 +258,10 @@ public:
virtual std::expected<void, EngineError> applyEdits(const std::string& editsJson) = 0;
// Per-character caret layout (JSON, PDF coords) of the most recent reflow_paragraph op, or
// empty if none. Lets the WASM live preview align a caret exactly to the rendered glyphs.
[[nodiscard]] virtual std::string lastReflowLayout() const { return {}; }
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveIncremental() const = 0;
@@ -81,6 +81,40 @@ std::vector<uint8_t> FontSubset::buildSubset(const std::vector<uint8_t>& origina
return result;
}
// Overwrite a few bytes of the sfnt 'name' table strings (family/full/PostScript/typographic
// names) with a deterministic seed so the subset has a UNIQUE name. Two reflow subsets of the
// same base font (e.g. Calibri) otherwise share the name "Calibri", and PDFium merges them —
// making the second subset's text map to the first's glyphs (scrambled output). Identical inputs
// hash to the same seed → same name → safe to share. The name is only used for de-dup; rendering
// uses the embedded glyphs + cmap, so a "garbled" name is harmless.
static void makeFontNameUnique(std::vector<uint8_t>& font, uint64_t seed) {
auto rd16 = [&](size_t o) -> uint32_t { return (o + 1 < font.size()) ? (uint32_t(font[o]) << 8) | font[o + 1] : 0; };
auto rd32 = [&](size_t o) -> uint32_t { return (o + 3 < font.size()) ? (uint32_t(font[o]) << 24) | (uint32_t(font[o + 1]) << 16) | (uint32_t(font[o + 2]) << 8) | font[o + 3] : 0; };
if (font.size() < 12) return;
uint32_t numTables = rd16(4);
size_t nameOff = 0;
for (uint32_t i = 0; i < numTables; ++i) {
size_t rec = 12 + size_t(i) * 16;
if (rec + 16 > font.size()) break;
if (font[rec] == 'n' && font[rec + 1] == 'a' && font[rec + 2] == 'm' && font[rec + 3] == 'e') { nameOff = rd32(rec + 8); break; }
}
if (nameOff == 0 || nameOff + 6 > font.size()) return;
uint32_t count = rd16(nameOff + 2);
size_t storageOff = nameOff + rd16(nameOff + 4);
for (uint32_t i = 0; i < count; ++i) {
size_t rec = nameOff + 6 + size_t(i) * 12;
if (rec + 12 > font.size()) break;
uint32_t nameID = rd16(rec + 6);
if (nameID == 1 || nameID == 4 || nameID == 6 || nameID == 16) {
uint32_t len = rd16(rec + 8);
size_t s = storageOff + rd16(rec + 10);
for (uint32_t b = 0; b < len && b < 8; ++b) {
if (s + b < font.size()) font[s + b] = static_cast<uint8_t>('A' + ((seed >> ((b % 8) * 4)) & 0x0F)); // 'A'..'P'
}
}
}
}
std::vector<uint8_t> FontSubset::buildSubsetByUnicode(const std::vector<uint8_t>& originalStream,
const std::vector<uint32_t>& codepoints) {
// Subset a font down to only the glyphs needed for `codepoints`, for embedding
+570 -4
View File
@@ -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()) {
+22
View File
@@ -13,6 +13,7 @@
#include <mutex>
#include <unordered_map>
namespace pdfengine::fonts::loader { class FontResolver; }
namespace pdfengine::fonts { class FontFace; }
namespace pdfengine::parser {
@@ -99,7 +100,13 @@ public:
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
// Per-character layout (PDF coords) of the LAST reflow_paragraph op, as JSON. The WASM
// preview uses it to align a custom caret exactly to the rendered glyphs. Empty if the
// most recent applyEdits contained no reflow_paragraph operation.
std::string lastReflowLayout() const { return lastReflowLayout_; }
private:
mutable std::string lastReflowLayout_;
NativeDocHandle doc_ = nullptr;
std::vector<uint8_t> memoryBuffer_;
@@ -126,7 +133,22 @@ private:
#ifdef PDFENGINE_WITH_PDFIUM
mutable std::unordered_map<std::string, FPDF_FONT> loadedFontsCache_;
mutable std::unordered_map<std::string, std::vector<uint8_t>> loadedFontDataBuffers_;
mutable std::unordered_map<std::string, std::shared_ptr<fonts::FontFace>> loadedMeasureFaces_;
mutable std::mutex loadedFontsMutex_;
// Resolve + load a PDFium font for emitting NEW text (mirrors the replace_text tiers:
// embedded full/subset reuse, base-14 standard, or system-font subset embed). Returns
// the FPDF_FONT (for FPDFPageObj_CreateTextObj) and the resolved font (FontFace for
// HarfBuzz width measurement during line-breaking). Either field may be null on failure.
struct EmissionFont {
FPDF_FONT font = nullptr;
std::shared_ptr<fonts::pdf_fonts::Font> resolved;
// A FontFace built from the EXACT bytes loaded into PDFium, so width measurement
// matches what PDFium renders (the resolver's face can have different advances).
std::shared_ptr<fonts::FontFace> measureFace;
};
EmissionFont loadEmissionFont(int pageIndex, const std::string& internalFontId,
double fontSize, const std::vector<uint32_t>& codepoints);
#endif
};