fix(reflow): kill bulge/merge + font-change on edit; add center/right, IME; harden Raw Text

Reflow (primary editor):
- Geometric backstop in reflow_paragraph: adopt any text object fully inside the paragraph
  bbox that the model's objectIndices omitted (PDFium glyph->object map can return -1), so it's
  deleted + its original font is resolved. Fixes both the leftover-glyph "bulge/merge" and the
  font-substitution-on-edit (same root cause). No-op on correctly-indexed paragraphs (gate-proven
  byte-identical: overlay diff unchanged at 1.69%/1.70%).
- Preserve data-fid on edited contentEditable nodes (extractFlatRuns climbs to nearest styled
  ancestor / inherits from adjacent run) so edits keep their real font instead of the dominant.
- Converge preview & commit through one buildReflowData (no preview-OK/commit-wrong drift).
- Center/right alignment: additive emission branch (greedy path only) + heading-align inference.
- IME composition handling (suppress render mid-composition; don't commit on composing Enter).
- Fix WASM document-handle leak (free superseded versions on documentId change/unmount).
- Quiet the backstop instrumentation (warn -> debug) now the root cause is confirmed.

Raw Text / StreamEditor (beta companion):
- Permission-gate both text_objects endpoints behind canModify; label tool "(beta)".
- P1a: preserve TJ kerning numbers for same-length edits (redistribute into original slots),
  fall back to single-string collapse otherwise (never worse than before).
- P1b: reject edits with characters unencodable in the run's font (clear 400 instead of corruption).
- P2a: commit Raw Text edits as a new document version (add_document) and adopt the id via
  pushHistory, so they join undo/redo instead of mutating in place.
- P2b: exact hit-boxes from real font metrics (re-measured once @font-face loads).

WASM preview rebuilt with the reflow changes; cache version bumped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Furqan-14
2026-06-17 17:12:53 +05:30
co-authored by Claude Opus 4.8
parent 21f0bfab9a
commit 660649a562
17 changed files with 707 additions and 98 deletions
+62 -7
View File
@@ -2683,6 +2683,51 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown);
}
// --- Geometric backstop (fixes BOTH the "bulge/merge" and the font-substitution bug) ---
// The frontend-supplied objectIndices can omit a paragraph object when its glyphs map to
// pageObjectIndex == -1 (load-dependent, and the map shifts after a prior edit re-serializes
// the stream). A missing index means that object is (a) NOT deleted -> its glyphs stay UNDER
// the new text ("bulge/merge"), and (b) NOT seen by resolveOrigFont/getFontDataFromObjects
// -> the run re-emits in a base-14 substitute ("font changes on edit"). Backstop: widen the
// working set to any TEXT object whose bbox is FULLY inside the union bbox of the supplied
// indices. Conservative (contained text only) so a neighbouring paragraph / image / rule line
// is never touched. When the supplied indices are already complete (the correct common case)
// NO extra object is contained -> paragraphSet == objectIndices -> byte-identical behaviour.
std::vector<int> paragraphSet = objectIndices;
{
float ul = 0, ub = 0, ur = 0, ut = 0; bool haveUnion = false;
for (int idx : objectIndices) {
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
if (!o) continue;
float l = 0, b = 0, r = 0, t = 0;
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
if (!haveUnion) { ul = l; ub = b; ur = r; ut = t; haveUnion = true; }
else { // plain comparisons (Windows headers #define min/max macros)
if (l < ul) ul = l; if (b < ub) ub = b;
if (r > ur) ur = r; if (t > ut) ut = t;
}
}
if (haveUnion) {
const float eps = 0.5f; // tolerate sub-pixel bbox slop
int nObjs = FPDFPage_CountObjects(page);
int adopted = 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 || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
float l = 0, b = 0, r = 0, t = 0;
if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue;
if (l >= ul - eps && b >= ub - eps && r <= ur + eps && t <= ut + eps) {
paragraphSet.push_back(k);
adopted++;
spdlog::debug("reflow_paragraph: adopted leftover text object idx={} inside paragraph bbox (not in objectIndices) -> prevents bulge/merge + font substitution", k);
}
}
if (adopted > 0)
spdlog::debug("reflow_paragraph: geometric backstop adopted {} object(s) the model omitted", adopted);
}
}
// 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) {
@@ -2717,7 +2762,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
// -> "Cuwi Le,e"). The handle stays valid because `page` is open through emission.
auto resolveOrigFont = [&](const std::string& fid) -> FPDF_FONT {
const std::string expected = baseNameFromInternalFontId(fid);
for (int idx : objectIndices) {
for (int idx : paragraphSet) {
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
FPDF_FONT fo = FPDFTextObj_GetFont(o);
@@ -2733,7 +2778,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
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], objectIndices, resolveOrigFont(rs.internalFontId));
fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId], paragraphSet, resolveOrigFont(rs.internalFontId));
}
for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId];
@@ -2882,7 +2927,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
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;
if (std::find(paragraphSet.begin(), paragraphSet.end(), k) != paragraphSet.end()) continue;
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
if (!o) continue;
float l = 0, bo = 0, rr = 0, tt = 0;
@@ -2896,10 +2941,12 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
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) {
// Delete the old paragraph objects (descending so indices stay valid). Uses paragraphSet
// so any object the model omitted but that the geometric backstop adopted is also removed
// (otherwise its glyphs would remain UNDER the freshly-emitted text -> "bulge/merge").
std::sort(paragraphSet.begin(), paragraphSet.end(), std::greater<int>());
int minIndex = paragraphSet.back();
for (int idx : paragraphSet) {
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx);
if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); }
}
@@ -2927,6 +2974,14 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
std::vector<double> adv; // advance (PDF units) of each char in lineText
double lineFontSize = 0.0;
double x = (li < lineX.size()) ? lineX[li] : columnLeft; // exact source left when provided
// Center/right alignment: shift the whole line within the column. Only on the
// greedy/re-wrapped path (no provided lineX) so the unchanged "lines" path and the
// existing left/justify behaviour are byte-identical. justifyThis is already false
// for center/right (it only triggers on align=="justify"), so no gap distribution.
if (li >= lineX.size()) {
if (align == "right") x = columnLeft + (columnWidth - naturalW);
else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0;
}
for (size_t k = 0; k < lw.size(); ++k) {
size_t wi = lw[k];
if (k > 0) {