docs: one manual, the pages a device has and a printable copy of all of them

Documentation becomes part of the release rather than a job afterwards.
docs/manual is now the only place teacher instructions are written, and
tools/build-manual produces both versions from it:

  classroom-manual.html   installed on every machine, showing only the
                          features THAT device has
  classroom-manual.pdf    the universal printed manual, every feature,
                          with a conditional sentence above each
                          optional page so it stays true in a room that
                          does not have them all

They cannot drift apart because neither is written by hand, and
build-release.sh builds and checks them on every release: a page that
declares a feature with no printed-if fails the build, because that is
exactly how the printed manual comes to state an optional feature as
though every room had it. So does a page naming a feature device.conf
does not have, and an optional feature with no page at all.

WHAT REACHES THE CLASSROOM ACCOUNT. Only a list of setting NAMES, and
only ones the package already defines as optional applications.
classroom-desktop-layout — already the one place that decides what a
device offers — writes them to /run/ekklesia-classroom/features.json,
and classroom-manual filters that against FEATURES before using it. No
values from device.conf, no broker password, no addresses. With no
manifest at all every page is shown, which is the printed manual and is
never wrong, only less specific.

Help is a Quick Launch button beside Parent Call, deliberately not in
the row with the applications: putting it there would move buttons
teachers reach for without looking. Every room gets it, because a
teacher who needs the manual is by definition one who does not know
where to look.

The PDF is written directly rather than converted. A manual is
headings, paragraphs, bullets, steps and one table; writing that with
the standard fonts is a couple of hundred lines and no dependency, so a
release can be built on any machine with Python. Pictures are embedded
in the digital manual as data URIs so it stays one file that works
wherever it is copied and with the network unplugged; the printed
manual names them instead, which is the one thing this builder does not
do and is noted where somebody would look for it.

Two defects found building it, both of which hung or corrupted the
output rather than failing: a paragraph opening with a bold word
matched the bullet guard but not the bullet rule, so nothing consumed
it and the build spun; and a list item wrapped across two source lines
ended the list, so a three-step procedure printed as three steps all
numbered 1.

Also adds the 0.2.6 physical checklist to docs/testing.md and the
release validator's checks for the Wi-Fi regression, the conversion
pipeline on a real Debian, the fonts, the manual and the dock order.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-22 20:21:39 -05:00
co-authored by Claude Opus 5
parent 55f4c183c7
commit f2942a2946
21 changed files with 2533 additions and 3 deletions
+736
View File
@@ -0,0 +1,736 @@
#!/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"<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 &middot; {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
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())
+59 -3
View File
@@ -52,6 +52,15 @@ done
find "$ROOT/scripts" "$ROOT/lib" "$ROOT/tests" \
-name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
# THE MANUAL IS PART OF THE RELEASE, NOT A JOB FOR AFTERWARDS.
#
# Built from docs/manual before the tests run, so that the test which
# checks the digital and printed versions against each other is checking
# what this package is about to ship rather than whatever was built last.
# A page that would make the printed manual wrong fails the build here.
echo "Building the teacher's manual"
"$ROOT/tools/build-manual"
for test in "$ROOT"/tests/test-*.sh; do
[[ -x "$test" ]] || continue
@@ -348,9 +357,10 @@ install -Dm644 \
"$ROOT/config/dbus/org.kde.LogoutPrompt.service" \
"$STAGE/usr/local/share/dbus-1/services/org.kde.LogoutPrompt.service"
# Two marks the classroom draws itself, because nothing in Breeze says
# either thing: a lesson, and fetching a child's grown-up.
for icon in ekklesia-wonderink ekklesia-parent-call; do
# Three marks the classroom draws itself, because nothing in Breeze says
# any of them: a lesson, fetching a child's grown-up, and tonight's
# slides.
for icon in ekklesia-wonderink ekklesia-parent-call ekklesia-presentations; do
install -Dm644 \
"$ROOT/config/icons/${icon}.svg" \
"$STAGE/usr/share/icons/hicolor/scalable/apps/${icon}.svg"
@@ -374,6 +384,50 @@ install -Dm755 \
"$ROOT/scripts/classroom-wonderink" \
"$STAGE/usr/local/bin/classroom-wonderink"
# PRESENTATIONS.
#
# The library a teacher sees, and the watcher that fills it from their
# downloads. Both are installed on every machine; PRESENTATIONS_ENABLED
# in device.conf is what decides whether either ever runs, and it
# defaults to false so an existing classroom upgrades unchanged.
# THE MANUAL.
#
# One HTML file carrying every page, and the command that opens it with
# the pages this device has no use for taken out. Installed on every
# machine including ones with no optional features at all, because the
# common pages — the bar, the browser, the volume, what to do when
# something is wrong — are the ones a new teacher needs most.
install -Dm644 \
"$ROOT/docs/manual/build/classroom-manual.html" \
"$STAGE/usr/local/share/ekklesia-classroom/manual/classroom-manual.html"
# The printable one goes on the machine too. A room whose printed copy has
# gone missing can print another without anybody fetching a file from a
# laptop, and its revision date is the same one the digital manual shows.
install -Dm644 \
"$ROOT/docs/manual/build/classroom-manual.pdf" \
"$STAGE/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf"
install -Dm755 \
"$ROOT/scripts/classroom-manual" \
"$STAGE/usr/local/bin/classroom-manual"
install -Dm755 \
"$ROOT/scripts/classroom-presentations" \
"$STAGE/usr/local/bin/classroom-presentations"
install -Dm755 \
"$ROOT/scripts/classroom-presentation-import" \
"$STAGE/usr/local/bin/classroom-presentation-import"
install -Dm644 \
"$ROOT/lib/classroom_presentations.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_presentations.py"
install -Dm644 \
"$ROOT/systemd/user/ekklesia-classroom-presentations.service" \
"$STAGE/etc/systemd/user/ekklesia-classroom-presentations.service"
install -Dm755 \
"$ROOT/scripts/classroom-audio" \
"$STAGE/usr/local/bin/classroom-audio"
@@ -482,6 +536,8 @@ install -Dm644 \
"$ROOT/lib/classroom_icons.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py"
# The palette and the overlay measurements. The DVD control bar, Parent
# Call and the Presentations library all draw themselves from this.
install -Dm644 \
"$ROOT/lib/classroom_style.py" \
"$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"
+175
View File
@@ -143,6 +143,181 @@ grep -q "classroom-wifi --check" /usr/local/bin/classroom-migrate
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
'import classroom_network; classroom_network.parse("")'
# THE 0.2.5 WI-FI REGRESSION, ASSERTED ON A REAL INSTALL.
#
# The prayer-room laptop's network is hidden and its name has a space in
# it. The migration dropped the hidden flag, so the connection it built
# could never associate. Checked against the module the package actually
# installed, not the one in a checkout.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 <<'WIFI_CHECK'
import classroom_network as n
stanza = n.wireless(n.parse(
'allow-hotplug wlp2s0\n'
'iface wlp2s0 inet dhcp\n'
'\twpa-ssid "ROGC Classroom"\n'
'\twpa-psk se#cret\n'
'\twpa-scan-ssid 1\n'
), lambda device: True)[0]
found = n.profile(stanza)
assert found["ssid"] == "ROGC Classroom", found
assert found["hidden"] is True, "the hidden flag was dropped again"
assert found["psk"] == "se#cret", "a # in the password was truncated again"
assert found["key_mgmt"] == "wpa-psk", found
# A rollback puts back exactly what it commented out, and nothing else.
before = ("auto enp1s0\niface enp1s0 inet static\n\taddress 10.40.0.9/24\n\n"
"allow-hotplug wlp2s0\niface wlp2s0 inet dhcp\n\twpa-ssid Sanctuary\n")
after = n.comment_out(before, n.wireless(n.parse(before),
lambda d: d == "wlp2s0")[0])
restored, count = n.uncomment(after)
assert count, "nothing was marked, so a rollback would have nothing to undo"
assert restored == before, "the rollback did not restore the file exactly"
WIFI_CHECK
# And the command itself carries both halves of the fix.
grep -q '802-11-wireless.hidden' /usr/local/bin/classroom-wifi
grep -q 'managed", "yes"' /usr/local/bin/classroom-wifi
# PRESENTATIONS.
#
# Off by default, with everything it needs present on every machine, so
# enabling it on a device is one line in device.conf and a reboot.
command -v classroom-presentations >/dev/null
command -v classroom-presentation-import >/dev/null
command -v soffice >/dev/null
command -v pdftoppm >/dev/null
command -v pdfinfo >/dev/null
[[ -f /etc/systemd/user/ekklesia-classroom-presentations.service ]]
# Enabled the same way the audio policy is, so it actually reaches a
# session rather than merely being installed. A unit file nobody starts
# is a teacher whose download never appears.
[[ -L /etc/systemd/user/plasma-workspace.target.wants/ekklesia-classroom-presentations.service ]]
[[ -f /usr/share/applications/ekklesia-presentations.desktop ]]
[[ -f /usr/share/icons/hicolor/scalable/apps/ekklesia-presentations.svg ]]
# A classroom that has never heard of the setting comes up without it.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \
'from classroom_config import feature; assert feature({}, "PRESENTATIONS_ENABLED") is False'
# Disabled is the default, so both commands decline to run and say why.
classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
classroom-presentation-import --once 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false'
# THE CONVERSION THIS WHOLE FEATURE RESTS ON.
#
# On a real Debian, with the fonts this package installs. No window, no
# splash, no first-run dialog, no editor — measured rather than assumed,
# because "LibreOffice is installed" is not the same claim as "a PPTX
# becomes slides a teacher can be shown".
mkdir -p /tmp/deck
python3 <<'MAKE_DECK'
page = ('<draw:page draw:name="p%d"><draw:frame svg:width="20cm" '
'svg:height="3cm" svg:x="1cm" svg:y="2cm"><draw:text-box>'
'<text:p>%s</text:p></draw:text-box></draw:frame></draw:page>')
open("/tmp/deck/deck.fodp", "w").write(
'<?xml version="1.0" encoding="UTF-8"?>'
'<office:document '
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" '
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" '
'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" '
'xmlns:presentation='
'"urn:oasis:names:tc:opendocument:xmlns:presentation:1.0" '
'office:version="1.3" '
'office:mimetype="application/vnd.oasis.opendocument.presentation">'
'<office:body><office:presentation>'
+ (page % (1, "Wednesday Bible Study"))
+ (page % (2, "Session 7"))
+ '</office:presentation></office:body></office:document>')
MAKE_DECK
HOME=/tmp/deck timeout 240 soffice --headless --norestore --nologo \
--convert-to pptx --outdir /tmp/deck /tmp/deck/deck.fodp >/dev/null 2>&1
[[ -f /tmp/deck/deck.pptx ]]
PYTHONPATH=/usr/local/lib/ekklesia-classroom HOME=/tmp/deck python3 <<'CONVERT_CHECK'
import pathlib
import classroom_presentations as library
ok, error = library.convert_to_pdf("/tmp/deck/deck.pptx", "/tmp/deck/slides.pdf")
assert ok, f"PPTX could not be converted: {error}"
pages = library.page_count("/tmp/deck/slides.pdf")
assert pages == 2, f"expected 2 slides, got {pages}"
made, error = library.render_pages("/tmp/deck/slides.pdf", "/tmp/deck/pages")
assert made == 2, f"expected 2 rendered pages, got {made}: {error}"
# The library asks for page-001.png whatever the page count's width is.
for number in (1, 2):
path = pathlib.Path(f"/tmp/deck/pages/page-{number:03d}.png")
assert path.exists(), f"{path} was not rendered"
assert path.stat().st_size > 0, f"{path} is empty"
# Slide 2 is the one a teacher picks as the card picture, so its text has
# to have survived the conversion.
text = library.first_text("/tmp/deck/slides.pdf", pages=2)
assert "Session 7" in text, f"text did not survive the conversion: {text!r}"
CONVERT_CHECK
# The fonts a Google Slides export asks for, by their real names.
fc-list 2>/dev/null | grep -qi 'Liberation Sans'
fc-list 2>/dev/null | grep -qi 'Carlito'
fc-list 2>/dev/null | grep -qi 'Caladea'
# And the one that must NOT be here, because it prompts for a licence.
! dpkg -l ttf-mscorefonts-installer 2>/dev/null | grep -q '^ii'
# THE MANUAL.
#
# Installed on every machine, works with no network at all, and shows
# only the features this device has.
[[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.html ]]
[[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf ]]
command -v classroom-manual >/dev/null
[[ -f /usr/share/applications/ekklesia-manual.desktop ]]
head -c 5 /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf \
| grep -q '%PDF-'
grep -aq 'Revision' /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf
! grep -qE 'https?://' \
/usr/local/share/ekklesia-classroom/manual/classroom-manual.html
# The manifest the manual reads carries feature NAMES and nothing else:
# no broker password, no addresses, nothing else out of device.conf.
printf 'PRESENTATIONS_ENABLED=true\nWONDERINK_ENABLED=false\nMQTT_PASSWORD=never-a-teacher\n' \
>> /etc/ekklesia-classroom/device.conf
classroom-desktop-layout >/dev/null 2>&1 || true
[[ -f /run/ekklesia-classroom/features.json ]]
grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json
! grep -q 'WONDERINK_ENABLED' /run/ekklesia-classroom/features.json
! grep -q 'never-a-teacher' /run/ekklesia-classroom/features.json
! grep -q '10.40.0.2' /run/ekklesia-classroom/features.json
classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown'
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
# The panel offers Presentations only where it is enabled, and the three
# applications that were already on the bar keep their order.
layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
grep -q 'var PRESENTATIONS = true;' "$layout"
grep -q 'ekklesia-presentations.desktop' "$layout"
grep -q 'ekklesia-manual.desktop' "$layout"
python3 - "$layout" <<'ORDER_CHECK'
import re
import sys
text = open(sys.argv[1]).read()
order = re.findall(r'launchers\.push\("applications:([^"]+)"\)', text)
expected = ["ekklesia-wonderink.desktop", "google-chrome.desktop",
"ekklesia-dvd.desktop", "ekklesia-presentations.desktop"]
assert order == expected, f"the dock order changed: {order}"
ORDER_CHECK
sed -i '/^PRESENTATIONS_ENABLED=true$/d;/^WONDERINK_ENABLED=false$/d;/^MQTT_PASSWORD=never-a-teacher$/d' \
/etc/ekklesia-classroom/device.conf
# The policy module and the daemon agree on what they import, on a machine
# rather than in a checkout.
PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \