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;
Binary file not shown.
+3
View File
@@ -253,6 +253,7 @@ export interface ReflowFragment {
internalFontId: string;
fontSize: number;
color: string;
advances?: number[]; // original per-char advance of unchanged text → pixel-perfect spacing
}
export interface ReflowParagraphData {
@@ -269,6 +270,8 @@ export interface ReflowParagraphData {
pushColumnLeft?: number;
// WYSIWYG mode: exact visual line breaks from the live editor (one inner array per line).
lines?: ReflowFragment[][];
lineBaselineY?: number[]; // original per-line baseline (parallel to `lines`)
lineX?: number[]; // original per-line left anchor (parallel to `lines`)
}
export interface DeleteAnnotationData {
+1 -1
View File
@@ -23,7 +23,7 @@ function getModule(): Promise<PdfiumModule | null> {
if (!modulePromise) {
modulePromise = (async () => {
try {
const V = '20260616a';
const V = '20260616b';
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
+86 -33
View File
@@ -5,10 +5,42 @@ import type { ReflowLayout } from '../lib/pdfiumEngine';
import type { ReflowParagraphPayload } from './TextEditLayer';
import type { ReflowFragment } from '../lib/gatewayService';
interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; }
interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; advances?: number[]; }
interface OrigLine { frags: { text: string; fid: string; size: number; color: string; advances?: number[] }[]; x: number; baselineY: number; }
interface ParagraphLayout {
columnLeft: number; columnRight: number; firstBaselineY: number; leading: number;
oldLineCount: number; align: 'left' | 'justify'; objectIndices: number[]; seedRuns: SeedRun[];
// Original lines (with per-char advances + exact left/baseline) for pixel-perfect reproduction.
origLines: OrigLine[];
}
// Per-character ORIGINAL advances for every run in a model line, computed ACROSS the line from
// glyph origins (so inter-run gaps are exact and per-run left-bearing never accumulates). Returns
// { runIdxInLine: advances[] } only for runs whose glyphs align 1:1 with their chars, plus the
// line's first-glyph x anchor. Mirrors the engine/harness so preview == commit == source.
function lineAdvances(line: any): { perRun: Record<number, number[]>; anchorX: number } {
const runs = line?.runs ?? [];
const seq: Array<{ ri: number; ci: number; ox: number }> = [];
const aligned: Record<number, boolean> = {};
for (let ri = 0; ri < runs.length; ri++) {
const gs = runs[ri].glyphs ?? [];
const ok = !!runs[ri].text && gs.length === runs[ri].text.length;
aligned[ri] = ok;
if (ok) for (let ci = 0; ci < runs[ri].text.length; ci++) seq.push({ ri, ci, ox: gs[ci].origin_x });
}
const perRun: Record<number, number[]> = {};
for (let ri = 0; ri < runs.length; ri++) if (aligned[ri]) perRun[ri] = new Array(runs[ri].text.length).fill(0);
const anchorX = seq.length ? seq[0].ox : (line?.x ?? 0);
for (let k = 0; k < seq.length; k++) {
const { ri, ci, ox } = seq[k];
perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : (line.x + line.w) - ox;
}
// Drop runs with a non-positive advance (out-of-order glyphs) so the engine measures them.
for (const k of Object.keys(perRun)) {
const ri = Number(k);
if (perRun[ri].some((a) => a <= 0)) delete perRun[ri];
}
return { perRun, anchorX };
}
interface ParagraphEditorProps {
documentId: string;
@@ -37,6 +69,7 @@ function computeLayout(para: any): ParagraphLayout {
let columnLeft = Infinity, columnRight = -Infinity, firstBaselineY = -Infinity;
const baselines: number[] = [], rightEdges: number[] = [], objectIndices: number[] = [];
const seedRuns: SeedRun[] = [];
const origLines: OrigLine[] = [];
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
if (typeof line.baseline_y === 'number') { baselines.push(line.baseline_y); firstBaselineY = Math.max(firstBaselineY, line.baseline_y); }
@@ -44,16 +77,22 @@ function computeLayout(para: any): ParagraphLayout {
columnRight = Math.max(columnRight, line.x + line.w);
rightEdges.push(line.x + line.w);
const lineRuns = line.runs ?? [];
const { perRun, anchorX } = lineAdvances(line);
const lineFrags: OrigLine['frags'] = [];
for (let ri = 0; ri < lineRuns.length; ri++) {
const r = lineRuns[ri];
(Array.isArray(r.object_indices) ? r.object_indices : []).forEach((o: number) => objectIndices.push(o));
let text = r.text ?? '';
const orig = r.text ?? '';
let text = orig;
if (li > 0 && ri === 0 && seedRuns.length > 0) {
const prev = seedRuns[seedRuns.length - 1].text;
if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text;
}
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', fontName: r.font_name ?? '' });
const adv = perRun[ri]; // present only for glyph-aligned (unchanged-able) runs
seedRuns.push({ text, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', fontName: r.font_name ?? '', advances: adv });
if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', advances: adv });
}
if (lineFrags.length) origLines.push({ frags: lineFrags, x: anchorX, baselineY: line.baseline_y ?? 0 });
}
const deltas: number[] = [];
for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]);
@@ -66,7 +105,7 @@ function computeLayout(para: any): ParagraphLayout {
for (let i = 0; i < rightEdges.length - 1; i++) if (rightEdges[i] >= columnRight - colW * 0.04) reaching++;
if (reaching >= (lines.length - 1) * 0.7) align = 'justify';
}
return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns };
return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns, origLines };
}
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
@@ -79,21 +118,21 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: nu
const size = parseFloat(el?.getAttribute('data-size') ?? '') || domSize;
const color = el?.getAttribute('data-color') ?? domColor;
const text = node.textContent ?? '';
if (text) out.push({ text, internalFontId: fid, fontSize: size, color });
node = walker.nextNode() as Text | null;
}
return out;
}
function buildOriginalLines(para: any, dominantFid: string): ReflowFragment[][] {
const out: ReflowFragment[][] = [];
for (const line of para?.lines ?? []) {
const frags: ReflowFragment[] = [];
for (const r of line.runs ?? []) {
const text = r.text ?? '';
if (text) frags.push({ text, internalFontId: r.internal_font_id || dominantFid, fontSize: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000' });
if (text) {
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
// Carry the source advances ONLY if this text node still exactly matches its seed span
// (unchanged): data-advances aligns 1:1 with the original chars, so a length match means
// the user hasn't edited it. Edited text drops them and the engine re-measures.
const aRaw = el?.getAttribute('data-advances');
if (aRaw) {
try {
const a = JSON.parse(aRaw) as number[];
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
} catch { /* ignore malformed */ }
}
out.push(frag);
}
if (frags.length) out.push(frags);
node = walker.nextNode() as Text | null;
}
return out;
}
@@ -185,18 +224,28 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
const bandTop = Math.max(0, editorTop - leadingPx * 0.5);
const buildOpJson = (runs: ReflowFragment[], originalLines?: ReflowFragment[][]): string => JSON.stringify({
version: '1.0',
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: {
objectIndices: layout.objectIndices,
runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
...(originalLines && originalLines.length ? { lines: originalLines } : {}),
columnLeft: layout.columnLeft, columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
} }],
});
const buildOpJson = (runs: ReflowFragment[], origLines?: OrigLine[]): string => {
const linesData = origLines && origLines.length ? {
lines: origLines.map((l) => l.frags.map((f) => ({
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
...(f.advances ? { advances: f.advances } : {}),
}))),
lineX: origLines.map((l) => l.x),
lineBaselineY: origLines.map((l) => l.baselineY),
} : {};
return JSON.stringify({
version: '1.0',
operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: {
objectIndices: layout.objectIndices,
runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
...linesData,
columnLeft: layout.columnLeft, columnRight,
pushColumnLeft: pushColumnLeft ?? layout.columnLeft,
firstBaselineY: layout.firstBaselineY, leading,
oldLineCount: layout.oldLineCount, align,
} }],
});
};
const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => {
if (!lay.lines.length) return null;
@@ -250,9 +299,10 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
const el = editRef.current;
if (!el) return;
const dpi = Math.round(72 * zoom);
// Before the first edit, keep the document's original line breaks (no re-wrap on open).
const originalLines = editedRef.current ? undefined : buildOriginalLines(para, dominantFid);
const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), originalLines));
// Before the first edit, keep the document's original line breaks + exact source positions
// (advances + per-line baseline/anchor) so the preview reproduces the page pixel-for-pixel.
const origLines = editedRef.current ? undefined : layout.origLines;
const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines));
if (!blob) { console.warn('[ParagraphEditor] WASM preview returned null'); return; }
engineLayoutRef.current = lay;
const url = URL.createObjectURL(blob);
@@ -299,6 +349,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
span.setAttribute('data-size', String(effSize));
span.setAttribute('data-color', r.color);
span.setAttribute('data-fontname', r.fontName);
// Source advances (aligned to this span's exact chars). extractFlatRuns only re-uses them
// while the span text is unchanged (length match), so edits cleanly fall back to measuring.
if (r.advances && r.advances.length === r.text.length) span.setAttribute('data-advances', JSON.stringify(r.advances));
span.style.fontSize = `${effSize * zoom}px`;
span.style.color = 'transparent';
span.textContent = r.text;
+6
View File
@@ -31,6 +31,9 @@ export interface ReflowFragment {
internalFontId: string;
fontSize: number;
color: string;
// Original per-character advance (PDF units) of UNCHANGED source text → pixel-perfect spacing.
// Omitted for edited/new fragments so the engine re-measures them.
advances?: number[];
}
export interface ReflowParagraphPayload {
@@ -45,6 +48,9 @@ export interface ReflowParagraphPayload {
pushColumnLeft?: number; // full-width push-down left for bullet items (see engine)
// WYSIWYG: exact visual line breaks from the live editor (one inner array per line).
lines?: ReflowFragment[][];
// Original per-line baseline + left anchor (parallel to `lines`), for exact vertical/left reproduction.
lineBaselineY?: number[];
lineX?: number[];
}
// A bullet marker glyph at the start of a list item (•, -, ▪, etc.).
+7
View File
@@ -228,6 +228,9 @@ class ReflowRun(BaseModel):
internalFontId: str
fontSize: float
color: str = "#000000"
# Original per-character advance (PDF units) of UNCHANGED source text, for pixel-perfect
# reflow spacing. Omitted for edited/new runs (engine re-measures those).
advances: list[float] | None = None
class ReflowParagraphData(BaseModel):
@@ -241,6 +244,10 @@ class ReflowParagraphData(BaseModel):
align: Literal["left", "justify"] = "left"
pushColumnLeft: float | None = None
lines: list[list[ReflowRun]] | None = None
# Original per-line baseline + left anchor (parallel to `lines`), so unchanged lines reproduce
# the source's exact vertical spacing and left edge instead of a uniform fallback.
lineBaselineY: list[float] | None = None
lineX: list[float] | None = None
class ReflowParagraphOperation(BaseModel):
+115 -9
View File
@@ -33,22 +33,61 @@ def para_fonts(p):
return sorted({r.internal_font_id for l in p.lines for r in l.runs if r.text.strip()})
def build_reflow_op(p, page_index, new_runs=None):
def line_advances(line):
"""Per-character ORIGINAL advances for every run in a line, computed ACROSS the line so each
glyph's advance = the next glyph's origin minus this one (captures inter-run gaps exactly; a
per-run computation would overshoot by each run's left-side bearing and smear cumulatively).
Returns {run_index_in_line: [adv,...]} only for runs whose glyphs align 1:1 with their chars."""
seq = [] # (run_idx_in_line, char_idx, origin_x) in reading order
aligned = {}
for ri, r in enumerate(line.runs):
gs = list(r.glyphs)
ok = bool(r.text) and len(gs) == len(r.text)
aligned[ri] = ok
if ok:
for ci in range(len(r.text)):
seq.append((ri, ci, gs[ci].origin_x))
out = {ri: [0.0] * len(line.runs[ri].text) for ri in aligned if aligned[ri]}
anchor_x = seq[0][2] if seq else line.x # the line's FIRST glyph origin (true left anchor)
for k, (ri, ci, ox) in enumerate(seq):
if k + 1 < len(seq):
out[ri][ci] = seq[k + 1][2] - ox # delta to next glyph (incl. inter-run gap)
else:
out[ri][ci] = (line.x + line.w) - ox # last glyph of line: to the line's right edge
# Guard: drop any run that produced a non-positive advance (out-of-order glyphs) -> measure it.
for ri in list(out.keys()):
if any(a <= 0 for a in out[ri]):
del out[ri]
return out, anchor_x
def build_reflow_op(p, page_index, new_runs=None, use_lines=False):
"""Build a reflow_paragraph op for paragraph p. If new_runs is None, re-emit the
paragraph's own runs unchanged (so a correct engine is a no-op on text)."""
paragraph's own runs unchanged (so a correct engine is a no-op on text).
use_lines=True sends the paragraph's ORIGINAL visual line breaks (the no-change-on-click
path) so the engine emits them verbatim instead of greedy-wrapping."""
runs = []
obj_idx = []
op_lines = []
op_line_x = []
op_line_base = []
for l in p.lines:
for r in l.runs:
line_frags = []
advs, anchor_x = line_advances(l)
for ri, r in enumerate(l.runs):
obj_idx.extend(list(r.object_indices))
if r.text == "":
continue
runs.append({
"text": r.text,
"internalFontId": r.internal_font_id,
"fontSize": r.font_size,
"color": "#000000",
})
frag = {"text": r.text, "internalFontId": r.internal_font_id,
"fontSize": r.font_size, "color": "#000000"}
if ri in advs:
frag["advances"] = advs[ri]
runs.append(frag)
line_frags.append(frag)
if line_frags:
op_lines.append(line_frags)
op_line_x.append(anchor_x)
op_line_base.append(l.baseline_y)
if new_runs is not None:
runs = new_runs
baselines = [l.baseline_y for l in p.lines]
@@ -70,6 +109,8 @@ def build_reflow_op(p, page_index, new_runs=None):
"leading": leading,
"oldLineCount": len(p.lines),
"align": "left",
**({"lines": op_lines, "lineBaselineY": op_line_base, "lineX": op_line_x}
if use_lines and new_runs is None else {}),
},
}
@@ -209,6 +250,55 @@ def para_textall(model):
return " ".join(r.text for p in model.paragraphs for l in p.lines for r in l.runs)
def _png_to_img(pageimg):
from PIL import Image # noqa: PLC0415
data = pageimg.data
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
return Image.open(io.BytesIO(data)).convert("RGB")
n = pageimg.width * pageimg.height
mode = "RGBA" if len(data) == n * 4 else "RGB"
return Image.frombytes(mode, (pageimg.width, pageimg.height), data).convert("RGB")
def _diff_stats(base_img, edited_img):
"""Return (total_nonzero_px, bbox_of_diff) between two RGB images."""
from PIL import ImageChops # noqa: PLC0415
diff = ImageChops.difference(base_img, edited_img)
bbox = diff.getbbox() # None if identical
nz = 0
for px in diff.getdata():
if px != (0, 0, 0):
nz += 1
return nz, bbox
def overlay(page_index=0, anchor="Java Backend Developer", edit=None, dpi=150, use_lines=False):
"""Objective pixel gate. Render the page, then reflow the paragraph containing `anchor`
(UNCHANGED if edit is None, else `edit(runs)->runs`), render again, and report the pixel
diff. A no-op reflow should ideally diff ~0 everywhere (the drift baseline); a real edit
should diff ONLY inside the edited region."""
base = _png_to_img(pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "").get_page(page_index).render(dpi))
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
m = doc.get_page(page_index).extract_document_model()
i, p = find_para(m, anchor)
if p is None:
log(f"overlay: anchor {anchor!r} not found"); return
new_runs = None
if edit is not None:
base_runs = [{"text": r.text, "internalFontId": r.internal_font_id, "fontSize": r.font_size, "color": "#000000"}
for l in p.lines for r in l.runs if r.text]
new_runs = edit(base_runs)
op = build_reflow_op(p, page_index, new_runs=new_runs, use_lines=use_lines)
doc.apply_edits(json.dumps({"version": "1.0", "operations": [op]}))
edited = _png_to_img(doc.get_page(page_index).render(dpi))
nz, bbox = _diff_stats(base, edited)
total = base.width * base.height
log(f"overlay [{page_index}] anchor={anchor!r} edit={'no-op' if edit is None else 'changed'} "
f"path={'lines' if use_lines else 'greedy'} dpi={dpi}")
log(f" diff pixels = {nz} / {total} ({100.0*nz/total:.3f}%) bbox={bbox}")
return nz, bbox
def render_after_edits():
"""Apply A (Summary) then B (bullets) on one doc, save, reload, render page 0 to PNG —
GROUND TRUTH: is the committed PDF visually corrupt, or only text extraction?"""
@@ -270,5 +360,21 @@ if __name__ == "__main__":
render_after_edits()
elif mode == "stress":
stress()
elif mode == "overlay":
# Baseline drift: no-op reflow, GREEDY path (post-edit behavior).
log("== GREEDY path (re-wrap from scratch; the 'while typing' path) ==")
overlay(0, "Java Backend Developer")
overlay(0, "Core Java")
overlay(1, "Architected and")
# no-op reflow, LINES path (the no-change-on-click path).
log("\n== LINES path (original breaks emitted verbatim; the 'on open' path) ==")
overlay(0, "Java Backend Developer", use_lines=True)
overlay(0, "Core Java", use_lines=True)
overlay(1, "Architected and", use_lines=True)
# noise floor for reference
base = _png_to_img(pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "").get_page(0).render(150))
b2 = _png_to_img(pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "").get_page(0).render(150))
nz, _ = _diff_stats(base, b2)
log(f"\n(noise floor, same pdf twice = {nz} px)")
OUT.write_text(report.getvalue(), encoding="utf-8")
print(f"wrote {OUT}")