PDFTranslator / test /verify_render.py
hoang.nguyen6
deploy
f66643d unverified
Raw
History Blame Contribute Delete
13.4 kB
"""Phase-3 render smoke + feasibility test.
Runs in two modes:
1. Pre-flight (default) — validates the plan's assumptions against the real PDF
+ parsed JSON without needing pdf2zh.render to exist yet:
* inputs load, schema is correct
* PyMuPDF + numpy can access pixmap samples
* detect_bg_color / detect_text_color produce sensible values on a real bbox
* font registration + text insertion work on a synthetic page
* stats: overflow risk, native-text presence, redaction necessity
2. Render (--render --font ... --output ...) — once pdf2zh.render is built,
runs the full pipeline and verifies the output PDF is non-empty and the
redacted+rendered text is extractable.
Usage:
python test/verify_render.py \
--input GK_ChuanMucKeToan_Nhom02.pdf \
--parsed output-3.translated.json
python test/verify_render.py \
--input GK_ChuanMucKeToan_Nhom02.pdf \
--parsed output-3.translated.json \
--render --font fonts/NotoSans-Regular.ttf --output GK.rendered.pdf
"""
from __future__ import annotations
import argparse
import importlib
import json
import re
import sys
from collections import Counter
from pathlib import Path
# Ensure project root is on sys.path when script is run directly.
sys.path.insert(0, str(Path(__file__).parent.parent))
import fitz
import numpy as np
# ---------------------------------------------------------------------------
# Pure helpers — duplicated here so the script runs without pdf2zh.render.
# Once color.py exists, the renderer should produce identical results.
# ---------------------------------------------------------------------------
def _pixmap_to_array(pm: fitz.Pixmap) -> np.ndarray:
arr = np.frombuffer(pm.samples, dtype=np.uint8).reshape(pm.height, pm.width, pm.n)
if pm.n == 4:
arr = arr[:, :, :3]
return arr
def _bbox_to_pixels(bbox, pw, ph, pm):
sx = pm.width / pw
sy = pm.height / ph
x0, y0, x1, y1 = bbox
px0 = max(0, int(round(x0 * sx)))
py0 = max(0, int(round(y0 * sy)))
px1 = min(pm.width, int(round(x1 * sx)))
py1 = min(pm.height, int(round(y1 * sy)))
return px0, py0, px1, py1
def detect_bg_color(arr, bbox_px, edge=2, qstep=16):
px0, py0, px1, py1 = bbox_px
if px1 - px0 < 2 * edge + 1 or py1 - py0 < 2 * edge + 1:
return (255, 255, 255)
top = arr[py0 : py0 + edge, px0:px1]
bot = arr[py1 - edge : py1, px0:px1]
left = arr[py0 + edge : py1 - edge, px0 : px0 + edge]
right = arr[py0 + edge : py1 - edge, px1 - edge : px1]
band = np.concatenate(
[
top.reshape(-1, 3),
bot.reshape(-1, 3),
left.reshape(-1, 3),
right.reshape(-1, 3),
]
)
if band.size == 0:
return (255, 255, 255)
q = (band // qstep) * qstep + qstep // 2
keys = q[:, 0].astype(np.int32) * 65536 + q[:, 1].astype(np.int32) * 256 + q[:, 2]
vals, counts = np.unique(keys, return_counts=True)
winner = vals[counts.argmax()]
return (int((winner >> 16) & 0xFF), int((winner >> 8) & 0xFF), int(winner & 0xFF))
def detect_text_color(arr, bbox_px, bg, edge=2, qstep=16, dist=32, min_ratio=0.05):
px0, py0, px1, py1 = bbox_px
inner = arr[py0 + edge : py1 - edge, px0 + edge : px1 - edge]
if inner.size == 0:
return (0, 0, 0)
flat = inner.reshape(-1, 3).astype(np.int32)
bg_arr = np.array(bg, dtype=np.int32)
d = np.sqrt(((flat - bg_arr) ** 2).sum(axis=1))
keep = flat[d > dist]
if keep.size == 0:
return (0, 0, 0)
q = (keep // qstep) * qstep + qstep // 2
keys = q[:, 0] * 65536 + q[:, 1] * 256 + q[:, 2]
vals, counts = np.unique(keys, return_counts=True)
idx = counts.argmax()
if counts[idx] / max(1, len(flat)) < min_ratio:
return (0, 0, 0)
winner = vals[idx]
return (int((winner >> 16) & 0xFF), int((winner >> 8) & 0xFF), int(winner & 0xFF))
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
def check_inputs(pdf_path: Path, json_path: Path) -> tuple[fitz.Document, dict]:
assert pdf_path.exists(), f"PDF missing: {pdf_path}"
assert json_path.exists(), f"JSON missing: {json_path}"
doc = fitz.open(str(pdf_path))
parsed = json.loads(json_path.read_text(encoding="utf-8"))
assert "pages" in parsed, "parsed JSON missing 'pages'"
print(f" pdf pages = {doc.page_count}, parsed pages = {len(parsed['pages'])}")
assert len(parsed["pages"]) <= doc.page_count, (
f"page count mismatch — JSON has {len(parsed['pages'])} pages "
f"but PDF has {doc.page_count}"
)
return doc, parsed
def check_schema(parsed: dict) -> None:
cat_counter: Counter[str] = Counter()
label_counter: Counter[str] = Counter()
fontsize_zero = 0
cells_total = 0
cells_with_translated = 0
for page in parsed["pages"]:
for el in page["elements"]:
cat_counter[el["category"]] += 1
label_counter[el["label"]] += 1
assert "bbox_pdf" in el and len(el["bbox_pdf"]) == 4
if el["category"] != "BYPASS" and el.get("font_size", 0) == 0:
fontsize_zero += 1
for c in el.get("cells", []):
cells_total += 1
assert (
"source_text" in c and "bbox_pdf" in c
), "cell missing source_text or bbox_pdf — old fixture schema?"
if c.get("translated_text"):
cells_with_translated += 1
print(f" categories: {dict(cat_counter)}")
print(f" labels: {dict(label_counter)}")
print(f" zero font_size (non-BYPASS): {fontsize_zero}")
print(f" cells: {cells_total} total, {cells_with_translated} translated")
assert cells_total > 0, "no cells found — TABLE elements missing cells"
if cells_total and cells_with_translated == 0:
print(
" NOTE: no cells have translated_text (all-formula table or phase 2 not yet run)"
)
def check_overflow_risk(parsed: dict) -> None:
"""Plan-feasibility check: how often is JSON font_size > bbox height?
Validates the decision to treat font_size as an UPPER BOUND, not truth.
"""
risk_count = 0
total = 0
for page in parsed["pages"]:
for el in page["elements"]:
if el["category"] not in ("FLOWING_TEXT", "IN_PLACE"):
continue
total += 1
x0, y0, x1, y1 = el["bbox_pdf"]
h = y1 - y0
fs = el.get("font_size", 0) or 0
if fs > h:
risk_count += 1
pct = 100 * risk_count / max(1, total)
print(
f" font_size > bbox_height in {risk_count}/{total} ({pct:.0f}%) text elements"
)
if pct > 10:
print(
" WARN: > 10% overflow if font_size used as truth — shrink-to-fit REQUIRED"
)
def check_native_text(doc: fitz.Document) -> None:
"""Plan-feasibility check: scanned vs native PDF.
If the PDF has a native text layer, simple draw_rect erasure is insufficient —
we must use add_redact_annot + apply_redactions to remove the text layer.
"""
pages_with_text = 0
sample = ""
for i in range(min(3, doc.page_count)):
t = doc[i].get_text("text").strip()
if t:
pages_with_text += 1
if not sample:
sample = t[:80]
print(f" pages with native text (first 3): {pages_with_text}/3")
if sample:
print(f" sample: {sample!r}")
if pages_with_text:
print(" WARN: native text detected — redaction (not just rect erase) required")
def check_pixmap_and_color(doc: fitz.Document, parsed: dict) -> None:
page = doc[0]
pm = page.get_pixmap()
arr = _pixmap_to_array(pm)
print(f" pixmap {pm.width}×{pm.height} n={pm.n} → array {arr.shape} {arr.dtype}")
pw, ph = parsed["pages"][0]["page_width"], parsed["pages"][0]["page_height"]
# Pick the first non-BYPASS element with a non-trivial bbox.
target = None
for el in parsed["pages"][0]["elements"]:
if el["category"] != "BYPASS" and (el["bbox_pdf"][2] - el["bbox_pdf"][0]) > 50:
target = el
break
assert target is not None, "no testable element on page 0"
bbox_px = _bbox_to_pixels(target["bbox_pdf"], pw, ph, pm)
bg = detect_bg_color(arr, bbox_px)
txt = detect_text_color(arr, bbox_px, bg)
print(f" element label={target['label']} bbox_px={bbox_px}")
print(f" bg={bg} text={txt}")
assert all(0 <= c <= 255 for c in bg + txt), "color channel out of range"
def check_synthetic_render(font_path: Path | None) -> None:
"""Confirm fitz can register a font and insert non-trivial text into a page."""
fp = str(font_path) if (font_path and font_path.exists()) else None
doc = fitz.open()
page = doc.new_page(width=400, height=200)
if fp:
page.insert_font(fontname="Body", fontfile=fp)
fontname = "Body"
else:
fontname = "helv"
page.draw_rect(fitz.Rect(20, 20, 380, 60), color=(1, 1, 1), fill=(1, 1, 1))
rem = page.insert_textbox(
fitz.Rect(20, 20, 380, 60),
"Phase-3 smoke: ăn cơm chưa? (UTF-8 OK)" if fp else "Phase-3 smoke (no font)",
fontname=fontname,
fontsize=12,
color=(0, 0, 0),
align=fitz.TEXT_ALIGN_LEFT,
)
print(f" insert_textbox returned remaining={rem:.1f}")
assert rem >= 0, "even synthetic insert overflowed — fitz/font setup wrong"
extracted = page.get_text("text")
if fp:
normalized = extracted.replace("\xad", "-").replace("\xa0", " ")
assert "Phase-3" in normalized, f"text not extractable: {extracted!r}"
print(f" synthetic page text-extracts: {extracted.strip()[:60]!r}")
def check_render_module() -> bool:
try:
importlib.import_module("pdf2zh.render")
return True
except ModuleNotFoundError:
return False
def run_full_render(args) -> None:
from pdf2zh.render import RenderConfig, render_document # noqa: WPS433
cfg = RenderConfig(font_path=args.font)
cfg.keep_typst_source = True
parsed = json.loads(Path(args.parsed).read_text(encoding="utf-8"))
render_document(args.input, parsed, args.output, cfg)
out = Path(args.output)
assert out.exists() and out.stat().st_size > 0, "render produced empty file"
rdoc = fitz.open(str(out))
text0 = rdoc[0].get_text("text")
print(f" output {out.name}: {out.stat().st_size:,} bytes, {rdoc.page_count} pages")
print(f" page 0 extracted text excerpt: {text0.strip()[:120]!r}")
# Check that at least one translated element is findable in the output.
# Skip elements whose translated_text equals source_text (proper names, etc.)
first_translation = next(
(
el["translated_text"]
for el in parsed["pages"][0]["elements"]
if el["translated_text"]
and el["translated_text"] != el.get("source_text", "")
),
None,
)
if first_translation:
snippet = re.sub(r"<[^>]+>", "", first_translation)[:30].strip()
if snippet and snippet in text0:
print(f" translated snippet found in page 0: {snippet!r}")
else:
print(
f" NOTE: snippet not found in page 0 (may be covered by overlay): {snippet!r}"
)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--input", required=True, help="Source PDF")
ap.add_argument("--parsed", required=True, help="Translated JSON (phase 2 output)")
ap.add_argument("--font", default=None, help="TTF font path (Unicode)")
ap.add_argument(
"--render", action="store_true", help="Run full render via pdf2zh.render"
)
ap.add_argument("--output", default=None, help="Output PDF path (when --render)")
args = ap.parse_args()
pdf_path = Path(args.input)
json_path = Path(args.parsed)
font_path = Path(args.font) if args.font else None
print("\n[1] check_inputs")
doc, parsed = check_inputs(pdf_path, json_path)
print("\n[2] check_schema")
check_schema(parsed)
print("\n[3] check_overflow_risk")
check_overflow_risk(parsed)
print("\n[4] check_native_text")
check_native_text(doc)
print("\n[5] check_pixmap_and_color")
check_pixmap_and_color(doc, parsed)
print("\n[6] check_synthetic_render")
check_synthetic_render(font_path)
print("\n[7] render module status: ", end="")
have_render = check_render_module()
print("AVAILABLE" if have_render else "not yet implemented (pdf2zh.render)")
if args.render:
if not have_render:
print(
"\nERROR: --render requested but pdf2zh.render module does not exist yet."
)
return 2
if not args.font or not args.output:
print("\nERROR: --render requires --font and --output.")
return 2
print("\n[8] run_full_render")
run_full_render(args)
print("\nALL CHECKS PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())