"""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'
    {kicker}
      {lis}
    ' 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'
    {kicker}
    {body}
    ' 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'{th}{"".join(body)}
    ' 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"
    1. {i}
    2. " 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'
    {left}
    ' f'
    {right}
    ') return f'
    {left}
    {right}
    ' 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") + '
    ' f'{rows}
    ' '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'
    {n}
    ' f'
    {kicker}

    {title}

    {body}
    {side}
    ') 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'
    {mark}
    ' f'
    {kicker}

    {title}

    {body}{fig}
    ') 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'{trs}
    ' 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'
      {heading}
      {body}
      ' 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'
      {head}
      {name}{lines}
      ') 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 = ('#ItemSpecificationQtyUOMPart 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)}{head}{"".join(trs)}
      ') 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 = ('SKUDescriptionQty' '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'{head}{"".join(trs)}
      ' 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 = ('MilestoneAcceptance criteriaShare 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'{head}{trs}
      ' 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