mirror of
https://github.com/storytold/indd-utils.git
synced 2026-11-06 18:42:12 +00:00
fix(render_compare): correct line-box offsets per page
The document-wide median per pair of box heights follows pagination drift on other pages. Each page uses its own median for a pair with at least 3 matched lines and falls back to the document's median otherwise. Add --reuse DIR to measure the tool on the renders of an earlier run.
This commit is contained in:
+22
-1
@@ -410,10 +410,30 @@ all boxes equal) and compared:
|
||||
| Mean difference | Mean absolute difference of the images after a 2 × 2 mean, from 0 to 1 |
|
||||
| Missing ink, extra ink | Share of pixels with ink (value below 200) in one image and none within 2 pixels in the other, after the 2 × 2 mean |
|
||||
| Lines | `pdftotext -bbox-layout` lines; a line's key is its text without white space, casefolded. Lines with the same key are matched, nearest first |
|
||||
| Moved line | A matched line whose left edge or top differs by more than 1 pt. Line boxes depend on the ascent and descent each PDF declares for its fonts, so between R and A the top is first corrected by the document's median difference for lines of the same pair of box heights |
|
||||
| Moved line | A matched line whose left edge or top differs by more than 1 pt. Between R and A the top is first corrected by a line-box offset (below) |
|
||||
| Character overlap | Share of the characters of a page (without white space) that the other page also has, counted as a multiset |
|
||||
| Font-complete | DesignCraft matches every font family of the document exactly (`font.list`); a page of another document is font-complete when every font `pdffonts` lists for the page in R belongs to such a family |
|
||||
|
||||
**Line-box offset (R vs A).** A line's box depends on the ascent and
|
||||
descent each PDF declares for its fonts, so the same line can have a
|
||||
different top in R and A without moving. Matched lines are grouped by
|
||||
their pair of box heights (R, A). Each page corrects a group by the
|
||||
median vertical difference of that group's lines on the page. A group
|
||||
with fewer than 3 lines on the page takes the median of the group over
|
||||
the whole document instead: with 3 lines the median is one of the lines
|
||||
and one line that moved does not change it, while with 2 lines it is
|
||||
their mean and a moved line shifts both. The per-page median keeps
|
||||
pagination drift on other pages out of a page's correction. A shift
|
||||
that most lines of a group share on one page is not counted as moved.
|
||||
|
||||
Evidence, on the same renders of 170 same-save documents (2026-10):
|
||||
with the document's median alone, 17,066 of 42,932 matched lines count
|
||||
as moved and 1,003 pages as `lines moved`; with the per-page median,
|
||||
11,786 lines and 905 pages. On one page whose lines have the same
|
||||
coordinates in two renderer builds, the document's median changed from
|
||||
11.6 to 73.6 pt between the builds and 29 of 30 matched lines counted as
|
||||
moved; with the page's median, none do.
|
||||
|
||||
**First differing page.** In A vs B, the first page that is not
|
||||
pixel-identical is where the converter's output first changes the
|
||||
rendering; later pages often only reflow. For that page the tool lists
|
||||
@@ -467,6 +487,7 @@ and the Python standard library only. Options:
|
||||
| `--link-fonts` | Give DesignCraft the font files a package keeps beside the IDML, in a `Document Fonts` folder; DesignCraft reads only that folder |
|
||||
| `--bin PATH` | Another converter binary |
|
||||
| `--gaps PATH` | compare.py's `gaps.tsv` to rank first-difference keys against (default `target/compare/gaps.tsv`) |
|
||||
| `--reuse DIR` | Take A.pdf, B.pdf and DesignCraft's results from `work/` of an earlier run in DIR (which may be `--out` itself) for each triple whose converted IDML has the same bytes as in that run; render the others, which needs `--designcraft`. For measuring a change to the tool on the same renders: pass the earlier run's `--bin` |
|
||||
| `--images ID:PAGE…` | Write images of pages of the last run (below) and nothing else |
|
||||
|
||||
Run it after converter changes, like `diff_outputs.py`: a page whose A vs
|
||||
|
||||
+81
-22
@@ -18,7 +18,7 @@ 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]
|
||||
[--gaps GAPS_TSV] [--reuse DIR]
|
||||
python3 -I tools/render_compare.py --images ID:PAGE [ID:PAGE...] [--out DIR]
|
||||
|
||||
Run from the repository root after `cargo build --release`. Needs
|
||||
@@ -34,6 +34,12 @@ triples. --max-pages limits the page-by-page comparison of each document
|
||||
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; other triples are rendered, which needs --designcraft. Use it
|
||||
to measure a change to this tool on the same renders.
|
||||
|
||||
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.
|
||||
@@ -69,6 +75,9 @@ 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
|
||||
@@ -457,8 +466,8 @@ 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 document's
|
||||
median difference for lines of the same box heights."""
|
||||
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:
|
||||
@@ -476,13 +485,24 @@ def line_stats(ref, oth, offsets=None):
|
||||
|
||||
|
||||
def box_offsets(pairs_by_page):
|
||||
"""Median vertical difference of matched lines per (reference box
|
||||
height, other box height) over a document."""
|
||||
groups = defaultdict(list)
|
||||
for ref, oth, pairs in 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"])
|
||||
return {k: statistics.median(v) for k, v in groups.items()}
|
||||
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):
|
||||
@@ -877,15 +897,48 @@ def ab_flags(p):
|
||||
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; 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:
|
||||
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"]}
|
||||
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")
|
||||
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()}
|
||||
@@ -940,7 +993,7 @@ def measure(cfg, t):
|
||||
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((r, a, match(r, a)[0]))
|
||||
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)
|
||||
@@ -950,7 +1003,7 @@ def measure(cfg, t):
|
||||
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)
|
||||
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)
|
||||
@@ -1285,6 +1338,7 @@ def report(args, cfg, judged, measured, secs):
|
||||
},
|
||||
"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,
|
||||
@@ -1345,6 +1399,8 @@ def main():
|
||||
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("--images", nargs="+", metavar="ID:PAGE",
|
||||
help="write images of these pages of an earlier run, and nothing else")
|
||||
args = ap.parse_args()
|
||||
@@ -1354,21 +1410,24 @@ def main():
|
||||
if args.images:
|
||||
images(args)
|
||||
return
|
||||
if not args.designcraft:
|
||||
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()
|
||||
_, _, version, _ = run([str(dc), "--version"], 60, text=True)
|
||||
cfg = {"bin": str(Path(args.bin).resolve()), "designcraft": str(dc), "out": str(out.resolve()),
|
||||
"tmp": str(tmp.resolve()), "link_fonts": args.link_fonts, "max_pages": args.max_pages,
|
||||
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,
|
||||
"tools": {"converter": args.bin,
|
||||
"repository": subprocess.run(["git", "-C", str(ROOT), "describe", "--always",
|
||||
"--dirty"], capture_output=True,
|
||||
text=True).stdout.strip(),
|
||||
"designcraft": version.strip(),
|
||||
"designcraft_sha256": hashlib.sha256(dc.read_bytes()).hexdigest()[:16]}}
|
||||
"designcraft": version,
|
||||
"designcraft_sha256": sha256(dc)[:16] if dc else None,
|
||||
"renders_reused_from": str(Path(args.reuse).resolve()) if args.reuse else None}}
|
||||
start = time.time()
|
||||
todo = candidates(args)
|
||||
results = {}
|
||||
|
||||
Reference in New Issue
Block a user