fix: updated text reflow bugs

This commit is contained in:
Furqan-14
2026-06-18 10:03:10 +05:30
parent 63369d4259
commit bd6ba9205f
6 changed files with 291 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
editing bullet: paragraph 5, line 2, x=66.6
scoped item lines [2..4): • Architecture & APIs: Microservices, | Design Patterns
op columnLeft(text indent)=78.1 pushColumnLeft(marker)=66.6 objs=16
--- AFTER (edited bullet lines) ---
--- ADJACENT bullets (must be unchanged) ---
[OK ] 'Languages:': '• Languages: Core Java, Java 8+, OOPs, Collections, Streams, L'
[OK ] 'Frameworks': '• Frameworks & Libraries: Spring Boot, Spring MVC, Spring Data'
[OK ] 'Databases': '• Databases & Caching: MySQL (Indexing, Query Tuning, Transact'
[OK ] 'Messaging': '• Messaging & Streaming: Apache Kafka (Producer/Consumer, Part'
[OK ] 'Cloud': '• Cloud & DevOps: AWS (EC2, S3, Lambda, RDS, CloudWatch), Dock'
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Reproduce the bullet-item reflow corruption against the CURRENT build .pyd: edit one bullet
("Architecture & APIs") and check whether (a) the edited item keeps its hanging indent and (b)
ADJACENT bullets are left intact. Mimics the frontend buildBulletItem scoping.
Run: gateway/.venv/Scripts/python.exe tests/edits/_bullet_repro.py
"""
from __future__ import annotations
import io, json, sys
from pathlib import Path
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib") # fresh build
import pdfengine # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
OUT = Path(__file__).resolve().parent / "_bullet_repro.out.txt"
rep = io.StringIO()
def log(*a): rep.write(" ".join(str(x) for x in a) + "\n")
def is_marker(t):
t = (t or "").strip()
return t == "" or (len(t) <= 2 and not t.isalnum()) # bullet glyph / symbol, not a word
def line_text(l):
return "".join(r.text for r in l.runs)
def para_text(p):
return "".join(line_text(l) for l in p.lines)
def find_line(model, needle):
for pi, p in enumerate(model.paragraphs):
for li, l in enumerate(p.lines):
if needle in line_text(l):
return pi, p, li
return -1, None, -1
def build_bullet_op(p, click_line, page_index, edit_fn):
lines = p.lines
col_left = min(l.x for l in lines)
col_right = max(l.x + l.w for l in lines)
def is_start(idx):
rs = list(lines[idx].runs)
return idx == 0 or (bool(rs) and is_marker(rs[0].text))
start = click_line
while start > 0 and not is_start(start):
start -= 1
end = click_line + 1
while end < len(lines) and not is_start(end):
end += 1
item_lines = lines[start:end]
log(f" scoped item lines [{start}..{end}): " + " | ".join(line_text(l)[:38] for l in item_lines))
text_indent = item_lines[0].x
obj_idx, runs = [], []
for idx, l in enumerate(item_lines):
rs = list(l.runs)
if idx == 0 and rs and is_marker(rs[0].text):
ti = 1
while ti < len(rs) and not (rs[ti].text or "").strip():
ti += 1
if ti < len(rs):
text_indent = rs[ti].x
rs = rs[ti:]
for r in rs:
obj_idx.extend(list(r.object_indices))
if r.text:
runs.append({"text": r.text, "internalFontId": r.internal_font_id,
"fontSize": r.font_size, "color": "#000000"})
runs = edit_fn(runs)
baselines = [l.baseline_y for l in lines]
leading = abs(baselines[start] - baselines[start + 1]) if start + 1 < len(baselines) else 14.0
return {
"id": "rf", "type": "reflow_paragraph", "pageIndex": page_index,
"data": {
"objectIndices": sorted(set(obj_idx)),
"runs": runs,
"columnLeft": text_indent, "columnRight": col_right,
"pushColumnLeft": col_left,
"firstBaselineY": baselines[start], "leading": leading,
"oldLineCount": len(item_lines), "align": "left",
},
}
WATCH = ("Languages:", "Frameworks", "Databases", "Messaging", "Cloud")
def run():
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
m = doc.get_page(0).extract_document_model()
pi, p, li = find_line(m, "Architecture & APIs")
if p is None:
log("Architecture bullet not found"); return
log(f"editing bullet: paragraph {pi}, line {li}, x={p.lines[li].x:.1f}")
before = {}
for n in WATCH:
_, pp, ll = find_line(m, n)
before[n] = line_text(pp.lines[ll]) if pp else "<none>"
def edit(runs):
out = []
for r in runs:
out.append(r)
if "Architecture," in r["text"]:
out.append({"text": " in the main app", "internalFontId": r["internalFontId"],
"fontSize": r["fontSize"], "color": "#000000"})
return out
op = build_bullet_op(p, li, 0, edit)
log(f" op columnLeft(text indent)={op['data']['columnLeft']:.1f} pushColumnLeft(marker)={op['data']['pushColumnLeft']:.1f} objs={len(op['data']['objectIndices'])}")
doc.apply_edits(json.dumps({"version": "1.0", "operations": [op]}))
m2 = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(0).extract_document_model()
log("\n--- AFTER (edited bullet lines) ---")
_, pa, la = find_line(m2, "in the main app")
if pa is None:
_, pa, la = find_line(m2, "API Gateway")
if pa:
for l in pa.lines:
t = line_text(l)
if any(k in t for k in ("Architecture", "Design", "app", "SOLID")):
fonts = sorted({r.internal_font_id for r in l.runs if r.text.strip()})
log(f" x={l.x:.1f} fonts={fonts} | {t[:64]!r}")
log("\n--- ADJACENT bullets (must be unchanged) ---")
for n in WATCH:
_, pp, ll = find_line(m2, n)
after = line_text(pp.lines[ll]) if pp else "<MISSING>"
same = "OK " if after == before[n] else "CHANGED"
log(f" [{same}] {n!r}: {after[:62]!r}")
if __name__ == "__main__":
run()
OUT.write_text(rep.getvalue(), encoding="utf-8")
print(rep.getvalue())
+17
View File
@@ -0,0 +1,17 @@
== GREEDY path (re-wrap from scratch; the 'while typing' path) ==
overlay [0] anchor='Java Backend Developer' edit=no-op path=greedy dpi=150
diff pixels = 35821 / 2103750 (1.703%) bbox=(112, 263, 1150, 1435)
overlay [0] anchor='Core Java' edit=no-op path=greedy dpi=150
diff pixels = 311942 / 2103750 (14.828%) bbox=(109, 291, 1166, 1489)
overlay [1] anchor='Architected and' edit=no-op path=greedy dpi=150
diff pixels = 315253 / 2103750 (14.985%) bbox=(109, 117, 1166, 1299)
== LINES path (original breaks emitted verbatim; the 'on open' path) ==
overlay [0] anchor='Java Backend Developer' edit=no-op path=lines dpi=150
diff pixels = 35441 / 2103750 (1.685%) bbox=(112, 263, 1150, 1435)
overlay [0] anchor='Core Java' edit=no-op path=lines dpi=150
diff pixels = 46851 / 2103750 (2.227%) bbox=(138, 291, 1129, 1435)
overlay [1] anchor='Architected and' edit=no-op path=lines dpi=150
diff pixels = 60236 / 2103750 (2.863%) bbox=(112, 117, 1156, 1004)
(noise floor, same pdf twice = 0 px)
+24
View File
@@ -0,0 +1,24 @@
########## REFLOW stress (multi-edit survival) ##########
=== STRESS page 0: sequentially reflowing 4 targeted paragraphs ===
page 0: 10/10 substrings survived: missing=[]
=== STRESS page 1: sequentially reflowing 4 targeted paragraphs ===
page 1: 9/9 substrings survived: missing=[]
==> STRESS PASS
########## REFLOW no-op overlay (regression: left + justify paragraphs must stay ~baseline) ##########
overlay [0] anchor='Java Backend Developer' edit=no-op path=greedy dpi=150
diff pixels = 35821 / 2103750 (1.703%) bbox=(112, 263, 1150, 1435)
overlay [0] anchor='Java Backend Developer' edit=no-op path=lines dpi=150
diff pixels = 35441 / 2103750 (1.685%) bbox=(112, 263, 1150, 1435)
########## CENTER align smoke ##########
'Software Engineer' heading not found (skip center smoke)
########## RAW TEXT StreamEditor smoke (P1a) ##########
extract_text_objects(0) -> 727 objects
replace obj#2 'FAISHAL' -> 'FAISHAX' : success=True
re-extract obj#2 = 'FAISHAX' (expected 'FAISHAX', match=True)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Verify the FRESH build-dir .pyd (the gateway copy is locked by the running gateway) without
touching the gateway. Importing pdfengine from the build lib FIRST caches it in sys.modules, so when
_reflow_repro imports pdfengine it reuses this fresh one. Runs the reflow regression gate + a Raw Text
(StreamEditor) smoke for the new TJ-positioning path.
Run: gateway/.venv/Scripts/python.exe tests/edits/_verify_build.py
"""
from __future__ import annotations
import sys
from pathlib import Path
BUILD_LIB = r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib"
sys.path.insert(0, BUILD_LIB)
import pdfengine # noqa: E402 -- FRESH build, cached in sys.modules before the harness imports it
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _reflow_repro as H # noqa: E402 -- reuses the cached fresh pdfengine
print("pdfengine loaded from:", pdfengine.__file__)
assert BUILD_LIB.lower() in pdfengine.__file__.lower(), "NOT the fresh build .pyd!"
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
def section(t):
H.report.write(f"\n########## {t} ##########\n")
# ---- 1. Reflow regression: multi-edit survival + no-op overlay diff (left/justify) ----
section("REFLOW stress (multi-edit survival)")
H.stress()
section("REFLOW no-op overlay (regression: left + justify paragraphs must stay ~baseline)")
H.overlay(0, "Java Backend Developer") # flowing left/justify body
H.overlay(0, "Java Backend Developer", use_lines=True)
# ---- 2. Center alignment: reflow a centered heading with align=center; assert no crash + survives ----
section("CENTER align smoke")
try:
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
m = doc.get_page(0).extract_document_model()
i, p = H.find_para(m, "Software Engineer")
if p is None:
H.log(" 'Software Engineer' heading not found (skip center smoke)")
else:
op = H.build_reflow_op(p, 0)
op["data"]["align"] = "center"
# widen the column to the page content box so centering is visible
op["data"]["columnLeft"] = 40.0
op["data"]["columnRight"] = m.width - 40.0
doc.apply_edits(__import__("json").dumps({"version": "1.0", "operations": [op]}))
m2 = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(0).extract_document_model()
_, pc = H.find_para(m2, "Software")
txt = H.para_text(pc) if pc else "<NOT FOUND>"
H.log(f" center reflow OK; text survived = {('Software' in txt and 'Engineer' in txt)} -> {txt[:60]!r}")
except Exception as exc:
H.log(f" CENTER smoke FAILED: {exc}")
# ---- 3. Raw Text StreamEditor: extract + same-length replace (P1a TJ preservation) ----
section("RAW TEXT StreamEditor smoke (P1a)")
try:
import tempfile, os
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf"); tmp.write(PDF.read_bytes()); tmp.close()
ed = pdfengine.StreamEditor(tmp.name)
objs = ed.extract_text_objects(0)
H.log(f" extract_text_objects(0) -> {len(objs)} objects")
# pick the first object with >=3 ASCII chars; do a SAME-LENGTH edit (exercises TJ redistribution)
target = None
for idx, o in enumerate(objs):
t = o["text"].decode("latin-1") if isinstance(o["text"], bytes) else o["text"]
if len(t) >= 3 and t.strip() and all(32 <= ord(c) < 127 for c in t):
target = (idx, t); break
if target is None:
H.log(" no suitable ASCII object found (skip)")
else:
idx, t = target
# same-length swap: reverse-safe -> replace each alnum char's case-insensitive 'a'->'e' won't change len
new = t[:-1] + ("X" if t[-1] != "X" else "Y") # same length, last char changed
out = tmp.name + ".out.pdf"
ok = ed.replace_text_object(0, idx, new.encode("latin-1"), out)
H.log(f" replace obj#{idx} {t!r} -> {new!r} : success={ok}")
if ok and os.path.exists(out):
ed2 = pdfengine.StreamEditor(out)
objs2 = ed2.extract_text_objects(0)
t2 = objs2[idx]["text"]; t2 = t2.decode("latin-1") if isinstance(t2, bytes) else t2
H.log(f" re-extract obj#{idx} = {t2!r} (expected {new!r}, match={t2 == new})")
os.remove(out)
os.remove(tmp.name)
except Exception as exc:
H.log(f" RAW TEXT smoke FAILED: {exc}")
OUT = Path(__file__).resolve().parent / "_verify_build.out.txt"
OUT.write_text(H.report.getvalue(), encoding="utf-8")
print(f"wrote {OUT}")