fix: updated text reflow bugs
This commit is contained in:
@@ -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 |
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user