fix: additional font handling, with operators font issues
This commit is contained in:
Binary file not shown.
@@ -27,8 +27,30 @@ FontFallback::FontFallback() {
|
||||
void FontFallback::initializeDefaults() {
|
||||
default_rules_.clear();
|
||||
|
||||
#ifdef PDFENGINE_FONT_DIR
|
||||
{
|
||||
const std::string fd = PDFENGINE_FONT_DIR;
|
||||
auto sans = [&](const char* s) { return std::vector<std::string>{ fd + "/Carlito-" + s + ".ttf" }; };
|
||||
auto serif = [&](const char* s) { return std::vector<std::string>{ fd + "/Tinos-" + s + ".ttf" }; };
|
||||
for (const char* fam : {"calibri", "carlito"}) {
|
||||
const std::string f = fam;
|
||||
default_rules_.push_back({f + "-bolditalic", sans("BoldItalic")});
|
||||
default_rules_.push_back({f + "-bold", sans("Bold")});
|
||||
default_rules_.push_back({f + "-italic", sans("Italic")});
|
||||
default_rules_.push_back({f + "-oblique", sans("Italic")});
|
||||
default_rules_.push_back({f, sans("Regular")});
|
||||
}
|
||||
for (const char* fam : {"times", "tinos", "serif"}) {
|
||||
const std::string f = fam;
|
||||
default_rules_.push_back({f + "-bolditalic", serif("BoldItalic")});
|
||||
default_rules_.push_back({f + "-bold", serif("Bold")});
|
||||
default_rules_.push_back({f + "-italic", serif("Italic")});
|
||||
default_rules_.push_back({f, serif("Regular")});
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(_WIN32)
|
||||
// Helvetica / Arial fallback rules
|
||||
default_rules_.push_back({"helvetica-bolditalic", {"C:\\Windows\\Fonts\\LiberationSans-BoldItalic.ttf", "C:\\Windows\\Fonts\\arialbi.ttf"}});
|
||||
default_rules_.push_back({"helvetica-bold", {"C:\\Windows\\Fonts\\LiberationSans-Bold.ttf", "C:\\Windows\\Fonts\\arialbd.ttf"}});
|
||||
default_rules_.push_back({"helvetica-oblique", {"C:\\Windows\\Fonts\\LiberationSans-Italic.ttf", "C:\\Windows\\Fonts\\ariali.ttf"}});
|
||||
@@ -38,13 +60,11 @@ void FontFallback::initializeDefaults() {
|
||||
default_rules_.push_back({"arial-italic", {"C:\\Windows\\Fonts\\LiberationSans-Italic.ttf", "C:\\Windows\\Fonts\\ariali.ttf"}});
|
||||
default_rules_.push_back({"arial", {"C:\\Windows\\Fonts\\LiberationSans-Regular.ttf", "C:\\Windows\\Fonts\\arial.ttf"}});
|
||||
|
||||
// Times fallback rules
|
||||
default_rules_.push_back({"times-bolditalic", {"C:\\Windows\\Fonts\\LiberationSerif-BoldItalic.ttf", "C:\\Windows\\Fonts\\timesbi.ttf"}});
|
||||
default_rules_.push_back({"times-bold", {"C:\\Windows\\Fonts\\LiberationSerif-Bold.ttf", "C:\\Windows\\Fonts\\timesbd.ttf"}});
|
||||
default_rules_.push_back({"times-italic", {"C:\\Windows\\Fonts\\LiberationSerif-Italic.ttf", "C:\\Windows\\Fonts\\timesi.ttf"}});
|
||||
default_rules_.push_back({"times", {"C:\\Windows\\Fonts\\LiberationSerif-Regular.ttf", "C:\\Windows\\Fonts\\times.ttf"}});
|
||||
|
||||
// Courier fallback rules
|
||||
default_rules_.push_back({"courier-bolditalic", {"C:\\Windows\\Fonts\\LiberationMono-BoldItalic.ttf", "C:\\Windows\\Fonts\\courbi.ttf"}});
|
||||
default_rules_.push_back({"courier-bold", {"C:\\Windows\\Fonts\\LiberationMono-Bold.ttf", "C:\\Windows\\Fonts\\courbd.ttf"}});
|
||||
default_rules_.push_back({"courier-oblique", {"C:\\Windows\\Fonts\\LiberationMono-Italic.ttf", "C:\\Windows\\Fonts\\couri.ttf"}});
|
||||
@@ -63,7 +83,6 @@ void FontFallback::initializeDefaults() {
|
||||
default_rules_.push_back({"japanese", {"C:\\Windows\\Fonts\\msgothic.ttc"}});
|
||||
default_rules_.push_back({"korean", {"C:\\Windows\\Fonts\\malgun.ttf"}});
|
||||
#elif defined(__APPLE__)
|
||||
// Helvetica / Arial fallback rules
|
||||
default_rules_.push_back({"helvetica-bolditalic", {"/Library/Fonts/LiberationSans-BoldItalic.ttf", "/System/Library/Fonts/Supplemental/Arial Bold Italic.ttf", "/Library/Fonts/Arial Bold Italic.ttf"}});
|
||||
default_rules_.push_back({"helvetica-bold", {"/Library/Fonts/LiberationSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf"}});
|
||||
default_rules_.push_back({"helvetica-oblique", {"/Library/Fonts/LiberationSans-Italic.ttf", "/System/Library/Fonts/Supplemental/Arial Italic.ttf", "/Library/Fonts/Arial Italic.ttf"}});
|
||||
@@ -73,19 +92,16 @@ void FontFallback::initializeDefaults() {
|
||||
default_rules_.push_back({"arial-italic", {"/Library/Fonts/LiberationSans-Italic.ttf", "/System/Library/Fonts/Supplemental/Arial Italic.ttf", "/Library/Fonts/Arial Italic.ttf"}});
|
||||
default_rules_.push_back({"arial", {"/Library/Fonts/LiberationSans-Regular.ttf", "/Library/Fonts/Arial.ttf"}});
|
||||
|
||||
// Times fallback rules
|
||||
default_rules_.push_back({"times-bolditalic", {"/Library/Fonts/LiberationSerif-BoldItalic.ttf", "/System/Library/Fonts/Supplemental/Times New Roman Bold Italic.ttf", "/Library/Fonts/Times New Roman Bold Italic.ttf"}});
|
||||
default_rules_.push_back({"times-bold", {"/Library/Fonts/LiberationSerif-Bold.ttf", "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf", "/Library/Fonts/Times New Roman Bold.ttf"}});
|
||||
default_rules_.push_back({"times-italic", {"/Library/Fonts/LiberationSerif-Italic.ttf", "/System/Library/Fonts/Supplemental/Times New Roman Italic.ttf", "/Library/Fonts/Times New Roman Italic.ttf"}});
|
||||
default_rules_.push_back({"times", {"/Library/Fonts/LiberationSerif-Regular.ttf", "/Library/Fonts/Times New Roman.ttf", "/System/Library/Fonts/Times.ttc"}});
|
||||
|
||||
// Courier fallback rules
|
||||
default_rules_.push_back({"courier-bolditalic", {"/Library/Fonts/LiberationMono-BoldItalic.ttf", "/System/Library/Fonts/Supplemental/Courier New Bold Italic.ttf", "/Library/Fonts/Courier New Bold Italic.ttf"}});
|
||||
default_rules_.push_back({"courier-bold", {"/Library/Fonts/LiberationMono-Bold.ttf", "/System/Library/Fonts/Supplemental/Courier New Bold.ttf", "/Library/Fonts/Courier New Bold.ttf"}});
|
||||
default_rules_.push_back({"courier-oblique", {"/Library/Fonts/LiberationMono-Italic.ttf", "/System/Library/Fonts/Supplemental/Courier New Italic.ttf", "/Library/Fonts/Courier New Italic.ttf"}});
|
||||
default_rules_.push_back({"courier", {"/Library/Fonts/LiberationMono-Regular.ttf", "/Library/Fonts/Courier New.ttf", "/System/Library/Fonts/Courier.dfont"}});
|
||||
#else
|
||||
// Helvetica / Arial fallback rules
|
||||
default_rules_.push_back({"helvetica-bolditalic", {"/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf"}});
|
||||
default_rules_.push_back({"helvetica-bold", {"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"}});
|
||||
default_rules_.push_back({"helvetica-oblique", {"/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf"}});
|
||||
@@ -95,13 +111,11 @@ void FontFallback::initializeDefaults() {
|
||||
default_rules_.push_back({"arial-italic", {"/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf"}});
|
||||
default_rules_.push_back({"arial", {"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"}});
|
||||
|
||||
// Times fallback rules
|
||||
default_rules_.push_back({"times-bolditalic", {"/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-BoldItalic.ttf"}});
|
||||
default_rules_.push_back({"times-bold", {"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"}});
|
||||
default_rules_.push_back({"times-italic", {"/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf"}});
|
||||
default_rules_.push_back({"times", {"/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"}});
|
||||
|
||||
// Courier fallback rules
|
||||
default_rules_.push_back({"courier-bolditalic", {"/usr/share/fonts/truetype/liberation/LiberationMono-BoldItalic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-BoldOblique.ttf"}});
|
||||
default_rules_.push_back({"courier-bold", {"/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"}});
|
||||
default_rules_.push_back({"courier-oblique", {"/usr/share/fonts/truetype/liberation/LiberationMono-Italic.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf"}});
|
||||
@@ -143,6 +157,26 @@ std::string FontFallback::getFallbackFontPath(const std::string& fontName, bool
|
||||
}
|
||||
}
|
||||
|
||||
// Generic bundled catch-all: any font that matched NO specific rule above (an exotic/embedded
|
||||
// family we don't recognise) still gets a real, present-on-both-engines fallback instead of OS
|
||||
// Arial (native only) or nothing (WASM -> garble). Pick serif (Tinos) vs sans (Carlito) from the
|
||||
// name; default sans. Recognised families (helvetica/arial/times/calibri/courier) already
|
||||
// returned above, so this never overrides them. Keeps WASM == native for every font.
|
||||
#ifdef PDFENGINE_FONT_DIR
|
||||
{
|
||||
auto has = [&](const char* s) { return lowerName.find(s) != std::string::npos; };
|
||||
const bool serif = has("times") || has("serif") || has("roman") || has("georgia") ||
|
||||
has("garamond") || has("minion") || has("cambria") || has("tinos") ||
|
||||
has("book antiqua") || has("palatino");
|
||||
const std::string base = serif ? "Tinos" : "Carlito";
|
||||
const std::string style = (bold && italic) ? "BoldItalic" : bold ? "Bold" : italic ? "Italic" : "Regular";
|
||||
const std::string p = std::string(PDFENGINE_FONT_DIR) + "/" + base + "-" + style + ".ttf";
|
||||
if (std::filesystem::exists(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(_WIN32)
|
||||
std::vector<std::string> lastResort;
|
||||
if (bold && italic) lastResort = {"C:\\Windows\\Fonts\\arialbi.ttf", "C:\\Windows\\Fonts\\timesbi.ttf"};
|
||||
@@ -190,4 +224,4 @@ void FontFallback::resetToDefaults() {
|
||||
custom_rules_.clear();
|
||||
}
|
||||
|
||||
} // namespace pdfengine::fonts::pdf_fonts
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -16,7 +16,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
const V = '20260618c';
|
||||
const V = '20260618f-allfonts';
|
||||
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' }));
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 495 KiB |
@@ -1,143 +0,0 @@
|
||||
#!/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,72 @@
|
||||
"""Phase 1 check: editing a styled run with characters NOT in its embedded subset now falls back to
|
||||
the bundled Carlito (metric-compatible with Calibri) instead of OS Arial/Helvetica, and the new
|
||||
glyphs survive.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_font_fallback_check.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
for pageno in range(doc.page_count):
|
||||
m = doc.get_page(pageno).extract_document_model()
|
||||
para = next((p for p in m.paragraphs if "Present" in ptext(p)), None)
|
||||
if para:
|
||||
break
|
||||
if not para:
|
||||
print("FAIL: couldn't find the 'Present' italic paragraph"); return
|
||||
fonts_before = sorted({r.internal_font_id for ln in para.lines for r in ln.runs if r.text.strip()})
|
||||
print("para text:", ptext(para)[:70])
|
||||
print("fonts before:", fonts_before)
|
||||
|
||||
oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines)
|
||||
fb = max(ln.baseline_y for ln in para.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True)
|
||||
lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 13.0
|
||||
fid = para.lines[0].runs[0].internal_font_id
|
||||
new_text = ptext(para).strip() + " Zephyr xkcd | the quux"
|
||||
|
||||
op = {"version": "1.0", "operations": [{"id": "f", "type": "reflow_paragraph", "pageIndex": pageno, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#1a5276"}],
|
||||
"columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, "leading": lead,
|
||||
"oldLineCount": len(para.lines), "align": "left", "paraId": "FONTCHK"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
|
||||
edited = None
|
||||
for pageno2 in range(r.page_count):
|
||||
mm = r.get_page(pageno2).extract_document_model()
|
||||
for p in mm.paragraphs:
|
||||
if "Zephyr" in ptext(p):
|
||||
edited = p; break
|
||||
if edited:
|
||||
break
|
||||
if not edited:
|
||||
print("FAIL: edited paragraph not found after save"); return
|
||||
names = sorted({(r.font_name or "") for ln in edited.lines for r in ln.runs if r.text.strip()})
|
||||
txt = ptext(edited)
|
||||
print("\nedited fonts:", names)
|
||||
print("glyphs survived: Zephyr=%s '|'=%s the=%s xkcd=%s" %
|
||||
("Zephyr" in txt, "|" in txt, "the" in txt.replace(" ", ""), "xkcd" in txt.replace(" ", "")))
|
||||
carlito = any("carlito" in n.lower() for n in names)
|
||||
arial = any(("arial" in n.lower() or "helvetica" in n.lower() or "liberation" in n.lower()) for n in names)
|
||||
print(f"\n=> fallback uses Carlito: {carlito} (uses Arial/Helvetica: {arial})")
|
||||
print("PHASE1 OK" if carlito and not arial else "PHASE1: check fonts above")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Does cross-page MIGRATION preserve hyphens/dashes? Grow the page-0 Technologies paragraph so it
|
||||
pushes the 'Architected ... high-scale e-commerce' paragraph onto page 2 (migration), then check the
|
||||
migrated text still has its hyphens.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_hyphen_check.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def find(doc, page, needle):
|
||||
for p in doc.get_page(page).extract_document_model().paragraphs:
|
||||
if needle in ptext(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def alltext(doc):
|
||||
out = []
|
||||
for i in range(doc.page_count):
|
||||
for p in doc.get_page(i).extract_document_model().paragraphs:
|
||||
out.append(ptext(p))
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
before = alltext(doc)
|
||||
print("BEFORE migration:")
|
||||
print(" 'high' + U+002D + 'scale' :", ("high-scale" in before))
|
||||
print(" 'high' + U+2011 + 'scale' :", ("high‑scale" in before))
|
||||
print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in before))
|
||||
print(" 'e' + U+2011 + 'commerce' :", ("e‑commerce" in before))
|
||||
|
||||
tech = find(doc, 0, "Technologies")
|
||||
oi = [i for ln in tech.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in tech.lines); cr = max(ln.x + ln.w for ln in tech.lines)
|
||||
fb = max(ln.baseline_y for ln in tech.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in tech.lines}, reverse=True)
|
||||
lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0
|
||||
big = ptext(tech).strip() + " " + ("filler " * 60)
|
||||
op = {"version": "1.0", "operations": [{"id": "g", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": big, "internalFontId": tech.lines[0].runs[0].internal_font_id,
|
||||
"fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb,
|
||||
"leading": lead, "oldLineCount": len(tech.lines), "align": "left", "paraId": "TECHPARA"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
after = alltext(r)
|
||||
print(f"\nAFTER migration (pages={r.page_count}):")
|
||||
print(" 'high' + U+002D + 'scale' :", ("high-scale" in after))
|
||||
print(" 'high' + U+2011 + 'scale' :", ("high‑scale" in after))
|
||||
print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in after))
|
||||
print(" 'e' + U+2011 + 'commerce' :", ("e‑commerce" in after))
|
||||
for i in range(r.page_count):
|
||||
a = find(r, i, "Architected")
|
||||
if a:
|
||||
safe = ptext(a)[:120].encode("ascii", "replace").decode("ascii")
|
||||
print(f"\n Architected (page {i}): {safe!r}")
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Diagnose: when the ITALIC run's fid is used, does the engine emit an ITALIC fallback (Carlito-
|
||||
Italic)? Rules out an engine style-derivation bug vs. a frontend mis-tagging issue.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_italic_diag.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
italic_fid = None; para = None; pageno = 0
|
||||
for pg in range(doc.page_count):
|
||||
m = doc.get_page(pg).extract_document_model()
|
||||
for p in m.paragraphs:
|
||||
for ln in p.lines:
|
||||
for r in ln.runs:
|
||||
if "italic" in (r.internal_font_id or "").lower() and r.text.strip():
|
||||
italic_fid = r.internal_font_id; para = p; pageno = pg; break
|
||||
if italic_fid: break
|
||||
if italic_fid: break
|
||||
if italic_fid: break
|
||||
if not italic_fid:
|
||||
print("no italic run found"); return
|
||||
print("italic fid:", italic_fid)
|
||||
print("para:", ptext(para)[:80])
|
||||
|
||||
oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines)
|
||||
fb = max(ln.baseline_y for ln in para.lines)
|
||||
op = {"version": "1.0", "operations": [{"id": "i", "type": "reflow_paragraph", "pageIndex": pageno, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": "Mar 2023 Present Zephyr xyzk | the quux",
|
||||
"internalFontId": italic_fid, "fontSize": 11.0, "color": "#1a5276"}],
|
||||
"columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, "leading": 13.0,
|
||||
"oldLineCount": len(para.lines), "align": "left", "paraId": "ITALCHK"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
names = set()
|
||||
for pg in range(r.page_count):
|
||||
for p in r.get_page(pg).extract_document_model().paragraphs:
|
||||
if "Zephyr" in ptext(p):
|
||||
for ln in p.lines:
|
||||
for rr in ln.runs:
|
||||
if rr.text.strip(): names.add(rr.font_name or "")
|
||||
print("emitted fonts:", sorted(names))
|
||||
print("=> italic fallback used:", any("italic" in n.lower() for n in names))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Diagnose early-overflow: edit the real Technologies paragraph with a long single-run text and
|
||||
report where each resulting line lands (page + baseline) vs the page-bottom limit.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_overflow_diag.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
m = doc.get_page(0).extract_document_model()
|
||||
tech = next(p for p in m.paragraphs if "Technologies" in ptext(p))
|
||||
oi = [i for ln in tech.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in tech.lines); cr = max(ln.x + ln.w for ln in tech.lines)
|
||||
fb = max(ln.baseline_y for ln in tech.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in tech.lines}, reverse=True)
|
||||
lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0
|
||||
page_h = doc.get_page(0).height
|
||||
tops = []
|
||||
for p in m.paragraphs:
|
||||
for ln in p.lines:
|
||||
if (ln.x + ln.w) > cl and ln.x < cr:
|
||||
tops.append(ln.y + ln.h)
|
||||
max_top = max(tops) if tops else 0
|
||||
mirror = page_h - max_top
|
||||
bottom_limit = max(18.0, min(mirror, page_h * 0.25))
|
||||
print(f"page_h={page_h:.0f} tech firstBaselineY={fb:.1f} leading={lead:.1f} cols=[{cl:.0f},{cr:.0f}]")
|
||||
print(f"max_top(in-col)={max_top:.1f} mirror={mirror:.1f} bottomLimitY(approx)={bottom_limit:.1f}")
|
||||
room_lines = int((fb - bottom_limit) / lead)
|
||||
print(f"=> room for ~{room_lines} lines on page 0 before hitting the bottom limit")
|
||||
|
||||
new_text = ptext(tech).strip() + " " + " ".join(f"w{n}" for n in range(40))
|
||||
op = {"version": "1.0", "operations": [{"id": "g", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": new_text, "internalFontId": tech.lines[0].runs[0].internal_font_id,
|
||||
"fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb,
|
||||
"leading": lead, "oldLineCount": len(tech.lines), "align": "left", "paraId": "DIAG"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
print(f"\nAFTER edit: pages={r.page_count}")
|
||||
for i in range(r.page_count):
|
||||
mm = r.get_page(i).extract_document_model()
|
||||
diag_lines = [ln for p in mm.paragraphs for ln in p.lines
|
||||
if any(getattr(rr, "para_id", "") == "DIAG" for rr in ln.runs)]
|
||||
ys = sorted((ln.baseline_y for ln in diag_lines), reverse=True)
|
||||
print(f" page {i}: {len(diag_lines)} DIAG line(s) baselineY range "
|
||||
f"{ys[0]:.0f}..{ys[-1]:.0f}" if ys else f" page {i}: 0 DIAG lines")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Stage 1 verification: PDFPARA object marks survive (a) save/reload and (b) cross-page migration.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_paraid_probe.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
|
||||
|
||||
def build_pdf() -> bytes:
|
||||
content = (
|
||||
b"BT /F1 14 Tf 72 720 Td (HEADING) Tj ET\n"
|
||||
b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n"
|
||||
b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n"
|
||||
b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n"
|
||||
)
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
pdf = b"%PDF-1.7\n"; offs = []
|
||||
for i, o in enumerate(objs, 1):
|
||||
offs.append(len(pdf)); pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n"
|
||||
xref = len(pdf); pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
||||
for o in offs: pdf += b"%010d 00000 n \n" % o
|
||||
pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
|
||||
return pdf
|
||||
|
||||
|
||||
def body_para(doc, page=0):
|
||||
m = doc.get_page(page).extract_document_model()
|
||||
return next(p for p in m.paragraphs
|
||||
if "quick brown fox" in " ".join("".join(r.text for r in ln.runs) for ln in p.lines))
|
||||
|
||||
|
||||
def reflow(doc, page, para, text, para_id):
|
||||
oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines)
|
||||
fb = max(ln.baseline_y for ln in para.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True)
|
||||
lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0
|
||||
op = {"version": "1.0", "operations": [{"id": "p", "type": "reflow_paragraph", "pageIndex": page, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": text, "internalFontId": para.lines[0].runs[0].internal_font_id,
|
||||
"fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb,
|
||||
"leading": lead, "oldLineCount": len(para.lines), "align": "left", "paraId": para_id}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
|
||||
|
||||
def all_para_ids(doc):
|
||||
ids = {}
|
||||
for i in range(doc.page_count):
|
||||
m = doc.get_page(i).extract_document_model()
|
||||
for p in m.paragraphs:
|
||||
for ln in p.lines:
|
||||
for r in ln.runs:
|
||||
if getattr(r, "para_id", ""):
|
||||
ids.setdefault(r.para_id, []).append(i)
|
||||
return ids
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
doc = pdfengine.PdfDocument.load_from_memory(build_pdf(), "")
|
||||
doc = reflow(doc, 0, body_para(doc), "Short edited text here.", "PARA_TEST_42")
|
||||
ids = all_para_ids(doc)
|
||||
if "PARA_TEST_42" in ids:
|
||||
print(f" ok mark survived save/reload: PARA_TEST_42 on pages {sorted(set(ids['PARA_TEST_42']))}")
|
||||
else:
|
||||
print(f" FAIL no paraId after save/reload. ids={ids}"); ok = False
|
||||
|
||||
doc2 = pdfengine.PdfDocument.load_from_memory(build_pdf(), "")
|
||||
big = "Edited overflow text " + " ".join(f"m{n:03d}" for n in range(80))
|
||||
doc2 = reflow(doc2, 0, body_para(doc2), big, "SPAN_ID_7")
|
||||
ids2 = all_para_ids(doc2)
|
||||
pages = sorted(set(ids2.get("SPAN_ID_7", [])))
|
||||
if len(pages) >= 2:
|
||||
print(f" ok mark survived cross-page migration: SPAN_ID_7 on pages {pages}")
|
||||
else:
|
||||
print(f" FAIL paraId not on both pages after overflow. pages={pages} all={ids2}"); ok = False
|
||||
|
||||
print("\nPASS" if ok else "\nFAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,41 +0,0 @@
|
||||
PDF: Mo-Faishal-Qureshi.pdf pages=2
|
||||
|
||||
=== PAGE 0 (612x792) paragraphs=10 ===
|
||||
[0.0] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='MO FAISHAL QURESHI'
|
||||
[0.1] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Software Engineer | 3.2 Years Experience'
|
||||
[0.2] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Professional Summary'
|
||||
[0.3] lines=5 fonts=['Calibri_TrueType_32']
|
||||
text='Java Backend Developer with 3.2 years of experience in designing, developing, and deployin'
|
||||
[0.4] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Technical Skills'
|
||||
[0.5] lines=11 fonts=['BCDGEE+TimesNewRomanPSMT', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32']
|
||||
text='• Languages: Core Java, Java 8+, OOPs, Collections, Streams, Lambda • '
|
||||
[0.6] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Professional Experience'
|
||||
[0.7] lines=19 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDJEE+Calibri-Italic', 'BCDKEE+Calibri', 'Calibri-Bold_TrueType_32', 'Calibri-Italic_TrueType_96', 'Calibri_TrueType_32']
|
||||
text='Software Engineer | TapQwik Software Pvt. Ltd. Mar 2023 – Present | '
|
||||
[0.8] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Projects'
|
||||
[0.9] lines=3 fonts=['BCDLEE+Calibri-Bold', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32']
|
||||
text='Project: LG – E-Commerce Platform Technologies: Java 8/17, Spring Boot, '
|
||||
|
||||
=== PAGE 1 (612x792) paragraphs=8 ===
|
||||
[1.0] lines=13 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32']
|
||||
text='Architected and developed a high-scale e-commerce backend for LG Electro'
|
||||
[1.1] lines=16 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'BCDLEE+Calibri-Bold', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32']
|
||||
text='Project: Wego – Hotel Booking Platform Technologies: Java 8/17, Spring B'
|
||||
[1.2] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Education'
|
||||
[1.3] lines=2 fonts=['Calibri-Bold_TrueType_32', 'Calibri-Italic_TrueType_96', 'Calibri_TrueType_32']
|
||||
text='Bachelor of Computer Applications 2023 HKBK Degree College | Bangalore, '
|
||||
[1.4] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Courses & Certifications'
|
||||
[1.5] lines=3 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'Calibri_TrueType_32']
|
||||
text='• Java Development Certification Course • Apache Kafka for Java Develope'
|
||||
[1.6] lines=1 fonts=['Calibri-Bold_TrueType_32']
|
||||
text='Soft Skills'
|
||||
[1.7] lines=2 fonts=['BCDKEE+Calibri', 'Calibri_TrueType_32']
|
||||
text='Problem-Solving • Team Collaboration • Adaptability • Ownership & Accoun'
|
||||
@@ -1,380 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scratch harness: reproduce the multi-edit font-collision scramble, and (after the
|
||||
fix) verify it's gone. Reads engine output through pybind (NOT HTTP/stdin) to avoid the
|
||||
Windows cp1252 mojibake trap. Writes a UTF-8 report to _reflow_repro.out.txt.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_reflow_repro.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine # noqa: E402
|
||||
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
OUT = Path(__file__).resolve().parent / "_reflow_repro.out.txt"
|
||||
report = io.StringIO()
|
||||
|
||||
|
||||
def log(*a):
|
||||
report.write(" ".join(str(x) for x in a) + "\n")
|
||||
|
||||
|
||||
def para_text(p):
|
||||
return " ".join(r.text for l in p.lines for r in l.runs)
|
||||
|
||||
|
||||
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 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).
|
||||
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:
|
||||
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
|
||||
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]
|
||||
first_baseline = baselines[0] if baselines else (p.y + p.h)
|
||||
if len(baselines) >= 2:
|
||||
leading = abs(baselines[0] - baselines[1])
|
||||
else:
|
||||
leading = p.lines[0].h if p.lines else 14.0
|
||||
if leading <= 0:
|
||||
leading = 14.0
|
||||
return {
|
||||
"id": "rf", "type": "reflow_paragraph", "pageIndex": page_index,
|
||||
"data": {
|
||||
"objectIndices": sorted(set(obj_idx)),
|
||||
"runs": runs,
|
||||
"columnLeft": p.x,
|
||||
"columnRight": p.x + p.w,
|
||||
"firstBaselineY": first_baseline,
|
||||
"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 {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def dump_structure():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
npages = 0
|
||||
while True:
|
||||
try:
|
||||
doc.get_page(npages)
|
||||
npages += 1
|
||||
except Exception:
|
||||
break
|
||||
log(f"PDF: {PDF.name} pages={npages}")
|
||||
for pi in range(npages):
|
||||
m = doc.get_page(pi).extract_document_model()
|
||||
log(f"\n=== PAGE {pi} ({m.width:.0f}x{m.height:.0f}) paragraphs={len(m.paragraphs)} ===")
|
||||
for i, p in enumerate(m.paragraphs):
|
||||
t = para_text(p)
|
||||
log(f" [{pi}.{i}] lines={len(p.lines)} fonts={para_fonts(p)}")
|
||||
log(f" text={t[:90]!r}")
|
||||
|
||||
|
||||
def find_para(model, needle):
|
||||
for i, p in enumerate(model.paragraphs):
|
||||
if needle in para_text(p):
|
||||
return i, p
|
||||
return -1, None
|
||||
|
||||
|
||||
def repro(page_index=0, a_needle="Java Backend Developer", b_needle="Core Java",
|
||||
survive=("Core", "Java")):
|
||||
"""Faithful app flow: edit paragraph A (modified), re-extract, then re-emit
|
||||
paragraph B UNCHANGED. B's text must survive intact in the committed PDF."""
|
||||
# ---- CONTROL: B-only on a fresh doc (proves B alone is fine) ----
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
m0 = doc.get_page(page_index).extract_document_model()
|
||||
bi, pb = find_para(m0, b_needle)
|
||||
log(f"\n--- CONTROL: re-emit B [{page_index}.{bi}] unchanged (no prior edit) ---")
|
||||
log(f" B text(before) = {para_text(pb)[:80]!r}")
|
||||
doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb, page_index)]}))
|
||||
m_ctrl = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(page_index).extract_document_model()
|
||||
_, pb_ctrl = find_para(m_ctrl, survive[0])
|
||||
ctrl_text = para_text(pb_ctrl) if pb_ctrl else "<B PARA NOT FOUND>"
|
||||
log(f" B text(after) = {ctrl_text[:80]!r}")
|
||||
ctrl_ok = all(s in ctrl_text for s in survive)
|
||||
log(f" CONTROL survive={survive} -> {'OK' if ctrl_ok else 'LOST'}")
|
||||
|
||||
# ---- TEST: edit A first, re-extract, then re-emit B unchanged ----
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
m0 = doc.get_page(page_index).extract_document_model()
|
||||
ai, pa = find_para(m0, a_needle)
|
||||
log(f"\n--- TEST: edit A [{page_index}.{ai}] THEN re-emit B unchanged (same doc) ---")
|
||||
# Modify A: prepend a word to its first run.
|
||||
a_runs = []
|
||||
first = True
|
||||
for l in pa.lines:
|
||||
for r in l.runs:
|
||||
if r.text == "":
|
||||
continue
|
||||
txt = ("EDITED " + r.text) if first else r.text
|
||||
first = False
|
||||
a_runs.append({"text": txt, "internalFontId": r.internal_font_id,
|
||||
"fontSize": r.font_size, "color": "#000000"})
|
||||
doc.apply_edits(json.dumps({"version": "1.0",
|
||||
"operations": [build_reflow_op(pa, page_index, new_runs=a_runs)]}))
|
||||
# Re-extract (frontend re-fetches the model after each edit) and re-locate B.
|
||||
m1 = doc.get_page(page_index).extract_document_model()
|
||||
bi2, pb2 = find_para(m1, b_needle)
|
||||
if pb2 is None:
|
||||
log(f" !! B not found after edit A (searched {b_needle!r})")
|
||||
else:
|
||||
log(f" B text(before 2nd edit) = {para_text(pb2)[:80]!r}")
|
||||
doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb2, page_index)]}))
|
||||
# In-memory (pre-save) B text: localizes corruption to emission vs save-merge.
|
||||
m_inmem = doc.get_page(page_index).extract_document_model()
|
||||
_, pb_inmem = find_para(m_inmem, survive[0])
|
||||
log(f" B text(in-memory, pre-save) = {(para_text(pb_inmem) if pb_inmem else '<NOT FOUND>')[:80]!r}")
|
||||
m2 = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(page_index).extract_document_model()
|
||||
_, pb_test = find_para(m2, survive[0])
|
||||
test_text = para_text(pb_test) if pb_test else "<B PARA NOT FOUND>"
|
||||
log(f" B text(after) = {test_text[:80]!r}")
|
||||
test_ok = all(s in test_text for s in survive)
|
||||
log(f" TEST survive={survive} -> {'OK' if test_ok else 'SCRAMBLED/LOST'}")
|
||||
if not test_ok:
|
||||
# Show the corrupted bullets paragraph (locate by leftover 'Languages'/'ang' or dump near B).
|
||||
for i, p in enumerate(m2.paragraphs):
|
||||
t = para_text(p)
|
||||
if "ang" in t or "OOP" in t or "Collec" in t or "Stream" in t or (b_needle[:3] in t):
|
||||
log(f" >> scrambled B candidate [{page_index}.{i}] = {t[:90]!r}")
|
||||
log(f"\n==> CONTROL={'OK' if ctrl_ok else 'FAIL'} TEST={'OK' if test_ok else 'FAIL'} "
|
||||
f"(bug reproduced if CONTROL=OK and TEST=FAIL)")
|
||||
|
||||
|
||||
def stress():
|
||||
"""Sequentially reflow EVERY paragraph on both pages (re-extracting between each, like the
|
||||
real app), then assert distinctive substrings from each survive — catches any font scramble
|
||||
across non-subset AND subset (BCDKEE+Calibri...) fonts under heavy multi-edit."""
|
||||
# Distinctive substrings to verify survive on each page after all edits.
|
||||
checks = {
|
||||
0: ["Professional", "Backend", "Core", "Java", "Frameworks", "Microservices",
|
||||
"TapQwik", "Present", "LG", "Commerce"],
|
||||
1: ["Architected", "Wego", "Booking", "Education", "Bachelor", "Certification",
|
||||
"Kafka", "Problem", "Ownership"],
|
||||
}
|
||||
# Edit (reflow-in-place) the paragraph CONTAINING each anchor, sequentially, re-extracting
|
||||
# between edits — exactly how a user edits N paragraphs in one session.
|
||||
anchors = {
|
||||
0: ["Backend", "Core Java", "Mar 2023", "LG"],
|
||||
1: ["Architected", "Wego", "Java Development", "Problem"],
|
||||
}
|
||||
failures = []
|
||||
for pi in (0, 1):
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
log(f"\n=== STRESS page {pi}: sequentially reflowing {len(anchors[pi])} targeted paragraphs ===")
|
||||
for anchor in anchors[pi]:
|
||||
m = doc.get_page(pi).extract_document_model()
|
||||
_, p = find_para(m, anchor)
|
||||
if p is None:
|
||||
log(f" anchor {anchor!r}: paragraph not found (possibly merged by a prior edit)")
|
||||
continue
|
||||
try:
|
||||
doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(p, pi)]}))
|
||||
except Exception as exc:
|
||||
log(f" anchor {anchor!r}: apply failed: {exc}")
|
||||
final = para_textall(doc.get_page(pi).extract_document_model())
|
||||
for needle in checks[pi]:
|
||||
if needle not in final:
|
||||
failures.append((pi, needle))
|
||||
present = [n for n in checks[pi] if n in final]
|
||||
log(f" page {pi}: {len(present)}/{len(checks[pi])} substrings survived: "
|
||||
f"missing={[n for n in checks[pi] if n not in final]}")
|
||||
log(f"\n==> STRESS {'PASS' if not failures else 'FAIL ' + str(failures)}")
|
||||
|
||||
|
||||
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?"""
|
||||
from PIL import Image # noqa: PLC0415
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
m0 = doc.get_page(0).extract_document_model()
|
||||
_, pa = find_para(m0, "Java Backend Developer")
|
||||
a_runs = []
|
||||
first = True
|
||||
for l in pa.lines:
|
||||
for r in l.runs:
|
||||
if r.text == "":
|
||||
continue
|
||||
a_runs.append({"text": ("EDITED " + r.text) if first else r.text,
|
||||
"internalFontId": r.internal_font_id, "fontSize": r.font_size, "color": "#000000"})
|
||||
first = False
|
||||
doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pa, 0, new_runs=a_runs)]}))
|
||||
m1 = doc.get_page(0).extract_document_model()
|
||||
_, pb = find_para(m1, "Core Java")
|
||||
doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb, 0)]}))
|
||||
out = doc.save_full()
|
||||
img = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).render(150)
|
||||
data = img.data
|
||||
png = Path(__file__).resolve().parent / "_reflow_render.png"
|
||||
n = img.width * img.height
|
||||
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
png.write_bytes(data) # already PNG-encoded
|
||||
else:
|
||||
mode = "RGBA" if len(data) == n * 4 else ("RGB" if len(data) == n * 3 else None)
|
||||
from PIL import Image # noqa: PLC0415
|
||||
Image.frombytes(mode, (img.width, img.height), data).convert("RGB").save(png)
|
||||
log(f"rendered committed page0 -> {png} ({img.width}x{img.height}, {len(data)} bytes)")
|
||||
|
||||
|
||||
def probe_fonts():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
for pi in (0, 1):
|
||||
fonts = doc.get_page(pi).get_fonts()
|
||||
log(f"\n=== PAGE {pi} fonts ({len(fonts)}) ===")
|
||||
for f in fonts:
|
||||
log(f" font_name={f.font_name!r:34} internal_id={f.internal_font_id!r:28} "
|
||||
f"type={f.type!r:12} flags={f.flags:<4} subset={f.is_subset} tag={f.subset_tag!r} "
|
||||
f"embedded={f.is_embedded}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dump"
|
||||
if mode == "dump":
|
||||
dump_structure()
|
||||
elif mode == "repro":
|
||||
repro()
|
||||
elif mode == "repro_diff":
|
||||
# A = Calibri body (Summary); B = a Calibri-Bold-only heading ("Projects").
|
||||
# Different base fonts -> if B survives, the scramble is same-/BaseFont aliasing.
|
||||
repro(a_needle="Java Backend Developer", b_needle="Projects", survive=("Projects",))
|
||||
elif mode == "probe":
|
||||
probe_fonts()
|
||||
elif mode == "render":
|
||||
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}")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Diagnose the 'star broke the font + overlap' report. Edit a Calibri run adding * and the unicode
|
||||
star, then check: which font emitted, whether the glyphs are present, and whether consecutive glyphs
|
||||
OVERLAP (x positions go backwards / a glyph has ~0 advance).
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_symbol_diag.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib")
|
||||
sys.path.insert(1, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
m = doc.get_page(1).extract_document_model()
|
||||
para = next(p for p in m.paragraphs if "Designed high-performance" in ptext(p))
|
||||
oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices]
|
||||
cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines)
|
||||
fb = max(ln.baseline_y for ln in para.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True)
|
||||
lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0
|
||||
fid = para.lines[0].runs[0].internal_font_id
|
||||
print("fid:", fid)
|
||||
new_text = "Star test asterisk * and blackstar ★ end"
|
||||
|
||||
op = {"version": "1.0", "operations": [{"id": "s", "type": "reflow_paragraph", "pageIndex": 1, "data": {
|
||||
"objectIndices": oi, "runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#000000"}],
|
||||
"columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, "leading": lead,
|
||||
"oldLineCount": len(para.lines), "align": "left", "paraId": "SYMCHK"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
|
||||
for pg in range(r.page_count):
|
||||
for p in r.get_page(pg).extract_document_model().paragraphs:
|
||||
if "asterisk" in ptext(p):
|
||||
fonts = sorted({rr.font_name for ln in p.lines for rr in ln.runs if rr.text.strip()})
|
||||
txt = ptext(p)
|
||||
print("emitted fonts:", fonts)
|
||||
print("has '*':", "*" in txt, " has U+2605:", "★" in txt)
|
||||
gl = [(g.text, g.origin_x) for ln in p.lines for rr in ln.runs for g in rr.glyphs]
|
||||
print("All glyphs:")
|
||||
for text, x in gl:
|
||||
print(f" {repr(text)}: x={x:.2f}")
|
||||
overlaps = 0
|
||||
for a, b in zip(gl, gl[1:]):
|
||||
if b[1] <= a[1] - 0.5 and b[1] > 0:
|
||||
overlaps += 1
|
||||
print(f"glyphs={len(gl)} backward/overlap steps={overlaps}")
|
||||
s = txt.find("blackstar")
|
||||
print("around star:", repr(txt[max(0,s-6):s+20]))
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
########## 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)
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/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}")
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Cross-page reflow on the REAL resume (embedded Calibri/Times subset fonts + cascade).
|
||||
Verifies that migrating EMBEDDED subset-font text objects across pages keeps glyphs intact.
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_xpage_real.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import io, json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return " ".join("".join(r.text for r in ln.runs) for ln in p.lines)
|
||||
|
||||
|
||||
def page_text(doc, i):
|
||||
m = doc.get_page(i).extract_document_model()
|
||||
return " ".join(ptext(p) for p in m.paragraphs)
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
print(f"loaded: pages={doc.page_count}")
|
||||
m0 = doc.get_page(0).extract_document_model()
|
||||
body = [p for p in m0.paragraphs if len(p.lines) >= 1]
|
||||
para = min(body, key=lambda p: min(ln.baseline_y for ln in p.lines))
|
||||
print(f"editing page-0 paragraph (lowest): {ptext(para)[:60]!r} lines={len(para.lines)}")
|
||||
|
||||
obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices]
|
||||
col_left = min(ln.x for ln in para.lines)
|
||||
col_right = max(ln.x + ln.w for ln in para.lines)
|
||||
first_baseline = max(ln.baseline_y for ln in para.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True)
|
||||
leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0
|
||||
fid = para.lines[0].runs[0].internal_font_id
|
||||
|
||||
marker = " ".join(f"X{n:03d}" for n in range(60))
|
||||
new_text = ptext(para).strip() + " " + marker + " ZZEND"
|
||||
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "rg", "type": "reflow_paragraph", "pageIndex": 0, "data": {
|
||||
"objectIndices": obj_idxs,
|
||||
"runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#000000"}],
|
||||
"columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline,
|
||||
"leading": leading, "oldLineCount": len(para.lines), "align": "left"}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
out = doc.save_full()
|
||||
r = pdfengine.PdfDocument.load_from_memory(out, "")
|
||||
print(f"after grow: pages={r.page_count}")
|
||||
|
||||
for i in range(r.page_count):
|
||||
t = page_text(r, i)
|
||||
has_marker = any(f"X{n:03d}" in t for n in range(60))
|
||||
print(f" page {i}: ZZEND={'ZZEND' in t} markers={has_marker} len={len(t)}")
|
||||
allt = " ".join(page_text(r, i) for i in range(r.page_count))
|
||||
print("Architected preserved:", "Architected" in allt)
|
||||
print("Education preserved:", "Education" in allt)
|
||||
nospace = allt.replace(" ", "")
|
||||
miss_join = [f"X{n:03d}" for n in range(60) if f"X{n:03d}" not in allt]
|
||||
miss_nospace = [f"X{n:03d}" for n in range(60) if f"X{n:03d}" not in nospace]
|
||||
print(f"markers intact (space-join): {60-len(miss_join)}/60 missing: {miss_join}")
|
||||
print(f"markers intact (no-space): {60-len(miss_nospace)}/60 missing: {miss_nospace}")
|
||||
p0 = page_text(r, 0); p1 = page_text(r, 1)
|
||||
print("\n--- page0 tail ---\n", p0[-220:])
|
||||
print("\n--- page1 head ---\n", p1[:220])
|
||||
orig = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
orig_p1 = page_text(orig, 1)
|
||||
for needle in ["Architected", "Education", "Soft", "Problem-Solving", "Ownership", "Certifications", "Bachelor"]:
|
||||
print(f" page1-orig token {needle!r}: present_before={needle in orig_p1} present_after={needle in allt}")
|
||||
for i in range(r.page_count):
|
||||
mm = r.get_page(i).extract_document_model()
|
||||
ys = [ln.baseline_y for p in mm.paragraphs for ln in p.lines]
|
||||
print(f" page {i}: lowest_baseline_y={min(ys):.1f} (page height 792; <0 means off-page)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Reproduce the user's bug: grow a page-0 paragraph so it overflows to page 1, save, THEN edit a
|
||||
paragraph that now lives on page 1 -> is the result scrambled (leftover glyphs / wrong fonts)?
|
||||
|
||||
Run: gateway/.venv/Scripts/python.exe tests/edits/_xpage_reedit.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "gateway"))
|
||||
import pdfengine
|
||||
PDF = ROOT / "Mo-Faishal-Qureshi.pdf"
|
||||
|
||||
|
||||
def ptext(p):
|
||||
return "".join(r.text for ln in p.lines for r in ln.runs)
|
||||
|
||||
|
||||
def find_para(doc, page, needle):
|
||||
m = doc.get_page(page).extract_document_model()
|
||||
for p in m.paragraphs:
|
||||
if needle in ptext(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def reflow_para(doc, page, para, new_text, align="left"):
|
||||
obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices]
|
||||
col_left = min(ln.x for ln in para.lines)
|
||||
col_right = max(ln.x + ln.w for ln in para.lines)
|
||||
first_baseline = max(ln.baseline_y for ln in para.lines)
|
||||
bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True)
|
||||
leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0
|
||||
fid = para.lines[0].runs[0].internal_font_id
|
||||
op = {"version": "1.0", "operations": [{
|
||||
"id": "e", "type": "reflow_paragraph", "pageIndex": page, "data": {
|
||||
"objectIndices": obj_idxs,
|
||||
"runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#000000"}],
|
||||
"columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline,
|
||||
"leading": leading, "oldLineCount": len(para.lines), "align": align}}]}
|
||||
doc.apply_edits(json.dumps(op))
|
||||
return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "")
|
||||
|
||||
|
||||
def main():
|
||||
doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "")
|
||||
tech = find_para(doc, 0, "Technologies")
|
||||
print("step1: growing page-0 para:", ptext(tech)[:50], "...")
|
||||
big = ptext(tech).strip() + " " + ("g" * 30 + " ") * 25
|
||||
doc = reflow_para(doc, 0, tech, big)
|
||||
print(" pages after grow:", doc.page_count)
|
||||
|
||||
arch = find_para(doc, 1, "Architected")
|
||||
if not arch:
|
||||
print(" !! 'Architected' not found on page 1; pages dump:")
|
||||
for i in range(doc.page_count):
|
||||
print(f" page {i}:", " | ".join(ptext(p)[:30] for p in doc.get_page(i).extract_document_model().paragraphs)[:200])
|
||||
return
|
||||
print("step2: editing page-1 para:", ptext(arch)[:50], "...")
|
||||
before = ptext(arch)
|
||||
doc2 = reflow_para(doc, 1, arch, "Architected and developed a high-scale ecommerce backend NEWTEXT123 for testing.")
|
||||
|
||||
after = ptext(find_para(doc2, 1, "Architected") or find_para(doc2, 0, "Architected"))
|
||||
print("\n BEFORE:", before[:90])
|
||||
print(" AFTER :", after[:120])
|
||||
print(" contains NEWTEXT123:", "NEWTEXT123" in after)
|
||||
clean = "Architected and developed a high-scale ecommerce backend NEWTEXT123 for testing."
|
||||
print(" clean match:", after.strip() == clean)
|
||||
for w in ["product", "catalog", "dynamic", "pricing", "transactions"]:
|
||||
if w in after:
|
||||
print(f" !! LEFTOVER old word in edited paragraph: {w!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -43,7 +43,12 @@ target_include_directories(pdfengine_wasm PRIVATE
|
||||
|
||||
target_link_libraries(pdfengine_wasm PRIVATE pdfengine::pdfengine)
|
||||
|
||||
set(_wasm_font_src "${CMAKE_SOURCE_DIR}/engine/assets/fonts")
|
||||
set(_wasm_font_dir "${CMAKE_BINARY_DIR}/embed_fonts")
|
||||
file(COPY "${_wasm_font_src}/" DESTINATION "${_wasm_font_dir}")
|
||||
|
||||
target_link_options(pdfengine_wasm PRIVATE
|
||||
"--embed-file" "${_wasm_font_dir}@/fonts"
|
||||
"-sMODULARIZE=1"
|
||||
"-sEXPORT_ES6=1"
|
||||
"-sENVIRONMENT=node,web"
|
||||
|
||||
Reference in New Issue
Block a user