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