feat: updated the text reflow engine
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user