feat: updated the text reflow engine

This commit is contained in:
Furqan-14
2026-06-16 16:45:04 +05:30
parent d53f059952
commit c449cf01b9
8 changed files with 310 additions and 87 deletions
+92 -44
View File
@@ -2548,7 +2548,13 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
auto data = op["data"];
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; };
// `advances` = the run's ORIGINAL per-character advance (PDF units), captured from
// the source glyph positions by the frontend. When present (and aligned 1:1 with the
// run's bytes — i.e. unchanged pure-ASCII text), layout uses these EXACT advances
// instead of re-measuring, so untouched text reproduces the source pixel-for-pixel
// and doesn't drift while you edit elsewhere. Edited/new/non-ASCII runs omit it and
// fall back to HarfBuzz measurement.
struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector<double> advances; };
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;
@@ -2572,6 +2578,9 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
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);
if (rj.contains("advances") && rj["advances"].is_array()) {
for (const auto& a : rj["advances"]) rs.advances.push_back(a.get<double>());
}
runs.push_back(std::move(rs));
};
// Optional WYSIWYG mode: the frontend provides the exact visual line breaks
@@ -2601,6 +2610,15 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
double leading = data.value("leading", 0.0);
int oldLineCount = data.value("oldLineCount", 1);
std::string align = data.value("align", std::string("left"));
// Original per-line baseline + left x (parallel to providedLines). When present, the
// lines path emits each line at its EXACT source baseline/x instead of the uniform
// firstBaselineY - i*leading, so unchanged text reproduces the source's actual
// (possibly non-uniform) line spacing pixel-for-pixel — no cumulative vertical drift.
std::vector<double> lineBaselineY, lineX;
if (data.contains("lineBaselineY") && data["lineBaselineY"].is_array())
for (const auto& v : data["lineBaselineY"]) lineBaselineY.push_back(v.get<double>());
if (data.contains("lineX") && data["lineX"].is_array())
for (const auto& v : data["lineX"]) lineX.push_back(v.get<double>());
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
@@ -2692,24 +2710,6 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
// ~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.
@@ -2736,32 +2736,63 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return out;
};
// Per-character advance for EVERY run, computed once: the source's ORIGINAL advances
// when the run carries them aligned 1:1 with its bytes (unchanged pure-ASCII text —
// reproduces the source exactly), otherwise HarfBuzz-measured (edited/new/non-ASCII).
// All width/gap/caret math below reads from here, so unchanged words never drift.
std::vector<std::vector<double>> runCharAdv(runs.size());
std::vector<char> runPerChar(runs.size(), 0); // 1 = emit per-character (exact placement)
for (size_t ri = 0; ri < runs.size(); ++ri) {
if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) {
runCharAdv[ri] = runs[ri].advances; // pinned to source positions
// Only emit per-character where the SOURCE advances actually diverge from the
// font's natural advances — there a single text object would re-space glyphs
// and smear (bold/tracked text). Where they match (most regular text), keep
// ONE object per segment: it renders identically AND avoids per-glyph bloat.
auto natural = perCharAdvances(ri, runs[ri].text);
bool diverges = natural.size() != runCharAdv[ri].size();
for (size_t c = 0; !diverges && c < natural.size(); ++c)
if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true;
runPerChar[ri] = diverges ? 1 : 0;
} else {
runCharAdv[ri] = perCharAdvances(ri, runs[ri].text); // re-measured (per-segment)
}
}
// Advance of run `ri`'s byte at offset `off` (0 if out of range).
auto charAdvAt = [&](int ri, size_t off) -> double {
const auto& v = runCharAdv[static_cast<size_t>(ri)];
return off < v.size() ? v[off] : 0.0;
};
auto isSpace = [](char ch) { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; };
struct Seg { int runIdx; std::string text; double width; };
struct Seg { int runIdx; std::string text; double width; size_t off; };
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).
// segments), append to `words`, return the new word indices. Widths and the trailing
// space gap come from runCharAdv, so unchanged words keep their exact source metrics.
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::string ft; std::vector<int> sof; std::vector<size_t> cof; // char-offset within its run
for (int ri : runIdxs) {
size_t off = 0;
for (char ch : runs[ri].text) { ft.push_back(ch); sof.push_back(ri); cof.push_back(off++); }
}
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), " ");
int st = sof[i]; size_t segOff = cof[i]; std::string frag; double fw = 0.0;
while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); fw += charAdvAt(st, cof[i]); i++; }
w.segs.push_back({st, frag, fw, segOff}); w.width += fw;
}
// Trailing gap = sum of the ORIGINAL advances of the run of space chars
// (the source/model may use several space glyphs per visual gap), so the
// inter-word spacing matches the source exactly for unchanged text. Consume
// them here (the loop top no longer needs to skip them).
while (i < ft.size() && isSpace(ft[i])) { w.spaceAfter += charAdvAt(sof[i], cof[i]); i++; }
out.push_back(words.size());
words.push_back(std::move(w));
}
@@ -2832,7 +2863,10 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
// 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;
// Exact source baseline for this line when provided (lines path, unchanged
// content); else the uniform fallback (greedy path / re-wrapped lines).
double baselineY = (li < lineBaselineY.size())
? lineBaselineY[li] : firstBaselineY - static_cast<double>(li) * leading;
auto& lw = lines[li];
double naturalW = 0.0;
for (size_t k = 0; k < lw.size(); ++k) {
@@ -2846,7 +2880,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
std::string lineText;
std::vector<double> adv; // advance (PDF units) of each char in lineText
double lineFontSize = 0.0;
double x = columnLeft;
double x = (li < lineX.size()) ? lineX[li] : columnLeft; // exact source left when provided
for (size_t k = 0; k < lw.size(); ++k) {
size_t wi = lw[k];
if (k > 0) {
@@ -2859,19 +2893,33 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
for (auto& seg : words[wi].segs) {
if (lineFontSize <= 0.0) lineFontSize = runs[seg.runIdx].fontSize;
FPDF_FONT font = runFonts[seg.runIdx].font;
if (font) {
auto emitObj = [&](const std::string& s, double atX) {
if (!font || s.empty()) return;
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);
if (!obj) return;
FPDFPageObj_SetFillColor(obj, runs[seg.runIdx].r, runs[seg.runIdx].g, runs[seg.runIdx].b, 255);
auto u16 = utf8_to_utf16le(s); u16.push_back(0);
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(u16.data()));
FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, atX, baselineY);
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
};
if (runPerChar[seg.runIdx]) {
// Source advances diverge from natural: emit ONE object per character at
// its EXACT original x so the glyphs land pixel-for-pixel on the source —
// FPDFText_SetText would otherwise re-space them at the font's natural
// advances and smear within a word (worst on bold/tracked text).
double gx = segX;
for (size_t c = 0; c < seg.text.size(); ++c) {
double a = charAdvAt(seg.runIdx, seg.off + c);
if (seg.text[c] != ' ') emitObj(std::string(1, seg.text[c]), gx);
lineText.push_back(seg.text[c]); adv.push_back(a);
gx += a;
}
} else {
// Edited / non-ASCII run: emit the whole segment (natural advances).
emitObj(seg.text, segX);
for (size_t c = 0; c < seg.text.size(); ++c) { lineText.push_back(seg.text[c]); adv.push_back(charAdvAt(seg.runIdx, seg.off + c)); }
}
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;