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;
|
||||
|
||||
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user