One LaTeX design for a company, in three templates
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>
This commit is contained in:
commit
adc58e19d7
39 files changed
+6627
No files matched your search
@@ -0,0 +1,5 @@
|
||||
\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[plain]{nordwind}
|
||||
\begin{document}
|
||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr. \logo{}
|
||||
\end{document}
|
||||
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The identity interface: the suite carries no company, and the company reaches it.
|
||||
|
||||
Two kinds of question, and both are needed. What the suite may NOT contain is a
|
||||
property of its source, so it is measured there. What a company really reaches on
|
||||
the page is a property of the page, so it is measured there.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import support
|
||||
from support import Check, Page, build
|
||||
|
||||
EXAMPLES = os.path.join(support.ROOT, "examples")
|
||||
|
||||
SUITE_FILES = ("business-identity.sty", "business-suite.sty",
|
||||
"business-brief.cls", "business-beamer.sty")
|
||||
|
||||
# A colour value in the suite is a company's property in a file every other company
|
||||
# loads. The palette belongs to the identity of the company, the roles to the
|
||||
# suite, and a role carries a name and never a number.
|
||||
COLOUR_VALUE = re.compile(r"\\definecolor\s*\{[^}]*\}\s*\{(HTML|RGB|rgb|cmyk|gray)\}")
|
||||
# The three templates take the identity from one place and carry none of their
|
||||
# own: not a colour, not a face. A copy passes every check that asks whether
|
||||
# the values are right, and is wrong on the day the one place changes.
|
||||
TEMPLATES = ("business-suite.sty", "business-brief.cls", "business-beamer.sty")
|
||||
OWN_VALUE = re.compile(r"^\s*(\\definecolor|\\colorlet|\\setmainfont"
|
||||
r"|\\setsansfont|\\setmonofont|\\newfontfamily"
|
||||
r"|\\renewcommand\*?\s*\\familydefault)", re.MULTILINE)
|
||||
# Everything after \endinput is dead: LaTeX stops reading there. A block moved
|
||||
# to the end of a file lands behind it without a word in any log, and every
|
||||
# document is then built without what it does. Measured in a sister package on
|
||||
# 2026-09-21, where the head height was measured by code that never ran.
|
||||
ENDINPUT = re.compile(r"^\\endinput\s*$", re.MULTILINE)
|
||||
|
||||
DOCUMENT = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\title{Lorem Ipsum}
|
||||
\author{Erika Muster}
|
||||
\date{21 September 2026}
|
||||
\begin{document}
|
||||
\maketitle
|
||||
\section{Consetetur}
|
||||
Dolor sit amet \success{} and \fail{} and an arrow →.
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# A company whose logo is a FILE and not a drawing, which is the other of the two
|
||||
# ways the suite carries a logo and the one the README shows. `example-image` is
|
||||
# the test image of TeX Live and lies on every installation.
|
||||
FILE_COMPANY = r"""\ProvidesPackage{filecompany}[2026/09/21 A company with a logo file]
|
||||
\RequirePackage{business-identity}
|
||||
\businesscolours{brand = blue}
|
||||
\setmainfont{Latin Modern Roman}
|
||||
\businessfonts{default = rm, smallcaps = false}
|
||||
\businessidentity{
|
||||
name = Filecompany,
|
||||
url = https://example.org,
|
||||
address = Filecompany — https://example.org,
|
||||
lockup = icon+text,
|
||||
icon = example-image,
|
||||
}
|
||||
\DeclareOption*{\PassOptionsToPackage{\CurrentOption}{business-suite}}
|
||||
\ProcessOptions\relax
|
||||
\RequirePackage{business-suite}
|
||||
\endinput
|
||||
"""
|
||||
|
||||
FILE_DOCUMENT = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[nologo]{filecompany}
|
||||
\begin{document}
|
||||
\pagestyle{empty}
|
||||
\noindent\logo[height=1cm]
|
||||
|
||||
\noindent\logo[height=2cm]
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
LANGUAGE = r"""\documentclass[a4paper,10pt,ngerman]{article}
|
||||
\usepackage{nordwind}
|
||||
\usepackage[ngerman]{babel}
|
||||
\title{Lorem Ipsum}
|
||||
\author{Erika Muster}
|
||||
\begin{document}
|
||||
\maketitle
|
||||
Dolor sit amet.
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# A company of two lines: the name, and the sentence it sets under the name, in
|
||||
# a face and a colour of its own. Two pages, because the first carries the
|
||||
# address line under the block and every other does not, and a logo in a
|
||||
# sentence, because that one stays the one line it has always been.
|
||||
TAGLINE_DOCUMENT = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\businessidentity{tagline={Vernunft und Freiheit},
|
||||
name face={\bfseries},
|
||||
%s}
|
||||
\title{Lorem Ipsum}
|
||||
\author{Erika Muster}
|
||||
\begin{document}
|
||||
\maketitle
|
||||
\section{Consetetur}
|
||||
Lorem ipsum dolor sit amet. The logo in a sentence: \logo{} stays one line.
|
||||
\newpage
|
||||
\section{Dolor Sit}
|
||||
At vero eos et accusam.
|
||||
\end{document}
|
||||
"""
|
||||
TAGLINE_QUIET = TAGLINE_DOCUMENT % "tagline color=business-running-quiet"
|
||||
# The same company without a word about the colour of its second line. Nothing
|
||||
# is then set for that line, so it stands in the colour of the name over it.
|
||||
TAGLINE_PLAIN = TAGLINE_DOCUMENT % "url=https://nordwind.example"
|
||||
|
||||
|
||||
# The lockup alone on a page, so nothing but it carries ink. `nologo` keeps the
|
||||
# head empty; what stands there is the one call being measured.
|
||||
SIZES = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[nologo]{nordwind}
|
||||
\businessidentity{tagline={Vernunft und Freiheit}}
|
||||
\begin{document}
|
||||
\thispagestyle{empty}
|
||||
\noindent\logo[tagline, height=2cm%s]
|
||||
\end{document}
|
||||
"""
|
||||
SIZES_MEASURED = SIZES % ""
|
||||
SIZES_FACTOR = SIZES % ", taglinefactor=0.4"
|
||||
SIZES_HIGH = SIZES % ", iconheight=3cm"
|
||||
SIZES_WIDE = SIZES % ", iconwidth=1cm"
|
||||
|
||||
|
||||
def spread(found, *words):
|
||||
"""The left and the right edge of a group of words of one line."""
|
||||
boxes = [word for word in found if word[0] in words]
|
||||
if not boxes:
|
||||
return None
|
||||
return min(box[1] for box in boxes), max(box[3] for box in boxes)
|
||||
|
||||
|
||||
def icon_box(page):
|
||||
"""The rectangle the icon stands in: the leftmost group of ink columns."""
|
||||
columns = page.ink_columns()
|
||||
if not columns:
|
||||
return None
|
||||
end = columns[0]
|
||||
for column in columns[1:]:
|
||||
if column - end > 2:
|
||||
break
|
||||
end = column
|
||||
rows = [y for y in range(page.height)
|
||||
if any(page.dark(x, y) for x in range(columns[0], end + 1))]
|
||||
factor = 72.0 / page.dpi
|
||||
return ((end - columns[0] + 1) * factor, (rows[-1] - rows[0] + 1) * factor)
|
||||
|
||||
|
||||
def lockup_sizes(check):
|
||||
"""The two sizes of the lockup that measure themselves.
|
||||
|
||||
The tagline is set to the size at which it is exactly as WIDE as the name
|
||||
over it, so the block has one left edge and one right edge. And the icon is
|
||||
as HIGH as that block, because the mark stands beside the text and a height
|
||||
of its own agrees with the text at one size of type and at no other. Both
|
||||
are measured here, and both overrides with them: a factor for the tagline
|
||||
and a height or a width for the icon, each one taking the measurement out of
|
||||
the way for that call alone.
|
||||
"""
|
||||
source = support.write("lockupsizes.tex", SIZES_MEASURED)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
found = support.words(pdf, 1)
|
||||
name = spread(found, "Nordwind")
|
||||
tag = spread(found, "Vernunft", "und", "Freiheit")
|
||||
check.that(name and tag, "the lockup did not reach the page")
|
||||
if not name or not tag:
|
||||
return
|
||||
wide, under = name[1] - name[0], tag[1] - tag[0]
|
||||
check.that(abs(wide - under) < 1,
|
||||
f"the name is {wide:.1f} points wide and the tagline "
|
||||
f"{under:.1f}: the second line is set to the width of the first")
|
||||
check.that(abs(name[0] - tag[0]) < 1,
|
||||
"the two lines of the block do not share their left edge")
|
||||
# The icon spans the block: from the top of the first line to the bottom of
|
||||
# the last. Measured at the ink of the drawing, which fills its own box.
|
||||
page = Page(pdf, 1)
|
||||
box = icon_box(page)
|
||||
check.that(box, "the icon did not reach the page")
|
||||
if not box:
|
||||
return
|
||||
block = max(word[4] for word in found) - min(word[2] for word in found)
|
||||
check.that(abs(box[1] - block) < 4,
|
||||
f"the icon is {box[1]:.1f} points high and the text beside it "
|
||||
f"{block:.1f}: the icon follows the text")
|
||||
# And each override takes one of the two measurements out of the way.
|
||||
source = support.write("lockupfactor.tex", SIZES_FACTOR)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
found = support.words(pdf, 1)
|
||||
name = spread(found, "Nordwind")
|
||||
tag = spread(found, "Vernunft", "und", "Freiheit")
|
||||
if name and tag:
|
||||
check.that(tag[1] - tag[0] < name[1] - name[0] - 5,
|
||||
"a company that names a factor for its tagline still got "
|
||||
"the line set to the width of the name")
|
||||
for name, fixture, index, asked in (("high", SIZES_HIGH, 1, 85.04),
|
||||
("wide", SIZES_WIDE, 0, 28.35)):
|
||||
source = support.write("lockup%s.tex" % name, fixture)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
box = icon_box(Page(pdf, 1))
|
||||
check.that(box, f"the icon of the {name} fixture did not reach the page")
|
||||
if not box:
|
||||
continue
|
||||
# Three centimetres are 85.04 points and one is 28.35. The drawing fills
|
||||
# its box, so a point and a half is the rasterizer.
|
||||
check.that(abs(box[index] - asked) < 2,
|
||||
f"the icon was asked for at {asked:.1f} points and came out "
|
||||
f"at {box[index]:.1f}")
|
||||
# The width follows the height in the proportions of the drawing: the icon
|
||||
# of this company is a disc, so the two are the same number.
|
||||
source = support.write("lockuphigh.tex", SIZES_HIGH)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
box = icon_box(Page(pdf, 1))
|
||||
if box:
|
||||
check.that(abs(box[0] - box[1]) < 2,
|
||||
f"the icon came out {box[0]:.1f} wide and {box[1]:.1f} high, "
|
||||
"so the proportions of the drawing were not kept")
|
||||
|
||||
|
||||
def source_checks(check):
|
||||
for name in SUITE_FILES:
|
||||
path = os.path.join(support.SUITE, name)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
text = handle.read()
|
||||
found = COLOUR_VALUE.search(text)
|
||||
check.that(found is None,
|
||||
f"{name} carries a colour value: {found.group(0) if found else ''}")
|
||||
ends = ENDINPUT.search(text)
|
||||
if ends:
|
||||
rest = text[ends.end():].strip()
|
||||
check.that(rest == "",
|
||||
f"{name} carries {len(rest.splitlines())} lines after "
|
||||
"\\endinput, which LaTeX never reads")
|
||||
if name in TEMPLATES:
|
||||
own = OWN_VALUE.search(text)
|
||||
check.that(own is None,
|
||||
f"{name} sets a colour or a face of its own: "
|
||||
f"{own.group(1) if own else ''} — it has to take both "
|
||||
"from the identity it loads")
|
||||
# Every role a template reads has to be declared, or the build fails with
|
||||
# "undefined color" in a company that does not set it.
|
||||
declared = set()
|
||||
with open(os.path.join(support.SUITE, "business-identity.sty"),
|
||||
encoding="utf-8") as handle:
|
||||
identity = handle.read()
|
||||
for name in re.findall(r"\\business@role\{([a-z-]+)\}", identity):
|
||||
declared.add(name)
|
||||
used = set()
|
||||
for name in SUITE_FILES:
|
||||
with open(os.path.join(support.SUITE, name), encoding="utf-8") as handle:
|
||||
used |= set(re.findall(r"business-([a-z-]+)", handle.read()))
|
||||
# The names of the packages themselves are no roles.
|
||||
used -= {"identity", "suite", "brief", "beamer"}
|
||||
missing = sorted(used - declared)
|
||||
check.that(not missing, f"roles read but never declared: {missing}")
|
||||
|
||||
|
||||
def page_checks(check):
|
||||
source = support.write("identity-probe.tex", DOCUMENT)
|
||||
pdf, _ = build(source, os.path.join(support.ROOT, "examples"))
|
||||
page = Page(pdf, 1)
|
||||
|
||||
# The logo stands at the right edge of the type area, on the same edge as
|
||||
# the text under it. Everything above the title belongs to the head.
|
||||
whole = page.box()
|
||||
check.that(whole is not None, "the page carries no ink at all")
|
||||
left, top, right, bottom = whole
|
||||
head = page.box(0, int(0.12 * page.height))
|
||||
check.that(head is not None, "the head of the page is empty")
|
||||
check.that(abs(head[2] - right) < 6,
|
||||
f"the head does not end on the right edge of the text: "
|
||||
f"{head[2]:.1f} against {right:.1f}")
|
||||
|
||||
# The page number stands at the foot, at the outer edge. Which rows the
|
||||
# foot occupies is READ off the page and not guessed from a share of its
|
||||
# height: the head and the foot follow the face of the company, and a share
|
||||
# that holds for one company misses the foot of the next — measured when the
|
||||
# example company changed its faces and the band that was searched came back
|
||||
# empty.
|
||||
rows = page.ink_rows()
|
||||
last, current = [], []
|
||||
for row in rows:
|
||||
if current and row - current[-1] > 2:
|
||||
last = current
|
||||
current = []
|
||||
current.append(row)
|
||||
foot = page.box(current[0], current[-1] + 1) if current else None
|
||||
check.that(foot is not None, "the foot carries no page number")
|
||||
check.that(foot is not None and abs(foot[2] - right) < 6,
|
||||
f"the page number is not at the right edge: "
|
||||
f"{foot[2] if foot else 0:.1f} against {right:.1f}")
|
||||
|
||||
# What the PDF says about itself. A file whose properties say "untitled" is
|
||||
# a file nobody finds again.
|
||||
#
|
||||
# It is read out of the XMP stream and not out of the info dictionary: that
|
||||
# is where a file search, a reading program and an archive look today, and
|
||||
# `hyperxmp` writes the author THERE and nowhere else — measured here, where
|
||||
# `pdfinfo` prints a title and no author for a document that carries both.
|
||||
xmp = support.run(["pdfinfo", "-meta", pdf]).stdout
|
||||
check.that("<dc:title>" in xmp and "Lorem Ipsum" in xmp,
|
||||
"the PDF carries no title")
|
||||
check.that("<dc:creator>" in xmp and "Erika Muster" in xmp,
|
||||
"the PDF carries no author")
|
||||
|
||||
# And the language, where the document says one. The template sets it
|
||||
# nowhere: hyperref takes it from babel, and a second setting would only
|
||||
# warn. That this really happens is measured and not assumed.
|
||||
spoken = support.write("language-probe.tex", LANGUAGE)
|
||||
speaking, _ = build(spoken, os.path.join(support.ROOT, "examples"))
|
||||
said = support.run(["pdfinfo", "-meta", speaking]).stdout
|
||||
check.that("dc:language" in said and "de" in said,
|
||||
"a document that declares its language carries none in its XMP")
|
||||
|
||||
# The faces of the company, and nothing that LaTeX fell back to. Computer
|
||||
# Modern is what arrives when a font has no shape in the encoding in force.
|
||||
faces = support.fonts(pdf)
|
||||
check.that(faces, "the PDF embeds no font at all")
|
||||
fallbacks = [face for face in faces if face.startswith("CMR")
|
||||
or face.startswith("CMSS") or face.startswith("SFRM")]
|
||||
check.that(not fallbacks, f"LaTeX fell back to {fallbacks}")
|
||||
# The marks come from the symbol family, because no text face carries them.
|
||||
check.that(any("DejaVu" in face for face in faces),
|
||||
f"the marks did not reach the page: {faces}")
|
||||
|
||||
|
||||
def plain(check):
|
||||
"""`plain` gives the identity and never the page of a document.
|
||||
|
||||
The two templates that ask for it are the letter class and the presentation
|
||||
theme, and neither of them is an ordinary document: a page style and a
|
||||
geometry laid over them wrecks their own layout. The case is measured with
|
||||
the smallest document that can show it, because the failure is not a wrong
|
||||
page — it is a run that stops: TeX skips a branch by counting `\\if` and
|
||||
`\\fi` TOKENS, so a flag declared inside the skipped block closes the skip
|
||||
with its own `\\fi` and the whole block runs. Reported on 2026-09-21 from a
|
||||
fourth company, where the letter and the deck stopped building.
|
||||
"""
|
||||
source = os.path.join(support.ROOT, "tests", "documents", "plain.tex")
|
||||
pdf, log = build(source, os.path.join(support.ROOT, "examples"))
|
||||
check.that(os.path.exists(pdf), "a document with `plain` does not build")
|
||||
with open(log, encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
# Nothing of the page: no head, no foot, no geometry.
|
||||
check.that("fancyhdr" not in text,
|
||||
"`plain` loads fancyhdr, so it carries the page of a document")
|
||||
check.that("geometry.sty" not in text,
|
||||
"`plain` loads geometry, so it lays out the page again")
|
||||
check.that("longtable" not in text,
|
||||
"`plain` loads the table machinery of the document template")
|
||||
|
||||
|
||||
def logo_from_file(check):
|
||||
"""The other way a company carries its logo: a file instead of a drawing.
|
||||
|
||||
Two heights in one document, and the second asks for twice the first. A
|
||||
height arrives as whatever the document writes — `1cm`, `2em`, `40pt` — and
|
||||
a computation that reads the number and drops the unit passes at one of
|
||||
them and fails at the other: measured in a sister package, where the gap
|
||||
under the logo collapsed at every height given in centimetres while the
|
||||
same formula looked right in em.
|
||||
"""
|
||||
support.write("filecompany.sty", FILE_COMPANY)
|
||||
source = support.write("logofile.tex", FILE_DOCUMENT)
|
||||
pdf, _ = build(source, support.BUILD)
|
||||
page = Page(pdf, 1)
|
||||
rows = page.ink_rows()
|
||||
check.that(rows, "the logo file did not reach the page at all")
|
||||
if not rows:
|
||||
return
|
||||
blocks, current = [], []
|
||||
for row in rows:
|
||||
if current and row - current[-1] > 2:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
current.append(row)
|
||||
blocks.append(current)
|
||||
check.that(len(blocks) == 2,
|
||||
f"the page should carry two logos, it carries {len(blocks)}")
|
||||
if len(blocks) != 2:
|
||||
return
|
||||
small = blocks[0][-1] - blocks[0][0] + 1
|
||||
large = blocks[1][-1] - blocks[1][0] + 1
|
||||
ratio = large / small
|
||||
check.that(1.9 < ratio < 2.1,
|
||||
f"the logo asked for at twice the height came out {ratio:.2f} "
|
||||
"times as high")
|
||||
|
||||
|
||||
def tagline(check):
|
||||
"""The lockup of two lines: the name, and the company's sentence under it.
|
||||
|
||||
Four things are measured, and each of them is a decision the family took.
|
||||
The head of the first page carries three lines — name, tagline, address —
|
||||
and the head of the second two, because the address belongs on the sheet
|
||||
that leaves the company and the tagline belongs to the mark. The logo in a
|
||||
SENTENCE stays one line, because a place asks for the tagline and a company
|
||||
only says what it is; without that division a mark in running text would
|
||||
open the line it stands in. And the page reserves the room for the taller
|
||||
block: the head is measured when the identity arrives, and a template that
|
||||
measured it before the company declared itself asks fancyhdr for the
|
||||
difference on every page — 16.8 points, measured on 2026-09-22.
|
||||
"""
|
||||
source = support.write("taglinedoc.tex", TAGLINE_QUIET)
|
||||
pdf, log = build(source, EXAMPLES)
|
||||
with open(log, encoding="utf-8") as handle:
|
||||
printed = handle.read()
|
||||
check.that("headheight is too small" not in printed,
|
||||
"the page kept less room for the head than the two-line lockup "
|
||||
"takes, so fancyhdr asked for the difference")
|
||||
for number, carries in ((1, True), (2, False)):
|
||||
found = support.words(pdf, number)
|
||||
head = [word for word in found if word[4] < 90]
|
||||
spoken = " ".join(word[0] for word in head)
|
||||
check.that("Vernunft" in spoken,
|
||||
f"the tagline is not in the head of page {number}: "
|
||||
f"{spoken}")
|
||||
check.that(("nordwind.example" in spoken) == carries,
|
||||
f"the address line is {'missing from' if carries else 'in'} "
|
||||
f"the head of page {number}, and it belongs on the first "
|
||||
"page alone")
|
||||
name = [word for word in head if word[0] == "Nordwind"]
|
||||
tag = [word for word in head if word[0] == "Vernunft"]
|
||||
if not name or not tag:
|
||||
continue
|
||||
name, tag = name[0], tag[0]
|
||||
# The two lines stand flush left with each other, and the second is the
|
||||
# smaller: the family sets the tagline at a share of the name.
|
||||
check.that(abs(name[1] - tag[1]) < 1,
|
||||
f"the name begins at {name[1]:.1f} points and the tagline "
|
||||
f"at {tag[1]:.1f}: the two lines of the block share their "
|
||||
"left edge")
|
||||
check.that(tag[2] > name[4] - 1,
|
||||
"the tagline does not stand under the name")
|
||||
high, low = name[4] - name[2], tag[4] - tag[2]
|
||||
check.that(low < high,
|
||||
f"the tagline is {low:.1f} points high and the name "
|
||||
f"{high:.1f}: the second line of the lockup is the smaller "
|
||||
"one")
|
||||
# A logo in a SENTENCE carries no tagline: the name stands in the line of
|
||||
# the body text, and the tagline of the company is nowhere near it.
|
||||
body = [word for word in support.words(pdf, 1) if word[2] > 200]
|
||||
inline = [word for word in body if word[0] == "Nordwind"]
|
||||
check.that(inline, "the logo in the sentence did not reach the page")
|
||||
if inline:
|
||||
near = [word for word in body
|
||||
if word[0] == "Vernunft"
|
||||
and abs(word[2] - inline[0][2]) < 30]
|
||||
check.that(not near,
|
||||
"the logo in a sentence carries the tagline, so a mark in "
|
||||
"running text opens the line it stands in")
|
||||
|
||||
|
||||
def darkest(page, word):
|
||||
"""The darkest pixel inside the box a word stands in, 0 to 255.
|
||||
|
||||
The darkest pixel is the core of a stem, and that is the colour the type is
|
||||
set in: the mean over a word measures how much white stands between its
|
||||
letters, which is a property of the letters and of the size, not of the
|
||||
colour.
|
||||
"""
|
||||
scale = page.dpi / 72.0
|
||||
left, top = int(word[1] * scale), int(word[2] * scale)
|
||||
right, low = int(word[3] * scale) + 1, int(word[4] * scale) + 1
|
||||
return min(page.pixels[y * page.width + x]
|
||||
for y in range(max(top, 0), min(low, page.height))
|
||||
for x in range(max(left, 0), min(right, page.width)))
|
||||
|
||||
|
||||
def tagline_colour(check):
|
||||
"""A company that names no colour for its second line sets none.
|
||||
|
||||
The tagline then stands in the colour the name over it stands in, because
|
||||
the lockup sets its colour once and no line of the block sets one of its
|
||||
own. Named, the colour reaches that line alone and the name keeps the one
|
||||
of the lockup. Measured at the darkest pixel of each word: the quiet role
|
||||
of the example company is a 55 percent tint of the brand colour, so the two
|
||||
lines come out far apart where it is named and together where it is not.
|
||||
"""
|
||||
tone = {}
|
||||
for name, fixture in (("quiet", TAGLINE_QUIET), ("plain", TAGLINE_PLAIN)):
|
||||
source = support.write("tagcolour%s.tex" % name, fixture)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
page = Page(pdf, 2)
|
||||
found = {word[0]: word for word in support.words(pdf, 2)
|
||||
if word[4] < 90}
|
||||
check.that("Nordwind" in found and "Vernunft" in found,
|
||||
f"the head of the {name} company carries no lockup")
|
||||
if "Nordwind" not in found or "Vernunft" not in found:
|
||||
return
|
||||
tone[name] = (darkest(page, found["Nordwind"]),
|
||||
darkest(page, found["Vernunft"]))
|
||||
check.that(abs(tone["plain"][0] - tone["plain"][1]) < 12,
|
||||
f"without a colour of its own the tagline comes out at "
|
||||
f"{tone['plain'][1]} against the {tone['plain'][0]} of the name: "
|
||||
"a colour was set for that line")
|
||||
check.that(tone["quiet"][1] - tone["quiet"][0] > 30,
|
||||
f"the named colour reached the tagline at {tone['quiet'][1]} "
|
||||
f"against the {tone['quiet'][0]} of the name, and the quiet role "
|
||||
"of the company is far lighter than its brand colour")
|
||||
check.that(abs(tone["quiet"][0] - tone["plain"][0]) < 12,
|
||||
"a colour named for the tagline changed the colour of the name")
|
||||
|
||||
|
||||
def main():
|
||||
check = Check("identity")
|
||||
source_checks(check)
|
||||
plain(check)
|
||||
page_checks(check)
|
||||
logo_from_file(check)
|
||||
tagline(check)
|
||||
tagline_colour(check)
|
||||
lockup_sizes(check)
|
||||
return check.done()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+747
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The page, measured where the reader looks at it.
|
||||
|
||||
Two things are measured, and the second is the one the family has paid for
|
||||
three times: an announcement and what it announces stand on the same page. The
|
||||
fixture produces that situation itself, over every distance to the page foot,
|
||||
instead of hoping that a document happens to carry it.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import support
|
||||
from support import Check, Page, build
|
||||
|
||||
EXAMPLES = os.path.join(support.ROOT, "examples")
|
||||
|
||||
HEAD = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\begin{document}
|
||||
"""
|
||||
|
||||
# Every distance from a full page to none: the announcement is pushed down the
|
||||
# page line by line, and at some of those distances it used to stay behind alone
|
||||
# at the foot while what it announced began overleaf.
|
||||
CASES = 16
|
||||
|
||||
|
||||
def table(marker):
|
||||
return ("\\begin{longtable}{ll}\n\\toprule\n"
|
||||
"\\businesstablehead{Name} & \\businesstablehead{Value}\\\\\n"
|
||||
"\\midrule\n\\endhead\n"
|
||||
"%s & 1\\\\ \\businessrowrule\n"
|
||||
"Second row & 2\\\\\n\\bottomrule\n\\end{longtable}\n" % marker)
|
||||
|
||||
|
||||
def long_table(marker):
|
||||
"""A table that asks for much more room than a heading does by itself."""
|
||||
rows = "".join("Row %d & %d\\\\ \\businessrowrule\n" % (number, number)
|
||||
for number in range(12))
|
||||
return ("\\begin{longtable}{ll}\n\\toprule\n"
|
||||
"\\businesstablehead{Name} & \\businesstablehead{Value}\\\\\n"
|
||||
"\\midrule\n\\endhead\n"
|
||||
"%s & 1\\\\ \\businessrowrule\n%s"
|
||||
"Last row & 2\\\\\n\\bottomrule\n\\end{longtable}\n"
|
||||
% (marker, rows))
|
||||
|
||||
|
||||
def pair_document():
|
||||
"""A heading directly under a heading, over every distance to the foot.
|
||||
|
||||
Two headings that follow one another are one announcement: the second names
|
||||
a part of what the first names, and a page that breaks between them carries
|
||||
a heading and nothing else.
|
||||
"""
|
||||
parts = [HEAD]
|
||||
for case in range(CASES):
|
||||
parts.append("\\newpage\n")
|
||||
parts.append("Filler line.\\par\n" * case)
|
||||
parts.append("\\section{Pair case %d}\n" % case)
|
||||
parts.append("\\subsection{Under case %d}\n" % case)
|
||||
parts.append("Lorem ipsum dolor sit amet.\\par\n" * 3)
|
||||
# And the same with something under the second heading that asks for much
|
||||
# more room than a heading does by itself: there the reservation of the
|
||||
# second one breaks the page, and the first is left behind alone.
|
||||
for case in range(CASES):
|
||||
parts.append("\\newpage\n")
|
||||
parts.append("Filler line.\\par\n" * case)
|
||||
parts.append("\\section{Deep case %d}\n" % case)
|
||||
parts.append("\\subsection{Deeper case %d}\n" % case)
|
||||
parts.append(long_table("Deep cell case %d" % case))
|
||||
# And with a picture under the second heading: a picture measures nothing
|
||||
# into the aux file, so the reservation of the heading is its own eight
|
||||
# lines, and what breaks the page is the picture that does not fit.
|
||||
for case in range(CASES):
|
||||
parts.append("\\newpage\n")
|
||||
parts.append("Filler line.\\par\n" * case)
|
||||
parts.append("\\section{Shown case %d}\n" % case)
|
||||
parts.append("\\subsection{Picture case %d}\n" % case)
|
||||
parts.append("\\begin{figure}[H]\\centering\n"
|
||||
"\\includegraphics[height=0.55\\textheight]"
|
||||
"{example-image}\n"
|
||||
"\\caption{Figure case %d}\n\\end{figure}\n" % case)
|
||||
parts.append("\\end{document}\n")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def sweep_document():
|
||||
parts = [HEAD]
|
||||
for case in range(CASES):
|
||||
parts.append("\\newpage\n")
|
||||
parts.append("Filler line.\\par\n" * case)
|
||||
parts.append("\\section{Heading case %d}\n" % case)
|
||||
parts.append("\\businessleadin[1]\nThe numbers:\n\\businesstogether\n")
|
||||
parts.append(table("Heading cell case %d" % case))
|
||||
for case in range(CASES):
|
||||
parts.append("\\newpage\n")
|
||||
parts.append("Filler line.\\par\n" * case)
|
||||
parts.append("\\businessleadin[1]\nSentence case %d:\n\\businesstogether\n"
|
||||
% case)
|
||||
parts.append(table("Sentence cell case %d" % case))
|
||||
parts.append("\\end{document}\n")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# The title rule, measured between three bands of ink: a paragraph, the heading,
|
||||
# a paragraph. The page carries nothing else, so which band is which is known
|
||||
# and not guessed.
|
||||
RULE = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[nologo]{nordwind}
|
||||
\begin{document}
|
||||
\pagestyle{empty}
|
||||
Lorem ipsum before the heading.
|
||||
|
||||
\section{Consetetur}
|
||||
|
||||
Dolor sit amet after the heading.
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# The head of a page: a heading on the left, the logo with the name on the
|
||||
# right. The two texts are of very different size, so any alignment but the one
|
||||
# on the text line is visible at once.
|
||||
HEADLINE = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
%s\begin{document}
|
||||
\section{Lorem}
|
||||
Dolor sit amet.
|
||||
\end{document}
|
||||
"""
|
||||
HEADLINE_ONE = HEADLINE % ""
|
||||
# The same head with the lockup of two lines. The heading on the left has to
|
||||
# stand on the line of the NAME, the first of the two, and not on the middle of
|
||||
# a block that has grown a second line.
|
||||
HEADLINE_TWO = HEADLINE % (
|
||||
"\\businessidentity{tagline={Vernunft und Freiheit}}\n")
|
||||
|
||||
|
||||
# The three pictures of the rule, each alone on a page of its own: one taller
|
||||
# than wide, one wider than tall, and a raster of pixels that is smaller than
|
||||
# the page and may not be blown up past twice its size.
|
||||
PICTURES = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\begin{document}
|
||||
\pagestyle{empty}
|
||||
\businessgraphic{example-image-9x16}
|
||||
\newpage
|
||||
\businessgraphic{example-image-16x9}
|
||||
\newpage
|
||||
\businessgraphic{example-image-a.png}
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# The same raster picture in a document that allows it a tenth of enlargement
|
||||
# and no more: 401.5 points wide by itself, so 441.6 with the option below,
|
||||
# where the line would otherwise give it 540.6.
|
||||
ZOOM = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[picturezoom=1.1]{nordwind}
|
||||
\begin{document}
|
||||
\businessgraphic{example-image-a.png}
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# A picture that does not fit into what is left of the page: half a page of
|
||||
# text, then a picture that would take a whole one.
|
||||
PICTUREROOM = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\begin{document}
|
||||
\pagestyle{empty}
|
||||
""" + "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.\\par\n" * 28 + r"""
|
||||
\businessgraphic{example-image-16x9}
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# A document that opens with a heading, and the same one that opens with a
|
||||
# paragraph: the distance under the title block belongs to the block and is the
|
||||
# same in both.
|
||||
OPENING = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\title{Lorem Ipsum}
|
||||
\author{Erika Muster}
|
||||
\date{21 September 2026}
|
||||
\begin{document}
|
||||
\maketitle
|
||||
%s
|
||||
\end{document}
|
||||
"""
|
||||
OPENING_HEADING = OPENING % "\n\\section{Consetetur}\n\nDolor sit amet.\n"
|
||||
OPENING_TEXT = OPENING % "\nDolor sit amet.\n"
|
||||
|
||||
|
||||
# The inner corner of the foot, with something in it that is taller than the
|
||||
# line the foot reserves. Without the measurement that gives the difference to
|
||||
# the foot, a logo of two centimetres is drawn into the text above it.
|
||||
FOOT = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\businessfootleft{\includegraphics[height=2cm]{example-image}}
|
||||
\begin{document}
|
||||
\section{Consetetur}
|
||||
""" + "Lorem ipsum dolor sit amet, consetetur sadipscing.\\par\n" * 40 + r"""
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
# The two distances of the page, asked of the page itself. What a document may
|
||||
# put in the foot changes how much room the foot needs, so the fixture is built
|
||||
# twice: once with the bare line of the foot, once with a picture of two
|
||||
# centimetres in it. The numbers come out of the run because they ARE the
|
||||
# definition — the page is laid out from them, and a distance read off the
|
||||
# raster carries the slack of whichever letters happen to stand at the edge.
|
||||
EDGES = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
%s\makeatletter
|
||||
\AtBeginDocument{%%
|
||||
\typeout{EDGES line=\the\baselineskip}%%
|
||||
\typeout{EDGES depth=\the\dp\strutbox}%%
|
||||
\typeout{EDGES topskip=\the\topskip}%%
|
||||
\typeout{EDGES headsep=\the\headsep}%%
|
||||
\typeout{EDGES footskip=\the\footskip}%%
|
||||
\typeout{EDGES foot=\the\business@foothigh}}
|
||||
\makeatother
|
||||
\begin{document}
|
||||
\section{Consetetur}
|
||||
""" + "Lorem ipsum dolor sit amet, consetetur sadipscing.\\par\n" * 40 + r"""
|
||||
\end{document}
|
||||
"""
|
||||
# A company whose mark is taller than the family's. The head has to keep the
|
||||
# room for it, and everything under the head has to move down by what it took:
|
||||
# the head is MEASURED at the head, and a document that asks for another height
|
||||
# is the only thing that proves the measurement is one.
|
||||
HEADROOM = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage[logoheight=%s]{nordwind}
|
||||
\begin{document}
|
||||
\section{Lorem}
|
||||
Dolor sit amet.
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
EDGES_LINE = EDGES % ""
|
||||
EDGES_PICTURE = EDGES % \
|
||||
"\\businessfootleft{\\includegraphics[height=2cm]{example-image}}\n"
|
||||
|
||||
|
||||
def bands(page):
|
||||
"""The rows of ink, grouped into the blocks they form."""
|
||||
rows = page.ink_rows()
|
||||
grouped, current = [], []
|
||||
for row in rows:
|
||||
if current and row - current[-1] > 1:
|
||||
grouped.append(current)
|
||||
current = []
|
||||
current.append(row)
|
||||
if current:
|
||||
grouped.append(current)
|
||||
return grouped
|
||||
|
||||
|
||||
def page_of(pdf, needle, count):
|
||||
"""The page a text stands on, counted from one; zero where it is missing."""
|
||||
for number in range(1, count + 1):
|
||||
found = support.run(["pdftotext", "-f", str(number), "-l", str(number),
|
||||
pdf, "-"]).stdout
|
||||
if needle in found:
|
||||
return number
|
||||
return 0
|
||||
|
||||
|
||||
def title_rule(check):
|
||||
source = support.write("titlerule.tex", RULE)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
page = Page(pdf, 1)
|
||||
blocks = bands(page)
|
||||
check.that(len(blocks) == 3,
|
||||
f"the fixture should carry three blocks of ink, it carries "
|
||||
f"{len(blocks)}")
|
||||
if len(blocks) != 3:
|
||||
return
|
||||
factor = 72.0 / page.dpi
|
||||
above = (blocks[1][0] - blocks[0][-1]) * factor
|
||||
below = (blocks[2][0] - blocks[1][-1]) * factor
|
||||
# The two distances of the family are one line and a half over a heading and
|
||||
# half a line under it, which is the three to one the rule asks for. What
|
||||
# the page shows is that glue plus the slack of the two faces that meet at
|
||||
# it — the descender of the paragraph, the capitals of the heading — and
|
||||
# that slack is under half a line for every face this suite has been set
|
||||
# in. Measured in the report of the origin, the document this family works
|
||||
# from: 19.7 points over a heading of the second level and 9.1 under it, at
|
||||
# a line of twelve points.
|
||||
line = 12.0
|
||||
check.that(1.25 * line < above < 2.0 * line,
|
||||
f"the room over the heading is {above:.1f} points, and one line "
|
||||
f"and a half of {line:.0f} points with the slack of the faces is "
|
||||
"between 15 and 24")
|
||||
check.that(0.4 * line < below < 1.1 * line,
|
||||
f"the room under the heading is {below:.1f} points, and half a "
|
||||
f"line of {line:.0f} points with the slack of the faces is "
|
||||
"between 5 and 13")
|
||||
check.that(above > below,
|
||||
f"the room over the heading is {above:.1f} points and under it "
|
||||
f"{below:.1f}: a heading belongs to what follows it")
|
||||
|
||||
|
||||
def left_edge(check):
|
||||
pdf, _ = build(os.path.join(EXAMPLES, "sample.tex"), EXAMPLES)
|
||||
page = Page(pdf, 1)
|
||||
edges = []
|
||||
for band in bands(page):
|
||||
columns = page.ink_columns(band[0], band[-1] + 1)
|
||||
if columns:
|
||||
edges.append(columns[0])
|
||||
left = min(edges)
|
||||
# A paragraph of this design begins at the margin, and so does a heading.
|
||||
# The bands that legitimately begin further right are the ones the layout
|
||||
# puts there: the folio at the foot, the logo and the address in the head,
|
||||
# and the cells of a table.
|
||||
off = [edge for edge in edges if edge - left > 4]
|
||||
check.that(len(off) <= len(edges) / 2,
|
||||
f"{len(off)} of {len(edges)} blocks do not begin on the left "
|
||||
"edge of the text")
|
||||
|
||||
|
||||
def together(check):
|
||||
source = support.write("sweep.tex", sweep_document())
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
count = support.pages(pdf)
|
||||
for case in range(CASES):
|
||||
heading = page_of(pdf, "Heading case %d" % case, count)
|
||||
cell = page_of(pdf, "Heading cell case %d" % case, count)
|
||||
check.that(heading and cell and heading == cell,
|
||||
f"heading case {case}: the heading stands on page {heading} "
|
||||
f"and its table on page {cell}")
|
||||
for case in range(CASES):
|
||||
sentence = page_of(pdf, "Sentence case %d:" % case, count)
|
||||
cell = page_of(pdf, "Sentence cell case %d" % case, count)
|
||||
check.that(sentence and cell and sentence == cell,
|
||||
f"sentence case {case}: the sentence stands on page "
|
||||
f"{sentence} and its table on page {cell}")
|
||||
|
||||
|
||||
def foot(check):
|
||||
"""What a document puts in the inner corner of the foot stays in the foot."""
|
||||
source = support.write("foot.tex", FOOT)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
page = Page(pdf, 1)
|
||||
blocks = bands(page)
|
||||
check.that(len(blocks) >= 2, "the page carries no foot")
|
||||
if len(blocks) < 2:
|
||||
return
|
||||
text, standing = blocks[-2], blocks[-1]
|
||||
factor = 72.0 / page.dpi
|
||||
gap = (standing[0] - text[-1]) * factor
|
||||
check.that(gap > 0,
|
||||
f"the foot overprints the text above it by {-gap:.1f} points")
|
||||
below = (page.height - standing[-1]) * factor
|
||||
check.that(14 < below < 57,
|
||||
f"the foot stands {below:.1f} points from the lower edge of the "
|
||||
"paper, outside the margin the page keeps")
|
||||
# And it is really the picture that stands there, not a line of text: a
|
||||
# block of two centimetres is 56.9 points high.
|
||||
high = (standing[-1] - standing[0] + 1) * factor
|
||||
check.that(high > 50,
|
||||
f"the foot block is {high:.1f} points high, so what the document "
|
||||
"put there did not arrive")
|
||||
|
||||
|
||||
def edges(check):
|
||||
"""One line of the text between the head and the text, and at the foot.
|
||||
|
||||
The head, the text and the foot are three blocks on one sheet, and the two
|
||||
distances between them are the same: exactly one line of the body text,
|
||||
whatever stands in the head and in the foot. Three lengths say it.
|
||||
|
||||
`\\headsep` is that distance over the text, which is how LaTeX defines it:
|
||||
from the lower edge of the head box to the upper edge of the text block.
|
||||
|
||||
`\\topskip` says where the FIRST line of a page stands in that block, and
|
||||
LaTeX sets it to a round ten points while a line stands `\\ht\\strutbox`
|
||||
over its own baseline. The block would then begin over its first line and
|
||||
the distance the page defines would not be the one a reader measures.
|
||||
|
||||
Under the text there is no such length: `\\footskip` runs from the baseline
|
||||
of the last line to the baseline of the foot, so the foot itself and what a
|
||||
line hangs below its baseline lie inside it. Both come off, and what is left
|
||||
is the line.
|
||||
"""
|
||||
for name, fixture, what in (("edgeline", EDGES_LINE, "a bare line"),
|
||||
("edgefoot", EDGES_PICTURE, "a picture")):
|
||||
source = support.write(name + ".tex", fixture)
|
||||
pdf, log = build(source, EXAMPLES)
|
||||
with open(log, encoding="utf-8") as handle:
|
||||
found = dict(re.findall(r"EDGES (\w+)=([0-9.]+)pt", handle.read()))
|
||||
check.that(len(found) == 6,
|
||||
f"the fixture with {what} in the foot reported "
|
||||
f"{len(found)} of the six lengths of the page")
|
||||
if len(found) != 6:
|
||||
continue
|
||||
line, depth = float(found["line"]), float(found["depth"])
|
||||
over = float(found["headsep"])
|
||||
under = float(found["footskip"]) - float(found["foot"]) - depth
|
||||
first = float(found["topskip"])
|
||||
check.that(abs(over - line) < 0.01,
|
||||
f"with {what} in the foot the head stands {over:.2f} points "
|
||||
f"over the text, and a line is {line:.2f}")
|
||||
check.that(abs(under - line) < 0.01,
|
||||
f"with {what} in the foot the text stands {under:.2f} points "
|
||||
f"over the foot, and a line is {line:.2f}")
|
||||
check.that(abs(first - (line - depth)) < 0.01,
|
||||
f"the first line of a page begins {first:.2f} points under "
|
||||
f"the upper edge of the text block, and a line stands "
|
||||
f"{line - depth:.2f} over its own baseline")
|
||||
# And the page shows it: between the last line of the text and what
|
||||
# stands in the foot there is a line, plus whatever that last line does
|
||||
# not hang below its baseline. Never less, and never a second line.
|
||||
page = Page(pdf, 1)
|
||||
blocks = bands(page)
|
||||
check.that(len(blocks) >= 2,
|
||||
f"the page with {what} in the foot carries no foot")
|
||||
if len(blocks) < 2:
|
||||
continue
|
||||
factor = 72.0 / page.dpi
|
||||
gap = (blocks[-1][0] - blocks[-2][-1]) * factor
|
||||
check.that(line - 0.5 < gap < line + depth + 0.5,
|
||||
f"with {what} in the foot the ink of the text stands "
|
||||
f"{gap:.1f} points over the ink of the foot, and a line of "
|
||||
f"{line:.0f} points with the depth of a line is between "
|
||||
f"{line:.0f} and {line + depth:.1f}")
|
||||
|
||||
|
||||
def headroom(check):
|
||||
"""A mark of another height, and the page that keeps the room for it.
|
||||
|
||||
The head reserves what it measures at itself, and that is only proven by a
|
||||
document which asks for a height the family did not set: at the one height
|
||||
every example carries, a reservation computed from a factor and one taken
|
||||
from the box look exactly alike. Two heights are built, the family's and
|
||||
twice it, and three things are measured — the log stays silent, the head
|
||||
grows with the mark, and the text under it moves down by what the head took.
|
||||
|
||||
Reported on 2026-09-22 from a sibling template of this family, which found
|
||||
the same gap in its own suite: the case had been reasoned about in the
|
||||
source and never built.
|
||||
"""
|
||||
seen = {}
|
||||
for name, height in (("small", "2em"), ("large", "4em")):
|
||||
source = support.write("headroom%s.tex" % name, HEADROOM % height)
|
||||
pdf, log = build(source, EXAMPLES)
|
||||
with open(log, encoding="utf-8") as handle:
|
||||
printed = handle.read()
|
||||
check.that("headheight is too small" not in printed,
|
||||
f"with a mark of {height} the page kept less room for the "
|
||||
"head than the head takes")
|
||||
reserved = {key: float(value) for key, value in
|
||||
re.findall(r"^\* \\(headheight|headsep|topmargin)"
|
||||
r"=(-?[0-9.]+)pt$", printed, re.MULTILINE)}
|
||||
check.that(len(reserved) == 3,
|
||||
f"with a mark of {height} the page did not report its "
|
||||
"layout")
|
||||
if len(reserved) != 3:
|
||||
return
|
||||
# Where the text block begins, out of the numbers the page was laid out
|
||||
# from: an inch of driver offset, the margin, the room for the head and
|
||||
# the distance under it. TeX points against the PostScript points of the
|
||||
# raster, which are the shorter by the ratio of the two inches.
|
||||
top = (72.27 + reserved["topmargin"] + reserved["headheight"]
|
||||
+ reserved["headsep"]) * 72.0 / 72.27
|
||||
page = Page(pdf, 1)
|
||||
factor = 72.0 / page.dpi
|
||||
blocks = bands(page)
|
||||
above = [band for band in blocks if band[-1] * factor < top]
|
||||
below = [band for band in blocks if band[0] * factor >= top - 1]
|
||||
check.that(len(above) + len(below) == len(blocks),
|
||||
f"with a mark of {height} a block of ink crosses the lower "
|
||||
"edge of the head")
|
||||
check.that(above and below,
|
||||
f"with a mark of {height} the page carries no head or no "
|
||||
"text")
|
||||
if not above or not below:
|
||||
return
|
||||
seen[name] = (reserved["headheight"], below[0][0] * factor, top)
|
||||
if len(seen) != 2:
|
||||
return
|
||||
# The mark doubles, so the room the head keeps grows with it — by about the
|
||||
# same twenty points, and never by nothing.
|
||||
grown = seen["large"][0] - seen["small"][0]
|
||||
check.that(grown > 15,
|
||||
f"the mark doubled and the room for the head grew by "
|
||||
f"{grown:.1f} points, so the room does not follow the mark")
|
||||
moved = seen["large"][1] - seen["small"][1]
|
||||
check.that(moved > 15,
|
||||
f"the text under the taller head begins {moved:.1f} points "
|
||||
"lower, so the page did not give the head what it took")
|
||||
|
||||
|
||||
def first_glyph(page, top, bottom, left, right):
|
||||
"""The column range of the leftmost letter in a rectangle of the page."""
|
||||
columns = [x for x in range(left, right)
|
||||
if any(page.dark(x, y) for y in range(top, bottom))]
|
||||
if not columns:
|
||||
return None
|
||||
start = end = columns[0]
|
||||
for column in columns[1:]:
|
||||
if column - end > 2:
|
||||
break
|
||||
end = column
|
||||
return start, end + 1
|
||||
|
||||
|
||||
def bottom_row(page, top, bottom, left, right):
|
||||
rows = [y for y in range(top, bottom)
|
||||
if any(page.dark(x, y) for x in range(left, right))]
|
||||
return rows[-1] if rows else None
|
||||
|
||||
|
||||
def head_line(check):
|
||||
"""The two texts of the head stand on ONE line.
|
||||
|
||||
The heading on the left is nine points, the name beside the logo sixteen,
|
||||
so the two carry every alignment differently: on a common top edge their
|
||||
baselines fall apart by the difference of the two cap heights, and the head
|
||||
reads as two blocks that missed each other. What is measured is the lower
|
||||
edge of the first capital on each side — the L of the heading and the N of
|
||||
the name, both flat on the baseline.
|
||||
|
||||
The tolerance is the rasterizer's own: a capital of sixteen points and one
|
||||
of nine end within a third of a point of each other at 300 dpi, while the
|
||||
defect this catches was sixteen points, the whole height of the name.
|
||||
|
||||
It is measured over both shapes of the lockup, the one of one line and the
|
||||
one of two: a block that has grown a second line still hands the head the
|
||||
baseline of its FIRST, or the heading on the left drops to the middle of a
|
||||
block it knows nothing about.
|
||||
"""
|
||||
for what, fixture, lines in (("one line", HEADLINE_ONE, 1),
|
||||
("two lines", HEADLINE_TWO, 2)):
|
||||
source = support.write("headline%d.tex" % lines, fixture)
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
page = Page(pdf, 1, dpi=300)
|
||||
# The topmost block of ink on the page IS the line of the head: the icon
|
||||
# reaches above and below the texts beside it, so the heading, the name
|
||||
# and the logo form one band. The address line under the logo and the
|
||||
# text of the page are the blocks after it.
|
||||
blocks = bands(page)
|
||||
check.that(len(blocks) >= 2, f"the page with {what} carries no head")
|
||||
if len(blocks) < 2:
|
||||
continue
|
||||
top, end = blocks[0][0], blocks[0][-1]
|
||||
middle = page.width // 2
|
||||
# On the right the first group of columns is the icon and the second the
|
||||
# name; on the left the first is the first letter of the heading.
|
||||
icon = first_glyph(page, top, end + 1, middle, page.width)
|
||||
check.that(icon, f"the head with {what} carries no logo")
|
||||
if not icon:
|
||||
continue
|
||||
# Right of the icon stand the lines of the block, and there they are
|
||||
# separate bands: the icon itself bridges them, because it is one shape
|
||||
# as tall as both. The name is the first of them.
|
||||
text = [y for y in range(top, end + 1)
|
||||
if any(page.dark(x, y) for x in range(icon[1] + 2, page.width))]
|
||||
rows, current = [], []
|
||||
for row in text:
|
||||
if current and row - current[-1] > 1:
|
||||
rows.append(current)
|
||||
current = []
|
||||
current.append(row)
|
||||
if current:
|
||||
rows.append(current)
|
||||
check.that(len(rows) == lines,
|
||||
f"the block beside the logo carries {len(rows)} lines and "
|
||||
f"should carry {lines}")
|
||||
if not rows:
|
||||
continue
|
||||
first, last = rows[0][0], rows[0][-1] + 1
|
||||
name = first_glyph(page, first, last, icon[1] + 2, page.width)
|
||||
heading = first_glyph(page, first, last, 0, middle)
|
||||
check.that(name and heading,
|
||||
f"the head with {what} carries no name or no heading")
|
||||
if not name or not heading:
|
||||
continue
|
||||
left_base = bottom_row(page, first, last, heading[0], heading[1])
|
||||
right_base = bottom_row(page, first, last, name[0], name[1])
|
||||
apart = abs(left_base - right_base) * 72.0 / page.dpi
|
||||
check.that(apart < 1,
|
||||
f"with {what} in the lockup the heading in the head and the "
|
||||
f"name beside the logo stand {apart:.1f} points apart and "
|
||||
"not on one line")
|
||||
|
||||
|
||||
def opening(check):
|
||||
"""The title block brings the distance under it, and nothing adds to it.
|
||||
|
||||
A heading brings three lines of space with it and the title block does too.
|
||||
Where a document opens with a heading, the two used to stand one under the
|
||||
other: 76.8 points between the date and the heading, against the 44.2 the
|
||||
same block gives a paragraph. Six lines of white where the page gives three
|
||||
everywhere else.
|
||||
"""
|
||||
gaps = []
|
||||
for name, source in (("openheading", OPENING_HEADING),
|
||||
("opentext", OPENING_TEXT)):
|
||||
pdf, _ = build(support.write(name + ".tex", source), EXAMPLES)
|
||||
page = Page(pdf, 1)
|
||||
blocks = bands(page)
|
||||
check.that(len(blocks) >= 6, f"{name}: the page carries no title block")
|
||||
if len(blocks) < 6:
|
||||
return
|
||||
factor = 72.0 / page.dpi
|
||||
# The bands of this page: the head, the address line under the logo,
|
||||
# the title, the author, the date — and then what the document opens
|
||||
# with, which is what this measures.
|
||||
gaps.append((blocks[5][0] - blocks[4][-1]) * factor)
|
||||
apart = abs(gaps[0] - gaps[1])
|
||||
check.that(apart < 6,
|
||||
f"a document that opens with a heading gives {gaps[0]:.1f} "
|
||||
f"points under the title block and one that opens with a "
|
||||
f"paragraph {gaps[1]:.1f}, which is {apart:.1f} points apart")
|
||||
|
||||
|
||||
def pairs(check):
|
||||
"""No page break between two headings that follow one another."""
|
||||
source = support.write("pairs.tex", pair_document())
|
||||
pdf, _ = build(source, EXAMPLES)
|
||||
count = support.pages(pdf)
|
||||
for case in range(CASES):
|
||||
upper = page_of(pdf, "Pair case %d" % case, count)
|
||||
lower = page_of(pdf, "Under case %d" % case, count)
|
||||
check.that(upper and lower and upper == lower,
|
||||
f"pair case {case}: the heading stands on page {upper} and "
|
||||
f"the heading under it on page {lower}")
|
||||
for case in range(CASES):
|
||||
upper = page_of(pdf, "Deep case %d" % case, count)
|
||||
lower = page_of(pdf, "Deeper case %d" % case, count)
|
||||
cell = page_of(pdf, "Deep cell case %d" % case, count)
|
||||
check.that(lower and cell and lower == cell,
|
||||
f"deep case {case}: the heading stands on page {lower} and "
|
||||
f"its table on page {cell}")
|
||||
for case in range(CASES):
|
||||
upper = page_of(pdf, "Shown case %d" % case, count)
|
||||
lower = page_of(pdf, "Picture case %d" % case, count)
|
||||
shown = page_of(pdf, "Figure case %d" % case, count)
|
||||
check.that(upper and lower and upper == lower,
|
||||
f"picture case {case}: the heading stands on page {upper} "
|
||||
f"and the heading under it on page {lower}")
|
||||
check.that(lower and shown and lower == shown,
|
||||
f"picture case {case}: the heading stands on page {lower} "
|
||||
f"and its picture on page {shown}")
|
||||
check.that(upper and lower and upper == lower,
|
||||
f"deep case {case}: the heading stands on page {upper} and "
|
||||
f"the heading under it on page {lower}")
|
||||
|
||||
|
||||
def pictures(check):
|
||||
"""What a picture takes of the page, measured at the ink on the page.
|
||||
|
||||
Upright: the full width of the line, at most half the height of the text.
|
||||
Lying: the full height that is free, at most the full width. A raster of
|
||||
pixels is enlarged twice over and no further. Every one of them centred.
|
||||
"""
|
||||
pdf, _ = build(support.write("pictures.tex", PICTURES), EXAMPLES)
|
||||
for number, (name, rule) in enumerate((
|
||||
("upright", "height"), ("lying", "width"),
|
||||
("raster", "zoom")), start=1):
|
||||
page = Page(pdf, number)
|
||||
factor = 72.0 / page.dpi
|
||||
# The page carries the head, the picture and the folio; the picture is
|
||||
# the tallest block of ink on it.
|
||||
blocks = bands(page)
|
||||
check.that(blocks, f"the {name} picture is not on its page")
|
||||
if not blocks:
|
||||
continue
|
||||
tallest = max(blocks, key=lambda band: band[-1] - band[0])
|
||||
top, bottom = tallest[0], tallest[-1]
|
||||
columns = page.ink_columns(top, bottom + 1)
|
||||
width = (columns[-1] - columns[0] + 1) * factor
|
||||
height = (bottom - top + 1) * factor
|
||||
left = columns[0] * factor
|
||||
right = page.width * factor - columns[-1] * factor
|
||||
# A4 is 597.5 points wide, the text block 540.6 with 28.35 on each
|
||||
# side, and the text is 713.9 points high.
|
||||
if rule == "height":
|
||||
check.that(abs(height - 0.5 * 713.9) < 12,
|
||||
f"the upright picture is {height:.1f} points high and "
|
||||
"not the half of the text height it may take")
|
||||
if rule == "width":
|
||||
check.that(abs(width - 540.6) < 4,
|
||||
f"the lying picture is {width:.1f} points wide against "
|
||||
"the 540.6 of the line")
|
||||
if rule == "zoom":
|
||||
# 401.5 points wide by itself, and the line gives 540.6, which is
|
||||
# 1.35 of it — under the two a raster may be enlarged by.
|
||||
check.that(abs(width - 540.6) < 4,
|
||||
f"the raster picture is {width:.1f} points wide against "
|
||||
"the 540.6 of the line")
|
||||
check.that(abs(left - right) < 4,
|
||||
f"the {name} picture stands {left:.1f} points from the left "
|
||||
f"edge and {right:.1f} from the right, so it is not centred")
|
||||
# A raster picture is enlarged as far as the document allows and no
|
||||
# further, which is what the option says.
|
||||
zoom, _ = build(support.write("zoom.tex", ZOOM), EXAMPLES)
|
||||
page = Page(zoom, 1)
|
||||
factor = 72.0 / page.dpi
|
||||
blocks = bands(page)
|
||||
tallest = max(blocks, key=lambda band: band[-1] - band[0])
|
||||
columns = page.ink_columns(tallest[0], tallest[-1] + 1)
|
||||
width = (columns[-1] - columns[0] + 1) * factor
|
||||
check.that(abs(width - 441.6) < 4,
|
||||
f"the raster picture is {width:.1f} points wide, and a tenth "
|
||||
"over its own 401.5 is 441.6")
|
||||
# And the picture that does not fit into what is left of the page stays on
|
||||
# it, small enough to fill what is there.
|
||||
room, _ = build(support.write("pictureroom.tex", PICTUREROOM), EXAMPLES)
|
||||
check.that(support.pages(room) == 1,
|
||||
f"the page with the picture broke into {support.pages(room)} "
|
||||
"pages instead of fitting the picture into what was left")
|
||||
if support.pages(room) == 1:
|
||||
page = Page(room, 1)
|
||||
factor = 72.0 / page.dpi
|
||||
tallest = max(bands(page), key=lambda band: band[-1] - band[0])
|
||||
high = (tallest[-1] - tallest[0] + 1) * factor
|
||||
# At the full width of the line the picture is 304 points high, and
|
||||
# what was left of the page is less than that.
|
||||
check.that(high < 300,
|
||||
f"the picture is {high:.1f} points high, so it was not made "
|
||||
"small enough for the room that was left")
|
||||
|
||||
|
||||
def main():
|
||||
check = Check("layout")
|
||||
title_rule(check)
|
||||
pairs(check)
|
||||
pictures(check)
|
||||
head_line(check)
|
||||
opening(check)
|
||||
edges(check)
|
||||
headroom(check)
|
||||
foot(check)
|
||||
left_edge(check)
|
||||
together(check)
|
||||
return check.done()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every build log of every document, read for what a green run hides.
|
||||
|
||||
A build that ends with a PDF has not said that it went well. LaTeX reports a
|
||||
box that runs over the edge, a character its font does not carry and a head
|
||||
that is too small for what stands in it, and then writes the page anyway. An
|
||||
error that stands in every log hides the next one that means something.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import support
|
||||
from support import Check, build
|
||||
|
||||
EXAMPLES = os.path.join(support.ROOT, "examples")
|
||||
DOCUMENTS = ("sample.tex", "logo-example.tex", "letter.tex",
|
||||
"presentation.tex")
|
||||
|
||||
# What no log of this suite may carry, and why each one matters.
|
||||
COMPLAINTS = (
|
||||
# An error stops nothing in nonstopmode: the run goes on and the page comes
|
||||
# out with the defect on it.
|
||||
(re.compile(r"^! ", re.MULTILINE), "an error"),
|
||||
# A box that runs over the edge is text outside the paper.
|
||||
(re.compile(r"^Overfull \\hbox", re.MULTILINE), "an overfull box"),
|
||||
# A character the font does not carry prints NOTHING, with no gap a reader
|
||||
# would notice and no error.
|
||||
(re.compile(r"Missing character", re.MULTILINE), "a missing character"),
|
||||
# A font shape LaTeX does not find is replaced silently, and the page then
|
||||
# carries a face nobody chose.
|
||||
(re.compile(r"Font shape .* undefined", re.MULTILINE),
|
||||
"an undefined font shape"),
|
||||
(re.compile(r"Some font shapes were not available", re.MULTILINE),
|
||||
"a substituted font"),
|
||||
# A head that is too small for what stands in it overprints the text under
|
||||
# it, and fancyhdr says so once per page.
|
||||
(re.compile(r"headheight is too small", re.MULTILINE),
|
||||
"a head that is too small"),
|
||||
# And a foot that is too small is the same defect at the other edge: the
|
||||
# page number and whatever the document put beside it are drawn into the
|
||||
# text above them. fancyhdr says so once per page.
|
||||
(re.compile(r"footskip is too small", re.MULTILINE),
|
||||
"a foot that is too small"),
|
||||
# A title block without an author prints the block and leaves the line out,
|
||||
# and the document says nothing about who wrote it.
|
||||
(re.compile(r"No \\author given", re.MULTILINE),
|
||||
"a title block without an author"),
|
||||
(re.compile(r"Package hyperref Warning", re.MULTILINE),
|
||||
"a warning of hyperref"),
|
||||
)
|
||||
|
||||
|
||||
def logs():
|
||||
"""Every log THIS RUN produced, from the examples and from the fixtures.
|
||||
|
||||
Only the logs under the test build: a log beside the examples was written
|
||||
by whatever state the working tree had when somebody last built there, and
|
||||
a defect that was repaired an hour ago still stands in it. Measured on
|
||||
2026-09-21, when a warning that the fresh build no longer carries was
|
||||
reported from a log of the build before it.
|
||||
|
||||
The probe of the converter is not among them either: it is run once, on
|
||||
purpose, to measure the width of a line, and it produces no page for any
|
||||
reader. Its log therefore always asks for the second run that a document
|
||||
gets and a measurement does not need.
|
||||
"""
|
||||
found = []
|
||||
for directory, _, names in os.walk(support.BUILD):
|
||||
for name in names:
|
||||
if name.endswith(".log") and name != "metrics.log":
|
||||
found.append(os.path.join(directory, name))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def main():
|
||||
check = Check("logs")
|
||||
for name in DOCUMENTS:
|
||||
source = os.path.join(EXAMPLES, name)
|
||||
if os.path.exists(source):
|
||||
build(source, EXAMPLES)
|
||||
read = logs()
|
||||
check.that(read, "no build log was found at all")
|
||||
for path in read:
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
for pattern, what in COMPLAINTS:
|
||||
hit = pattern.search(text)
|
||||
check.that(hit is None,
|
||||
f"{os.path.basename(path)} reports {what}: "
|
||||
f"{text[hit.start():hit.start() + 120].splitlines()[0] if hit else ''}")
|
||||
print(f" {len(read)} logs read")
|
||||
return check.done()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The way from Markdown to the PDF of a company, measured at the PDF.
|
||||
|
||||
The converter is the second road into the same design, and the two have to end
|
||||
in the same page: the same font, the same logo, the same table that fits the
|
||||
line. What is measured here is the result and not the call.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import support
|
||||
from support import Check, Page
|
||||
|
||||
EXAMPLES = os.path.join(support.ROOT, "examples")
|
||||
CONVERTER = os.path.join(support.ROOT, "bin", "md2pdf.py")
|
||||
OUT = os.path.join(support.BUILD, "md2pdf")
|
||||
|
||||
|
||||
def runnable(check):
|
||||
"""The converter is called by its name, so it has to be executable.
|
||||
|
||||
The README describes exactly that way — a link in a directory of the PATH —
|
||||
and a file without the bit ends in "Permission denied". Reported on
|
||||
2026-09-21 from a company that had linked it and called it.
|
||||
"""
|
||||
check.that(os.access(CONVERTER, os.X_OK),
|
||||
"bin/md2pdf.py carries no execute bit, so a link to it on the "
|
||||
"PATH cannot be called by its name")
|
||||
|
||||
|
||||
def convert(check):
|
||||
shutil.rmtree(OUT, ignore_errors=True)
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
result = support.run(["python3", CONVERTER, "--identity",
|
||||
"nordwind", "--out-dir", OUT, "--keep-tex",
|
||||
os.path.join(EXAMPLES, "report.md")])
|
||||
pdf = os.path.join(OUT, "report.pdf")
|
||||
check.that(os.path.exists(pdf),
|
||||
f"the converter produced no PDF:\n{result.stdout[-1500:]}")
|
||||
return pdf
|
||||
|
||||
|
||||
def measured(check):
|
||||
"""The line and the character were measured, not taken from the fallback.
|
||||
|
||||
The numbers the filters compute a column width from belong to the paper, the
|
||||
margins and the face of the company. They live outside TeX, in a Lua filter,
|
||||
so the converter builds a probe with the identity of the company and reads
|
||||
them out of it. Without the probe every company would compute with the
|
||||
numbers of the company this suite came from.
|
||||
"""
|
||||
metrics = os.path.join(OUT, "build", "metrics.txt")
|
||||
check.that(os.path.exists(metrics),
|
||||
"the converter wrote no measurement of the line")
|
||||
if not os.path.exists(metrics):
|
||||
return
|
||||
with open(metrics, encoding="utf-8") as handle:
|
||||
found = dict(re.findall(r"^(\w+) ([0-9.]+)pt$", handle.read(),
|
||||
re.MULTILINE))
|
||||
check.that("linewidth" in found, "the probe measured no line width")
|
||||
if "linewidth" not in found:
|
||||
return
|
||||
# A4 is 597.5 points wide and the template keeps 1cm on each side, so the
|
||||
# line is 540 points give or take the rounding of the paper.
|
||||
width = float(found["linewidth"])
|
||||
check.that(530 < width < 545,
|
||||
f"the measured line is {width} points wide, which is not the "
|
||||
"type area of an A4 page with the margins of this template")
|
||||
|
||||
|
||||
def inside(check, pdf):
|
||||
"""Nothing of the document stands outside the type area.
|
||||
|
||||
A Markdown table says nothing about its column widths, and without the
|
||||
filter LaTeX writes the last words of a wide table past the edge of the
|
||||
paper.
|
||||
"""
|
||||
page = Page(pdf, 1)
|
||||
factor = 72.0 / page.dpi
|
||||
columns = page.ink_columns()
|
||||
right = columns[-1] * factor
|
||||
left = columns[0] * factor
|
||||
# The paper is 595 points wide and the template keeps 1cm, 28.35 points, on
|
||||
# each side. Half a point of tolerance for the rounding of the raster.
|
||||
check.that(left > 27, f"ink stands {left:.1f} points from the left edge, "
|
||||
"inside the margin")
|
||||
check.that(right < 568, f"ink reaches {right:.1f} points, past the right "
|
||||
"margin of the page")
|
||||
|
||||
|
||||
def content(check, pdf):
|
||||
text = support.text(pdf)
|
||||
check.that("Lorem Ipsum" in text, "the title block did not arrive")
|
||||
check.that("Consetetur sadipscing elitr" in text and "41'820" in text,
|
||||
"the table did not arrive")
|
||||
# The path in the running text is broken at the characters that structure
|
||||
# it, so it stays inside the line. What arrives in the text layer is the
|
||||
# path with its pieces, never a line that runs past the margin.
|
||||
check.that("business-identity.sty" in text.replace("\n", ""),
|
||||
"the path in the running text did not arrive")
|
||||
faces = support.fonts(pdf)
|
||||
check.that(any("DejaVu" in face for face in faces),
|
||||
f"the marks did not reach the page: {faces}")
|
||||
|
||||
|
||||
# A formula in the three ways a writer writes one, and a fourth that is none:
|
||||
# inside a fenced code block the dollars are an example of themselves and stay
|
||||
# on the page as text.
|
||||
FORMULA = """---
|
||||
title: Formula
|
||||
author: Erika Muster
|
||||
---
|
||||
|
||||
# Consetetur
|
||||
|
||||
Inline $E = mc^2$ in a sentence.
|
||||
|
||||
$$a = \\frac{b}{c}$$
|
||||
|
||||
$$
|
||||
\\text{Ratio}
|
||||
=
|
||||
\\frac{\\text{Nutzen}}
|
||||
{\\text{Kosten}}
|
||||
$$
|
||||
|
||||
```text
|
||||
$$
|
||||
x
|
||||
=
|
||||
y
|
||||
$$
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def formula(check):
|
||||
"""A formula arrives as a formula, also written over several lines.
|
||||
|
||||
The grammar of the reader sees a line carrying nothing but `=` as the
|
||||
underline of a heading, so a formula over several lines used to come out as
|
||||
a heading of the first level with the dollars in it, and the build stayed
|
||||
green. Reported on 2026-09-21 from another company of this family, which
|
||||
built a twelve-page analysis with the formula standing as a green heading.
|
||||
"""
|
||||
path = os.path.join(support.BUILD, "formula.md")
|
||||
os.makedirs(support.BUILD, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(FORMULA)
|
||||
result = support.run(["python3", CONVERTER, "--identity", "nordwind",
|
||||
"--out-dir", OUT, "--keep-tex", path])
|
||||
pdf = os.path.join(OUT, "formula.pdf")
|
||||
check.that(os.path.exists(pdf),
|
||||
f"the document with the formula did not build:\n"
|
||||
f"{result.stdout[-1500:]}")
|
||||
if not os.path.exists(pdf):
|
||||
return
|
||||
text = support.text(pdf)
|
||||
# The dollars of a formula never reach the page; the ones in the code block
|
||||
# do, and they are the proof that nothing was repaired in there.
|
||||
body = text.split("$$")[0] if "$$" in text else text
|
||||
check.that(text.count("$$") == 2,
|
||||
f"the page carries {text.count('$$')} rows of dollars, and only "
|
||||
"the two of the code block belong there")
|
||||
check.that("Ratio" in body and "Nutzen" in body,
|
||||
"the formula over several lines did not arrive as a formula")
|
||||
check.that("Kosten" in body, "the second half of the formula is missing")
|
||||
faces = support.fonts(pdf)
|
||||
check.that(any("Math" in face or "math" in face for face in faces),
|
||||
f"no mathematics face on a page with three formulas: {faces}")
|
||||
|
||||
|
||||
# The same document twice: once written in Markdown, once in LaTeX. A document
|
||||
# looks the same whichever road it takes, so the two pages carry their ink in
|
||||
# the same places.
|
||||
TWIN_BODY = (
|
||||
("Consetetur", "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, "
|
||||
"sed diam nonumy eirmod tempor invidunt ut labore et dolore magna "
|
||||
"aliquyam erat."),
|
||||
("Dolor Sit", "Stet clita kasd gubergren, no sea takimata sanctus est "
|
||||
"Lorem ipsum dolor sit amet."),
|
||||
("Sadipscing Elitr", "At vero eos et accusam et justo duo dolores et ea "
|
||||
"rebum."),
|
||||
)
|
||||
TWIN_MD = "---\ntitle: Lorem Ipsum\nauthor: Erika Muster\ndate: 21 September 2026\n---\n\n" \
|
||||
+ "\n".join(f"{'#' * (level + 1)} {head}\n\n{text}\n"
|
||||
for level, (head, text) in enumerate(TWIN_BODY))
|
||||
TWIN_TEX = r"""\documentclass[a4paper,10pt]{article}
|
||||
\usepackage{nordwind}
|
||||
\title{Lorem Ipsum}
|
||||
\author{Erika Muster}
|
||||
\date{21 September 2026}
|
||||
\begin{document}
|
||||
\maketitle
|
||||
""" + "\n".join(
|
||||
"\\%s{%s}\n\n%s\n" % (("section", "subsection", "subsubsection")[level],
|
||||
head, text)
|
||||
for level, (head, text) in enumerate(TWIN_BODY)) + r"""
|
||||
\end{document}
|
||||
"""
|
||||
|
||||
|
||||
def bands(pdf):
|
||||
"""The rows of ink of the first page, grouped into blocks, in points."""
|
||||
page = Page(pdf, 1)
|
||||
factor = 72.0 / page.dpi
|
||||
grouped, current = [], []
|
||||
for row in page.ink_rows():
|
||||
if current and row - current[-1] > 1:
|
||||
grouped.append((current[0] * factor, current[-1] * factor))
|
||||
current = []
|
||||
current.append(row)
|
||||
if current:
|
||||
grouped.append((current[0] * factor, current[-1] * factor))
|
||||
return grouped
|
||||
|
||||
|
||||
# A picture out of Markdown: the same rules as one written in LaTeX, and the
|
||||
# converter is what says which of them is a raster of pixels.
|
||||
PICTURE_MD = """---
|
||||
title: Lorem Ipsum
|
||||
author: Erika Muster
|
||||
---
|
||||
|
||||
## Consetetur
|
||||
|
||||

|
||||
|
||||
## Dolor Sit
|
||||
|
||||

|
||||
"""
|
||||
|
||||
|
||||
def pictures(check):
|
||||
"""A picture out of Markdown takes the size of the family.
|
||||
|
||||
The converter hands every picture to pandoc's own bound, and that is where
|
||||
the rules of the template hang: the lying picture on the first page takes
|
||||
the whole line, the raster one on the second is the one the converter marks
|
||||
as pixels, and both stand centred.
|
||||
"""
|
||||
path = support.write("picture.md", PICTURE_MD)
|
||||
result = support.run(["python3", CONVERTER, "--identity", "nordwind",
|
||||
"--out-dir", OUT, path])
|
||||
pdf = os.path.join(OUT, "picture.pdf")
|
||||
check.that(os.path.exists(pdf),
|
||||
f"the document with the pictures did not build:\n"
|
||||
f"{result.stdout[-1000:]}")
|
||||
if not os.path.exists(pdf):
|
||||
return
|
||||
for number, name in ((1, "lying"), (2, "raster")):
|
||||
page = Page(pdf, number)
|
||||
factor = 72.0 / page.dpi
|
||||
rows = page.ink_rows()
|
||||
check.that(rows, f"the {name} picture is not on page {number}")
|
||||
if not rows:
|
||||
continue
|
||||
grouped, current = [], []
|
||||
for row in rows:
|
||||
if current and row - current[-1] > 1:
|
||||
grouped.append(current)
|
||||
current = []
|
||||
current.append(row)
|
||||
grouped.append(current)
|
||||
tallest = max(grouped, key=lambda band: band[-1] - band[0])
|
||||
columns = page.ink_columns(tallest[0], tallest[-1] + 1)
|
||||
width = (columns[-1] - columns[0] + 1) * factor
|
||||
left = columns[0] * factor
|
||||
right = page.width * factor - columns[-1] * factor
|
||||
check.that(width <= 545,
|
||||
f"the {name} picture is {width:.1f} points wide, more than "
|
||||
"the 540.6 of the line")
|
||||
check.that(abs(left - right) < 4,
|
||||
f"the {name} picture stands {left:.1f} points from the left "
|
||||
f"edge and {right:.1f} from the right, so it is not centred")
|
||||
|
||||
|
||||
def twins(check):
|
||||
"""A document out of Markdown is the document out of LaTeX.
|
||||
|
||||
Both roads end in the same template, so the same content has to stand in
|
||||
the same places: same distances under the title block, same three lines
|
||||
over every heading, same line for every paragraph.
|
||||
"""
|
||||
md = support.write("twin.md", TWIN_MD)
|
||||
result = support.run(["python3", CONVERTER, "--identity", "nordwind",
|
||||
"--out-dir", OUT, md])
|
||||
converted = os.path.join(OUT, "twin.pdf")
|
||||
check.that(os.path.exists(converted),
|
||||
f"the twin out of Markdown did not build:\n"
|
||||
f"{result.stdout[-1000:]}")
|
||||
written, _ = support.build(support.write("twin.tex", TWIN_TEX), EXAMPLES)
|
||||
if not os.path.exists(converted):
|
||||
return
|
||||
out_of_md, out_of_tex = bands(converted), bands(written)
|
||||
check.that(len(out_of_md) == len(out_of_tex),
|
||||
f"the page out of Markdown carries {len(out_of_md)} blocks of "
|
||||
f"ink and the one out of LaTeX {len(out_of_tex)}")
|
||||
if len(out_of_md) != len(out_of_tex):
|
||||
return
|
||||
worst = max(abs(a[0] - b[0]) for a, b in zip(out_of_md, out_of_tex))
|
||||
check.that(worst < 1,
|
||||
f"a block of the page stands {worst:.1f} points lower out of "
|
||||
"Markdown than out of LaTeX")
|
||||
|
||||
|
||||
def missing(check):
|
||||
"""A document that does not exist ends in a message and never in a PDF."""
|
||||
result = support.run(["python3", CONVERTER, "--identity",
|
||||
"nordwind", "--out-dir", OUT,
|
||||
os.path.join(OUT, "gibtesnicht.md")])
|
||||
check.that(result.returncode != 0,
|
||||
"the converter reported success for a file that does not exist")
|
||||
check.that("gibtesnicht.md" in result.stdout,
|
||||
"the message does not name the file that is missing")
|
||||
check.that(not os.path.exists(os.path.join(OUT, "gibtesnicht.pdf")),
|
||||
"the converter produced a PDF for a file that does not exist")
|
||||
|
||||
|
||||
def main():
|
||||
check = Check("md2pdf")
|
||||
runnable(check)
|
||||
pdf = convert(check)
|
||||
if os.path.exists(pdf):
|
||||
measured(check)
|
||||
inside(check, pdf)
|
||||
content(check, pdf)
|
||||
formula(check)
|
||||
pictures(check)
|
||||
twins(check)
|
||||
missing(check)
|
||||
return check.done()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The values the family decided, against the defaults of this suite.
|
||||
|
||||
The suite came out of `pacta.sty`, and the proportions of its logo were decided
|
||||
there and confirmed for every company of the family. A derivation that replaces
|
||||
one of those numbers has to REPRODUCE it; where it does not, it is a new value,
|
||||
and nobody decided it.
|
||||
|
||||
That is not an abstract risk. On 2026-09-21 this suite carried four derivations
|
||||
in place of those numbers for half a day — the logo of the page head measured
|
||||
off the running head instead of 2em, the one of the title block as three times
|
||||
that instead of 6em, the name beside the logo fitted to the ink of the drawing
|
||||
instead of 0.8 of its height, the gap in ems of the name instead of 0.1 of that
|
||||
height — and every one of them came out at another size than in the two companies
|
||||
beside it. It was visible on the first page of the first document.
|
||||
|
||||
The origin is read where LaTeX finds it. Where it is not installed, this says so
|
||||
and passes: a company that has no Pacta beside it cannot compare, and a test that
|
||||
fails for a missing neighbour says nothing about this suite.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import support
|
||||
from support import Check
|
||||
|
||||
SUITE = os.path.join(support.SUITE, "business-suite.sty")
|
||||
IDENTITY = os.path.join(support.SUITE, "business-identity.sty")
|
||||
|
||||
# What is compared, and where each value stands in the two files.
|
||||
HEIGHTS = (
|
||||
("logoheight", r"\\newcommand\{\\pacta@logoheight\}\{([^}]*)\}",
|
||||
r"\\newcommand\{\\business@logoheight\}\{([^}]*)\}",
|
||||
"the logo of the page head"),
|
||||
("titlelogoheight", r"\\newcommand\{\\pacta@titlelogoheight\}\{([^}]*)\}",
|
||||
r"\\newcommand\{\\business@titlelogoheight\}\{([^}]*)\}",
|
||||
"the logo of the title block"),
|
||||
)
|
||||
# The three proportions of the lockup, as the origin writes them: the name at
|
||||
# 0.8 of the height, the gap beside it at 0.1, the gap over the address at 0.1.
|
||||
FACTORS = (
|
||||
("namefactor", 0.8, r"\\newcommand\{\\business@namefactor\}\{([0-9.]+)\}",
|
||||
"the size of the name beside the logo"),
|
||||
("namegap", 0.1, r"\\newcommand\{\\business@namegap\}\{([0-9.]+)\}",
|
||||
"the gap between the logo and the name"),
|
||||
("addressgap", 0.1, r"\\newcommand\{\\business@addressgap\}\{([0-9.]+)\}",
|
||||
"the gap between the lockup and the address line"),
|
||||
)
|
||||
|
||||
|
||||
def read(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def first(pattern, text):
|
||||
found = re.search(pattern, text)
|
||||
return found.group(1) if found else None
|
||||
|
||||
|
||||
def main():
|
||||
check = Check("origin")
|
||||
ours = read(SUITE) + read(IDENTITY)
|
||||
|
||||
# The factors of the family stand here as numbers, because they are what
|
||||
# the origin writes into its own `\fpeval` calls and cannot be read out of
|
||||
# it without parsing TeX. The heights are read out of the file itself.
|
||||
for name, value, pattern, what in FACTORS:
|
||||
mine = first(pattern, ours)
|
||||
check.that(mine is not None, f"{name} is not declared in this suite")
|
||||
check.that(mine is not None and abs(float(mine) - value) < 0.0001,
|
||||
f"{what}: this suite says {mine}, the family {value}")
|
||||
|
||||
origin = support.run(["kpsewhich", "pacta.sty"]).stdout.strip()
|
||||
if not origin or not os.path.exists(origin):
|
||||
print(" pacta.sty is not installed; the heights were not compared")
|
||||
return check.done()
|
||||
theirs = read(origin)
|
||||
for name, there, here, what in HEIGHTS:
|
||||
want = first(there, theirs)
|
||||
mine = first(here, ours)
|
||||
check.that(want is not None, f"{name} was not found in {origin}")
|
||||
check.that(mine is not None, f"{name} is not declared in this suite")
|
||||
check.that(want is not None and mine is not None and want == mine,
|
||||
f"{what}: this suite says {mine}, the origin {want}")
|
||||
return check.done()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user