Row {i:03}
' for i in range(286) ) + "# /// script
# requires-python = ">=3.11"
# dependencies = ["pymupdf==1.28.2"]
# ///
"""Exercise the real CLI and a PDF reader, including ActualText and Type3 glyphs.
Run after building Moli:
uv run scripts/check-native-pdf.py --moli target/debug/moli
Unlike lopdf's basic extractor, MuPDF interprets ActualText, reversed text and
colored Type3 glyphs. Keep these reader checks separate from the Rust structural
and plain-text regressions. Artifacts and observed process costs are retained.
"""
import argparse
import base64
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import subprocess
import threading
import time
import pymupdf
ROOT = Path(__file__).resolve().parents[1]
def data_url(data, media_type):
return f"data:{media_type};base64," + base64.b64encode(data).decode()
def capture(binary, output, name, html, url=None):
path = output / f"{name}.pdf"
started = time.perf_counter()
peak_kib = 0
with path.open("wb") as target:
process = subprocess.Popen(
[str(binary), "fetch", "--resource", "--layout", "--wait-until", "load",
"--log-level", "error", "--http-no-proxy", "*", "--dump", "pdf",
url or data_url(html.encode(), "text/html")],
stdout=target, stderr=subprocess.PIPE,
)
while process.poll() is None:
if time.perf_counter() - started > 120:
process.kill()
process.communicate()
raise TimeoutError(f"{name} exceeded 120 seconds")
try:
for line in Path(f"/proc/{process.pid}/status").read_text().splitlines():
if line.startswith("VmHWM:"):
peak_kib = max(peak_kib, int(line.split()[1]))
except FileNotFoundError:
pass
time.sleep(0.005)
stderr = process.communicate()[1].decode(errors="replace")
if process.returncode:
raise RuntimeError(f"{name} failed: {stderr}")
seconds = time.perf_counter() - started
document = pymupdf.open(path)
text = " ".join(" ".join(page.get_text().split()) for page in document)
(output / f"{name}.txt").write_text(text)
document[0].get_pixmap(matrix=pymupdf.Matrix(1.5, 1.5)).save(output / f"{name}.png")
links = [{"page": page.number, "kind": link["kind"],
"rect": list(link["from"]), "uri": link.get("uri"),
"destination_page": link.get("page"),
"destination_point": list(link["to"]) if "to" in link else None}
for page in document for link in page.get_links()]
(output / f"{name}-links.json").write_text(json.dumps(links, indent=2) + "\n")
info = {"pages": len(document), "pdf_bytes": path.stat().st_size,
"seconds": round(seconds, 4), "peak_rss_kib_sampled": peak_kib,
"fonts": sorted({font[3] for page in document for font in page.get_fonts()}),
"image_count": sum(len(page.get_images()) for page in document),
"link_count": len(links)}
document.close()
return text, info
def screenshot(binary, output, name, html, url=None):
path = output / f"{name}-screenshot.png"
result = subprocess.run(
[str(binary), "fetch", "--resource", "--layout", "--wait-until", "load",
"--log-level", "error", "--http-no-proxy", "*", "--dump", "screenshot",
url or data_url(html.encode(), "text/html")],
capture_output=True, check=True, timeout=120,
)
path.write_bytes(result.stdout)
return pymupdf.Pixmap(path)
def check_button_backgrounds(binary, output, html):
reference = screenshot(binary, output, "backgrounds", html)
with pymupdf.open(output / "backgrounds.pdf") as document:
page = document[0]
# CLI PDF fits the viewport width inside the default 1 cm margins.
margin = 72 / 2.54
scale = (page.rect.width - 2 * margin) / reference.width
clip = pymupdf.Rect(margin, margin, page.rect.width - margin,
margin + reference.height * scale)
rendered = page.get_pixmap(matrix=pymupdf.Matrix(1 / scale, 1 / scale), clip=clip)
rendered.save(output / "backgrounds-rendered.png")
for x, y in [(110, 112), (320, 112), (460, 112)]:
expected = reference.pixel(x, y)[:3]
actual = rendered.pixel(round(margin / scale + x - rendered.x),
round(margin / scale + y - rendered.y))[:3]
assert min(expected) < 180, ("missing screenshot background", x, y, expected)
assert max(abs(a - b) for a, b in zip(actual, expected)) <= 3, (
"PDF button background differs from screenshot", x, y, actual, expected
)
# A correct background must also leave the white labels visible. Check
# each label's interior separately from its extractable Unicode text.
for x0, x1 in [(115, 265), (310, 470)]:
white = 0
for y in range(121, 140):
for x in range(x0, x1):
pixel = rendered.pixel(round(margin / scale + x - rendered.x),
round(margin / scale + y - rendered.y))[:3]
white += min(pixel) > 235
assert white > 80, ("white button label is not visible", x0, white)
@contextmanager
def fixture_server(resources):
"""Serve only fixed in-memory documents; no public web resources are fetched."""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = resources.get(self.path)
self.send_response(200 if body is not None else 404)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write((body or "Missing fixture").encode())
def log_message(self, *_):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
server.server_close()
thread.join()
def check_links(binary, output, style):
# Explicit geometry avoids dependence on system fonts and viewport guesses.
# Text uses bundled fonts; the child uses a vector icon without font lookup.
html = style + """
Font Text 123 中文
office fi ffi á
Second page a
abc אבג xyz
Color ★
Weight 650
Italic
""" text, results["shaping"] = capture(args.moli.resolve(), args.output_dir, "shaping", html) assert text == " ".join(expected), text html = style + """Opacity text
Shadow text once
Filtered text once
Hidden opacity
Hidden mask
""" text, results["effects"] = capture(args.moli.resolve(), args.output_dir, "effects", html) for phrase in ["Opacity text", "Shadow text once", "Filtered text once"]: assert text.count(phrase) == 1, text assert "Hidden" not in text, text # Exceeds the full-document screenshot CSS dimension limit. There is no # full-document pixel allocation in this path, even at the default viewport. html = style + 'Row {i:03}
' for i in range(286) ) + "