Files
mpm-documentation-kit/_STANDARDS/mpmdoc/doc.py
T
Jason StedwellandClaude Opus 5.5 5b52636003 MPM Documentation Kit v1.3 (CW-034)
MPM Document Standard 1.3, mpmdoc build engine, templates for TN, INS, SOP,
BOM, SVY, TAP, DWG, TSB, PRO, SOW and WO (Chinese editions for TN and DWG),
glossary, MPM fonts and logos, and the mpm-technical-documents plugin 1.3.0.
Worked examples ship in the kit zip on Drive only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWatTMmqmGatgHjDZvWs5b
2026-11-09 11:51:56 -06:00

518 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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