195 lines
7.5 KiB
Python
195 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""What every test of this suite needs: build a document, read its page.
|
||
|
|
|
||
|
|
A test of a template measures the PAGE and not the source: a command can carry
|
||
|
|
the right name and put the wrong thing on the paper, and a package can define
|
||
|
|
every colour a company asks for and never use one of them.
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
|
||
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
||
|
|
BUILD = os.path.join(ROOT, "tests", "build")
|
||
|
|
SUITE = os.path.join(ROOT, "texmf", "tex", "latex", "business-suite")
|
||
|
|
|
||
|
|
|
||
|
|
def run(command, cwd=ROOT, environment=None):
|
||
|
|
return subprocess.run(command, cwd=cwd,
|
||
|
|
env=dict(os.environ, **(environment or {})),
|
||
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
|
|
encoding="utf-8", errors="replace")
|
||
|
|
|
||
|
|
|
||
|
|
def searchpath(*directories):
|
||
|
|
return os.pathsep.join([directory + "//" for directory in
|
||
|
|
(ROOT,) + directories] + [""])
|
||
|
|
|
||
|
|
|
||
|
|
def build(source, directory=None):
|
||
|
|
"""One document, built the way the npm script builds it.
|
||
|
|
|
||
|
|
The source may be a path in the repository or a file written for the test;
|
||
|
|
what comes back is the path of the PDF and the path of the log.
|
||
|
|
"""
|
||
|
|
directory = directory or os.path.dirname(os.path.abspath(source))
|
||
|
|
stem = os.path.splitext(os.path.basename(source))[0]
|
||
|
|
out = os.path.join(BUILD, stem)
|
||
|
|
os.makedirs(out, exist_ok=True)
|
||
|
|
# `-g` forces the run even where latexmk thinks the result is up to date.
|
||
|
|
# Without it a test reads the log of the LAST build that really happened,
|
||
|
|
# and a warning that belongs to a state the working tree has left stands in
|
||
|
|
# it — measured on 2026-09-21, where two fixtures reported a rerun that the
|
||
|
|
# current source no longer asks for.
|
||
|
|
result = run(["latexmk", "-xelatex", "-interaction=nonstopmode",
|
||
|
|
"-file-line-error", "-emulate-aux-dir", "-halt-on-error", "-g",
|
||
|
|
"-auxdir=" + out, "-outdir=" + out,
|
||
|
|
os.path.abspath(source)],
|
||
|
|
cwd=directory,
|
||
|
|
environment={"TEXINPUTS": searchpath(directory, out)})
|
||
|
|
pdf = os.path.join(out, stem + ".pdf")
|
||
|
|
log = os.path.join(out, stem + ".log")
|
||
|
|
if not os.path.exists(pdf):
|
||
|
|
raise AssertionError(f"{source} did not build:\n{result.stdout[-2000:]}")
|
||
|
|
return pdf, log
|
||
|
|
|
||
|
|
|
||
|
|
def write(name, text):
|
||
|
|
"""A fixture document, in the build directory beside its result.
|
||
|
|
|
||
|
|
What was built from the last version of this fixture goes with it. latexmk
|
||
|
|
decides from its own database whether a run is needed, and for a fixture
|
||
|
|
that is rewritten under the same name it decided wrong: the test then reads
|
||
|
|
a PDF built from a source that no longer exists — measured on 2026-09-21,
|
||
|
|
where a renamed fixture was still measured against the text of the run
|
||
|
|
before it.
|
||
|
|
"""
|
||
|
|
os.makedirs(BUILD, exist_ok=True)
|
||
|
|
shutil.rmtree(os.path.join(BUILD, os.path.splitext(name)[0]),
|
||
|
|
ignore_errors=True)
|
||
|
|
path = os.path.join(BUILD, name)
|
||
|
|
with open(path, "w", encoding="utf-8") as handle:
|
||
|
|
handle.write(text)
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
class Page:
|
||
|
|
"""One rendered page, as pixels, with the questions a layout test asks."""
|
||
|
|
|
||
|
|
def __init__(self, pdf, page=1, dpi=150):
|
||
|
|
stem = os.path.join(BUILD, "page")
|
||
|
|
run(["pdftoppm", "-r", str(dpi), "-f", str(page), "-l", str(page),
|
||
|
|
"-gray", pdf, stem])
|
||
|
|
name = f"{stem}-{page}.pgm"
|
||
|
|
if not os.path.exists(name):
|
||
|
|
name = f"{stem}-{page:02d}.pgm"
|
||
|
|
with open(name, "rb") as handle:
|
||
|
|
data = handle.read()
|
||
|
|
fields, offset = [], 0
|
||
|
|
while len(fields) < 4:
|
||
|
|
end = data.index(b"\n", offset)
|
||
|
|
line = data[offset:end]
|
||
|
|
offset = end + 1
|
||
|
|
if not line.startswith(b"#"):
|
||
|
|
fields += line.split()
|
||
|
|
self.width, self.height = int(fields[1]), int(fields[2])
|
||
|
|
self.pixels = data[offset:]
|
||
|
|
self.dpi = dpi
|
||
|
|
|
||
|
|
# What counts as ink. The furniture of this page is QUIET — a page number at
|
||
|
|
# 55 percent of a dark blue comes out around 160 in grey — so a threshold
|
||
|
|
# that only sees black misses exactly the things a layout test looks for:
|
||
|
|
# measured on 2026-09-21, when the example company changed its palette and the
|
||
|
|
# page number stopped existing for the test while standing on the page.
|
||
|
|
INK = 200
|
||
|
|
|
||
|
|
def dark(self, x, y):
|
||
|
|
return self.pixels[y * self.width + x] < self.INK
|
||
|
|
|
||
|
|
def ink_rows(self):
|
||
|
|
"""Every row of the page that carries ink, from the top."""
|
||
|
|
return [y for y in range(self.height)
|
||
|
|
if any(self.dark(x, y) for x in range(self.width))]
|
||
|
|
|
||
|
|
def ink_columns(self, top=0, bottom=None):
|
||
|
|
bottom = self.height if bottom is None else bottom
|
||
|
|
return [x for x in range(self.width)
|
||
|
|
if any(self.dark(x, y) for y in range(top, bottom))]
|
||
|
|
|
||
|
|
def box(self, top=0, bottom=None):
|
||
|
|
"""The rectangle the ink of a band of rows stands in, in points."""
|
||
|
|
bottom = self.height if bottom is None else bottom
|
||
|
|
columns = self.ink_columns(top, bottom)
|
||
|
|
rows = [y for y in range(top, bottom)
|
||
|
|
if any(self.dark(x, y) for x in range(self.width))]
|
||
|
|
if not columns or not rows:
|
||
|
|
return None
|
||
|
|
factor = 72.0 / self.dpi
|
||
|
|
return (columns[0] * factor, rows[0] * factor,
|
||
|
|
columns[-1] * factor, rows[-1] * factor)
|
||
|
|
|
||
|
|
|
||
|
|
def fonts(pdf):
|
||
|
|
"""Which faces the finished PDF really carries."""
|
||
|
|
result = run(["pdffonts", pdf])
|
||
|
|
found = []
|
||
|
|
for line in result.stdout.splitlines()[2:]:
|
||
|
|
if line.strip():
|
||
|
|
found.append(line.split()[0].split("+")[-1])
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
def pages(pdf):
|
||
|
|
result = run(["pdfinfo", pdf])
|
||
|
|
found = re.search(r"^Pages:\s+(\d+)", result.stdout, re.MULTILINE)
|
||
|
|
return int(found.group(1)) if found else 0
|
||
|
|
|
||
|
|
|
||
|
|
def text(pdf):
|
||
|
|
result = run(["pdftotext", "-layout", pdf, "-"])
|
||
|
|
return result.stdout
|
||
|
|
|
||
|
|
|
||
|
|
def words(pdf, page=1):
|
||
|
|
"""Every word of one page with the box it stands in, in points.
|
||
|
|
|
||
|
|
What comes back is a list of (word, left, top, right, bottom). The raster
|
||
|
|
cannot answer everything a layout test asks: two lines of text that a logo
|
||
|
|
stands beside are one band of ink, because the logo bridges the gap between
|
||
|
|
them. The text layer carries each word on its own, with the box the type
|
||
|
|
really occupies, so lines that touch in the picture stay two lines here.
|
||
|
|
"""
|
||
|
|
result = run(["pdftotext", "-bbox", "-f", str(page), "-l", str(page),
|
||
|
|
pdf, "-"])
|
||
|
|
found = []
|
||
|
|
for line in result.stdout.splitlines():
|
||
|
|
box = re.search(r'<word xMin="([\d.]+)" yMin="([\d.]+)" '
|
||
|
|
r'xMax="([\d.]+)" yMax="([\d.]+)">(.*)</word>', line)
|
||
|
|
if box:
|
||
|
|
found.append((box.group(5),) + tuple(float(box.group(index))
|
||
|
|
for index in (1, 2, 3, 4)))
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
class Check:
|
||
|
|
"""The count and the first failure, printed the way a suite prints it."""
|
||
|
|
|
||
|
|
def __init__(self, name):
|
||
|
|
self.name = name
|
||
|
|
self.green = 0
|
||
|
|
self.failures = []
|
||
|
|
|
||
|
|
def that(self, condition, message):
|
||
|
|
if condition:
|
||
|
|
self.green += 1
|
||
|
|
else:
|
||
|
|
self.failures.append(message)
|
||
|
|
|
||
|
|
def done(self):
|
||
|
|
for failure in self.failures:
|
||
|
|
print(f" {failure}", file=sys.stderr)
|
||
|
|
total = self.green + len(self.failures)
|
||
|
|
print(f"{self.name}: {self.green}/{total}")
|
||
|
|
return 1 if self.failures else 0
|