tools/check.sh is what build-release.sh ran before building — every script's syntax, the manual, every tests/test-*.sh — moved, not copied, so a new test is run by both with nothing else to edit. It now runs every test even after one fails, keeps each one's output and a summary table for a CI run, and with --base checks the change's hygiene too. The manual's revision date honours SOURCE_DATE_EPOCH, so a release built from a commit a day later ships the manual that commit holds. Co-Authored-By: Claude Opus 5.5 <[email protected]>
744 lines
26 KiB
Python
Executable File
744 lines
26 KiB
Python
Executable File
#!/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 os
|
||
import re
|
||
import sys
|
||
from datetime import date, datetime
|
||
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"<strong>\1</strong>", 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 = [
|
||
"<!DOCTYPE html>", '<html lang="en">', "<head>",
|
||
'<meta charset="utf-8">',
|
||
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
||
"<title>Classroom manual</title>",
|
||
f"<style>{STYLE}</style>", "</head>", "<body>", '<div class="sheet">',
|
||
"<h1>Classroom manual</h1>",
|
||
f'<p class="revision">River of Glory · {revision}</p>',
|
||
"<nav id='contents'></nav>",
|
||
]
|
||
|
||
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'<section id="{anchor}"{attribute}>')
|
||
parts.append(f'<h2>{inline_html(header["title"])}</h2>')
|
||
|
||
for kind, content in body:
|
||
if kind == "h2":
|
||
parts.append(f"<h3>{inline_html(content)}</h3>")
|
||
elif kind == "h3":
|
||
parts.append(f"<h4>{inline_html(content)}</h4>")
|
||
elif kind == "p":
|
||
parts.append(f"<p>{inline_html(content)}</p>")
|
||
elif kind == "bullets":
|
||
parts.append("<ul>" + "".join(
|
||
f"<li>{inline_html(i)}</li>" for i in content) + "</ul>")
|
||
elif kind == "steps":
|
||
parts.append("<ol>" + "".join(
|
||
f"<li>{inline_html(i)}</li>" for i in content) + "</ol>")
|
||
elif kind == "image":
|
||
alt, source = content
|
||
parts.append(
|
||
f'<figure><img src="{embedded(source)}" '
|
||
f'alt="{html.escape(alt)}">'
|
||
f'<figcaption>{inline_html(alt)}</figcaption></figure>')
|
||
elif kind == "table":
|
||
rows = []
|
||
for number, row in enumerate(content):
|
||
tag = "th" if number == 0 else "td"
|
||
rows.append("<tr>" + "".join(
|
||
f"<{tag}>{inline_html(c)}</{tag}>" for c in row) +
|
||
"</tr>")
|
||
parts.append("<table>" + "".join(rows) + "</table>")
|
||
|
||
parts.append('<p><a class="top" href="#contents">Back to the top</a></p>')
|
||
parts.append("</section>")
|
||
|
||
parts.append("</div>")
|
||
parts.append(f"<script>{VIEWER}</script>")
|
||
parts.append("</body></html>")
|
||
|
||
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
|
||
|
||
# The day the source was committed, when the build says so, rather than
|
||
# the day it was built: a release built from a commit a day later must
|
||
# still ship the manual that commit holds, or the build changes the
|
||
# tree it was built from. SOURCE_DATE_EPOCH is the usual name for it.
|
||
epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
||
day = datetime.fromtimestamp(int(epoch)).date() if epoch else date.today()
|
||
revision = f"Revision {day:%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())
|