Files
indd-utils/tools/render_compare.py
aiongg bff03f709c tools: add check.py, a check run that reuses conversions, validations and renders
compare.py --cache keeps conversions by content SHA-256 and file name,
--valid-cache keeps schema validation results by part; render_compare.py
takes conversions from the same cache and reuses verdicts and
measurements with --reuse-results. Runs killed by a signal or a timeout
and failed renders are not kept.
2026-10-12 22:09:46 +00:00

1557 lines
68 KiB
Python

#!/usr/bin/env python3
"""Compare renders of corpus documents that come with an InDesign PDF.
For every INDD file in the corpus with a reference IDML and a PDF that show
the same save (a same-save triple; rule in docs/measurement.md), compare
three PDFs page by page:
R the InDesign PDF from the corpus
A DesignCraft's PDF of the reference IDML
B DesignCraft's PDF of the converter's IDML
A vs B measures the converter: fonts, linked files and renderer are the
same on both sides, so any difference comes from the IDML. R vs A
measures DesignCraft. DesignCraft is an input: pass its command-line
program with --designcraft PATH (or the DESIGNCRAFT_CLI environment
variable).
Usage: python3 -I tools/render_compare.py --designcraft PATH [--bin PATH]
[--jobs N] [--limit N] [--file SUBSTR] [--exclude PREFIX]...
[--versions 13-21] [--max-pages N] [--link-fonts] [--out DIR]
[--gaps GAPS_TSV] [--reuse DIR] [--reuse-results DIR] [--cache DIR]
python3 -I tools/render_compare.py --images ID:PAGE [ID:PAGE...] [--out DIR]
Run from the repository root after `cargo build --release`. Needs
poppler-utils (pdfinfo, pdftoppm, pdftotext, pdffonts) and the Python
standard library only.
--exclude leaves out files whose path under corpus/ starts with PREFIX
(default own/; --exclude '' leaves out nothing). --versions limits the
measured triples to INDD major versions in that range; other triples are
listed in triples.tsv but not measured. --limit judges only the first N
triples. --max-pages limits the page-by-page comparison of each document
(default 100). --link-fonts gives DesignCraft the font files that a
package keeps beside the IDML, in a `Document Fonts` folder (DesignCraft
reads only that folder).
--reuse DIR takes A.pdf, B.pdf and the renderer's results from the work/
folder of an earlier run in DIR (which may be --out itself) instead of
rendering, for each triple whose converted IDML has the same bytes as in
that run and whose renders succeeded there; other triples are rendered,
which needs --designcraft. Use it to measure a change to this tool on the
same renders.
--reuse-results DIR takes the verdict and measurement of a triple from
work/ID/result.pickle of an earlier run in DIR when everything they depend
on besides the corpus files is the same: this tool and compare.py, the
DesignCraft binary, --max-pages, --link-fonts, the converted IDML, the
converter's stderr and the output of `indd xmp` and `indd uids`. A triple
whose A or B render failed or timed out gets no result.pickle, so it is
rendered again.
--cache DIR takes the conversions (and the output of `indd xmp` and `indd
uids`) from compare.py's --cache DIR and adds to it, with the conversion
timeout of this tool.
Outputs go to --out (default target/render-compare): triples.tsv,
docs.tsv, pages.tsv, lines.tsv, dropped.tsv, issues.tsv, summary.json, and
work/ID/ with the converted IDML, A.pdf, B.pdf and the renderer's results.
--images ID:PAGE writes side-by-side (R | A | B) and A/B difference images
of pages of an earlier run to images/, and does nothing else. --gaps names
the gaps.tsv of a compare.py run (default target/compare/gaps.tsv); the
summary lists its document count next to each key found on a first
differing page.
"""
import argparse
import hashlib
import json
import os
import pickle
import re
import statistics
import struct
import subprocess
import sys
import tempfile
import time
import unicodedata
import xml.etree.ElementTree as ET
import zipfile
import zlib
from collections import Counter, defaultdict
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import compare # noqa: E402
ROOT = compare.ROOT
CORPUS = ROOT / "corpus"
DPI = 50
# Fewest matched lines of one pair of box heights on a page for the page's
# own median offset; see box_offsets.
MIN_PAGE_LINES = 3
PX = DPI / 72.0
RENDER_TIMEOUT = 600
CONVERT_TIMEOUT = 120
TOOL_TIMEOUT = 600
RTL = re.compile("[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]")
FONT_EXT = (".otf", ".ttf", ".ttc", ".otc", ".pfb", ".pfm", ".afm", ".dfont")
def run(cmd, timeout, text=False):
"""Run a command; (exit code or "timeout", seconds, stdout, stderr)."""
t = time.time()
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return "timeout", timeout, b"" if not text else "", ""
out = p.stdout.decode("utf-8", "replace") if text else p.stdout
return p.returncode, round(time.time() - t, 2), out, p.stderr.decode("utf-8", "replace")
# ---------------------------------------------------------------- triples
def major(version):
try:
return int(version.split(".")[0])
except ValueError:
return None
def candidates(args):
"""Inventory rows with an IDML and a PDF, without duplicates."""
lo, hi = (int(x) for x in args.versions.split("-"))
known = compare.known_digests()
out = []
for r in compare.inventory():
path = r["path"]
if not r.get("idml") or not r.get("pdf") or r.get("dup_of"):
continue
if args.file and args.file not in path:
continue
if any(e and path.startswith(e) for e in args.exclude):
continue
skip = []
v, dom = major(r["version"]), major(r.get("idml_dom") or "")
if r["order"] != "LE":
skip.append("not little-endian")
if v is None or not lo <= v <= hi:
skip.append(f"version outside {args.versions}")
if v != dom:
skip.append("IDML of another major version")
sha = compare.digest(CORPUS / path, known)
out.append({"id": sha[:10], "key": compare.cache_key(CORPUS / path, sha), "path": path,
"idml": r["idml"], "pdf": r["pdf"], "version": r["version"], "skip": skip})
return out[:args.limit] if args.limit else out
def pdf_info(pdf):
"""(pages, Creator) from pdfinfo."""
code, _, out, _ = run(["pdfinfo", str(pdf)], 120, text=True)
pages = re.search(r"^Pages:\s+(\d+)", out, re.M)
creator = re.search(r"^Creator:\s+(.*)$", out, re.M)
return (int(pages.group(1)) if pages else None), (creator.group(1).strip() if creator else "")
def idml_pages(idml):
"""Number of <Page> elements in an IDML package's spreads."""
try:
with zipfile.ZipFile(idml) as z:
return sum(len(re.findall(rb"<Page\s", z.read(n))) for n in z.namelist()
if n.startswith("Spreads/") and n.endswith(".xml"))
except (zipfile.BadZipFile, OSError, KeyError):
return None
def judge(cfg, t):
"""Same-save verdict of one triple. Converts the INDD (the IDML's
stale rule needs the converter's stories); a measured triple keeps its
conversion in work/ID/ours.idml."""
indd, idml, pdf = CORPUS / t["path"], CORPUS / t["idml"], CORPUS / t["pdf"]
pages, creator = pdf_info(pdf)
_, _, pdf_xmp, _ = run(["pdfinfo", "-meta", str(pdf)], 120)
if cfg["cache"] and not t["skip"]:
rec = compare.convert_cached(cfg["bin"], indd, t["key"], cfg["cache"], True,
CONVERT_TIMEOUT)
xmp = Path(cfg["cache"]) / f"{t['key']}.xmp"
indd_xmp = xmp.read_bytes() if xmp.exists() else b""
else:
code, _, indd_xmp, _ = run([cfg["bin"], "xmp", str(indd)], 120)
if code != 0:
indd_xmp = b""
n_idml = idml_pages(idml)
reasons = compare.pdf_stale_reasons(indd_xmp, pdf_xmp, pages, n_idml, creator)
res = dict(t, pdf_pages=pages, idml_pages=n_idml, creator=creator, reasons=reasons)
if t["skip"]:
return res
work = Path(cfg["out"]) / "work" / t["id"]
work.mkdir(parents=True, exist_ok=True)
ours = work / "ours.idml"
ours.unlink(missing_ok=True)
if cfg["cache"]:
code, secs, err = rec["exit"], rec.get("secs"), rec["stderr"]
if code == 0:
try:
os.link(Path(cfg["cache"]) / f"{t['key']}.idml", ours)
except OSError:
ours.write_bytes((Path(cfg["cache"]) / f"{t['key']}.idml").read_bytes())
else:
code, secs, _, err = run([cfg["bin"], "convert", str(indd), str(ours)], CONVERT_TIMEOUT)
res["convert"] = {"exit": code, "secs": secs,
"warnings": sum(1 for x in err.splitlines() if x.startswith("warning: ")),
"error": compare.error_message(err) if code != 0 else ""}
if code != 0:
res["reasons"] = reasons + ["conversion failed"]
return res
if cfg["cache"]:
uids_path = Path(cfg["cache"]) / f"{t['key']}.uids"
uids_out = uids_path.read_bytes() if uids_path.exists() else None
else:
u = subprocess.run([cfg["bin"], "uids", str(indd)], capture_output=True)
uids_out = u.stdout if u.returncode == 0 else None
uids = None
if uids_out is not None:
uids = {}
for line in uids_out.decode().splitlines():
uid, cls = line.split("\t")
uids[int(uid)] = None if cls == "-" else int(cls, 16)
# Everything the verdict and the measurement depend on besides the
# corpus files; with --reuse-results, an earlier result with the same
# key is taken as it is.
res["key"] = dict(cfg["result_key"], ours=sha256(ours),
stderr=hashlib.sha256(err.encode()).hexdigest(),
xmp=hashlib.sha256(indd_xmp).hexdigest(),
uids=hashlib.sha256(uids_out).hexdigest() if uids_out is not None else None)
prev = earlier_result(cfg, t, res["key"])
if prev:
res["reused_result"] = prev
return res
try:
ref, mine = compare.load(idml), compare.load(ours)
except SystemExit as e:
res["reasons"] = reasons + [f"IDML unreadable: {e}"[:200]]
return res
stale = compare.stale_reasons(compare.modify_date(indd_xmp), compare.idml_modify_date(idml),
uids, ref[0], ref[1], mine[1])
res["reasons"] = reasons + [f"IDML stale: {s}" for s in stale]
st = compare.Stats()
compare.value_coverage(st, ref, mine)
res["gap_keys"] = sorted(compare.gap_name(k) for k in st.gap_total
if st.gap_wrong[k] + st.gap_missing[k])
return res
# ---------------------------------------------------------------- rendering
def package(src_idml, dest, doc, link_fonts):
"""A folder of symbolic links to everything beside the reference IDML,
with `doc` linked as doc.idml, so that A and B find the same linked
files and fonts."""
dest.mkdir(parents=True, exist_ok=True)
for e in src_idml.parent.iterdir():
link = dest / e.name
if e.name != src_idml.name and not link.is_symlink() and not link.exists():
link.symlink_to(e)
fonts = [e for e in src_idml.parent.iterdir() if e.suffix.lower() in FONT_EXT and e.is_file()]
has_folder = any(e.name.lower() == "document fonts" for e in dest.iterdir())
if link_fonts and fonts and not has_folder:
(dest / "Document Fonts").mkdir()
for f in fonts:
(dest / "Document Fonts" / f.name).symlink_to(f)
d = dest / "doc.idml"
if d.is_symlink() or d.exists():
d.unlink()
d.symlink_to(doc)
return d
def designcraft(cfg, idml, pdf):
"""Render an IDML to PDF; exit code, time, font list, preflight items
and warnings."""
pdf.unlink(missing_ok=True)
code, secs, out, err = run([cfg["designcraft"], "run", "--in", str(idml), "--cmd", "font.list",
"--cmd", "preflight.run", "--export", str(pdf)], RENDER_TIMEOUT,
text=True)
lines = [x for x in out.splitlines() if x.strip()]
res = {"exit": code, "secs": secs, "fonts": None, "preflight": None,
"warnings": [x[len("warning: "):] for x in err.splitlines() if x.startswith("warning: ")],
"stderr": err[-2000:] if code != 0 else ""}
try:
res["fonts"] = json.loads(lines[0])
res["preflight"] = json.loads(lines[1])
except (IndexError, ValueError):
pass
return res
def boxes(pdf):
"""Per page (1-based): {"media", "crop", "trim"} boxes as (x0, y0, x1, y1)."""
code, _, out, _ = run(["pdfinfo", "-box", "-f", "1", "-l", "1000000", str(pdf)], 300, text=True)
pages = defaultdict(dict)
box = r"(MediaBox|CropBox|TrimBox):\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
for m in re.finditer(r"^Page\s+(\d+)\s+" + box, out, re.M):
pages[int(m.group(1))][m.group(2)[:-3].lower()] = tuple(float(m.group(i)) for i in range(3, 7))
if not pages: # pdfinfo leaves out "Page N" for a single page
for m in re.finditer("^" + box, out, re.M):
pages[1][m.group(1)[:-3].lower()] = tuple(float(m.group(i)) for i in range(2, 6))
n = re.search(r"^Pages:\s+(\d+)", out, re.M)
return (int(n.group(1)) if n else 0), dict(pages)
def trim_offset(b):
"""(trim box, offset of the trim box's top-left corner in the crop box)
in points; pdftoppm and pdftotext -cropbox work in the crop box."""
crop = b.get("crop") or b.get("media")
trim = b.get("trim") or crop
return trim, (trim[0] - crop[0], crop[3] - trim[3])
def raster(pdf, outdir, last, first=1, dpi=DPI):
"""Greyscale page images; {page: path}."""
outdir.mkdir(parents=True, exist_ok=True)
run(["pdftoppm", "-r", str(dpi), "-gray", "-f", str(first), "-l", str(last), str(pdf),
str(outdir / "p")], TOOL_TIMEOUT)
out = {}
for f in outdir.iterdir():
m = re.fullmatch(r"p-(\d+)\.pgm", f.name)
if m:
out[int(m.group(1))] = f
return out
def read_pgm(path):
"""(width, height, bytes) of a binary 8-bit PGM file."""
data = path.read_bytes()
fields, pos = [], 0
while len(fields) < 4:
m = re.compile(rb"\s*(#[^\n]*\n\s*)*(\S+)").match(data, pos)
fields.append(m.group(2))
pos = m.end()
if fields[0] != b"P5" or int(fields[3]) > 255:
raise ValueError(f"{path}: not an 8-bit binary PGM")
w, h = int(fields[1]), int(fields[2])
return w, h, data[pos + 1:pos + 1 + w * h]
def crop(img, left, top, w, h):
iw, ih, px = img
left, top = max(0, min(iw, left)), max(0, min(ih, top))
w, h = max(0, min(iw - left, w)), max(0, min(ih - top, h))
return w, h, b"".join(px[(top + y) * iw + left:(top + y) * iw + left + w] for y in range(h))
def page_image(path, b):
"""A page image cropped to the trim box."""
img = read_pgm(path)
trim, (dx, dy) = trim_offset(b)
return crop(img, round(dx * PX), round(dy * PX), round((trim[2] - trim[0]) * PX),
round((trim[3] - trim[1]) * PX))
def halve(img):
"""2 x 2 mean, so that one-pixel shifts of anti-aliased edges count little."""
w, h, px = img
w2, h2 = w // 2, h // 2
rows = []
for y in range(h2):
r0 = px[2 * y * w:2 * y * w + 2 * w2]
r1 = px[(2 * y + 1) * w:(2 * y + 1) * w + 2 * w2]
s = [a + b for a, b in zip(r0, r1)]
rows.append(bytes((s[i] + s[i + 1] + 2) >> 2 for i in range(0, 2 * w2, 2)))
return w2, h2, rows
INK = bytes(49 if v < 200 else 48 for v in range(256)) # "1" for ink, "0" for paper
def ink_masks(rows, w):
"""Per row, the ink pixels (value < 200) as bits of an integer."""
return [int(r.translate(INK), 2) if w else 0 for r in rows]
def dilate(masks, w, k=2):
full = (1 << w) - 1
horiz = []
for m in masks:
d = m
for s in range(1, k + 1):
d |= (m << s) | (m >> s)
horiz.append(d & full)
out = []
for y in range(len(horiz)):
d = 0
for v in horiz[max(0, y - k):y + k + 1]:
d |= v
out.append(d)
return out
def pix_stats(a, b):
"""Pixel measures of page image `b` against `a` (both full resolution):
identical bytes; mean absolute difference (0-1) and share of pixels
that differ by more than 48 of 255 after a 2 x 2 mean; ink (< 200) in
`a` with no ink within 2 pixels in `b` (missing ink) and the reverse
(extra ink), as shares of the pixels."""
if a is None or b is None:
return {"pix": "missing"}
if max(abs(a[0] - b[0]), abs(a[1] - b[1])) > 3:
return {"pix": "size"}
if a == b:
return {"pix": "ok", "identical": True, "mad": 0.0, "diff": 0.0, "missing_ink": 0.0,
"extra_ink": 0.0}
w, h = min(a[0], b[0]), min(a[1], b[1])
a, b = halve(crop(a, 0, 0, w, h)), halve(crop(b, 0, 0, w, h))
w, h = a[0], a[1]
n = (w * h) or 1
total = big = 0
for ra, rb in zip(a[2], b[2]):
d = [abs(x - y) for x, y in zip(ra, rb)]
total += sum(d)
big += sum(1 for x in d if x > 48)
ia, ib = ink_masks(a[2], w), ink_masks(b[2], w)
da, db = dilate(ia, w), dilate(ib, w)
full = (1 << w) - 1
missing = sum((x & ~y & full).bit_count() for x, y in zip(ia, db))
extra = sum((x & ~y & full).bit_count() for x, y in zip(ib, da))
return {"pix": "ok", "identical": False, "mad": total / n / 255, "diff": big / n,
"missing_ink": missing / n, "extra_ink": extra / n}
# ---------------------------------------------------------------- text
def norm(s):
s = unicodedata.normalize("NFKC", s)
return re.sub("[\\s\u00ad\ufeff\u200b]", "", s).casefold()
def norm_story(s):
"""Story text as a search key: also without hyphens, which line breaks
add."""
return re.sub("[\\-\u2010\u2011]", "", norm(s))
XHTML = "{http://www.w3.org/1999/xhtml}"
BAD_XML = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]")
def text_pages(pdf):
"""Per page: lines of words from pdftotext -bbox-layout, in points
from the crop box's top-left corner. A line is
{"words": [(x0, x1, text)], "x", "xend", "top", "height", "key", "text", ...};
to_trim adds the same positions in the trim box (tx, txend, ty, twords)."""
code, _, out, _ = run(["pdftotext", "-q", "-cropbox", "-bbox-layout", str(pdf), "-"],
TOOL_TIMEOUT)
if code != 0 or not out:
return []
out = BAD_XML.sub("", out.decode("utf-8", "replace"))
try:
root = ET.fromstring(out)
except ET.ParseError:
return []
pages = []
for pg in root.iter(XHTML + "page"):
lines = []
for ln in pg.iter(XHTML + "line"):
words = [(float(w.get("xMin")), float(w.get("xMax")), w.text or "")
for w in ln.iter(XHTML + "word") if (w.text or "").strip()]
if not words:
continue
text = " ".join(w[2] for w in words)
key = norm(text)
if not key:
continue
x, top = float(ln.get("xMin")), float(ln.get("yMin"))
lines.append({"words": words, "x": x, "xend": words[-1][1], "top": top,
"height": float(ln.get("yMax")) - top, "key": key, "text": text,
"rtl": bool(RTL.search(text)), "tx": x, "ty": top,
"txend": words[-1][1], "twords": words,
"h": round(float(ln.get("yMax")) - top, 1)})
pages.append(lines)
return pages
def to_trim(lines, b):
_, (dx, dy) = trim_offset(b)
for ln in lines:
ln["tx"], ln["ty"], ln["txend"] = ln["x"] - dx, ln["top"] - dy, ln["xend"] - dx
ln["twords"] = [(a - dx, b - dx, t) for a, b, t in ln["words"]]
return lines
def match(ref, oth):
"""Greedy nearest match of lines with identical text: (pairs, unmatched
reference lines, unmatched other lines)."""
by = defaultdict(list)
for j, ln in enumerate(oth):
by[ln["key"]].append(j)
used, pairs, unref = set(), [], []
for i, ln in enumerate(ref):
cands = [j for j in by.get(ln["key"], []) if j not in used]
if not cands:
unref.append(i)
continue
j = min(cands, key=lambda j: abs(oth[j]["ty"] - ln["ty"]) + abs(oth[j]["tx"] - ln["tx"]))
used.add(j)
pairs.append((i, j))
return pairs, unref, [j for j in range(len(oth)) if j not in used]
def char_overlap(ref, oth):
"""Share of the reference page's characters (without spaces) that the
other page has too, as a multiset overlap; None for an empty page."""
a = Counter("".join(ln["key"] for ln in ref))
b = Counter("".join(ln["key"] for ln in oth))
n = sum(a.values())
return (sum((a & b).values()) / n if n else None), n
def line_stats(ref, oth, offsets=None):
"""Line match of a page. A matched line moved when its left edge or the
top of its box differs by more than 1 pt. Line boxes come from the
fonts' ascent and descent as each PDF declares them, so between R and A
the top of the box is first corrected by `offsets`, the page's offsets
from box_offsets."""
pairs, unref, unoth = match(ref, oth)
moved = []
for i, j in pairs:
dy = oth[j]["ty"] - ref[i]["ty"]
if offsets is not None:
dy -= offsets.get((ref[i]["h"], oth[j]["h"]), 0.0)
if abs(dy) > 1 or abs(oth[j]["tx"] - ref[i]["tx"]) > 1:
moved.append((i, j))
ov, n = char_overlap(ref, oth)
return {"lines": len(ref), "matched": len(pairs), "moved": len(moved), "chars": n,
"overlap": ov,
"ex_moved": [(ref[i]["text"][:50], round(ref[i]["tx"], 1), round(ref[i]["ty"], 1),
round(oth[j]["tx"], 1), round(oth[j]["ty"], 1)) for i, j in moved[:3]],
"ex_unmatched": [ref[i]["text"][:50] for i in unref[:3]]}
def box_offsets(pairs_by_page):
"""{page: {(reference box height, other box height): offset}}: the
median vertical difference of the page's matched lines with those box
heights, or the document's median for them when the page has fewer than
MIN_PAGE_LINES such lines. A document-wide median alone follows
pagination drift on other pages: where most lines of a document moved,
it shifts the lines of pages that did not move."""
doc, pages = defaultdict(list), {}
for pno, ref, oth, pairs in pairs_by_page:
groups = defaultdict(list)
for i, j in pairs:
groups[(ref[i]["h"], oth[j]["h"])].append(oth[j]["ty"] - ref[i]["ty"])
for k, v in groups.items():
doc[k].extend(v)
pages[pno] = groups
doc = {k: statistics.median(v) for k, v in doc.items()}
return {pno: {k: statistics.median(v) if len(v) >= MIN_PAGE_LINES else doc[k]
for k, v in groups.items()}
for pno, groups in pages.items()}
def line_checks(lines):
"""Overflow and spread lines of one page (docs/measurement.md, "Render
comparison"). Right-to-left lines are left out."""
lines = [ln for ln in lines if not ln["rtl"]]
edges = Counter(round(ln["txend"] * 2) / 2 for ln in lines)
freq = [e for e, n in edges.items() if n >= 3]
cols = Counter((round(ln["tx"]), round(ln["txend"])) for ln in lines)
cols = [c for c, n in cols.items() if n >= 3]
over, spread = [], []
def em(ln):
return 0.8 * max(ln["height"], 1)
by_y = sorted(lines, key=lambda ln: (round(ln["ty"] / 0.6), ln["tx"]))
for a, b in zip(by_y, by_y[1:]):
if abs(a["ty"] - b["ty"]) > 0.6 or len(a["twords"]) + len(b["twords"]) > 6:
continue
if b["tx"] - a["txend"] < 3 * em(a):
continue
for left, right in cols:
if abs(a["tx"] - left) <= 1 and abs(b["txend"] - right) <= 1:
spread.append({"key": a["key"] + b["key"], "text": a["text"] + " … " + b["text"],
"x": a["tx"], "xend": b["txend"], "y": a["ty"],
"words": len(a["twords"]) + len(b["twords"])})
break
for ln in lines:
x, xe = ln["tx"], ln["txend"]
if any(abs(xe - e) <= 1 for e in freq):
gaps = [b[0] - a[1] for a, b in zip(ln["twords"], ln["twords"][1:])]
if 2 <= len(ln["twords"]) <= 6 and gaps and min(gaps) >= em(ln):
spread.append({"key": ln["key"], "text": ln["text"], "x": x, "xend": xe,
"y": ln["ty"], "words": len(ln["twords"])})
continue
for e in freq:
if x <= e - 50 and e + 2 <= xe <= e + 80:
# The usual left edge of lines ending on e: a positive indent
# means the line starts after a prefix (number, bullet, tab).
lefts = Counter(round(m["tx"]) for m in lines if abs(m["txend"] - e) <= 1)
left = lefts.most_common(1)[0][0] if lefts else x
over.append({"key": ln["key"], "text": ln["text"], "x": x, "xend": xe, "edge": e,
"indent": x - left, "y": ln["ty"], "words": len(ln["twords"])})
break
return over, spread
def script_of(pages):
"""The writing system of most letters in a document's text."""
c = Counter()
for lines in pages[:20]:
for ln in lines:
for ch in ln["key"]:
if ch.isalpha():
name = unicodedata.name(ch, "")
if "HIRAGANA" in name or "KATAKANA" in name:
c["Japanese"] += 1
elif name.startswith("CJK"):
c["Han"] += 1
elif name.startswith("HANGUL"):
c["Korean"] += 1
else:
c[name.split(" ")[0].capitalize() or "other"] += 1
if sum(c.values()) < 20:
return "no text"
if c["Han"] and c["Japanese"] * 20 >= c["Han"]:
return "Japanese"
return c.most_common(1)[0][0]
# ---------------------------------------------------------------- fonts
def family_key(s):
return re.sub(r"[^a-z0-9]", "", s.lower())
def ps_family(fontname):
"""The family part of a PDF font name ("ABCDEF+MinionPro-Regular")."""
f = fontname.split("+", 1)[-1]
return family_key(f.split("-")[0].split(",")[0])
def page_fonts(pdf, page):
code, _, out, _ = run(["pdffonts", "-f", str(page), "-l", str(page), str(pdf)], 120, text=True)
lines = out.splitlines()
try:
start = next(i for i, x in enumerate(lines) if x.startswith("---")) + 1
except StopIteration:
return []
return [x.split()[0] for x in lines[start:] if x.split() and x.split()[0] != "[none]"]
def font_ok(families, fontname):
"""Whether DesignCraft has the font a PDF font name belongs to:
`families` maps family keys to True when every face of the family
matched exactly."""
k = ps_family(fontname)
if len(k) < 3:
return False
for fam, ok in families.items():
if fam and (k.startswith(fam) or fam.startswith(k)):
return ok
return False
# ---------------------------------------------------------------- IDML
def local(tag):
return tag.rsplit("}", 1)[-1]
def page_parts(z):
"""Per document page, in order: the package parts whose items can draw
on it: its spread and its master spread."""
dm = ET.fromstring(z.read("designmap.xml"))
spreads = [el.get("src") for el in dm if local(el.tag) == "Spread"]
masters = {}
for el in dm:
if local(el.tag) == "MasterSpread":
src = el.get("src")
root = ET.fromstring(z.read(src))
for ms in root.iter("MasterSpread"):
masters[ms.get("Self")] = src
out = []
for src in spreads:
root = ET.fromstring(z.read(src))
for pg in root.iter("Page"):
parts = [src]
m = masters.get(pg.get("AppliedMaster"))
if m:
parts.append(m)
out.append(parts)
return out
STYLE_REFS = ("AppliedParagraphStyle", "AppliedCharacterStyle", "AppliedObjectStyle",
"AppliedTableStyle", "AppliedCellStyle", "AppliedTOCStyle")
def part_items(z, part, cache):
"""(Self identifiers, applied styles, parent stories) of the elements of
one package part."""
if part not in cache:
selfs, styles, stories = set(), set(), set()
if part in z.namelist():
for el in ET.fromstring(z.read(part)).iter():
if el.get("Self"):
selfs.add(el.get("Self"))
styles.update(el.get(k) for k in STYLE_REFS if el.get(k))
if el.get("ParentStory"):
stories.add(el.get("ParentStory"))
cache[part] = (selfs, styles, stories)
return cache[part]
def based_on(z):
"""Style Self -> the style it is based on."""
out = {}
if "Resources/Styles.xml" in z.namelist():
for el in ET.fromstring(z.read("Resources/Styles.xml")).iter():
b = el.findtext("Properties/BasedOn")
if el.get("Self") and b:
out[el.get("Self")] = b
return out
def page_selfs(z, parts, cache, based):
"""`Self` identifiers of everything that can draw on a page: the items
of its parts, the stories of their text frames (and of frames anchored
in those stories), and the styles they apply with their BasedOn chains.
Returns (item and story selfs, story ids, style selfs)."""
selfs, styles, stories = set(), set(), set()
todo, seen = list(parts), set()
while todo:
part = todo.pop()
if part in seen:
continue
seen.add(part)
items, applied, parents = part_items(z, part, cache)
selfs |= items
styles |= applied
for ps in parents - stories:
stories.add(ps)
todo.append(f"Stories/Story_{ps}.xml")
chain = set()
for st in styles:
for _ in range(50):
if not st or st in chain:
break
chain.add(st)
st = based.get(st)
return selfs, stories, chain
def page_gaps(ref_idml, ours_idml, page, identical):
"""compare.py's gap keys and extra-value keys of the elements that can
draw on a page (page_selfs): the styles, and the items and stories that
draw on none of the pixel-identical pages `identical`; if those have
none, all of them. A style can change one page and not another, so
styles are always kept. Returns ({gap name: values wrong or missing},
{name: extra values}, scope)."""
with zipfile.ZipFile(ref_idml) as z:
pp = page_parts(z)
if page > len(pp):
return {}, {}, ""
cache, based = {}, based_on(z)
selfs, sids, styles = page_selfs(z, pp[page - 1], cache, based)
other, other_sids = set(), set()
for n in identical:
if n <= len(pp):
a, b, _ = page_selfs(z, pp[n - 1], cache, based)
other |= a
other_sids |= b
ref, ours = compare.load(ref_idml), compare.load(ours_idml)
def keys(selfs, sids):
def sub(d):
el, st, rg = d
return ({k: v for k, v in el.items() if k[1] in selfs},
{k: v for k, v in st.items() if k in sids},
{k: v for k, v in rg.items() if k in sids})
st = compare.Stats()
compare.value_coverage(st, sub(ref), sub(ours))
gaps = {compare.gap_name(k): st.gap_wrong[k] + st.gap_missing[k] for k in st.gap_total
if st.gap_wrong[k] + st.gap_missing[k]}
return gaps, {compare.gap_name(k): n for k, n in st.extra_values.items() if n}
gaps, extras = keys((selfs - other) | styles, sids - other_sids)
if gaps or extras:
return gaps, extras, "only on the page"
return (*keys(selfs | styles, sids), "on the page")
def style_table(z):
"""Paragraph and character styles: Self -> attributes plus simple
Properties children."""
tables = {"ParagraphStyle": {}, "CharacterStyle": {}}
root = ET.fromstring(z.read("Resources/Styles.xml"))
for el in root.iter():
if el.tag in tables and el.get("Self"):
d = dict(el.attrib)
for k in ("BasedOn", "Leading", "AppliedFont"):
v = el.findtext(f"Properties/{k}")
if v is not None:
d[k] = v
tables[el.tag][el.get("Self")] = d
return tables
def resolve(name, table, key):
for _ in range(20):
st = table.get(name)
if st is None:
return None
if key in st:
return st[key]
name = st.get("BasedOn")
return None
def number(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def frame_heights(z):
"""Story id -> list of (frame height, top + bottom inset) of the text
frames on spreads."""
out = defaultdict(list)
for n in z.namelist():
if not n.startswith("Spreads/"):
continue
for tf in ET.fromstring(z.read(n)).iter("TextFrame"):
ys = [number(p.get("Anchor", "").split(" ")[-1]) for p in tf.iter("PathPointType")]
ys = [y for y in ys if y is not None]
if not ys:
continue
t = [number(x) or 0.0 for x in tf.get("ItemTransform", "1 0 0 1 0 0").split()]
sy = (t[2] ** 2 + t[3] ** 2) ** 0.5 if len(t) == 6 else 1.0
tb = 0.0
pref = tf.find("TextFramePreference")
if pref is not None:
if pref.get("InsetSpacing"):
tb = 2 * (number(pref.get("InsetSpacing")) or 0.0)
else:
v = [number(x.text) for x in pref.iterfind("Properties/InsetSpacing/ListItem")]
if len(v) == 4 and None not in v:
tb = v[0] + v[2]
out[tf.get("ParentStory")].append(((max(ys) - min(ys)) * sy, tb))
return out
def dropped_text(ref_idml, r_pages, a_pages):
"""Stories in one text frame that R shows and A does not, with the
frame's inner height and the first character's size and leading."""
r_text = ["".join(norm_story(ln["text"]) for ln in lines) for lines in r_pages]
a_text = "".join(r for lines in a_pages for r in (norm_story(ln["text"]) for ln in lines))
out = []
with zipfile.ZipFile(ref_idml) as z:
styles = style_table(z)
frames = frame_heights(z)
for n in z.namelist():
if not n.startswith("Stories/"):
continue
root = ET.fromstring(z.read(n))
for story in root.iter("Story"):
sid = story.get("Self")
if len(frames.get(sid, [])) != 1:
continue
text = compare.story_text(story)
if RTL.search(text):
continue
key = norm_story(text)[:24]
if len(key) < 4:
continue
pages = [i + 1 for i, t in enumerate(r_text) if key in t]
if not pages or key in a_text:
continue
psr = story.find(".//ParagraphStyleRange")
csr = story.find(".//CharacterStyleRange")
pa = psr.attrib if psr is not None else {}
ca = dict(csr.attrib) if csr is not None else {}
if csr is not None:
for k in ("Leading", "AppliedFont"):
v = csr.findtext(f"Properties/{k}")
if v is not None:
ca[k] = v
pst, cst = pa.get("AppliedParagraphStyle"), ca.get("AppliedCharacterStyle")
def get(k):
for v in (ca.get(k), resolve(cst, styles["CharacterStyle"], k),
pa.get(k), resolve(pst, styles["ParagraphStyle"], k)):
if v is not None:
return v
return None
size = number(get("PointSize")) or 12.0
lead = number(get("Leading"))
leading = lead if lead is not None else size * (number(get("AutoLeading")) or 120) / 100
fonts = {x.text for x in story.iter("AppliedFont") if x.text}
if get("AppliedFont"):
fonts.add(get("AppliedFont"))
h, tb = frames[sid][0]
out.append({"story": sid, "key": key, "pages_R": pages, "frame_h": h - tb,
"size": size, "leading": leading, "fonts": sorted(fonts)})
return out
# ---------------------------------------------------------------- one document
def ra_flags(p):
out = []
if p.get("size_RA"):
out.append("page size differs")
px = p.get("RA_pix") or {}
if px.get("pix") == "missing":
out.append("page missing")
if px.get("pix") == "ok":
if px["missing_ink"] > 0.02:
out.append("ink missing")
if px["extra_ink"] > 0.02:
out.append("ink extra")
t = p.get("RA_text")
if t and t["chars"] >= 20:
if t["overlap"] is not None and t["overlap"] < 0.9:
out.append("text missing")
if t["lines"] >= 3 and t["matched"] / t["lines"] < 0.8:
out.append("line breaks differ")
if t["matched"] >= 3 and t["moved"] / t["matched"] > 0.2:
out.append("lines moved")
return out
def ab_flags(p):
out = []
px = p.get("AB_pix") or {}
if px.get("pix") in ("missing", "size"):
out.append("page missing" if px["pix"] == "missing" else "page size differs")
if px.get("pix") == "ok" and not px["identical"]:
out.append("pixels differ")
if px["missing_ink"] > 0.005:
out.append("ink missing")
if px["extra_ink"] > 0.005:
out.append("ink extra")
t = p.get("AB_text")
if t and t["chars"]:
if t["overlap"] is not None and t["overlap"] < 0.99:
out.append("text differs")
if t["moved"]:
out.append("lines moved")
if t["matched"] < t["lines"]:
out.append("lines differ")
return out
def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def reuse(cfg, work, res):
"""With --reuse, take A.pdf, B.pdf and the renderer's results from the
earlier run when its converted IDML has the same bytes and both renders
succeeded; True if taken."""
if not cfg["reuse"]:
return False
src = Path(cfg["reuse"]) / "work" / work.name
try:
prev = json.loads((src / "run.json").read_text())
same = prev.get("ours_sha256")
if same is None and src.resolve() != work.resolve():
same = sha256(src / "ours.idml")
except (OSError, ValueError):
return False
pdfs = [src / "A.pdf", src / "B.pdf"]
if same != res["ours_sha256"] or not all(f.exists() for f in pdfs) \
or "A" not in prev or "B" not in prev or not renders_ok(prev):
return False
if src.resolve() != work.resolve():
for f in pdfs:
link = work / f.name
link.unlink(missing_ok=True)
link.symlink_to(f.resolve())
res["A"], res["B"], res["reused"] = prev["A"], prev["B"], str(src)
return True
def measure(cfg, t):
"""Render A and B and compare R, A and B page by page."""
work = Path(cfg["out"]) / "work" / t["id"]
ref_idml, r_pdf = CORPUS / t["idml"], CORPUS / t["pdf"]
res = {"id": t["id"], "ours_sha256": sha256(work / "ours.idml")}
if not reuse(cfg, work, res):
if not cfg["designcraft"]:
raise RuntimeError("renders not reusable and no --designcraft")
a_doc = package(ref_idml, work / "pkgA", ref_idml, cfg["link_fonts"])
res["A"] = designcraft(cfg, a_doc, work / "A.pdf")
b_doc = package(ref_idml, work / "pkgB", work / "ours.idml", cfg["link_fonts"])
res["B"] = designcraft(cfg, b_doc, work / "B.pdf")
(work / "run.json").write_text(json.dumps(res, indent=1))
pdfs = {"R": r_pdf, "A": work / "A.pdf", "B": work / "B.pdf"}
info = {k: boxes(p) for k, p in pdfs.items() if p.exists()}
res["pages"] = {k: v[0] for k, v in info.items()}
n = min(res["pages"].get("R", 0), cfg["max_pages"])
fl = (res["A"].get("fonts") or [])
families = {}
for f in fl:
k = family_key(f.get("family", ""))
families[k] = families.get(k, True) and f.get("matchStatus") == "exact"
res["fonts_missing"] = sorted({f["family"] for f in fl if f.get("matchStatus") == "missing"})
res["fonts_substituted"] = sorted({f"{f['family']} {f.get('style', '')}" for f in fl
if f.get("matchStatus") not in ("exact", "missing")})
res["font_complete"] = res["A"]["exit"] == 0 and all(families.values())
text = {k: text_pages(p) for k, p in pdfs.items() if k in info}
res["script"] = script_of(text.get("R", []))
for k in text:
for i, lines in enumerate(text[k]):
b = info[k][1].get(i + 1)
if b:
to_trim(lines, b)
rows = []
with tempfile.TemporaryDirectory(dir=cfg["tmp"]) as tmp:
imgs = {k: raster(p, Path(tmp) / k, n) for k, p in pdfs.items() if k in info and n}
for pno in range(1, n + 1):
p = {"page": pno}
bx = {k: info[k][1].get(pno) for k in info if pno <= info[k][0]}
for k, b in bx.items():
if b:
trim, _ = trim_offset(b)
p[f"size_{k}"] = (round(trim[2] - trim[0], 1), round(trim[3] - trim[1], 1))
for x, y in (("R", "A"), ("A", "B")):
sx, sy = p.get(f"size_{x}"), p.get(f"size_{y}")
p[f"size_{x}{y}"] = bool(sx and sy and (abs(sx[0] - sy[0]) > 1 or abs(sx[1] - sy[1]) > 1))
im = {}
for k in bx:
f = imgs.get(k, {}).get(pno)
im[k] = page_image(f, bx[k]) if f and bx[k] else None
p["RA_pix"] = pix_stats(im.get("R"), im.get("A"))
p["AB_pix"] = pix_stats(im.get("A"), im.get("B"))
rows.append(p)
for f in (imgs.get(k, {}).get(pno) for k in imgs):
if f:
f.unlink()
def lines(k, pno):
pages = text.get(k, [])
return pages[pno - 1] if pno <= len(pages) else None
ra_pairs = []
for p in rows:
r, a, b = lines("R", p["page"]), lines("A", p["page"]), lines("B", p["page"])
if r is not None and a is not None:
ra_pairs.append((p["page"], r, a, match(r, a)[0]))
if a is not None and b is not None:
p["AB_text"] = line_stats(a, b)
offsets = box_offsets(ra_pairs)
all_ok = res["font_complete"]
checks = []
for p in rows:
pno = p["page"]
r, a = lines("R", pno), lines("A", pno)
if r is not None and a is not None:
p["RA_text"] = line_stats(r, a, offsets.get(pno, {}))
p["fontok"] = all_ok or (res["A"]["exit"] == 0 and all(
font_ok(families, f) for f in page_fonts(r_pdf, pno)))
p["RA_flags"], p["AB_flags"] = ra_flags(p), ab_flags(p)
found = {}
for k in ("R", "A", "B"):
ls = lines(k, pno)
if ls is not None:
found[k] = line_checks(ls)
for k, (over, spread) in found.items():
for kind, items in (("overflow", over), ("spread", spread)):
in_r = {x["key"] for x in (found.get("R", ([], []))[0 if kind == "overflow" else 1])}
for x in items:
checks.append(dict(x, page=pno, pdf=k, check=kind,
in_R=(x["key"] in in_r) if k != "R" else ""))
res["page_rows"] = rows
res["checks"] = checks
res["dropped"] = []
if text.get("R") and text.get("A") and res["A"]["exit"] == 0:
try:
res["dropped"] = dropped_text(ref_idml, text["R"], text["A"])
except (ET.ParseError, KeyError, zipfile.BadZipFile) as e:
res["dropped_error"] = str(e)[:200]
differ = [p["page"] for p in rows if (p["AB_pix"] or {}).get("identical") is not True]
res["ab_first"] = differ[0] if differ else None
res["ab_after"] = len(differ) - 1 if differ else 0
res["ab_gaps"], res["ab_extras"] = {}, {}
if differ and res["B"]["exit"] == 0 and (work / "ours.idml").exists():
try:
same = [p["page"] for p in rows if (p["AB_pix"] or {}).get("identical") is True]
res["ab_gaps"], res["ab_extras"], res["ab_scope"] = page_gaps(
ref_idml, work / "ours.idml", differ[0], same)
except (ET.ParseError, KeyError, zipfile.BadZipFile, SystemExit) as e:
res["ab_gaps_error"] = str(e)[:200]
return res
def renders_ok(m):
"""Whether a measurement (or none) has both renders, A and B, without a
failure or a timeout."""
return m is None or m["A"]["exit"] == 0 and m["B"]["exit"] == 0
def earlier_result(cfg, t, key):
"""With --reuse-results, the (verdict, measurement) of the triple in the
earlier run if it was judged with the same key and its renders
succeeded; its PDFs are linked into this run's work folder."""
if not cfg["reuse_results"]:
return None
src = Path(cfg["reuse_results"]) / "work" / t["id"]
try:
prev = pickle.loads((src / "result.pickle").read_bytes())
except (OSError, pickle.UnpicklingError, EOFError):
return None
if prev.get("key") != key or not renders_ok(prev["measured"]):
return None
work = Path(cfg["out"]) / "work" / t["id"]
if src.resolve() != work.resolve():
for name in ("A.pdf", "B.pdf"):
if (src / name).exists():
link = work / name
link.unlink(missing_ok=True)
link.symlink_to((src / name).resolve())
(work / "result.pickle").write_bytes(pickle.dumps(prev))
prev["judged"]["result_reused"] = True
return prev["judged"], prev["measured"]
def process(cfg, t):
"""Judge one triple and, if it is a same-save triple to measure, measure
it. A judged triple's verdict and measurement go to work/ID/result.pickle."""
try:
res = judge(cfg, t)
if "reused_result" in res:
return tuple(res["reused_result"])
work = Path(cfg["out"]) / "work" / t["id"]
(work / "result.pickle").unlink(missing_ok=True)
m = None
if res["skip"] or res["reasons"]:
(work / "ours.idml").unlink(missing_ok=True)
else:
m = measure(cfg, t)
if "key" in res and renders_ok(m):
# Pickled: the tables need the values' types back (tuples). A
# failed or timed-out render is not kept, so that the next run
# renders the triple again.
(work / "result.pickle").write_bytes(pickle.dumps(
{"key": res["key"], "judged": res, "measured": m}))
return res, m
except Exception as e: # report the triple and go on with the others
import traceback
return dict(t, reasons=[f"tool error: {type(e).__name__}: {e}"[:300]],
traceback=traceback.format_exc()[-2000:]), None
# ---------------------------------------------------------------- reports
def fmt(v, nd=4):
if v is None:
return ""
if isinstance(v, bool):
return "yes" if v else "no"
if isinstance(v, float):
return f"{v:.{nd}f}"
if isinstance(v, (list, tuple)):
return ",".join(fmt(x, nd) for x in v)
return str(v).replace("\t", " ").replace("\n", " ")
def write_tsv(path, cols, rows):
with open(path, "w") as f:
f.write("\t".join(cols) + "\n")
for r in rows:
f.write("\t".join(fmt(r.get(c)) for c in cols) + "\n")
def doc_issues(m):
"""DesignCraft (R vs A) issue kinds of one document: kind -> (pages,
items)."""
out = defaultdict(lambda: [set(), 0])
def add(kind, page, n=1):
out[kind][0].add(page)
out[kind][1] += n
if m["A"]["exit"] != 0:
add("render failed" if m["A"]["exit"] != "timeout" else "render timed out", 0)
return out
if m["pages"].get("A") != m["pages"].get("R"):
add("page count differs", 0)
for p in m["page_rows"]:
for f in p["RA_flags"]:
add(f, p["page"])
for c in m["checks"]:
if c["pdf"] == "A" and not c["in_R"]:
add("line past the column edge" if c["check"] == "overflow" else "spread line",
c["page"])
for d in m["dropped"]:
short = d["frame_h"] < 1.05 * d["size"]
add("story not drawn, frame shorter than the point size" if short else
"story not drawn", d["pages_R"][0])
for item in (m["A"].get("preflight") or {}).get("issues", []):
page = item.get("page") # 0-based; none for an item off the pages
add(f"preflight: {item.get('kind')}", page + 1 if isinstance(page, int) else 0)
return out
def images(args):
"""Side-by-side and A/B difference images of pages of an earlier run."""
out = Path(args.out)
tri = {}
with open(out / "triples.tsv") as f:
cols = f.readline().rstrip("\n").split("\t")
for line in f:
r = dict(zip(cols, line.rstrip("\n").split("\t")))
tri[r["id"]] = r
(out / "images").mkdir(exist_ok=True)
dpi = 72
with tempfile.TemporaryDirectory() as tmp:
for spec in args.images:
tid, page = spec.split(":")
page = int(page)
work = out / "work" / tid
pdfs = {"R": CORPUS / tri[tid]["pdf"], "A": work / "A.pdf", "B": work / "B.pdf"}
im = {}
for k, pdf in pdfs.items():
if not pdf.exists():
continue
n, bx = boxes(pdf)
f = raster(pdf, Path(tmp) / f"{tid}-{page}-{k}", page, page, dpi).get(page)
if f and bx.get(page):
trim, (dx, dy) = trim_offset(bx[page])
s = dpi / 72
im[k] = crop(read_pgm(f), round(dx * s), round(dy * s),
round((trim[2] - trim[0]) * s), round((trim[3] - trim[1]) * s))
h = max(i[1] for i in im.values())
gap = 8
w = sum(i[0] for i in im.values()) + gap * (len(im) - 1)
rows = []
for y in range(h):
parts = []
for k in ("R", "A", "B"):
if k in im:
iw, ih, px = im[k]
parts.append(px[y * iw:(y + 1) * iw] if y < ih else b"\xff" * iw)
rows.append((b"\x80" * gap).join(parts))
write_png(out / "images" / f"{tid}-p{page}.png", w, h, rows, grey=True)
if "A" in im and "B" in im:
aw, ah, apx = im["A"]
bw, bh, bpx = im["B"]
w2, h2 = min(aw, bw), min(ah, bh)
rows = []
for y in range(h2):
ra, rb = apx[y * aw:y * aw + w2], bpx[y * bw:y * bw + w2]
row = bytearray()
for x, z in zip(ra, rb):
if x == z:
v = 255 - (255 - x) // 3
row += bytes((v, v, v))
elif x < z: # darker in A: A only
row += bytes((220, 30, 30))
else: # darker in B: B only
row += bytes((30, 60, 220))
rows.append(bytes(row))
write_png(out / "images" / f"{tid}-p{page}-ab.png", w2, h2, rows, grey=False)
print(f"{tid} page {page}: images/{tid}-p{page}.png"
+ (f", images/{tid}-p{page}-ab.png" if "A" in im and "B" in im else ""))
def write_png(path, w, h, rows, grey):
def chunk(kind, data):
c = kind + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
raw = b"".join(b"\x00" + r for r in rows)
path.write_bytes(b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 0 if grey else 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
def dc_warning_kind(msg):
"""A DesignCraft warning with its names, paths and numbers replaced."""
msg = re.sub(r"`[^`]*`", "`…`", msg)
msg = re.sub(r"'[^']*'", "'…'", msg)
return compare.warning_kind(re.sub(r"\([^)]*\)", "(…)", msg))
def compare_gap_docs(path):
"""Documents affected per gap key in a gaps.tsv of compare.py, if it
exists."""
path = Path(path)
if not path.exists():
return {}
out = {}
for line in path.read_text().splitlines()[1:]:
f = line.split("\t")
out[f"{f[0]} {f[1]}"] = int(f[2])
return out
def report(args, cfg, judged, measured, secs):
out = Path(args.out)
tcols = ["id", "verdict", "reasons", "version", "pdf_pages", "idml_pages", "creator",
"path", "idml", "pdf"]
for r in judged:
r["verdict"] = ("not measured" if r.get("skip") else
"same save" if not r["reasons"] else "rejected")
r["reasons"] = "; ".join(r.get("skip", []) + r["reasons"]) if isinstance(r["reasons"], list) \
else r["reasons"]
write_tsv(out / "triples.tsv", tcols, judged)
by_id = {r["id"]: r for r in judged}
pages, docs, lines, dropped, issues = [], [], [], [], []
conv_docs = []
ra_all, ra_complete = defaultdict(lambda: [0, 0, 0]), defaultdict(lambda: [0, 0, 0])
dc_warn = Counter()
times = []
for m in measured:
t = by_id[m["id"]]
for p in m["page_rows"]:
ra, ab = p.get("RA_pix") or {}, p.get("AB_pix") or {}
rt, bt = p.get("RA_text") or {}, p.get("AB_text") or {}
pages.append({"id": m["id"], "page": p["page"], "size_R": p.get("size_R"),
"size_A": p.get("size_A"), "size_B": p.get("size_B"),
"fontok": p["fontok"], "RA_mad": ra.get("mad"),
"RA_missing_ink": ra.get("missing_ink"), "RA_extra_ink": ra.get("extra_ink"),
"RA_lines": rt.get("lines"), "RA_matched": rt.get("matched"),
"RA_moved": rt.get("moved"), "RA_char_overlap": rt.get("overlap"),
"AB_identical": ab.get("identical"), "AB_mad": ab.get("mad"),
"AB_missing_ink": ab.get("missing_ink"), "AB_extra_ink": ab.get("extra_ink"),
"AB_lines": bt.get("lines"), "AB_matched": bt.get("matched"),
"AB_moved": bt.get("moved"), "AB_char_overlap": bt.get("overlap"),
"RA_flags": p["RA_flags"], "AB_flags": p["AB_flags"]})
for c in m["checks"]:
lines.append(dict(c, id=m["id"], text=c["text"][:80]))
for d in m["dropped"]:
dropped.append(dict(d, id=m["id"], shown_R=True, shown_A=False,
ratio=d["frame_h"] / d["size"] if d["size"] else None))
iss = doc_issues(m)
for kind, (pgs, n) in sorted(iss.items()):
issues.append({"id": m["id"], "version": t["version"], "script": m["script"],
"font_complete": m["font_complete"], "issue": kind,
"pages": sorted(x for x in pgs if x), "items": n})
for tab in ((ra_all, ra_complete) if m["font_complete"] else (ra_all,)):
tab[kind][0] += 1
tab[kind][1] += len([x for x in pgs if x])
tab[kind][2] += n
for w in set(dc_warning_kind(x) for x in m["A"]["warnings"]):
dc_warn[w] += 1
for k in ("A", "B"):
if isinstance(m[k]["secs"], (int, float)):
times.append(m[k]["secs"])
rows = m["page_rows"]
ident = sum(1 for p in rows if (p["AB_pix"] or {}).get("identical") is True)
top = sorted(m["ab_gaps"].items(), key=lambda kv: -kv[1])
docs.append({"id": m["id"], "version": t["version"], "script": m["script"],
"pages_R": m["pages"].get("R"), "pages_A": m["pages"].get("A"),
"pages_B": m["pages"].get("B"), "pages_compared": len(rows),
"convert_exit": t["convert"]["exit"], "convert_secs": t["convert"]["secs"],
"A_exit": m["A"]["exit"], "A_secs": m["A"]["secs"],
"B_exit": m["B"]["exit"], "B_secs": m["B"]["secs"],
"font_complete": m["font_complete"],
"fonts_missing": ";".join(m["fonts_missing"]),
"fonts_substituted": ";".join(m["fonts_substituted"]),
"links_missing": sum(1 for i in (m["A"].get("preflight") or {}).get("issues", [])
if i.get("kind") == "missingLink"),
"overset_A": sum(1 for i in (m["A"].get("preflight") or {}).get("issues", [])
if i.get("kind") == "overset"),
"overset_B": sum(1 for i in (m["B"].get("preflight") or {}).get("issues", [])
if i.get("kind") == "overset"),
"AB_identical_pages": ident, "AB_first_page": m["ab_first"],
"AB_pages_after": m["ab_after"],
"AB_first_page_gaps": "; ".join(f"{k} ({n})" for k, n in top),
"AB_gaps_scope": m.get("ab_scope", ""),
"RA_issues": "; ".join(f"{k}: {len(v[0])}" for k, v in sorted(iss.items())),
"path": t["path"]})
conv_docs.append((m, len(rows), ident))
write_tsv(out / "pages.tsv", list(pages[0].keys()) if pages else ["id"], pages)
write_tsv(out / "docs.tsv", list(docs[0].keys()) if docs else ["id"], docs)
write_tsv(out / "lines.tsv", ["id", "page", "pdf", "check", "in_R", "x", "xend", "edge",
"indent", "words", "y", "text"], lines)
write_tsv(out / "dropped.tsv", ["id", "story", "key", "shown_R", "shown_A", "pages_R",
"frame_h", "size", "ratio", "leading", "fonts"], dropped)
write_tsv(out / "issues.tsv", ["id", "version", "script", "font_complete", "issue", "pages",
"items"], issues)
gap_docs = compare_gap_docs(args.gaps)
first_keys, first_extra = Counter(), Counter()
# Gap keys of the documents that render pixel-identical: a key that
# many of them have too is unlikely to change a rendering.
same_keys = Counter(k for m, n, ident in conv_docs if n and ident == n
for k in by_id[m["id"]].get("gap_keys", []))
no_key = 0
for m, n, ident in conv_docs:
if m["ab_first"] is None:
continue
first_keys.update(m["ab_gaps"].keys())
first_extra.update(m["ab_extras"].keys())
if not m["ab_gaps"] and not m["ab_extras"]:
no_key += 1
rejected = Counter(x for r in judged if r["verdict"] == "rejected"
for x in r["reasons"].split("; "))
compared = [(m, n, i) for m, n, i in conv_docs if m["B"]["exit"] == 0]
complete = [m for m in measured if m["font_complete"]]
def table(tab):
return {k: {"documents": v[0], "pages": v[1], "items": v[2]}
for k, v in sorted(tab.items(), key=lambda kv: (-kv[1][0], kv[0]))}
summary = {
"options": {"exclude": args.exclude, "versions": args.versions, "limit": args.limit,
"file": args.file, "max_pages": args.max_pages, "link_fonts": args.link_fonts},
"tools": cfg["tools"],
"seconds": round(secs),
"triples": {
"candidates": len(judged),
"measured_versions": sum(1 for r in judged if r["verdict"] != "not measured"),
"same_save": len(measured),
"rejected_by": dict(rejected.most_common()),
},
"converter": {
"documents": len(measured),
"conversion_failures": sum(1 for r in judged if r.get("convert", {}).get("exit") not in (0, None)),
"B_render_failures": sum(1 for m in measured if m["B"]["exit"] != 0),
"documents_compared": len(compared),
"documents_identical": sum(1 for m, n, i in compared if n and i == n),
"pages_compared": sum(n for _, n, _ in compared),
"pages_identical": sum(i for _, _, i in compared),
"documents_differing": sum(1 for m, n, i in compared if i < n),
"pages_after_first_difference": sum(m["ab_after"] for m, _, _ in compared),
"first_difference_without_gap": no_key,
"first_difference_gaps": [{"key": k, "documents": n,
"identical_documents_with_gap": same_keys[k],
"compare_documents": gap_docs.get(k)}
for k, n in first_keys.most_common()],
"first_difference_extras": [{"key": k, "documents": n} for k, n in first_extra.most_common()],
},
"designcraft": {
"renders": 2 * len(measured),
"renders_reused": 2 * sum(1 for m in measured if m.get("reused")),
"render_failures": sum(1 for m in measured for k in ("A", "B") if m[k]["exit"] not in (0, "timeout")),
"render_timeouts": sum(1 for m in measured for k in ("A", "B") if m[k]["exit"] == "timeout"),
"render_seconds_median": statistics.median(times) if times else None,
"render_seconds_max": max(times) if times else None,
"font_complete_documents": len(complete),
"font_complete_without_text": sum(1 for m in complete if m["script"] == "no text"),
"font_complete_pages": sum(len(m["page_rows"]) for m in complete),
"issues_font_complete": table(ra_complete),
"issues_all": table(ra_all),
"warnings": dict(dc_warn.most_common(30)),
},
}
(out / "summary.json").write_text(json.dumps(summary, indent=1, ensure_ascii=False) + "\n")
tr, c, d = summary["triples"], summary["converter"], summary["designcraft"]
print(f"triples: {tr['candidates']} with IDML and PDF; {tr['measured_versions']} in the measured "
f"versions; {tr['same_save']} same-save, measured")
for k, n in rejected.most_common():
print(f" rejected: {n:4} {k}")
print(f"\nconverter (A vs B): {c['documents_identical']} of {c['documents_compared']} documents and "
f"{c['pages_identical']} of {c['pages_compared']} pages pixel-identical; "
f"conversion failures {c['conversion_failures']}, B render failures {c['B_render_failures']}")
print(f" first differing page in {c['documents_differing']} documents; "
f"{c['pages_after_first_difference']} differing pages after it; "
f"{c['first_difference_without_gap']} without a value gap on that page")
print(" value gaps on the first differing page (documents; pixel-identical documents with the "
"same gap; documents in compare.py's gaps.tsv):")
for g in c["first_difference_gaps"][:40]:
print(f" {g['documents']:4} {g['identical_documents_with_gap']:4} "
f"{g['compare_documents'] or '':>5} {g['key'][:100]}")
print(f"\nDesignCraft (R vs A): {d['renders']} renders, {d['render_failures']} failed, "
f"{d['render_timeouts']} timed out; median {d['render_seconds_median']} s, "
f"max {d['render_seconds_max']} s")
print(f" font-complete documents: {d['font_complete_documents']} of {len(measured)} "
f"({d['font_complete_pages']} pages)")
print(" issues on font-complete documents (documents, pages, items):")
for k, v in d["issues_font_complete"].items():
print(f" {v['documents']:4} {v['pages']:5} {v['items']:6} {k}")
print(" issues on all documents (documents, pages, items):")
for k, v in d["issues_all"].items():
print(f" {v['documents']:4} {v['pages']:5} {v['items']:6} {k}")
print(f"\n{round(secs)} s; tables in {out}")
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--designcraft", default=os.environ.get("DESIGNCRAFT_CLI"),
help="DesignCraft command-line program (or DESIGNCRAFT_CLI)")
ap.add_argument("--bin", default=str(compare.BIN), help="converter binary")
ap.add_argument("--jobs", type=int, default=os.cpu_count() or 1)
ap.add_argument("--limit", type=int, default=0, help="judge only the first N triples")
ap.add_argument("--file", help="only triples whose INDD path contains this")
ap.add_argument("--exclude", action="append", default=None,
help="leave out paths under corpus/ starting with this (default own/)")
ap.add_argument("--versions", default="13-21", help="INDD major versions to measure")
ap.add_argument("--max-pages", type=int, default=100)
ap.add_argument("--link-fonts", action="store_true",
help="give DesignCraft the font files beside the IDML as Document Fonts")
ap.add_argument("--out", default=str(ROOT / "target" / "render-compare"))
ap.add_argument("--gaps", default=str(ROOT / "target" / "compare" / "gaps.tsv"),
help="compare.py's gaps.tsv, to rank first-difference keys against")
ap.add_argument("--reuse", metavar="DIR",
help="take the renders of an earlier run in DIR where the IDML is the same")
ap.add_argument("--reuse-results", metavar="DIR",
help="take the verdicts and measurements of an earlier run in DIR where "
"the IDML and the converter's other output are the same")
ap.add_argument("--cache", metavar="DIR",
help="take the conversions from compare.py's --cache DIR (and add to it)")
ap.add_argument("--images", nargs="+", metavar="ID:PAGE",
help="write images of these pages of an earlier run, and nothing else")
args = ap.parse_args()
if args.exclude is None:
args.exclude = ["own/"]
out = Path(args.out)
if args.images:
images(args)
return
if not args.designcraft and not args.reuse:
ap.error("--designcraft PATH (or DESIGNCRAFT_CLI) is required")
out.mkdir(parents=True, exist_ok=True)
tmp = out / "tmp"
tmp.mkdir(exist_ok=True)
dc = Path(args.designcraft).resolve() if args.designcraft else None
version = run([str(dc), "--version"], 60, text=True)[2].strip() if dc else None
cfg = {"bin": str(Path(args.bin).resolve()), "designcraft": str(dc) if dc else None,
"out": str(out.resolve()), "tmp": str(tmp.resolve()), "link_fonts": args.link_fonts,
"max_pages": args.max_pages,
"reuse": str(Path(args.reuse).resolve()) if args.reuse else None,
"reuse_results": str(Path(args.reuse_results).resolve()) if args.reuse_results
else None,
"cache": str(compare.cache_dir(args.bin, args.cache).resolve()) if args.cache else None,
"tools": {"converter": args.bin,
"repository": subprocess.run(["git", "-C", str(ROOT), "describe", "--always",
"--dirty"], capture_output=True,
text=True).stdout.strip(),
"designcraft": version,
"designcraft_sha256": sha256(dc)[:16] if dc else None,
"renders_reused_from": str(Path(args.reuse).resolve()) if args.reuse else None}}
tool = hashlib.sha256(Path(__file__).read_bytes() + Path(compare.__file__).read_bytes())
cfg["result_key"] = {"tool": tool.hexdigest()[:16],
"designcraft": cfg["tools"]["designcraft_sha256"],
"max_pages": args.max_pages, "link_fonts": args.link_fonts}
if cfg["cache"]:
Path(cfg["cache"]).mkdir(parents=True, exist_ok=True)
start = time.time()
todo = candidates(args)
results = {}
with ProcessPoolExecutor(max(1, args.jobs)) as pool:
# Largest files first, so that the longest renders do not come last.
order = sorted(todo, key=lambda t: -(CORPUS / t["path"]).stat().st_size)
jobs = {pool.submit(process, cfg, t): t["id"] for t in order}
for n, job in enumerate(as_completed(jobs), 1):
res, m = job.result()
results[jobs[job]] = (res, m)
if "traceback" in res:
print(f"tool error on {res['id']}:\n{res['traceback']}", file=sys.stderr)
if n % 25 == 0:
print(f" {n}/{len(todo)} triples", file=sys.stderr)
judged = [results[t["id"]][0] for t in todo]
if cfg["reuse_results"]:
print(f"verdicts and measurements reused: "
f"{sum(1 for r in judged if r.get('result_reused'))} of "
f"{sum(1 for r in judged if not r.get('skip'))} judged triples", file=sys.stderr)
measured = [results[t["id"]][1] for t in todo if results[t["id"]][1] is not None]
report(args, cfg, judged, measured, time.time() - start)
if __name__ == "__main__":
main()