747 lines
32 KiB
Python
747 lines
32 KiB
Python
#!/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())
|