initial commit
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare real production-worker TTC face 0/1 rendering with separate TTFs.
|
||||
|
||||
Fonts/config/cache exist only inside TemporaryDirectory. No font is installed
|
||||
or retained in the evidence directory. Requires fontTools, Pillow, fontconfig,
|
||||
and the pdf-worker-evidence build target (which does not link PDFium itself).
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
import fontTools
|
||||
from fontTools.ttLib import TTCollection, TTFont
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FAMILY = "DocView Collection Test"
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open("rb") as source:
|
||||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||||
|
||||
|
||||
def make_pdf(path):
|
||||
stream = (b"BT /Regular 24 Tf 30 150 Td (Collection face 0123456789 AVWA) Tj ET\n"
|
||||
b"BT /Bold 24 Tf 30 70 Td (Collection face 0123456789 AVWA) Tj ET\n")
|
||||
objects = [b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 430 240] /Resources << /Font << /Regular 4 0 R /Bold 5 0 R >> >> /Contents 6 0 R >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"endstream"]
|
||||
output = bytearray(b"%PDF-1.7\n"); offsets = [0]
|
||||
for index, obj in enumerate(objects, 1):
|
||||
offsets.append(len(output)); output += f"{index} 0 obj\n".encode() + obj + b"\nendobj\n"
|
||||
xref = len(output); output += f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode()
|
||||
for offset in offsets[1:]:
|
||||
output += f"{offset:010d} 00000 n \n".encode()
|
||||
output += f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
||||
path.write_bytes(output)
|
||||
|
||||
|
||||
def rename_copy(source, target, style):
|
||||
# Format conversion is a modified font. Rename primary names so no
|
||||
# Reserved Font Name is used by our private derived test fonts.
|
||||
font = TTFont(source, recalcTimestamp=False)
|
||||
replacements = {1: FAMILY, 3: f"DocViewCollectionTest-{style}", 4: f"{FAMILY} {style}",
|
||||
6: f"DocViewCollectionTest-{style}", 16: FAMILY, 18: f"{FAMILY} {style}",
|
||||
21: FAMILY, 25: "DocViewCollectionTest"}
|
||||
for record in font["name"].names:
|
||||
if record.nameID in replacements:
|
||||
record.string = replacements[record.nameID].encode(record.getEncoding())
|
||||
font.save(target); font.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--build-dir", type=Path, default=ROOT / os.environ.get("DOCVIEW_BUILD_DIR", "build"))
|
||||
parser.add_argument("--regular", type=Path, default=Path("/usr/share/fonts/liberation/LiberationSans-Regular.ttf"))
|
||||
parser.add_argument("--bold", type=Path, default=Path("/usr/share/fonts/liberation/LiberationSans-Bold.ttf"))
|
||||
parser.add_argument("--license", type=Path, default=Path("/usr/share/licenses/ttf-liberation/LICENSE"))
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "tests/results/font-collection")
|
||||
args = parser.parse_args()
|
||||
executable = args.build_dir.resolve() / "pdf-worker-evidence"
|
||||
if not executable.is_file():
|
||||
raise SystemExit("Build the pdf-worker-evidence target first.")
|
||||
license_text = args.license.read_text()
|
||||
assert "SIL OPEN FONT LICENSE Version 1.1" in license_text
|
||||
assert all(p.is_file() for p in (args.regular, args.bold))
|
||||
assert shutil.disk_usage(tempfile.gettempdir()).free > 64 * 1024 * 1024
|
||||
output = args.output.resolve(); output.mkdir(parents=True, exist_ok=True)
|
||||
pair_pdf = output / "regular-bold.pdf"; make_pdf(pair_pdf)
|
||||
cases = {"paired-styles": pair_pdf, "existing-standard14": ROOT / "tests/fixtures/pdf/extended/unembedded-font.pdf"}
|
||||
report = {"status": "incomplete", "fontTools_version": fontTools.__version__,
|
||||
"fontconfig_version": subprocess.run(["fc-match", "--version"], capture_output=True, text=True, check=True).stderr.strip(),
|
||||
"worker_sha256": digest(args.build_dir / "docview-pdf-worker"),
|
||||
"evidence_exporter_sha256": digest(executable),
|
||||
"font_sources": [{"path": str(p), "sha256": digest(p), "bytes": p.stat().st_size} for p in (args.regular, args.bold)],
|
||||
"license": {"path": str(args.license), "sha256": digest(args.license), "identifier": "OFL-1.1"},
|
||||
"generated_family": FAMILY, "fonts_redistributed": False,
|
||||
"selection_transport": "production WorkerProcess + FontBroker + sandboxed PDF worker; no direct PDFium exporter", "runs": [], "comparisons": []}
|
||||
temporary = None
|
||||
with tempfile.TemporaryDirectory(prefix="docview-ttc-") as name:
|
||||
temporary = Path(name)
|
||||
standalone = temporary / "standalone"; collection_dir = temporary / "collection"
|
||||
standalone.mkdir(); collection_dir.mkdir()
|
||||
paths = [standalone / "regular.ttf", standalone / "bold.ttf"]
|
||||
for source, path, style in zip((args.regular, args.bold), paths, ("Regular", "Bold")):
|
||||
rename_copy(source, path, style)
|
||||
collection = TTCollection()
|
||||
collection.fonts = [TTFont(path, recalcTimestamp=False) for path in paths]
|
||||
ttc_path = collection_dir / "two-faces.ttc"
|
||||
collection.save(ttc_path, shareTables=False); collection.close()
|
||||
ttc_data = ttc_path.read_bytes()
|
||||
assert ttc_data[:4] == b"ttcf" and struct.unpack_from(">I", ttc_data, 8)[0] == 2
|
||||
report["generated_fonts"] = {
|
||||
"standalone": [{"style": style, "bytes": p.stat().st_size, "sha256": digest(p)} for p, style in zip(paths, ("Regular", "Bold"))],
|
||||
"collection": {"bytes": len(ttc_data), "sha256": digest(ttc_path), "face_offsets": list(struct.unpack_from(">2I", ttc_data, 12)), "shared_tables": False}}
|
||||
for mode, fonts in (("standalone", standalone), ("collection", collection_dir)):
|
||||
cache = temporary / (mode + "-cache"); cache.mkdir()
|
||||
config = temporary / (mode + ".conf")
|
||||
config.write_text('<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">\n'
|
||||
'<fontconfig>\n<dir>' + escape(str(fonts)) + '</dir>\n<cachedir>' + escape(str(cache))
|
||||
+ '</cachedir>\n<alias><family>sans-serif</family><prefer><family>' + FAMILY
|
||||
+ '</family></prefer></alias>\n</fontconfig>\n')
|
||||
env = os.environ.copy(); env["FONTCONFIG_FILE"] = str(config); env["FONTCONFIG_PATH"] = str(temporary)
|
||||
listing = subprocess.check_output(["fc-list", "--format=%{file}\t%{index}\t%{style}\n"], env=env, text=True)
|
||||
index = []
|
||||
for line in listing.splitlines():
|
||||
path, face, style = line.split("\t"); assert Path(path).parent == fonts
|
||||
index.append({"file": Path(path).name, "face_index": int(face), "style": style})
|
||||
assert len(index) == 2
|
||||
if mode == "collection": assert {item["face_index"] for item in index} == {0, 1}
|
||||
for case, source in cases.items():
|
||||
destination = output / mode / case
|
||||
started = time.monotonic()
|
||||
process = subprocess.run([str(executable), str(source), str(destination)], env=env,
|
||||
text=True, capture_output=True, timeout=60)
|
||||
if process.returncode:
|
||||
raise RuntimeError(f"{mode}/{case}: exit {process.returncode}\n{process.stdout}\n{process.stderr}")
|
||||
metadata = json.loads((destination / "metadata.json").read_text())
|
||||
selected = [s for s in metadata["fontSelections"] if s.get("found")]
|
||||
assert selected and all(s["actualFace"] == FAMILY for s in selected)
|
||||
assert metadata["fontBytesTransferred"] > 0
|
||||
if mode == "collection" and case == "paired-styles":
|
||||
assert {s["faceIndex"] for s in selected} == {0, 1}
|
||||
assert all(s["faceIndex"] == (1 if s["requestedWeight"] >= 600 else 0) for s in selected)
|
||||
report["runs"].append({"mode": mode, "case": case, "source_sha256": digest(source),
|
||||
"elapsed_seconds": time.monotonic() - started, "font_index": index,
|
||||
"selections": selected, "font_bytes_transferred": metadata["fontBytesTransferred"]})
|
||||
for case in cases:
|
||||
baseline = output / "standalone" / case / "page-1.png"
|
||||
collected = output / "collection" / case / "page-1.png"
|
||||
left = Image.open(baseline).convert("RGB"); right = Image.open(collected).convert("RGB")
|
||||
assert left.size == right.size
|
||||
difference = ImageChops.difference(left, right)
|
||||
histogram = ImageChops.lighter(ImageChops.lighter(difference.getchannel("R"), difference.getchannel("G")), difference.getchannel("B")).histogram()
|
||||
report["comparisons"].append({"case": case, "dimensions": list(left.size), "equal_pixels": histogram[0],
|
||||
"different_pixels": sum(histogram[1:]), "mean_absolute_channel_error": sum(ImageStat.Stat(difference).mean) / 3,
|
||||
"standalone_png_sha256": digest(baseline), "collection_png_sha256": digest(collected)})
|
||||
assert difference.getbbox() is None, f"Raster mismatch in {case}"
|
||||
if case == "paired-styles":
|
||||
regular = right.crop((50, 120, 830, 200)).convert("L")
|
||||
bold = right.crop((50, 280, 830, 360)).convert("L")
|
||||
regular_ink = sum(regular.histogram()[:128]); bold_ink = sum(bold.histogram()[:128])
|
||||
assert bold_ink > regular_ink > 0 and ImageChops.difference(regular, bold).getbbox() is not None
|
||||
report["style_probe"] = {"regular_dark_pixels": regular_ink, "bold_dark_pixels": bold_ink,
|
||||
"equal_text_at_equal_font_size": True, "regular_bold_distinct": True}
|
||||
report["temporary_fonts_removed"] = not temporary.exists()
|
||||
assert report["temporary_fonts_removed"]
|
||||
report["status"] = "pass"
|
||||
(output / "comparison.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user