# /// 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 + """ Relative
First Second
Rotate
Clipped text
Internal Named Hidden
Destination
""" child = """ """ with fixture_server({"/index.html": html, "/fixtures/child.html": child}) as origin: text, info = capture(binary, output, "links", html, origin + "/index.html") reference = screenshot(binary, output, "links", html, origin + "/index.html") with pymupdf.open(output / "links.pdf") as document: page = document[0] margin = 72 / 2.54 scale = (page.rect.width - margin * 2) / reference.width links = page.get_links() by_uri = {} for link in links: if link["kind"] == pymupdf.LINK_URI: by_uri.setdefault(link["uri"], []).append(link) assert set(by_uri) == {origin + "/target?q=1&x=2", "https://links.example/wrapped", "https://links.example/rotated", "https://links.example/clipped", "https://links.example/paged", origin + "/fixtures/child-target"}, by_uri def css_rect(link): rect = link["from"] return pymupdf.Rect(*[(value - margin) / scale for value in rect]) def near(actual, expected, tolerance=1): assert max(abs(a - b) for a, b in zip(actual, expected)) <= tolerance, (actual, expected) near(css_rect(by_uri[origin + "/target?q=1&x=2"][0]), (40, 40, 140, 70)) near(css_rect(by_uri["https://links.example/clipped"][0]), (40, 230, 110, 260)) near(css_rect(by_uri[origin + "/fixtures/child-target"][0]), (310, 240, 410, 270)) wrapped = by_uri["https://links.example/wrapped"] assert len(wrapped) == 2, wrapped assert all(css_rect(link).height < 31 for link in wrapped), wrapped near(css_rect(by_uri["https://links.example/rotated"][0]), (285, 100, 386.60, 175.98), tolerance=1.5) # MuPDF exposes annotation rectangles; inspect QuadPoints as well # so a rotated link cannot silently degrade to its bounding box. rotated = by_uri["https://links.example/rotated"][0] kind, values = document.xref_get_key(rotated["xref"], "QuadPoints") assert kind == "array" and len(values.strip("[]").split()) == 8, values points = [float(v) for v in values.strip("[]").split()] assert abs(points[1] - points[3]) > 10, points rendered = page.get_pixmap(matrix=pymupdf.Matrix(1 / scale, 1 / scale)) rendered.save(output / "links-rendered.png") # Interior samples couple visible content to the independently # asserted annotation regions, including rotation, clip and iframe. for x, y in [(45, 45), (45, 250), (120, 250), (316, 245), (302, 150)]: actual = rendered.pixel(round(margin / scale + x), round(margin / scale + y))[:3] expected = reference.pixel(x, y)[:3] assert max(abs(a - b) for a, b in zip(actual, expected)) <= 3, ( "linked content differs from screenshot", x, y, actual, expected) assert min(reference.pixel(45, 45)[:3]) < 220 assert min(reference.pixel(120, 250)[:3]) > 250, "overflow clip must hide content" internal = [link for link in links if link["kind"] == pymupdf.LINK_GOTO] destination_page = int(3200 * scale / (page.rect.height - margin * 2)) assert destination_page > 0 assert len(internal) == 2 and all(link["page"] == destination_page for link in internal), internal target = next(link for link in internal if abs(link["to"].x - (margin + 40 * scale)) < 1) near(target["to"], (margin + 40 * scale, margin + 3200 * scale - destination_page * (page.rect.height - margin * 2))) assert all(margin <= link["to"].x <= margin + 340 * scale for link in internal), internal assert all("hidden" not in str(link) for page in document for link in page.get_links()) assert len(document) == 2 page_height = (page.rect.height - margin * 2) / scale near(css_rect(by_uri["https://links.example/paged"][0]), (600, 1600, 680, page_height)) second = document[1].get_links() assert len(second) == 1 and second[0].get("uri") == "https://links.example/paged", second near(css_rect(second[0]), (600, 0, 680, 3200 - page_height)) preview = document[1].get_pixmap() preview.save(output / "links-page-2.png") x, y = second[0]["from"].tl assert max(abs(a - b) for a, b in zip(preview.pixel(round(x + 3), round(y + 3))[:3], (208, 230, 248))) <= 1 assert "Destination" in text and "Hidden" not in text, text assert all("MoliPDFTest" in font for font in info["fonts"]), info["fonts"] return info def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--moli", type=Path, default=ROOT / "target/debug/moli") parser.add_argument("--output-dir", type=Path, default=ROOT / "target/native-pdf-check") args = parser.parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) font = data_url((ROOT / "moli-paint/tests/fixtures/moli-pdf.ttf").read_bytes(), "font/ttf") cjk = data_url((ROOT / "moli-layout/tests/fixtures/moli-cjk.ttf").read_bytes(), "font/ttf") style = f"""""" results = {} text, results["minimal"] = capture(args.moli.resolve(), args.output_dir, "minimal", style + "

Font Text 123

") assert text == "Font Text 123", text assert results["minimal"]["image_count"] == 0 assert any("+MoliPDFTest" in font for font in results["minimal"]["fonts"]) html = style + """""" text, results["backgrounds"] = capture(args.moli.resolve(), args.output_dir, "backgrounds", html) assert text == "Search Gradient", text check_button_backgrounds(args.moli.resolve(), args.output_dir, html) results["links"] = check_links(args.moli.resolve(), args.output_dir, style) expected = ["Font Text 123 中文", "office fi ffi á", "Second page a", "abc אבג xyz", "Color ★", "Weight 650", "Italic"] 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 + '
' + "".join( f'

Row {i:03}

' for i in range(286) ) + "
" text, results["long_document"] = capture(args.moli.resolve(), args.output_dir, "long-document", html) assert text == " ".join(f"Row {i:03}" for i in range(286)), text assert results["long_document"]["pages"] > 1 assert results["long_document"]["image_count"] == 0 for name, info in results.items(): assert all(font.split("+")[-1] in {"MoliPDFTest", "DroidSansFallback"} for font in info["fonts"]), ("unexpected system font", name, info["fonts"]) report = {"binary": str(args.moli.resolve()), "binary_bytes": args.moli.stat().st_size, "reader": pymupdf.version[0], "cases": results} (args.output_dir / "results.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report, indent=2)) if __name__ == "__main__": main()