A company that writes with LaTeX ends up with a document class, a letter class and a presentation theme that share a design and not a line of code. Every fix is then made three times, two of them late and the third never. Here the page is written once. A company declares its colours, faces and logo in one file, and the document template, the letter class and the presentation theme read that one file. Nothing in the suite carries a colour value, a font name or a file name of any company, which is what lets it be published while the companies stay private. Every measure of the page follows from a measurement or from a named definition: the room the head and the foot need is taken from the boxes they really build, one line of the body text stands between the head and the text and between the text and the foot, a heading never stands alone at the foot of a page, a picture takes the size of the family, and a Markdown file reaches the same page as the same document written in LaTeX. The suite ships with a company that does not exist, Nordwind AG, so that it builds and is measured anywhere: three colours, the TeX Gyre families every installation carries, and an icon drawn in TikZ. 574 checks over five measurements, all of them on the rendered page or on the build log rather than on the source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
7.5 KiB
Python
196 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
|