#!/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"\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"

{inline_html(content)}

") elif kind == "h3": parts.append(f"

{inline_html(content)}

") elif kind == "p": parts.append(f"

{inline_html(content)}

") elif kind == "bullets": parts.append("") elif kind == "steps": parts.append("
    " + "".join( f"
  1. {inline_html(i)}
  2. " 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)}" for c in row) + "") parts.append("" + "".join(rows) + "
") parts.append('

Back to the top

') parts.append("
") parts.append("
") 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 # 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())