517 lines
27 KiB
Python
517 lines
27 KiB
Python
"""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
|