Files
mpm-documentation-kit/_STANDARDS/mpmdoc/doc.py
T

517 lines
27 KiB
Python
Raw Normal View History

2026-11-09 11:51:56 -06:00
"""mpmdoc.Doc — builds an MPM-standard PDF from a project's build.py.
Typical use (see templates/TN/build.py for a full example):
doc = Doc(number="TN-NSH-B17-01", rev="0.3", date="2026-11-05",
customer="Nashville MCC (WeGo)", subject="Bay 17",
title="Bay 17 Sign: Mounting Height Change", lede="...",
meta=[("Site", "Nashville MCC · Bay 17"), ("Requirement", "Bottom ≥ 12'-9\"")],
slug="Nashville_Bay17_Sign-Height-Change",
history=[("0.1", "2026-11-05", "Initial draft."), ...])
doc.add(doc.kpis([...]), doc.result([...]), doc.section("..."), doc.table(...))
doc.build()
Builder methods return HTML strings (so they can be nested in doc.two(...));
doc.add(...) appends them in order. Content strings are HTML: use <b>, <br>, etc.
Cross-references: write [[fig:key]] / [[tab:key]] in any text; they resolve to
"Fig 2" / "Table 1" at build time from the ref= given to figure()/table().
"""
import base64
import re
from pathlib import Path
from . import brand
_brand_mod = brand # Doc.__init__ takes a `brand=` argument that shadows the module
from .units import show_date
from . import i18n
HERE = Path(__file__).parent
NUM_RE = re.compile(r"^(?P<type>[A-Z]{2,3})-(?P<cust>[A-Z0-9]{2,4})-(?P<subj>[A-Z0-9]{2,8})-(?P<seq>\d{2})$")
REV_RE = re.compile(r"^\d+\.\d+$")
SLUG_RE = re.compile(r"^[A-Za-z0-9.\-]+(_[A-Za-z0-9.\-]+)+$")
TONES = {None: "", "": "", "key": "key", "strong": "strong", "ok": "ok", "bad": "bad"}
NOTE_TONES = {"info": "", "warn": "warn", "ok": "ok", "verify": "verify"}
class KeyRow(list):
"""A table row rendered with the gold 'key' highlight: KeyRow(["a", "b"])."""
class TotalRow(list):
"""A subtotal row with a gold top rule (BOMs)."""
def up(s):
return f'<span class="up">{s}</span>'
def dn(s):
return f'<span class="dn">{s}</span>'
def verify(text="To verify"):
"""Inline red-outlined tag for a point of record still to confirm (SVY)."""
return f'<span class="vtag">{text}</span>'
def ui(label):
"""An on-screen control or label the reader taps/reads: ui('Load Credential Now')."""
return f'<span class="ui">{label}</span>'
TBC = '<span class="tbc">TBC</span>'
class Doc:
def __init__(self, number, rev, date, title, lede, meta, slug, history,
customer=None, subject="", kicker=None, footer=None,
classification=None, prepared_by="Jason Stedwell · MPM", brand="mpm", lang="en"):
self.brand = brand
if lang not in i18n.LANGS:
raise ValueError(f"lang must be one of {i18n.LANGS}")
self.lang = lang
if brand != "mpm":
raise ValueError("brand must be 'mpm'")
brand = _brand_mod
m = NUM_RE.match(number)
if not m:
raise ValueError(f"Document number '{number}' is not TYPE-CUST-SUBJ-NN (e.g. TN-NSH-B17-01)")
self.type = m["type"]
if self.type not in brand.TYPES:
raise ValueError(f"Unknown document type '{self.type}'. Known: {', '.join(brand.TYPES)}")
if m["cust"] not in brand.CUSTOMERS:
raise ValueError(f"Customer code '{m['cust']}' is not in brand.CUSTOMERS / registry.md — register it first")
if not REV_RE.match(rev):
raise ValueError(f"Revision '{rev}' must be numeric MAJOR.MINOR (0.1, 0.2 … 1.0, 1.1)")
if not SLUG_RE.match(slug):
raise ValueError(f"slug '{slug}' must be Customer_Short-Title (underscores between fields, hyphens within)")
if not history or history[-1][0] != rev:
raise ValueError("history must end with an entry for the current revision")
if not 2 <= len(meta) <= 2:
raise ValueError("meta takes exactly two type-specific cells; Document/Revision/Date are added for you")
self.number, self.rev, self.date = number, rev, date
self.status = "Draft" if float(rev) < 1.0 else "Released"
self.header_label, kick_label, default_cls = brand.TYPES[self.type]
if lang != "en":
if self.type not in i18n.TYPES[lang]:
raise ValueError(f"{lang} editions are defined for {sorted(i18n.TYPES[lang])} only (standard §12)")
zh_head, kick_label = i18n.TYPE_ZH[self.type]
self.header_label = f"{zh_head} · {self.header_label}"
self.customer = customer or brand.CUSTOMERS[m["cust"]]
self.subject = subject
self.title, self.lede, self.meta, self.slug, self.history = title, lede, meta, slug, history
self.kicker = kicker or " · ".join(x for x in (kick_label, self.customer, subject) if x)
self.classification = classification or default_cls
if self.classification not in brand.CLASSIFICATIONS:
raise ValueError(f"classification must be one of {list(brand.CLASSIFICATIONS)}")
ctx = footer or " · ".join(x for x in (self.customer, subject) if x)
mark = brand.CLASSIFICATIONS[self.classification]
if mark and lang == "zh":
mark = f"{i18n.MARK_ZH[self.classification]} {mark}"
self.footer = "MESSAGE POINT MEDIA · " + " · ".join(x for x in (ctx, mark) if x)
self.prepared_by = prepared_by
self.parts, self._n, self._refs = [], {"fig": 0, "tab": 0}, {}
import inspect
self.src_dir = Path(inspect.stack()[1].filename).resolve().parent
def L(self, key):
return i18n.L(getattr(self, "lang", "en"), key)
def fmt_date(self, d):
return i18n.show_date(d, getattr(self, "lang", "en"))
@property
def fig_label(self):
return self.L("FIG")
# ---- assembly ---------------------------------------------------------
def add(self, *html):
self.parts.extend(html)
return self
def _num(self, kind, ref):
self._n[kind] += 1
n = self._n[kind]
if ref:
if ref in self._refs:
raise ValueError(f"duplicate ref '{ref}'")
fig = self.L("Fig")
self._refs[ref] = f"{fig if kind == 'fig' else self.L('Table')} {n}"
return n
# ---- masthead (emitted automatically) -------------------------------
def _masthead(self):
cells = [(self.L("Document"), self.number),
(self.L("Revision"), f"{self.rev} · {self.L(self.status)}"),
(self.L("Effective" if self.type == "SOP" else "Date"), self.fmt_date(self.date)),
*self.meta]
meta = "".join(f'<div{" class=\"draft\"" if i == 1 and self.status == "Draft" else ""}><b>{k}</b><span>{v}</span></div>'
for i, (k, v) in enumerate(cells))
gov = (f'<p class="govern">{i18n.governing_note(self.number, self.rev)}</p>' if self.lang != "en" else "")
return (f'<div class="kicker">{self.kicker}</div><h1>{self.title}</h1>'
f'<p class="lede">{self.lede}</p><div class="meta">{meta}</div>{gov}')
# ---- blocks -------------------------------------------------------------
def section(self, title, new_page=False):
return f'<h2 class="sec{" brk" if new_page else ""}">{title}</h2>'
def sub(self, title):
return f'<h3 class="sub">{title}</h3>'
def p(self, text):
return f"<p>{text}</p>"
def kpis(self, tiles):
"""tiles: [(label, value, sub, tone)] — tone None|'key'|'strong'|'ok'|'bad'. 3–4 tiles."""
if not 2 <= len(tiles) <= 5:
raise ValueError("kpis: use 3–4 tiles")
out = []
for t in tiles:
label, value, sub, tone = (list(t) + [None, None])[:4]
cls = TONES[tone]
out.append(f'<div{f" class=\"{cls}\"" if cls else ""}><b>{label}</b><span>{value}</span>'
f'{f"<small>{sub}</small>" if sub else ""}</div>')
return f'<div class="kpis">{"".join(out)}</div>'
def dark(self, kicker, items):
lis = "".join(f"<li>{i}</li>" for i in items)
return f'<div class="dark"><div class="kicker">{kicker}</div><ul>{lis}</ul></div>'
def result(self, items):
return self.dark(self.L("Result"), items)
def before_you_start(self, items):
return self.dark(self.L("Before you start"), items)
def note(self, kicker, body, tone="info"):
if isinstance(body, (list, tuple)):
body = "<ul>" + "".join(f"<li>{b}</li>" for b in body) + "</ul>"
return f'<div class="note {NOTE_TONES[tone]}"><div class="kicker">{kicker}</div>{body}</div>'
def table(self, cols, rows, caption=None, ref=None, cls=""):
"""cols: [(label, td_classes[, width])] td_classes from: r c m d q n u pn item.
rows: list of lists; wrap in KeyRow([...]) or TotalRow([...]) for emphasis.
A cell may be (html, extra_classes)."""
th = "".join(
f'<th class="{" ".join(c for c in col[1].split() if c in ("r", "c"))}"'
f'{f" style=\"width:{col[2]}\"" if len(col) > 2 else ""}>{col[0]}</th>' for col in cols)
body = []
for row in rows:
rc = ' class="key"' if isinstance(row, KeyRow) else ' class="tot"' if isinstance(row, TotalRow) else ""
tds = []
for (col, cell) in zip(cols, row):
extra = ""
if isinstance(cell, tuple):
cell, extra = cell
c = f"{col[1]} {extra}".strip()
tds.append(f'<td{f" class=\"{c}\"" if c else ""}>{cell}</td>')
body.append(f"<tr{rc}>{''.join(tds)}</tr>")
out = f'<table class="{cls}"><thead><tr>{th}</tr></thead><tbody>{"".join(body)}</tbody></table>'
if caption:
n = self._num("tab", ref)
out += f'<div class="tcap"><b>{self.L("TABLE")} {n}</b>{caption}</div>'
return out
def figure(self, svg, caption, ref=None, style=""):
n = self._num("fig", ref)
st = f' style="{style}"' if style else ""
return (f'<div class="fig"><div class="figbox"{st}>{svg}</div>'
f'<div class="cap"><b>{self.fig_label.upper() if self.brand == "mpm" else self.fig_label} {n}</b>{caption}</div></div>')
def calc(self, lines):
"""lines: '# comment' strings, or (name, working, result[, verdict[, tail]]) tuples.
verdict: 'PASS' | 'FAIL' | 'CHECK' | None; tail: plain text after the bold result.
Names are padded so the '=' signs align."""
eqs = [l for l in lines if not isinstance(l, str)]
w = max((len(l[0]) for l in eqs), default=0)
# also align the first ' = ' inside the working (symbolic = numeric)
w2 = max((len(l[1].split(" = ", 1)[0]) for l in eqs if " = " in l[1]), default=0)
out = []
for l in lines:
if isinstance(l, str):
out.append(f'<span class="c">{l}</span>')
continue
name, working, res, *rest = l
if " = " in working:
a, b = working.split(" = ", 1)
working = f"{a.ljust(w2)} = {b}"
verdict = rest[0] if rest else None
tail = rest[1] if len(rest) > 1 else ""
pill = f'<span class="pill {verdict.lower()}">{self.L(verdict)}</span>' if verdict else ""
out.append(f"{name.ljust(w)} = {working} = <b>{res}</b>{tail}{pill}")
return f'<div class="calc">{chr(10).join(out)}</div>'
def bullets(self, items, check=False):
cls = "chk" if check else "plain"
return f'<ul class="{cls}">' + "".join(f"<li>{i}</li>" for i in items) + "</ul>"
def steps(self, items):
return "<ol>" + "".join(f"<li>{i}</li>" for i in items) + "</ol>"
def two(self, left, right, ratio=None):
"""Two columns. ratio=(3, 2) makes the left column 3/5 wide."""
if ratio:
a, b = ratio
return (f'<div class="two"><div style="flex:{a}">{left}</div>'
f'<div style="flex:{b}">{right}</div></div>')
return f'<div class="two"><div>{left}</div><div>{right}</div></div>'
def assumptions(self, assumptions, open_items):
"""Standard closing block for TN (and SVY): assumptions left, open items (checkboxes) right."""
left = f'<div class="list-head">{self.L("Assumptions")}</div>' + self.bullets(assumptions)
right = f'<div class="list-head">{self.L("Open items")}</div>' + (self.bullets(open_items, check=True) if open_items
else f'<p class="lede" style="font-size:9pt">{self.L("None.")}</p>')
return f'<div class="keep">{self.section(self.L("Assumptions and open items"))}{self.two(left, right)}</div>'
def signoff(self, labels=("Prepared by", "Checked by", "Date")):
return '<div class="sign">' + "".join(f"<div>{self.L(l)}</div>" for l in labels) + "</div>"
def raw(self, html):
return html
def _history(self):
"""Revision history: its own block, last thing in the document. render.py anchors it to the
bottom of the last page when it shares that page with content (via the two sentinels)."""
rows = "".join(f'<tr><td class="m">{r}</td><td class="dt">{self.fmt_date(d)}</td><td>{t}</td></tr>'
for r, d, t in self.history)
return ('<div id="rev-spacer"></div>'
'<div class="revs"><span class="sent" id="s-top">QQREVTOPQQ</span>'
'<div class="kicker">' + self.L("Revision history") + '</div><table class="tight">'
f'{rows}</table>'
'<span class="sent" id="s-end">QQREVENDQQ</span></div>')
# ---- procedures (INS / SOP) ----------------------------------------------
def glance(self, items):
"""Process-at-a-glance tiles. items: (label, sub[, mark[, other]]).
mark overrides the auto number (e.g. '✓'); other=True greys the tile (done by someone else)."""
out, n = [], 0
for it in items:
label, sub, mark, other = (list(it) + [None, None])[:4]
if mark is None:
n += 1
mark = n
out.append(f'<div{" class=\"other\"" if other else ""}><i>{mark}</i>{label}<small>{sub}</small></div>')
return f'<div class="glance">{"".join(out)}</div>'
def step(self, n, kicker, title, body, figure=None, wide=False, side="35%"):
"""One walkthrough step. body: HTML (use doc.steps([...]) + doc.note(...)).
figure: HTML for the side column (doc.figure(...) or doc.shots(...)). wide=True puts it below."""
side = f'<div class="side" style="width:{side}">{figure}</div>' if figure and not wide else (
f'<div class="side">{figure}</div>' if figure else "")
return (f'<div class="step{" wide" if wide else ""}"><div class="body"><div class="sh"><div class="num">{n}</div>'
f'<div><div class="kicker">{kicker}</div><h3>{title}</h3></div></div>{body}</div>{side}</div>')
def special(self, kicker, title, body, figure=None, mark="!"):
"""Special-case panel (e.g. 'Outside the U.S.?'): gold frame, red badge. For a situation only some readers hit."""
fig = f'<div class="sp-fig">{figure}</div>' if figure else ""
return (f'<div class="special"><div class="sh"><div class="num bad">{mark}</div>'
f'<div><div class="kicker">{kicker}</div><h3>{title}</h3></div></div>{body}{fig}</div>')
def chain(self, items):
return '<div class="chain">' + "<b>→</b>".join(f"<span>{i}</span>" for i in items) + "</div>"
def shot(self, src, caption="", width="100%"):
"""Screenshot / photo from a file next to build.py (path relative to source/). Captioned, not numbered."""
import mimetypes
p = (self.src_dir / src) if not Path(src).is_absolute() else Path(src)
mime = mimetypes.guess_type(str(p))[0] or "image/png"
data = base64.b64encode(p.read_bytes()).decode()
cap = f'<div class="scap">{caption}</div>' if caption else ""
return f'<div class="shot" style="width:{width}"><img src="data:{mime};base64,{data}">{cap}</div>'
def shots(self, *items):
"""Several shots side by side: doc.shots(doc.shot(a, 'x'), doc.shot(b, 'y'))."""
return f'<div class="shots">{"".join(items)}</div>'
def checklist(self, title, items, cols=2):
"""Closing checklist (INS 'Final check', SOP 'Setup checklist')."""
if cols == 2:
h = (len(items) + 1) // 2
body = self.two(self.bullets(items[:h], check=True), self.bullets(items[h:], check=True))
else:
body = self.bullets(items, check=True)
return f'<div class="keep">{self.section(title)}{body}</div>'
# ---- surveys / plans (SVY / TAP) -------------------------------------------
def kv(self, rows, cls=""):
"""Key-value control table: [(label, value)]. Front matter of SVY and TAP."""
trs = "".join(f'<tr><th>{k}</th><td>{v}</td></tr>' for k, v in rows)
return f'<table class="kv {cls}">{trs}</table>'
def part(self, label, title, note="", internal=False, new_page=True):
"""Part divider (SVY Part 1 customer / Part 2 MPM internal)."""
tag = '<span class="int-tag">MPM internal</span>' if internal else ""
n = f'<p class="part-note">{note}</p>' if note else ""
return (f'<div class="part{" brk" if new_page else ""}{" internal" if internal else ""}">'
f'<div class="kicker">{label}{tag}</div><h2>{title}</h2>{n}</div>')
def photo(self, src, caption, ref=None):
"""Site photo from source/ (numbered FIG like any figure). Use doc.photos(...) to place two side by side."""
import mimetypes
p = self.src_dir / src
mime = mimetypes.guess_type(str(p))[0] or "image/jpeg"
data = base64.b64encode(p.read_bytes()).decode()
n = self._num("fig", ref)
return (f'<div class="photo"><img src="data:{mime};base64,{data}">'
f'<div class="cap"><b>FIG {n}</b>{caption}</div></div>')
def photos(self, *items):
return f'<div class="photos">{"".join(items)}</div>'
def transcript(self, heading, lines):
"""Verbatim field-notes transcription. lines: strings; a line starting with '• ' is a bullet."""
body = "".join(f'<li>{l[2:]}</li>' if l.startswith("• ") else f"<p>{l}</p>" for l in lines)
body = body.replace("</li><p>", "</li></ul><p>").replace("</p><li>", "</p><ul><li>")
if body.startswith("<li>"):
body = "<ul>" + body
if body.endswith("</li>"):
body += "</ul>"
return f'<div class="transcript"><div class="t-head">{heading}</div>{body}</div>'
def approval(self, name, role, date, label="Prepared and approved by"):
return (f'<div class="approval keep"><div class="kicker">{self.L(label)}</div>'
f'<div class="ap-name">{name}</div><div class="ap-role">{role}</div>'
f'<div class="ap-date">{self.fmt_date(date)} · {self.number} Rev {self.rev}</div></div>')
def parties(self, items):
"""[(heading, {name, contact, address, phone, email, coordinates})] side by side."""
out = []
for head, p in items:
lines = "".join(f'<div class="pline{" mono" if k in ("phone", "email", "web", "coordinates") else ""}">{p[k]}</div>'
for k in ("contact", "address", "phone", "email", "web", "coordinates") if p.get(k))
name = f'<div class="pname">{p["name"]}</div>' if p.get("name") else ""
out.append(f'<div class="party"><div class="ph">{head}</div><div class="pb">{name}{lines}</div></div>')
return f'<div class="parties">{"".join(out)}</div>'
def pill(self, text, color="navy"):
"""Status pill: green | amber | red | navy | teal."""
return f'<p><span class="spill {color}">{text}</span></p>'
def signatures(self, items):
"""[(role, who)] signature lines."""
sigs = "".join(f'<div class="sig"><div class="line"></div><div class="role">{r}</div>'
f'{f"<div class=who>{w}</div>" if w else ""}</div>' for r, w in items)
return f'<div class="sigs keep">{sigs}</div>'
# ---- BOM -------------------------------------------------------------------
def bom(self, sections, checks=("Pulled", "Packed")):
"""sections: [(title, rows, subtotal_label)]; rows: (item, spec, qty, uom[, part_no]).
Line numbers run on across sections; each section gets a subtotal row."""
head = ('<tr><th>#</th><th>Item</th><th>Specification</th><th class="r">Qty</th><th>UOM</th><th>Part no.</th>'
+ "".join(f'<th class="c">{c}</th>' for c in checks) + "</tr>")
out, n = [], 0
for title, rows, subtotal in sections:
trs = []
for r in rows:
item, spec, qty, uom, pn = (list(r) + ["—"])[:5]
n += 1
trs.append(f'<tr><td class="n">{n:02d}</td><td class="item"><b>{item}</b></td><td class="m bom">{spec}</td>'
f'<td class="q">{qty}</td><td class="u">{uom}</td><td class="pn">{pn}</td>'
+ "".join('<td class="c"><span class="box"></span></td>' for _ in checks) + "</tr>")
uoms = {r[3] for r in rows}
tot = sum(r[2] for r in rows) if len(uoms) == 1 else ""
label = f"{subtotal} · {len(rows)} line item{'s' if len(rows) != 1 else ''}"
trs.append(f'<tr class="tot"><td></td><td colspan="2">{label}</td><td class="q">{tot}</td>'
f'<td class="u">{uoms.pop() if len(uoms) == 1 else ""}</td><td colspan="{1 + len(checks)}"></td></tr>')
out.append(f'{self.section(title)}<table class="bomt"><thead>{head}</thead><tbody>{"".join(trs)}</tbody></table>')
return "".join(out)
# ---- commercial and field (PRO / SOW / WO / TSB) ------------------------------
@staticmethod
def money(v):
return f"${v:,.2f}"
def priced(self, groups, total_label="Grand total (plus applicable sales tax)"):
"""Priced bill of materials. groups: [(subtotal_label, [(sku, description, qty, extended)])].
Extended prices are numbers; subtotals and the grand total are computed, never typed.
The grand total is kept in self.price_total for milestones(expect=...)."""
head = ('<tr><th style="width:20%">SKU</th><th>Description</th><th class="r" style="width:7%">Qty</th>'
'<th class="r" style="width:17%">Extended price</th></tr>')
trs, grand = [], 0.0
for label, rows in groups:
sub = 0.0
for sku, desc, qty, ext in rows:
sub += ext
trs.append(f'<tr><td class="m">{sku}</td><td>{desc}</td><td class="r">{qty}</td><td class="r m">{self.money(ext)}</td></tr>')
grand += sub
if label:
trs.append(f'<tr class="sub"><td colspan="3">{label}</td><td class="r m">{self.money(sub)}</td></tr>')
trs.append(f'<tr class="grand"><td colspan="3">{total_label}</td><td class="r m">{self.money(grand)}</td></tr>')
self.price_total = round(grand, 2)
return f'<table class="prt"><thead>{head}</thead><tbody>{"".join(trs)}</tbody></table>'
def milestones(self, rows, expect=None, total_label="Total (plus applicable sales tax)"):
"""Payment milestones: [(milestone, acceptance criteria, share, amount)]. The total is computed;
pass expect=doc.price_total to fail the build if the milestones don't add up to the price."""
total = round(sum(r[3] for r in rows), 2)
if expect is not None and abs(total - expect) > 0.005:
raise ValueError(f"milestones total {self.money(total)} != priced total {self.money(expect)}")
head = ('<tr><th style="width:20%">Milestone</th><th>Acceptance criteria</th><th style="width:20%">Share of contract</th>'
'<th class="r" style="width:15%">Amount</th></tr>')
trs = "".join(f'<tr><td><b>{m}</b></td><td>{c}</td><td>{sh}</td><td class="r m">{self.money(a)}</td></tr>' for m, c, sh, a in rows)
trs += f'<tr class="grand"><td colspan="3">{total_label}</td><td class="r m">{self.money(total)}</td></tr>'
return f'<table class="prt"><thead>{head}</thead><tbody>{trs}</tbody></table>'
def fields(self, items, cols=2, title=None):
"""Fill-in record (WO completion, TSB compliance record): [label | (label, hint)] with writing lines."""
cells = []
for it in items:
lab, hint = (it, "") if isinstance(it, str) else it
cells.append(f'<div class="fld"><b>{lab}</b><span class="ln"></span>{f"<small>{hint}</small>" if hint else ""}</div>')
body = f'<div class="fields c{cols}">{"".join(cells)}</div>'
return f'<div class="keep">{self.section(title)}{body}</div>' if title else body
# ---- output ---------------------------------------------------------------
def html(self):
css = (HERE / "mpm.css").read_text()
fonts = brand.FONTS_CSS.read_text()
body = self._masthead() + "".join(self.parts) + self._history()
for k, v in self._refs.items():
body = body.replace(f"[[fig:{k}]]", v).replace(f"[[tab:{k}]]", v)
left = re.findall(r"\[\[(?:fig|tab):[^\]]+\]\]", body)
if left:
raise ValueError(f"unresolved cross-references: {left}")
lang = getattr(self, "lang", "en")
if lang != "en":
body = body.replace(TBC, f'<span class="tbc">{self.L("TBC")}</span>')
css, body = i18n.add_fallback(css), i18n.add_fallback(body)
# one subset for the page AND its running header/footer: Chromium draws those only with
# fonts the page itself has loaded, so render.py reuses this exact @font-face text
self._cjk_css = i18n.font_css(self.title + body + self.header_label + self.footer + "第页共")
fonts += self._cjk_css
hl = ' lang="zh-CN"' if lang == "zh" else ""
return f'<!doctype html><html{hl}><head><meta charset="utf-8"><title>{self.number} · {self.title}</title><style>{fonts}{css}</style></head><body>{body}</body></html>'
def filename(self, lang=None):
lang = lang or getattr(self, "lang", "en")
return f"{self.number}_{self.slug}{i18n.SUFFIX[lang]}_Rev{self.rev}.pdf"
def _check_english(self, out_dir):
"""A translated edition is built only against an English edition at the same revision."""
if getattr(self, "lang", "en") != "en":
en = Path(out_dir) / self.filename("en")
if not en.exists():
raise SystemExit(f"Build the English edition first: {en.name} not found in {out_dir}. "
"A Chinese edition carries the same number, slug and revision as the English.")
def build(self, out_dir=None, html_dir=None):
"""Render the PDF. Defaults: PDF to the project folder (parent of source/),
intermediate HTML to source/_build/."""
from .render import render_pdf
import inspect
caller = Path(inspect.stack()[1].filename).resolve().parent
out_dir = Path(out_dir) if out_dir else caller.parent
html_dir = Path(html_dir) if html_dir else caller / "_build"
html_dir.mkdir(parents=True, exist_ok=True)
html_path = html_dir / f"{self.number}.html"
html_path.write_text(self.html())
self._check_english(out_dir)
out = out_dir / self.filename()
render_pdf(html_path, out, self)
print(out)
return out