#!/usr/bin/env python3
"""Build the teacher's manual, both of it, from docs/manual.
tools/build-manual build everything into docs/manual/build
tools/build-manual --check verify only; what the release runs
TWO OUTPUTS, ONE SOURCE.
classroom-manual.html installed on every classroom computer. It
carries every page, each marked with the
device.conf setting that decides whether that
computer shows it, and hides the ones this
device does not have when it is opened.
classroom-manual.pdf the universal printed manual. Every page,
every feature, with a conditional sentence
above the optional ones so that it stays true
in a room that does not have them all.
They cannot drift apart because neither is written: both are generated
from the same Markdown, and --check fails the release if a page is
missing what the printed version needs.
WHY THE PDF IS WRITTEN HERE RATHER THAN CONVERTED.
A manual is headings, paragraphs, bullets, numbered steps and one small
table. Writing that into a PDF directly is a couple of hundred lines with
the standard fonts, no embedding and no dependency — and it means a
release can be built on any machine with Python. Converting the HTML
through LibreOffice was the alternative: it needs LibreOffice on whatever
builds the release, it lays a web page out like a web page, and its
output changes with the version installed. This does not.
"""
import argparse
import base64
import html
import mimetypes
import re
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "docs" / "manual"
BUILD = SOURCE / "build"
sys.path.insert(0, str(ROOT / "lib"))
from classroom_config import FEATURES # noqa: E402
# ------------------------------------------------------------ reading
def parse(path):
"""One manual page: its header, and its body as a list of blocks."""
text = path.read_text(encoding="utf-8")
header = {}
if text.startswith("---\n"):
end = text.index("\n---\n", 3)
for line in text[4:end].splitlines():
if ":" in line:
key, value = line.split(":", 1)
header[key.strip()] = value.strip()
text = text[end + 5:]
return header, blocks(text)
_IMAGE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)\s*$")
def starts_block(line):
"""Whether this line begins something that is not a paragraph.
One test, used by every branch below AND by the paragraph loop, so
the two cannot disagree about a line. They did: the paragraph loop
stopped at any line beginning with "*", while the bullet rule needed
"* " — so a paragraph opening with a bold word consumed nothing,
advanced nothing, and the build hung. Every page whose first line is
bold hit it.
"""
return (line.startswith(("## ", "### ", "* ", "|"))
or bool(re.match(r"^\d+\. ", line))
or bool(_IMAGE.match(line)))
def gather(lines, index, marker):
"""A whole list, with each item's wrapped continuation lines folded in.
A step written across two lines in the source is one step, not a step
and a paragraph. Without this the list ended at the first wrapped
item and the next marker started a NEW list — so a three-step
procedure printed as three separate steps all numbered 1, which is
the worst possible way to get an instruction wrong.
"""
items = []
pattern = re.compile(marker)
while index < len(lines):
line = lines[index]
if pattern.match(line):
items.append(re.sub(marker, "", line).strip())
index += 1
elif items and line.strip() and line[0] in " \t":
# An indented line under an item belongs to that item.
items[-1] += " " + line.strip()
index += 1
else:
break
return items, index
def blocks(text):
"""Markdown, as much of it as a teacher's manual needs.
Headings, paragraphs, bullets, numbered steps and a simple table.
Deliberately not a Markdown library: the same text has to come out
right as a web page and as typeset print, and the small fixed set
that does is easier to keep honest than a general one.
"""
out = []
lines = text.splitlines()
index = 0
while index < len(lines):
line = lines[index].rstrip()
if not line.strip():
index += 1
continue
if _IMAGE.match(line):
match = _IMAGE.match(line)
out.append(("image", (match.group(1), match.group(2))))
index += 1
elif line.startswith("### "):
out.append(("h3", line[4:].strip()))
index += 1
elif line.startswith("## "):
out.append(("h2", line[3:].strip()))
index += 1
elif line.startswith("| "):
rows = []
while index < len(lines) and lines[index].startswith("|"):
cells = [c.strip() for c in lines[index].strip().strip("|").split("|")]
# The |---|---| rule under a header row is not data.
if not all(set(c) <= set("-: ") for c in cells):
rows.append(cells)
index += 1
out.append(("table", rows))
elif line.startswith("* "):
items, index = gather(lines, index, r"^\* ")
out.append(("bullets", items))
elif re.match(r"^\d+\. ", line):
items, index = gather(lines, index, r"^\d+\. ")
out.append(("steps", items))
else:
# Always consumes the line it was given, whatever it is, so
# this loop can never fail to advance.
paragraph = [line.strip()]
index += 1
while index < len(lines) and lines[index].strip() and \
not starts_block(lines[index]):
paragraph.append(lines[index].strip())
index += 1
out.append(("p", " ".join(paragraph)))
return out
def pages():
found = []
for path in sorted(SOURCE.glob("*.md")):
if path.name == "README.md":
continue
header, body = parse(path)
header["path"] = path
found.append((header, body))
found.sort(key=lambda page: int(page[0].get("order", "99")))
return found
# ------------------------------------------------------------ marking
_BOLD = re.compile(r"\*\*(.+?)\*\*")
def inline_html(text):
return _BOLD.sub(r"\1", html.escape(text))
def plain(text):
"""The same text with its marking removed, for the PDF."""
return _BOLD.sub(r"\1", text)
# --------------------------------------------------------------- HTML
STYLE = """
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 24px 80px;
background: #141c20; color: #e6edef;
font-family: "Noto Sans", "DejaVu Sans", sans-serif;
font-size: 19px; line-height: 1.65;
}
.sheet { max-width: 46rem; margin: 0 auto; }
h1 { font-size: 2.1rem; margin: 2rem 0 0.2rem; }
h2 { font-size: 1.5rem; margin: 2.4rem 0 0.4rem;
border-bottom: 1px solid #25333a; padding-bottom: .3rem; }
h3 { font-size: 1.15rem; margin: 1.6rem 0 0.3rem; color: #9fd8d1; }
p, li { margin: .5rem 0; }
ol, ul { padding-left: 1.4rem; }
strong { color: #ffffff; }
table { border-collapse: collapse; margin: 1rem 0; width: 100%; }
td, th { border: 1px solid #25333a; padding: .5rem .7rem; text-align: left; }
th { background: #0f171a; }
nav { margin: 1.5rem 0 2.5rem; padding: 1rem 1.2rem;
background: #0f171a; border: 1px solid #25333a; border-radius: 14px; }
nav a { color: #7fd0c6; text-decoration: none; display: block; padding: .2rem 0; }
nav a:hover { text-decoration: underline; }
figure { margin: 1.2rem 0; }
figure img { max-width: 100%; height: auto; display: block;
border: 1px solid #25333a; border-radius: 12px; }
figcaption { color: #93a5aa; font-size: .9rem; margin-top: .4rem; }
.revision { color: #93a5aa; font-size: .95rem; }
.top { color: #7fd0c6; text-decoration: none; font-size: .95rem; }
@media print {
body { background: #fff; color: #000; font-size: 11pt; }
nav, .top { display: none; }
}
"""
def embedded(source):
"""A picture as a data: URI, so the manual stays ONE file.
The installed manual is composed into the teacher's cache with this
device's features declared in it, so a relative path would point at a
directory that is not beside the copy being opened. Embedding means
the file works wherever it is put, with nothing to install alongside
it and nothing to go missing — which is also what makes it work with
the network unplugged.
"""
path = SOURCE / source
kind = mimetypes.guess_type(path.name)[0] or "image/png"
data = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{kind};base64,{data}"
def render_html(all_pages, revision):
parts = [
"", '', "
",
'',
'',
"Classroom manual",
f"", "", "", '
',
"
Classroom manual
",
f'
River of Glory · {revision}
',
"",
]
for header, body in all_pages:
feature = header.get("feature", "")
anchor = header["path"].stem
# The one thing the page carries into the browser: which setting
# decides whether this device shows it. No value, no other
# setting, and nothing about the machine.
attribute = f' data-feature="{html.escape(feature)}"' if feature else ""
parts.append(f'')
parts.append(f'
{inline_html(header["title"])}
')
for kind, content in body:
if kind == "h2":
parts.append(f"
" for i in content) + "")
elif kind == "image":
alt, source = content
parts.append(
f''
f'{inline_html(alt)}')
elif kind == "table":
rows = []
for number, row in enumerate(content):
tag = "th" if number == 0 else "td"
rows.append("
" + "".join(
f"<{tag}>{inline_html(c)}{tag}>" for c in row) +
"
")
parts.append(f"")
parts.append("")
return "\n".join(parts)
# What the manual does when a teacher opens it.
#
# classroom-manual writes the device's enabled features into
# window.CLASSROOM_FEATURES before this runs. It is a list of setting
# names and nothing else — no values from device.conf, no broker
# password, no addresses, nothing an administrator set. With no list at
# all, every page is shown, which is the printed manual's behaviour and
# is always true, just less specific.
VIEWER = """
(function () {
var have = window.CLASSROOM_FEATURES || null;
var sections = document.querySelectorAll('section');
var contents = document.getElementById('contents');
for (var i = 0; i < sections.length; i++) {
var needs = sections[i].getAttribute('data-feature');
if (needs && have && have.indexOf(needs) === -1) {
sections[i].remove();
continue;
}
var link = document.createElement('a');
link.href = '#' + sections[i].id;
link.textContent = sections[i].querySelector('h2').textContent;
contents.appendChild(link);
}
})();
"""
# ---------------------------------------------------------------- PDF
# The 14 fonts every PDF reader has. Nothing is embedded, so the file is
# small and identical everywhere.
BODY_FONT = "Helvetica"
BOLD_FONT = "Helvetica-Bold"
PAGE_WIDTH, PAGE_HEIGHT = 595, 842 # A4 in points
MARGIN_X, MARGIN_TOP, MARGIN_BOTTOM = 62, 780, 64
# Helvetica's own widths, per 1000 units, for the printable ASCII range.
# Wrapping by character count puts a line of capitals off the page and
# leaves a line of commas half empty, and this is a manual people print.
_WIDTHS = {
" ": 278, "!": 278, '"': 355, "#": 556, "$": 556, "%": 889, "&": 667,
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, "_": 556,
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
}
def width_of(text, size, bold=False):
total = 0
for character in text:
if character.isdigit():
total += 556
elif character.isupper():
total += 722 if bold else 667
elif character.islower():
total += 556 if bold else 500
else:
total += _WIDTHS.get(character, 556)
return total * size / 1000.0
def wrap(text, size, available, bold=False):
lines = []
current = ""
for word in text.split():
candidate = f"{current} {word}".strip()
if current and width_of(candidate, size, bold) > available:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
return lines or [""]
def escape_pdf(text):
out = []
for character in text:
if character in "()\\":
out.append("\\" + character)
elif ord(character) < 128:
out.append(character)
else:
# The standard encoding has no place for a curly quote or a
# dash a writer typed without thinking. Replaced rather than
# dropped, so a printed line never loses a word.
out.append({
"’": "'", "‘": "'", "“": '"', "”": '"',
"—": "-", "–": "-", " ": " ", "·": "-",
"→": "->", "←": "<-", "↑": "up",
"↓": "down", "×": "x", "⏏": "eject",
"✕": "X", "◀": "<", "▶": ">",
}.get(character, "?"))
return "".join(out)
class Pdf:
"""A very small PDF writer: text, in two weights, on A4 pages."""
def __init__(self):
self.pages = []
self.current = []
self.y = MARGIN_TOP
def _show(self, text, x, y, size, bold):
font = "F2" if bold else "F1"
self.current.append(
f"BT /{font} {size} Tf 1 0 0 1 {x:.1f} {y:.1f} Tm "
f"({escape_pdf(text)}) Tj ET")
def break_page(self):
if self.current:
self.pages.append(self.current)
self.current = []
self.y = MARGIN_TOP
def space(self, amount):
self.y -= amount
def need(self, amount):
if self.y - amount < MARGIN_BOTTOM:
self.break_page()
def text(self, content, size=10.5, bold=False, indent=0, leading=None):
leading = leading or size * 1.42
available = PAGE_WIDTH - 2 * MARGIN_X - indent
for line in wrap(content, size, available, bold):
self.need(leading)
self._show(line, MARGIN_X + indent, self.y, size, bold)
self.y -= leading
def bullet(self, mark, content, size=10.5, indent=16):
leading = size * 1.42
available = PAGE_WIDTH - 2 * MARGIN_X - indent - 14
lines = wrap(content, size, available)
self.need(leading * len(lines))
self._show(mark, MARGIN_X + indent, self.y, size, False)
for number, line in enumerate(lines):
if number:
self.need(leading)
self._show(line, MARGIN_X + indent + 16, self.y, size, False)
self.y -= leading
def rule(self):
self.need(10)
self.current.append(
f"0.75 w 0.6 0.6 0.6 RG {MARGIN_X} {self.y + 4:.1f} m "
f"{PAGE_WIDTH - MARGIN_X} {self.y + 4:.1f} l S")
self.y -= 8
def build(self):
self.break_page()
objects = []
count = len(self.pages)
# 1 catalog, 2 pages, 3..(2+n) page objects, then the contents,
# then the two fonts.
kids = " ".join(f"{3 + i} 0 R" for i in range(count))
objects.append("<< /Type /Catalog /Pages 2 0 R >>")
objects.append(
f"<< /Type /Pages /Kids [{kids}] /Count {count} >>")
for index in range(count):
contents = 3 + count + index
objects.append(
f"<< /Type /Page /Parent 2 0 R "
f"/MediaBox [0 0 {PAGE_WIDTH} {PAGE_HEIGHT}] "
f"/Resources << /Font << /F1 {3 + 2 * count} 0 R "
f"/F2 {4 + 2 * count} 0 R >> >> "
f"/Contents {contents} 0 R >>")
for page in self.pages:
stream = "\n".join(page)
objects.append(
f"<< /Length {len(stream.encode('latin-1', 'replace'))} >>\n"
f"stream\n{stream}\nendstream")
for font in (BODY_FONT, BOLD_FONT):
objects.append(
f"<< /Type /Font /Subtype /Type1 /BaseFont /{font} "
f"/Encoding /WinAnsiEncoding >>")
out = ["%PDF-1.4"]
offsets = []
position = len(out[0]) + 1
for number, body in enumerate(objects, start=1):
chunk = f"{number} 0 obj\n{body}\nendobj"
offsets.append(position)
out.append(chunk)
position += len(chunk.encode("latin-1", "replace")) + 1
table = [f"xref\n0 {len(objects) + 1}", "0000000000 65535 f "]
table += [f"{offset:010d} 00000 n " for offset in offsets]
out.append("\n".join(table))
out.append(
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{position}\n%%EOF")
return ("\n".join(out)).encode("latin-1", "replace")
def render_pdf(all_pages, revision):
pdf = Pdf()
pdf.space(60)
pdf.text("Classroom manual", size=26, bold=True)
pdf.space(6)
pdf.text("River of Glory", size=13)
pdf.space(2)
pdf.text(revision, size=10.5)
pdf.space(18)
pdf.text(
"This manual covers every classroom computer in the church. Not "
"every room has every button, so some pages begin by saying when "
"they apply. If your computer does not have what a page "
"describes, that page is not for your room.", size=10.5)
pdf.space(10)
pdf.text(
"Keep this copy in the room. If the date above is older than the "
"one on the computer's own Help, ask the office for a new one.",
size=10.5)
pdf.break_page()
for header, body in all_pages:
pdf.space(6)
pdf.text(plain(header["title"]), size=17, bold=True)
pdf.space(2)
pdf.rule()
pdf.space(6)
# THE CONDITIONAL SENTENCE.
#
# This is what lets one printed manual be true in every room. A
# page for something optional says so before it says anything
# else, so a teacher in a room without it knows within one line.
if header.get("printed-if"):
pdf.text(plain(header["printed-if"]) + ".", size=10.5, bold=True)
pdf.space(6)
for kind, content in body:
if kind == "h2":
pdf.space(8)
pdf.text(plain(content), size=12.5, bold=True)
pdf.space(3)
elif kind == "h3":
pdf.space(5)
pdf.text(plain(content), size=11, bold=True)
elif kind == "p":
pdf.text(plain(content))
pdf.space(5)
elif kind == "bullets":
for item in content:
pdf.bullet("-", plain(item))
pdf.space(5)
elif kind == "steps":
for number, item in enumerate(content, start=1):
pdf.bullet(f"{number}.", plain(item))
pdf.space(5)
elif kind == "image":
pdf.text(f"[ Picture: {plain(content[0])} — see the "
"computer's own Help ]", size=9.5)
pdf.space(5)
elif kind == "table":
for number, row in enumerate(content):
pdf.bullet(
" ", " ".join(plain(c) for c in row)
if number else
" ".join(plain(c).upper() for c in row))
pdf.space(5)
pdf.break_page()
return pdf.build()
# -------------------------------------------------------------- check
def check(all_pages):
"""Everything that would let the two versions drift apart."""
problems = []
if not all_pages:
problems.append("there are no manual pages at all")
seen = set()
for header, body in all_pages:
name = header["path"].name
for required in ("title", "order"):
if not header.get(required):
problems.append(f"{name}: no {required}")
if header.get("order") in seen:
problems.append(f"{name}: another page already has order "
f"{header.get('order')}")
seen.add(header.get("order"))
feature = header.get("feature")
if feature:
# A page for an optional feature MUST carry the sentence the
# printed manual puts above it, or the printed manual quietly
# becomes wrong for every room without that feature.
if not header.get("printed-if"):
problems.append(
f"{name}: declares feature {feature} but has no "
"printed-if, so the printed manual would state it "
"unconditionally")
if feature not in FEATURES:
problems.append(
f"{name}: {feature} is not a device.conf feature "
f"(known: {', '.join(sorted(FEATURES))})")
if not body:
problems.append(f"{name}: no content")
for kind, content in body:
if kind != "image":
continue
picture = SOURCE / content[1]
if not picture.exists():
problems.append(f"{name}: {content[1]} is not there")
elif not content[0].strip():
# The printed manual prints the alt text as the caption,
# so a picture with none leaves a bare "[ Picture: ]".
problems.append(
f"{name}: {content[1]} has no caption, which the "
"printed manual needs")
# Every optional feature the package has should be documented, or a
# room gets a button the manual never mentions.
documented = {h.get("feature") for h, _ in all_pages if h.get("feature")}
for feature in FEATURES:
if feature not in documented:
problems.append(
f"{feature} is an optional feature with no manual page")
return problems
def main():
parser = argparse.ArgumentParser(
description="Build the teacher's manual, digital and printed.")
parser.add_argument("--check", action="store_true",
help="verify the source and the built files only")
arguments = parser.parse_args()
all_pages = pages()
problems = check(all_pages)
for problem in problems:
print(f"build-manual: {problem}", file=sys.stderr)
if problems:
return 1
revision = f"Revision {date.today():%d %B %Y}"
if arguments.check:
for name in ("classroom-manual.html", "classroom-manual.pdf"):
if not (BUILD / name).exists():
print(f"build-manual: {name} has not been built; run "
"tools/build-manual", file=sys.stderr)
return 1
print(f"build-manual: {len(all_pages)} pages, "
f"{len([h for h, _ in all_pages if h.get('feature')])} of them "
"optional, and both versions are built")
return 0
BUILD.mkdir(parents=True, exist_ok=True)
(BUILD / "classroom-manual.html").write_text(
render_html(all_pages, revision), encoding="utf-8")
(BUILD / "classroom-manual.pdf").write_bytes(
render_pdf(all_pages, revision))
print(f"build-manual: {BUILD / 'classroom-manual.html'}")
print(f"build-manual: {BUILD / 'classroom-manual.pdf'}")
print(f"build-manual: {revision}")
return 0
if __name__ == "__main__":
sys.exit(main())