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:
co-authored by
Claude Opus 4.8
parent
21f0bfab9a
commit
660649a562
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user