"""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 ,
, 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[A-Z]{2,3})-(?P[A-Z0-9]{2,4})-(?P[A-Z0-9]{2,8})-(?P\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'{s}'
def dn(s):
return f'{s}'
def verify(text="To verify"):
"""Inline red-outlined tag for a point of record still to confirm (SVY)."""
return f'{text}'
def ui(label):
"""An on-screen control or label the reader taps/reads: ui('Load Credential Now')."""
return f'{label}'
TBC = 'TBC'
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'{k}{v}
'
for i, (k, v) in enumerate(cells))
gov = (f'{i18n.governing_note(self.number, self.rev)}
' if self.lang != "en" else "")
return (f'{self.kicker}
{self.title}
'
f'{self.lede}
{meta}
{gov}')
# ---- blocks -------------------------------------------------------------
def section(self, title, new_page=False):
return f'{title}
'
def sub(self, title):
return f'{title}
'
def p(self, text):
return f"{text}
"
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'{label}{value}'
f'{f"{sub}" if sub else ""}
')
return f'{"".join(out)}
'
def dark(self, kicker, items):
lis = "".join(f"{i}" for i in items)
return f''
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 = "" + "".join(f"- {b}
" for b in body) + "
"
return f''
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' 2 else ""}>{col[0]} | ' 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'{cell} | ')
body.append(f"{''.join(tds)}
")
out = f''
if caption:
n = self._num("tab", ref)
out += f'{self.L("TABLE")} {n}{caption}
'
return out
def figure(self, svg, caption, ref=None, style=""):
n = self._num("fig", ref)
st = f' style="{style}"' if style else ""
return (f'{svg}
'
f'
{self.fig_label.upper() if self.brand == "mpm" else self.fig_label} {n}{caption}
')
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'{l}')
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'{self.L(verdict)}' if verdict else ""
out.append(f"{name.ljust(w)} = {working} = {res}{tail}{pill}")
return f'{chr(10).join(out)}
'
def bullets(self, items, check=False):
cls = "chk" if check else "plain"
return f'' + "".join(f"- {i}
" for i in items) + "
"
def steps(self, items):
return "" + "".join(f"- {i}
" for i in items) + "
"
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'')
return f''
def assumptions(self, assumptions, open_items):
"""Standard closing block for TN (and SVY): assumptions left, open items (checkboxes) right."""
left = f'{self.L("Assumptions")}
' + self.bullets(assumptions)
right = f'{self.L("Open items")}
' + (self.bullets(open_items, check=True) if open_items
else f'{self.L("None.")}
')
return f'{self.section(self.L("Assumptions and open items"))}{self.two(left, right)}
'
def signoff(self, labels=("Prepared by", "Checked by", "Date")):
return '' + "".join(f"
{self.L(l)}
" for l in labels) + "
"
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'| {r} | {self.fmt_date(d)} | {t} |
'
for r, d, t in self.history)
return (''
'QQREVTOPQQ'
'
' + self.L("Revision history") + '
'
'
QQREVENDQQ ')
# ---- 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'{mark}{label}{sub}
')
return f'{"".join(out)}
'
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'{figure}
' if figure and not wide else (
f'{figure}
' if figure else "")
return (f'')
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'{figure}
' if figure else ""
return (f'')
def chain(self, items):
return '' + "→".join(f"{i}" for i in items) + "
"
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'{caption}
' if caption else ""
return f'
{cap}
'
def shots(self, *items):
"""Several shots side by side: doc.shots(doc.shot(a, 'x'), doc.shot(b, 'y'))."""
return f'{"".join(items)}
'
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'{self.section(title)}{body}
'
# ---- surveys / plans (SVY / TAP) -------------------------------------------
def kv(self, rows, cls=""):
"""Key-value control table: [(label, value)]. Front matter of SVY and TAP."""
trs = "".join(f'| {k} | {v} |
' for k, v in rows)
return f''
def part(self, label, title, note="", internal=False, new_page=True):
"""Part divider (SVY Part 1 customer / Part 2 MPM internal)."""
tag = 'MPM internal' if internal else ""
n = f'{note}
' if note else ""
return (f''
f'
{label}{tag}
{title}
{n}
')
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'
'
f'
FIG {n}{caption}
')
def photos(self, *items):
return f'{"".join(items)}
'
def transcript(self, heading, lines):
"""Verbatim field-notes transcription. lines: strings; a line starting with '• ' is a bullet."""
body = "".join(f'{l[2:]}' if l.startswith("• ") else f"{l}
" for l in lines)
body = body.replace("", "
").replace("
", "
- ")
if body.startswith("
- "):
body = "
" + body
if body.endswith("
"):
body += "
"
return f''
def approval(self, name, role, date, label="Prepared and approved by"):
return (f'{self.L(label)}
'
f'
{name}
{role}
'
f'
{self.fmt_date(date)} · {self.number} Rev {self.rev}
')
def parties(self, items):
"""[(heading, {name, contact, address, phone, email, coordinates})] side by side."""
out = []
for head, p in items:
lines = "".join(f'{p[k]}
'
for k in ("contact", "address", "phone", "email", "web", "coordinates") if p.get(k))
name = f'{p["name"]}
' if p.get("name") else ""
out.append(f'')
return f'{"".join(out)}
'
def pill(self, text, color="navy"):
"""Status pill: green | amber | red | navy | teal."""
return f'{text}
'
def signatures(self, items):
"""[(role, who)] signature lines."""
sigs = "".join(f'{r}
'
f'{f"
{w}
" if w else ""}
' for r, w in items)
return f'{sigs}
'
# ---- 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 = ('| # | Item | Specification | Qty | UOM | Part no. | '
+ "".join(f'{c} | ' for c in checks) + "
")
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'| {n:02d} | {item} | {spec} | '
f'{qty} | {uom} | {pn} | '
+ "".join(' | ' for _ in checks) + "
")
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' | {label} | {tot} | '
f'{uoms.pop() if len(uoms) == 1 else ""} | |
')
out.append(f'{self.section(title)}')
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 = ('| SKU | Description | Qty | '
'Extended price |
')
trs, grand = [], 0.0
for label, rows in groups:
sub = 0.0
for sku, desc, qty, ext in rows:
sub += ext
trs.append(f'| {sku} | {desc} | {qty} | {self.money(ext)} |
')
grand += sub
if label:
trs.append(f'| {label} | {self.money(sub)} |
')
trs.append(f'| {total_label} | {self.money(grand)} |
')
self.price_total = round(grand, 2)
return f''
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 = ('| Milestone | Acceptance criteria | Share of contract | '
'Amount |
')
trs = "".join(f'| {m} | {c} | {sh} | {self.money(a)} |
' for m, c, sh, a in rows)
trs += f'| {total_label} | {self.money(total)} |
'
return f''
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'{lab}{f"{hint}" if hint else ""}
')
body = f'{"".join(cells)}
'
return f'{self.section(title)}{body}
' 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'{self.L("TBC")}')
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'{self.number} · {self.title}{body}'
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