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
This commit is contained in:
Jason StedwellandClaude Opus 5.5 committed 2026-11-09 11:51:56 -06:00
commit 5b52636003
143 files changed
+4097

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
"""mpmdoc — Message Point Media document engine. See ../README.md and ../MPM_Document_Standard.md."""
from .doc import Doc, KeyRow, TotalRow, up, dn, ui, verify, TBC # noqa: F401
from .sheet import Drawing # noqa: F401
from . import brand, units, figkit # noqa: F401
__version__ = "1.0"
+91
View File
@@ -0,0 +1,91 @@
"""MPM brand tokens — the single source for colours, fonts and the document-type
and customer-code registries. Figures (figs.py) import colours from here so a
palette change lands everywhere at once.
See MPM_Document_Standard.md §3 (Numbering) and §6 (Visual system).
Fonts and logos are NOT stored here — they live in DOCUMENTATION PROJECT/_ASSETS/mpm/.
"""
from pathlib import Path
# ---- shared assets (DOCUMENTATION PROJECT/_ASSETS/mpm) ----------------------
def _find_assets():
for p in Path(__file__).resolve().parents:
if (p / "_ASSETS" / "mpm").is_dir():
return p / "_ASSETS" / "mpm"
raise FileNotFoundError("Could not find _ASSETS/mpm above _STANDARDS — see _ASSETS/README.md")
ASSETS = _find_assets()
FONTS_CSS = ASSETS / "fonts" / "fonts_embed.css" # generated by _ASSETS/mpm/fonts/make_embed.py
LOGO_HEADER = ASSETS / "logos" / "png" / "Logo-Gold-Black.png" # gold icon + dark text, on white
LOGO_ON_DARK = ASSETS / "logos" / "png" / "Logo-Gold-White.png" # dark title blocks / banners
LOGO_MONO = ASSETS / "logos" / "png" / "Logo-Black-Text.png" # single-colour black print
TAGLINE = "Born to Innovate. Built to Last."
# ---- palette (official MPM colour system; see _ASSETS/README.md) -----------
INK = "#232022" # Dark Shade · text, dark callouts, primary strokes
GOLD = "#DCBB4F" # Middle Gold · brand accent: rules, step numbers, header underline
KICK = "#998643" # Dark Gold · kicker text, labels, FIG/TABLE tags (gold for text on light backgrounds)
MUTE = "#849698" # Light Accent · secondary text, ledes, captions, footer
CREAM = "#F5F1EC" # meta cells, notes, calc blocks
LINE = "#E6E0D8" # table rules, figure frames
KEYBG = "#FBF6E6" # highlighted ("key") table row / KPI tile
RED = "#A42D20" # warnings, failing values, minimum/limit lines
REDBG = "#F7E9E7"
GRN = "#3F6B3A" # PASS, passing values
GRNBG = "#EAF1E6"
BLU = "#2F5A8A" # data-series only (e.g. blue channel); never for UI chrome
GRY = "#B8B1A8" # de-emphasised data series
FOOT = MUTE # running footer text (Light Accent)
# ---- type ----------------------------------------------------------------
FONT_HEAD = "Montserrat" # headings, kickers, labels, captions
FONT_BODY = "Open Sans" # body copy
FONT_MONO = "JetBrains Mono" # numbers, dimensions, part numbers, calcs
# ---- document types (TYPE field of TYPE-CUST-SUBJ-NN) ----------------------
# code: (header label, kicker label, default classification)
TYPES = {
"INS": ("Installation Guide", "Installation guide", "customer"),
"SOP": ("Standard Operating Procedure", "Procedure", "internal"),
"TN": ("Technical Note", "Technical note", "customer"),
"BOM": ("Bill of Materials", "Bill of materials", "internal"),
"SVY": ("Site Survey", "Site survey", "customer"),
"TAP": ("Technical Action Plan", "Technical action plan", "confidential"),
"DWG": ("Drawing", "Drawing", "customer"),
"TSB": ("Technical Service Bulletin", "Service bulletin", "customer"), # internal edition: classification="internal"
"PRO": ("Proposal", "Proposal", "confidential"),
"SOW": ("Statement of Work", "Statement of work", "customer"),
"WO": ("Field Work Order", "Work order", "internal"),
}
# ---- customer / department codes (CUST field) -------------------------------
# Add new codes here AND in registry.md before issuing a number.
CUSTOMERS = {
"LT": "LakeTran",
"NSH": "Nashville MCC (WeGo)",
"CMT": "CommTran",
"OMT": "Omnitrans",
"SOR": "SORTA / Metro",
"COTA": "COTA",
"GIL": "GILLIG",
# internal departments / company-wide
"MPM": "Message Point Media", # company-wide / product documents
"IT": "MPM IT",
"ENG": "MPM Engineering",
"OPS": "MPM Field Operations",
"QA": "MPM Quality",
}
# provider block for PRO / SOW / WO parties
PROVIDER = {"name": "Message Point Media of Alabama, Inc", "contact": "Jason Stedwell, Director of Technical Services",
"address": "Irondale, AL", "web": "messagepoint.tv"}
CLASSIFICATIONS = {
# key: footer suffix shown after the context ("" = no marking)
"public": "",
"customer": "",
"internal": "INTERNAL USE",
"confidential": "CONFIDENTIAL",
}
+517
View File
@@ -0,0 +1,517 @@
"""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
+70
View File
@@ -0,0 +1,70 @@
"""Shared SVG helpers for figures. Import what you need in a project's figs.py:
from mpmdoc.figkit import T, svg_open, markers, INK, GOLD, KICK, ...
Conventions (MPM_Document_Standard.md §7):
* labels Montserrat 700, uppercase, letter-spaced; values/dimensions JetBrains Mono 700
* strokes INK; brand accent GOLD/KICK; limits and warnings RED (dashed)
* draw at viewBox width 700 for full-width figures, ~240 for step side-figures
"""
from .brand import INK, GOLD, KICK, MUTE, CREAM, LINE, RED, REDBG, GRN, GRNBG, BLU, GRY # noqa: F401
def T(x, y, s, size=8, w=600, fill=INK, anchor="start", rot=None, fam="Montserrat", ls=0.4, halo=False):
"""One <text> element. halo=True adds a white outline so labels read over linework."""
r = f' transform="rotate({rot} {x} {y})"' if rot is not None else ""
h = ' stroke="#fff" stroke-width="3" paint-order="stroke"' if halo else ""
return (f'<text x="{x:.1f}" y="{y:.1f}" font-family="{fam}" font-size="{size}" font-weight="{w}" '
f'fill="{fill}" text-anchor="{anchor}" letter-spacing="{ls}"{r}{h}>{s}</text>')
def svg_open(w, h, grid=False, prefix="f"):
"""Open an <svg> at width 100% with shared markers; grid=True adds the faint drafting grid."""
s = f'<svg viewBox="0 0 {w} {h}" width="100%" xmlns="http://www.w3.org/2000/svg">' + markers(prefix)
if grid:
s += f'<rect width="{w}" height="{h}" fill="url(#{prefix}grid)"/>'
return s
def markers(p="f"):
"""Arrowheads (#{p}a ink, #{p}g gold), hatches (#{p}h grey, #{p}r red) and grid (#{p}grid)."""
return (f'<defs><marker id="{p}a" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">'
f'<path d="M0,1.5 L10,5 L0,8.5 z" fill="{INK}"/></marker>'
f'<marker id="{p}g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">'
f'<path d="M0,1 L10,5 L0,9 z" fill="{KICK}"/></marker>'
f'<pattern id="{p}h" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
f'<line x1="0" y1="0" x2="0" y2="6" stroke="#CFC7BC" stroke-width="1.2"/></pattern>'
f'<pattern id="{p}r" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
f'<line x1="0" y1="0" x2="0" y2="5" stroke="{RED}" stroke-width="1.3"/></pattern>'
f'<pattern id="{p}grid" width="14" height="14" patternUnits="userSpaceOnUse">'
f'<path d="M14 0H0V14" fill="none" stroke="#EFEAE3" stroke-width="0.7"/></pattern></defs>')
def dim_h(x1, x2, y, label, ext_from=None, p="f", size=8.5):
"""Horizontal dimension line from x1 to x2 at height y with centred label.
ext_from: y the extension lines start from (the feature edge)."""
o = []
if ext_from is not None:
for x in (x1, x2):
o.append(f'<line x1="{x}" y1="{ext_from}" x2="{x}" y2="{y + (4 if y > ext_from else -4)}" stroke="{MUTE}" stroke-width=".6"/>')
o.append(f'<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" stroke="{INK}" stroke-width=".8" marker-start="url(#{p}a)" marker-end="url(#{p}a)"/>')
o.append(T((x1 + x2) / 2, y - 4, label, size=size, w=700, anchor="middle", fam="JetBrains Mono", ls=0, halo=True))
return "".join(o)
def dim_v(x, y1, y2, label, ext_from=None, p="f", size=8.5):
"""Vertical dimension line from y1 to y2 at x, label rotated alongside."""
o = []
if ext_from is not None:
for y in (y1, y2):
o.append(f'<line x1="{ext_from}" y1="{y}" x2="{x + (4 if x > ext_from else -4)}" y2="{y}" stroke="{MUTE}" stroke-width=".6"/>')
o.append(f'<line x1="{x}" y1="{y1}" x2="{x}" y2="{y2}" stroke="{INK}" stroke-width=".8" marker-start="url(#{p}a)" marker-end="url(#{p}a)"/>')
cy = (y1 + y2) / 2
o.append(T(x - 5, cy, label, size=size, w=700, anchor="middle", fam="JetBrains Mono", ls=0, rot=-90, halo=True))
return "".join(o)
def tag(x, y, t, r=10):
"""Gold callout bubble matching the drawing schedule tags (B-1, F-1 …)."""
return (f'<circle cx="{x}" cy="{y}" r="{r}" fill="{GOLD}" stroke="{INK}" stroke-width=".8"/>'
+ T(x, y + 3, t, size=7, w=800, anchor="middle", ls=0))
+207
View File
@@ -0,0 +1,207 @@
"""Simplified Chinese (简体中文) editions for manufacturers. See MPM_Document_Standard.md §12.
A Chinese edition is a second PDF of the same document: same number, revision and geometry, body text
in Chinese, frame labels bilingual, file name ending `_ZH_RevX.Y.pdf`. The English edition governs.
It is built by `source/build_zh.py` next to the English `build.py`:
from mpmdoc import Doc, i18n
tr = i18n.Translator({...document terms...}) # master glossary: _STANDARDS/glossary_zh.md
doc = Doc(..., slug=<same as English>, lang="zh") # TN; Drawing(..., lang="zh") for DWG
...
tr.check() # fails the build on any untranslated figure text
What the engine does for lang="zh":
* embeds the MPM SC font (Noto Sans SC, _ASSETS/mpm/fonts/cjk/) subset to the characters used, and adds
it as the fallback to every font stack, so Latin text and numbers stay in the brand fonts
* prints its own labels in Chinese (frame labels bilingual), dates as 2026年11月9日
* adds the governing-language statement (Doc: under the masthead; Drawing: last general note on each sheet
unless already present)
* names the file {NUMBER}_{slug}_ZH_Rev{X.Y}.pdf and refuses to build unless the English edition at the
same revision is already in the project folder
"""
import base64
import io
import re
from pathlib import Path
from .brand import ASSETS
LANGS = ("en", "zh")
TYPES = {"zh": {"TN", "DWG"}} # types with a defined Chinese edition (standard §12)
SUFFIX = {"en": "", "zh": "_ZH"}
FAMILY = "MPM SC"
CJK_DIR = ASSETS / "fonts" / "cjk"
GLOSSARY = Path(__file__).resolve().parent.parent / "glossary_zh.md"
# type code -> (header label, kicker label)
TYPE_ZH = {"TN": ("技术说明", "技术说明"), "DWG": ("图纸", "图纸")}
MARK_ZH = {"internal": "内部资料", "confidential": "机密"}
# engine labels: English -> Chinese. Frame labels a reader at MPM must find their way by stay bilingual.
ZH = {
"Document": "文件编号", "Revision": "版本", "Date": "日期", "Effective": "生效日期",
"Draft": "草稿", "Released": "已发布",
"Revision history": "修订记录 · Revision history",
"Fig": "图", "FIG": "图", "Table": "表", "TABLE": "表",
"Result": "结论", "Before you start": "开始之前",
"Assumptions": "假设条件", "Open items": "待办事项", "Assumptions and open items": "假设条件与待办事项",
"None.": "无。",
"Prepared by": "编制", "Checked by": "校对", "Prepared and approved by": "编制及批准",
"PASS": "合格", "FAIL": "不合格", "CHECK": "需核查",
"TBC": "待确认", "To verify": "待核实",
# drawing frame
"Schedule": "部件表 · Schedule", "General notes": "总说明 · General notes",
"Tag": "编号", "Designation": "名称", "Description": "说明", "Qty": "数量",
"Project": "项目", "Client": "客户 Client", "Location": "地点 Location", "Scale": "比例 Scale",
"Drawn": "制图 Drawn", "Checked": "校对 Checked", "Approved": "审核 Approved",
"Dwg no.": "图号 Dwg no.", "Sheet": "图页 Sheet", "Rev": "版本 Rev", "Date ": "日期 Date",
"Dwg no. · Sheet": "图号 · 图页 · 中文版", "SCALE": "比例", "N.T.S.": "不按比例", "As shown": "见图",
"Irondale, AL": "Irondale, AL, USA",
"Do not distribute without authorization.": "未经授权不得分发。",
"Proprietary to Message Point Media": "MPM 专有资料",
}
def L(lang, key):
return key if lang == "en" else ZH.get(key, key)
def show_date(d, lang="en"):
from .units import show_date as _en, parse_date
if lang == "en":
return _en(d)
d = parse_date(d)
return f"{d.year}年{d.month}月{d.day}日"
def governing_note(number, rev):
return f"本文件为 {number} Rev {rev} 英文版的中文译本;如有歧义,以英文版为准。"
def page_of(lang):
if lang == "en":
return 'Page <span class="pageNumber"></span> of <span class="totalPages"></span>'
return '第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页'
# ---- fonts -------------------------------------------------------------------------------
_CJK = re.compile(r"[⺀-鿿 -〿＀-￯㄀-ㄯ]")
def has_cjk(s):
return bool(_CJK.search(s))
def font_css(text):
"""@font-face rules for MPM SC, subset to the CJK characters in `text` (woff2, inlined)."""
from fontTools.ttLib import TTFont
from fontTools.subset import Subsetter, Options
chars = {ord(c) for c in text if _CJK.match(c)} | {ord(c) for c in ":;,。()、"}
rules = []
for style, weights in (("Regular", "100 549"), ("Bold", "550 900")):
src = CJK_DIR / f"NotoSansSC-{style}.ttf"
if not src.exists():
raise FileNotFoundError(f"{src} missing: run _ASSETS/mpm/fonts/cjk/make_cjk.py (or stage it)")
f = TTFont(src)
opts = Options(); opts.flavor = "woff2"; opts.layout_features = ["*"]; opts.notdef_outline = True
sub = Subsetter(opts); sub.populate(unicodes=chars); sub.subset(f)
buf = io.BytesIO(); f.flavor = "woff2"; f.save(buf)
b64 = base64.b64encode(buf.getvalue()).decode()
rules.append(f"@font-face{{font-family:'{FAMILY}';font-weight:{weights};font-style:normal;"
f"src:url(data:font/woff2;base64,{b64}) format('woff2')}}")
return "".join(rules)
_FACE = re.compile(r"(@font-face\s*\{[^}]*\})")
def add_fallback(text):
"""Append MPM SC to every font stack (CSS declarations and SVG font-family attributes),
leaving @font-face rules alone."""
def css(m):
v = m.group(1)
return m.group(0) if FAMILY in v else f"font-family:{v.rstrip()},'{FAMILY}'"
def attr(m):
v = m.group(1)
return m.group(0) if FAMILY in v else f"font-family=\"{v},'{FAMILY}'\""
parts = _FACE.split(text)
for i, p in enumerate(parts):
if i % 2 == 0:
p = re.sub(r"font-family:\s*([^;}\"]+)", css, p)
p = re.sub(r'font-family="([^"]+)"', attr, p)
parts[i] = p
return "".join(parts)
# ---- translation ---------------------------------------------------------------------------
def load_glossary(path=GLOSSARY):
"""Master MPM terms: `| English | 中文 | note |` rows in _STANDARDS/glossary_zh.md.
Keys are matched case-insensitively against whole strings."""
g = {}
if not Path(path).exists():
return g
for line in Path(path).read_text().splitlines():
if not line.startswith("|") or line.startswith("|---") or line.startswith("| English"):
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) >= 2 and cells[0] and cells[1]:
g[cells[0].upper()] = cells[1]
return g
UNITS = re.compile(r"(?<![A-Za-z])(mm|cm|m|in|ft|W|kW|Wh|V|VAC|VDC|A|mA|Hz|kHz|kg|g|lb|lbs|N|N·m|°C|°F|nits|cd/m²|Mbps|ms|h|min|s|px|IP\d\d)(?![A-Za-z])")
KEEP = re.compile(r"^([A-Z]{1,3}-?\d+[A-Z]?|[A-Z]{1,3}\d+(\.\d+)?|LM-[\w.-]+|[\d\s.,:;×xX\[\]\"'/+\-−–°Ø()%=<>≤≥~]+)$")
class Translator:
"""Translates figure and table text for a Chinese edition.
tr = i18n.Translator({"VIEWED FROM PARKING LOT": "从停车场方向看"},
suffixes=[(" POST CL TO CL", " 立柱中心距")], keep=r"^LM-")
svg_zh = tr.svg(svg_en) # every <text>/<tspan> run
s_zh = tr("BRACKET") # one string
tr.check() # raise, listing every English string left untranslated
Lookup order: the document's own table (exact), then the master glossary (whole string,
case-insensitive), then suffix rules for dimension labels ("3840 ACTIVE FACE"). Numbers, tags
(S-1, M3), part numbers and strings already in Chinese pass through."""
def __init__(self, table=None, suffixes=(), keep=None, glossary=True):
self.table = dict(table or {})
self.gloss = load_glossary() if glossary else {}
self.suffixes = list(suffixes)
self.keep = re.compile(keep) if keep else None
self.missing = []
self._longest = sorted(self.gloss.items(), key=lambda kv: -len(kv[0]))
def __call__(self, s):
if s in self.table:
return self.table[s]
t = s.strip()
if not t or has_cjk(t):
return s
if t.upper() in self.gloss:
return self.gloss[t.upper()]
if t[:1].isdigit() or t[:1] in "−-[":
for en, zh in self.suffixes:
if s.endswith(en):
return s[: -len(en)] + zh
for en, zh in self._longest: # "3840 ACTIVE FACE": number + glossary term, longest first
if t.upper().endswith(" " + en):
return s[: len(s) - len(en)] + zh
if KEEP.match(UNITS.sub("", t)):
return s
if KEEP.match(t) or (self.keep and self.keep.search(t)):
return s
self.missing.append(s)
return s
def svg(self, svg):
return re.sub(r">([^<>]+)</(text|tspan)>", lambda m: ">" + self(m.group(1)) + f"</{m.group(2)}>", svg)
def check(self):
if self.missing:
raise SystemExit("Untranslated text (add to the document table or glossary_zh.md): "
+ repr(sorted(set(self.missing))))
+200
View File
@@ -0,0 +1,200 @@
/* MPM document stylesheet — single source for every MPM document.
Reconciled from INS-LT-15R-01, TN-NSH-B17-01, TN-CMT-P25-01, BOM-OMT-HW-01 (Oct–Nov 2026).
Do not copy this into project build scripts; mpmdoc injects it. */
:root{
--ink:#232022; --gold:#DCBB4F; --kick:#998643; --mute:#849698; --cream:#F5F1EC; --line:#E6E0D8;
--keybg:#FBF6E6; --red:#A42D20; --redbg:#F7E9E7; --grn:#3F6B3A; --grnbg:#EAF1E6;
}
*{box-sizing:border-box}
body{margin:0;font-family:'Open Sans',Montserrat,sans-serif;font-size:9.6pt;line-height:1.45;color:var(--ink);-webkit-print-color-adjust:exact}
h1,h2,h3,h4{font-family:Montserrat,sans-serif;margin:0}
b,strong{font-weight:700}
p{margin:0 0 8px}
/* ---- masthead ------------------------------------------------------- */
.kicker{font-family:Montserrat;font-weight:700;font-size:7.6pt;letter-spacing:.14em;text-transform:uppercase;color:var(--kick)}
h1{font-size:23pt;font-weight:800;line-height:1.1;margin:5px 0 6px}
.lede{color:var(--mute);font-size:10.4pt;margin:0 0 14px}
.meta{display:flex;gap:9px;margin-bottom:14px}
.meta div{flex:1;background:var(--cream);border-radius:7px;padding:10px 13px}
.meta b{display:block;font-family:Montserrat;font-weight:700;font-size:6.6pt;letter-spacing:.12em;text-transform:uppercase;opacity:.75;margin-bottom:6px}
.meta span{font-family:Montserrat;font-weight:600;font-size:8.6pt}
.meta .draft span{color:var(--red)}
/* ---- headline numbers (KPI tiles) ----------------------------------- */
.kpis{display:flex;gap:8px;margin-bottom:12px}
.kpis div{flex:1;border:1px solid var(--line);border-radius:8px;padding:10px 12px 9px}
.kpis b{display:block;font-family:Montserrat;font-weight:700;font-size:6.4pt;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);margin-bottom:4px}
.kpis span{font-family:'JetBrains Mono',Montserrat,monospace;font-weight:700;font-size:15pt;line-height:1.1}
.kpis small{display:block;font-family:Montserrat;font-weight:600;font-size:7pt;color:var(--mute);margin-top:3px}
.kpis .key{border-color:var(--gold);background:var(--keybg)}
.kpis .strong{border-color:var(--ink)}
.kpis .ok{border-color:var(--grn);background:var(--grnbg)}.kpis .ok span{color:var(--grn)}
.kpis .bad{border-color:var(--red);background:var(--redbg)}.kpis .bad span{color:var(--red)}
/* ---- dark summary box (Result / Before you start / Summary) ----------- */
.dark{background:var(--ink);color:#fff;border-radius:8px;border-left:5px solid var(--gold);padding:10px 18px 9px;margin-bottom:14px;break-inside:avoid}
.dark .kicker{color:var(--gold);margin-bottom:6px}
.dark ul{margin:0;padding-left:14px}.dark li{margin:3px 0}
/* ---- sections ---------------------------------------------------------- */
h2.sec{font-size:10.5pt;font-weight:800;letter-spacing:.08em;text-transform:uppercase;border-bottom:2px solid var(--gold);padding-bottom:5px;margin:4px 0 10px;break-after:avoid}
h3.sub{font-family:Montserrat;font-weight:800;font-size:8.6pt;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 5px}
.brk{break-before:page}
.two{display:flex;gap:18px}.two>div{flex:1;min-width:0}
.keep{break-inside:avoid}
/* ---- tables -------------------------------------------------------------- */
table{width:100%;border-collapse:collapse;font-size:8.8pt;margin-bottom:6px}
th{text-align:left;font-family:Montserrat;font-weight:700;font-size:6.8pt;letter-spacing:.1em;text-transform:uppercase;padding:6px 8px;border-bottom:2px solid var(--gold);vertical-align:bottom}
td{padding:4px 8px;border-bottom:1px solid var(--line);vertical-align:top}
tr{break-inside:avoid}thead{display:table-header-group}
th.r,td.r{text-align:right} th.c,td.c{text-align:center}
td.m{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8.6pt;white-space:nowrap}
td.d{font-family:'JetBrains Mono',Montserrat,monospace;font-weight:700;font-size:8.6pt;white-space:nowrap}
td.q{font-family:'JetBrains Mono',Montserrat,monospace;font-weight:700;text-align:right;width:44px}
tr.key td{background:var(--keybg)}
tr.tot td{border-bottom:none;border-top:2px solid var(--gold);font-family:Montserrat;font-weight:700;font-size:8pt;padding-top:8px}
table.tight td{padding:3px 8px}
.up{color:var(--grn)}.dn{color:var(--red)}.k{color:var(--kick)}
.tbc{color:var(--red);font-weight:700}
.mono{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8.6pt}
/* ---- captions (TABLE n / FIG n) ----------------------------------------- */
.tcap{font-family:Montserrat;font-weight:600;font-size:7.2pt;color:var(--mute);margin:0 0 12px}
.cap{font-family:Montserrat;font-weight:600;font-size:7.2pt;color:var(--mute);text-align:center;margin:5px 0 12px}
.tcap b,.cap b{color:var(--kick);letter-spacing:.08em;margin-right:4px}
.figbox{border:1px solid var(--line);border-radius:8px;padding:8px;background:#fff}
.fig{break-inside:avoid}
/* ---- calculation block --------------------------------------------------- */
.calc{background:var(--cream);border-radius:8px;padding:8px 16px;font-family:'JetBrains Mono',Montserrat,monospace;font-size:8.4pt;line-height:1.65;margin-bottom:12px;break-inside:avoid;white-space:pre}
.calc .c{color:var(--mute)}
.pill{display:inline-block;color:#fff;font-family:Montserrat;font-weight:800;font-size:6.8pt;letter-spacing:.12em;border-radius:3px;padding:0 6px;margin-left:6px;line-height:15px;vertical-align:1px}
.pill.pass{background:var(--grn)}.pill.fail{background:var(--red)}.pill.check{background:var(--kick)}
/* ---- notes / callouts ---------------------------------------------------- */
.note{background:var(--cream);border-left:4px solid var(--gold);border-radius:0 7px 7px 0;padding:8px 13px;margin:8px 0 12px;font-size:8.9pt;break-inside:avoid}
.note .kicker{font-size:6.8pt;margin-bottom:2px}
.note ul{margin:2px 0 0;padding-left:15px}.note li{margin:2px 0}
.note.warn{background:var(--redbg);border-color:var(--red)}.note.warn .kicker{color:var(--red)}
.note.ok{background:var(--grnbg);border-color:var(--grn)}.note.ok .kicker{color:var(--grn)}
.note.verify{background:#fff;border:1.5px dashed var(--kick);border-left:4px solid var(--kick)}
/* ---- lists --------------------------------------------------------------- */
ol{margin:0 0 8px;padding-left:18px}ol li{margin:3px 0}
ul.plain{margin:0 0 8px;padding-left:16px}ul.plain li{margin:4px 0}
.chk{list-style:none;padding:0;margin:0 0 8px}
.chk li{padding-left:22px;position:relative;margin:4px 0}
.chk li:before{content:"";position:absolute;left:0;top:3px;width:11px;height:11px;border:1.5px solid var(--kick);border-radius:3px}
.list-head{font-family:Montserrat;font-weight:700;font-size:7.6pt;letter-spacing:.14em;text-transform:uppercase;color:var(--kick);margin-bottom:4px}
/* ---- procedures (INS / SOP) ---------------------------------------------- */
.glance{display:flex;gap:6px;margin-bottom:16px}
.glance div{flex:1;border:1px solid var(--line);border-radius:7px;padding:9px 4px 7px;text-align:center;font-family:Montserrat;font-weight:700;font-size:7.4pt;line-height:1.25}
.glance i{display:block;margin:0 auto 5px;width:19px;height:19px;border-radius:50%;background:var(--gold);font-style:normal;font-weight:800;font-size:8pt;line-height:19px}
.glance small{display:block;font-weight:600;font-size:6.2pt;letter-spacing:.12em;color:var(--mute);margin-top:3px;text-transform:uppercase}
.glance .other{border-style:dashed;background:#FBFAF8;color:var(--mute)}
.glance .other i{background:var(--line);color:var(--mute)}
.step{display:flex;gap:18px;padding:10px 0 11px;border-bottom:1px solid var(--line);break-inside:avoid}
.step .body{flex:1}
.step .side{width:35%;flex:none}
.step.wide{display:block;break-inside:auto}.step.wide .side{width:100%;margin-top:8px;break-inside:avoid}
.step.wide .body{break-inside:avoid}
.sh{display:flex;align-items:center;gap:11px;margin-bottom:7px}
.num{min-width:30px;padding:0 5px;height:30px;border-radius:5px;background:var(--gold);color:#fff;font-family:Montserrat;font-weight:800;font-size:14pt;line-height:30px;text-align:center;flex:none}
.sh .kicker{font-size:6.8pt}
.sh h3{font-size:13.5pt;font-weight:800;line-height:1.15}
.chain{display:flex;align-items:center;gap:6px;margin:0 0 8px;font-family:Montserrat;font-weight:700;font-size:7.4pt}
.chain span{background:var(--cream);border-radius:5px;padding:4px 9px}
.chain b{color:var(--kick)}
/* ---- BOM / checklists ---------------------------------------------------- */
td.n{font-family:'JetBrains Mono',Montserrat,monospace;font-weight:700;color:var(--kick);width:30px}
td.item b{font-family:Montserrat;font-weight:700;font-size:9pt}
td.u{font-family:Montserrat;font-weight:600;font-size:7.4pt;color:var(--mute);text-transform:uppercase;letter-spacing:.08em;width:40px}
td.pn{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8pt;color:var(--mute);width:78px}
.box{display:inline-block;width:11px;height:11px;border:1.5px solid var(--kick);border-radius:3px;vertical-align:middle}
.sign{display:flex;gap:14px;margin-top:6px;break-inside:avoid}
.sign div{flex:1;border-top:1.5px solid var(--ink);padding-top:5px;font-family:Montserrat;font-weight:700;font-size:6.6pt;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);height:26px}
/* ---- revision history: own block, last page -------------------------- */
.revs{position:relative;background:var(--cream);border-radius:8px;border-top:3px solid var(--gold);padding:7px 13px 3px;margin-top:8px;break-inside:avoid}
.revs .kicker{font-size:6.8pt;margin-bottom:1px;padding-bottom:3px;border-bottom:1.5px solid var(--gold)}
.revs table{font-size:7.8pt;margin:0}.revs table.tight td{padding:2px 8px}.revs th{padding:4px 8px}
.revs th{border-bottom-width:1.5px}
.revs td{border-bottom-color:#E2DCD3}.revs tr:last-child td{border-bottom:none}
.revs td.m{width:52px}.revs td.dt{width:88px;font-family:'JetBrains Mono',Montserrat,monospace;font-size:8pt;white-space:nowrap}
.sent{position:absolute;left:4px;font-size:2px;line-height:2px;color:#fff}
#s-top{top:0}#s-end{bottom:0}
/* ---- procedure extras ----------------------------------------------------- */
.ui{display:inline-block;font-family:Montserrat;font-weight:700;font-size:7.8pt;line-height:1.25;padding:0 5px;border:1.2px solid var(--gold);border-radius:4px;background:var(--keybg);white-space:nowrap}
.shots{display:flex;gap:8px;justify-content:center;align-items:flex-start}
.shot{text-align:center;break-inside:avoid}
.shot img{width:100%;border:1px solid var(--line);border-radius:7px;display:block}
.scap{font-family:Montserrat;font-weight:600;font-size:7pt;color:var(--mute);margin-top:4px;line-height:1.3}
.special{border:1.5px solid var(--gold);background:#FFFCF2;border-radius:9px;padding:11px 16px 6px;margin:12px 0;break-inside:avoid}
.special .sp-fig{margin:6px auto 4px;max-width:62%}
.num.bad{background:var(--red)}
table.bomt{table-layout:fixed}
table.bomt td.m.bom{white-space:normal;line-height:1.5;font-size:8pt}
table.bomt th:nth-child(1){width:30px}table.bomt th:nth-child(2){width:27%}
table.bomt th:nth-child(4){width:44px}table.bomt th:nth-child(5){width:44px}table.bomt th:nth-child(6){width:70px}
table.bomt th:nth-child(n+7){width:54px}
/* ---- surveys / plans ------------------------------------------------------ */
table.kv{margin:0 0 14px}
table.kv th{width:24%;border-bottom:1px solid var(--line);vertical-align:top;padding:5px 8px;font-size:6.6pt}
table.kv td{padding:5px 8px}
.part{background:var(--ink);color:#fff;border-radius:8px;border-left:5px solid var(--gold);padding:12px 18px;margin:0 0 14px;break-after:avoid}
.part .kicker{color:var(--gold)}
.part h2{font-size:17pt;font-weight:800;margin:3px 0 0}
.part .part-note{margin:6px 0 0;color:#D8D2CA;font-size:9pt}
.part.internal{border-left-color:var(--red)}
.int-tag{display:inline-block;margin-left:8px;background:var(--red);color:#fff;border-radius:3px;padding:0 6px;letter-spacing:.12em}
.vtag{display:inline-block;font-family:Montserrat;font-weight:800;font-size:6.4pt;letter-spacing:.12em;text-transform:uppercase;color:var(--red);border:1.2px solid var(--red);border-radius:3px;padding:0 4px;line-height:12px;vertical-align:1px;white-space:nowrap}
.photos{display:flex;gap:12px;align-items:flex-start;margin:4px 0 6px;break-inside:avoid}
.photos .photo{flex:1;min-width:0}
.photo{break-inside:avoid}
.photo img{width:100%;max-height:2.7in;object-fit:contain;border:1px solid var(--line);border-radius:7px;display:block;background:#fff}
.photos .cap{text-align:left}
.transcript{background:#FFFDF6;border:1px solid var(--line);border-left:4px solid var(--kick);border-radius:0 8px 8px 0;padding:10px 16px;margin:6px 0 12px;font-size:9pt;break-inside:avoid}
.transcript .t-head{font-family:Montserrat;font-weight:800;font-size:9.5pt;margin-bottom:6px}
.transcript p{margin:0 0 4px}.transcript ul{margin:0 0 4px;padding-left:16px}.transcript li{margin:2px 0}
.approval{margin-top:16px;border-top:1.5px solid var(--ink);padding-top:6px;width:55%}
.approval .ap-name{font-family:Montserrat;font-weight:800;font-size:11pt;margin-top:4px}
.approval .ap-role{color:var(--mute)}
.approval .ap-date{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8pt;margin-top:2px}
/* ---- commercial and field components (PRO / SOW / WO / TSB) ----------- */
table.prt{margin:4px 0 12px}
table.prt td.r,table.prt th.r{text-align:right}
table.prt tr.sub td{font-family:Montserrat;font-weight:700;font-size:7.6pt;border-top:1px solid var(--ink);background:var(--cream)}
table.prt tr.grand td{font-family:Montserrat;font-weight:800;font-size:8.6pt;border-top:2px solid var(--gold);border-bottom:none;padding-top:7px}
.parties{display:flex;gap:10px;margin:0 0 14px}
.party{flex:1;border:1px solid var(--line);border-radius:8px;overflow:hidden;break-inside:avoid}
.party .ph{background:var(--cream);color:var(--kick);font-family:Montserrat;font-weight:700;font-size:6.8pt;letter-spacing:.14em;text-transform:uppercase;padding:6px 12px;border-bottom:2px solid var(--gold)}
.party .pb{padding:8px 12px}
.party .pname{font-family:Montserrat;font-weight:700;font-size:9.4pt}
.party .pline{color:var(--ink);margin-top:1px}
.party .pline.mono{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8.4pt}
.spill{display:inline-block;font-family:Montserrat;font-weight:700;font-size:7pt;letter-spacing:.12em;text-transform:uppercase;padding:3px 10px;border-radius:20px;color:#fff;background:var(--ink)}
.spill.red{background:var(--red)}.spill.green{background:var(--grn)}.spill.amber,.spill.gold{background:var(--gold);color:var(--ink)}
.spill.grey,.spill.gray{background:var(--mute)}.spill.navy{background:var(--ink)}.spill.teal{background:var(--kick)}
.sigs{display:flex;gap:26px;margin-top:22px}
.sig{flex:1}
.sig .line{border-top:1.5px solid var(--ink);margin-top:30px;padding-top:5px}
.sig .role{font-family:Montserrat;font-weight:700;font-size:6.8pt;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
.sig .who{margin-top:1px}
.fields{display:grid;gap:4px 22px;margin:2px 0 12px}
.fields.c1{grid-template-columns:1fr}.fields.c2{grid-template-columns:1fr 1fr}.fields.c3{grid-template-columns:1fr 1fr 1fr}
.fld{break-inside:avoid}
.fld b{display:block;font-family:Montserrat;font-weight:700;font-size:6.6pt;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);margin-top:6px}
.fld .ln{display:block;height:20px;border-bottom:1px solid var(--ink)}
.fld small{display:block;color:var(--mute);font-size:7.4pt;margin-top:2px}
h3.sub{break-after:avoid}
table.fill td{height:24px}
.govern{font-size:8pt;color:var(--mute);margin:-4px 0 12px;border-left:2px solid var(--gold);padding-left:8px}
+115
View File
@@ -0,0 +1,115 @@
"""PDF rendering: Chromium via Playwright, US Letter, running header + footer.
Requires: pip install playwright && playwright install chromium
(Claude's cloud workspace already has both.)
"""
import base64
import re
from pathlib import Path
from . import brand, i18n
MARGIN = {"top": "1.0in", "bottom": "0.7in", "left": "0.62in", "right": "0.62in"}
def _header(doc, fonts, logo):
return (f'<style>{fonts}</style><div style="width:100%;margin:0 0.62in;font-family:Montserrat;-webkit-print-color-adjust:exact;">'
f'<div style="display:flex;justify-content:space-between;align-items:center;padding-bottom:7px;border-bottom:2px solid {brand.GOLD}">'
f'<img src="{logo}" style="height:36px">'
f'<div style="text-align:right"><div style="font-weight:800;font-size:10px;letter-spacing:2.2px;color:{brand.INK}">{doc.header_label.upper()}</div>'
f'<div style="font-weight:700;font-size:8px;letter-spacing:1.2px;color:{brand.KICK};margin-top:3px">{doc.number} · REV {doc.rev}</div></div></div></div>')
def _footer(doc, fonts):
"""MESSAGE POINT MEDIA · tagline (left) | CONTEXT [· MARKING] · NUMBER · Page x of y (right)."""
ctx = doc.footer.upper().replace("MESSAGE POINT MEDIA · ", "", 1)
return (f'<style>{fonts}</style><div style="width:100%;margin:0 0.62in;display:flex;justify-content:space-between;'
f'font-family:Montserrat;font-weight:600;font-size:7px;letter-spacing:1.1px;white-space:nowrap;color:{brand.FOOT}">'
f'<span>MESSAGE POINT MEDIA · <span style="letter-spacing:.3px;font-size:7.5px;color:{brand.KICK}">{brand.TAGLINE}</span></span>'
f'<span>{ctx} · {doc.number} · {i18n.page_of(getattr(doc, "lang", "en"))}</span></div>')
def _header_logo(width=1400, path=None):
"""Brand logo scaled for the running header (the MPM master PNG is ~5800 px wide)."""
raw = (path or brand.LOGO_HEADER).read_bytes()
try:
import io
from PIL import Image
im = Image.open(io.BytesIO(raw))
if im.width > width:
im = im.resize((width, round(im.height * width / im.width)), Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, "PNG", optimize=True)
return buf.getvalue()
except ImportError:
pass
return raw
PAGE_H_PT = 792.0 # US Letter
CONTENT_BOTTOM_PT = PAGE_H_PT - 0.7 * 72 # bottom margin 0.7 in.
HEADER_BOTTOM_PT = 1.0 * 72
def _rev_spacer_px(pdf_bytes, content_bottom_pt=None, header_bottom_pt=None):
"""Find the revision block via its sentinels. If it shares the last page with other content,
return the spacer (px) that moves it to the bottom of that page; else 0. Needs poppler's pdftotext."""
import re
import shutil
import subprocess
import tempfile
if not shutil.which("pdftotext"):
import sys
print("warning: poppler's pdftotext not found; the revision block will not be anchored to the page "
"bottom (install poppler: brew install poppler / apt install poppler-utils)", file=sys.stderr)
return 0, None
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(pdf_bytes)
f.flush()
xml = subprocess.run(["pdftotext", "-bbox", f.name, "-"], capture_output=True, text=True).stdout
pages = re.findall(r"<page[^>]*>(.*?)</page>", xml, re.S)
word = re.compile(r'<word xMin="[\d.]+" yMin="([\d.]+)" xMax="[\d.]+" yMax="([\d.]+)">([^<]*)</word>')
for i, pg in enumerate(pages):
words = [(float(a), float(b), t) for a, b, t in word.findall(pg)]
top = [w for w in words if "QQREVTOPQQ" in w[2]]
end = [w for w in words if "QQREVENDQQ" in w[2]]
if not top or not end:
continue
y_top, y_end = top[0][0], end[0][1]
above = [w for w in words if (header_bottom_pt or HEADER_BOTTOM_PT) < w[1] < y_top - 1 and "QQREV" not in w[2]]
if not above: # block sits alone on its page: leave it at the top
return 0, len(pages)
free_pt = (content_bottom_pt or CONTENT_BOTTOM_PT) - y_end
return max(0.0, free_pt * 96 / 72 - 4), len(pages)
return 0, len(pages)
def render_pdf(html_path, out_path, doc):
from playwright.sync_api import sync_playwright
fonts = brand.FONTS_CSS.read_text()
logo = "data:image/png;base64," + base64.b64encode(_header_logo()).decode()
if getattr(doc, "lang", "en") != "en":
fonts += doc._cjk_css # same subset the page loaded (see Doc.html)
hdr, ftr, margin = _header(doc, fonts, logo), _footer(doc, fonts), MARGIN
if getattr(doc, "lang", "en") != "en":
hdr, ftr = i18n.add_fallback(hdr), i18n.add_fallback(ftr)
bounds = (CONTENT_BOTTOM_PT, HEADER_BOTTOM_PT)
opts = dict(format="Letter", print_background=True, display_header_footer=True,
header_template=hdr, footer_template=ftr, margin=margin)
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page()
pg.goto(Path(html_path).resolve().as_uri(), wait_until="load")
# wait for every image to decode and every font to load, so pass 1 measures the final layout
pg.wait_for_function("Array.from(document.images).every(i => i.complete && i.naturalWidth > 0)")
pg.evaluate("() => Promise.all([document.fonts.ready, ...Array.from(document.images).map(i => i.decode().catch(() => null))])")
pg.wait_for_timeout(150)
first = pg.pdf(**opts) # pass 1: find the revision block
spacer, n_pages = _rev_spacer_px(first, *bounds)
pg.add_style_tag(content=f".sent{{display:none}}#rev-spacer{{height:{spacer:.1f}px}}")
final = pg.pdf(**opts) # pass 2: block anchored, sentinels hidden
if spacer and n_pages and len(re.findall(rb"/Type\s*/Page[^s]", final)) != n_pages:
pg.add_style_tag(content="#rev-spacer{height:0}")
final = pg.pdf(**opts)
b.close()
Path(out_path).write_bytes(final)
+190
View File
@@ -0,0 +1,190 @@
"""mpmdoc.Drawing — 11×17 landscape drawing sheets (DWG). See MPM_Document_Standard.md §9.7.
dwg = Drawing(number="DWG-NSH-B17-01", rev="0.1", date="2026-11-06",
title="Bay 17 Sign Hanger", subtitle="Mounting arrangement",
project="Nashville MCC Bay 17", client="Nashville MCC (WeGo)", location="Nashville, TN",
discipline="Mechanical drawing · Mounting", drawn="JS", checked="—", approved="—",
slug="Nashville_Bay17_Sign-Hanger", history=[("0.1", "2026-11-06", "Initial issue.")])
dwg.sheet(views=[dwg.view(svg, 1, "Elevation A", "1:10", area="1 / 1 / 3 / 4"), ...],
schedule=[("B-1", "Bracket", "6 mm HDG steel", 1)], notes=["All dimensions in mm.", ...],
scale="As shown")
dwg.build()
Each dwg.sheet(...) is one 11×17 page. The view area is a 6-column × 4-row grid; place each view with a
CSS grid-area "row-start / col-start / row-end / col-end". Sheet numbers and the title block are automatic.
"""
import base64
from pathlib import Path
from . import brand
from .doc import Doc
from .units import show_date
from . import i18n
W_IN, H_IN = 17, 11
CSS = """
@page{size:17in 11in;margin:0}
*{box-sizing:border-box}
body{margin:0;font-family:'Open Sans',Montserrat,sans-serif;color:var(--ink);-webkit-print-color-adjust:exact}
.sheet{width:17in;height:11in;padding:.3in;position:relative;break-after:page;overflow:hidden}
.sheet:last-child{break-after:auto}
.frame{position:absolute;inset:.3in;border:1.6px solid var(--ink)}
.zones{position:absolute;inset:.3in;pointer-events:none}
.zh,.zv{position:absolute;font-family:Montserrat;font-weight:700;font-size:7pt;color:var(--mute);display:flex}
.zh{left:0;right:0;height:.2in;justify-content:space-around;align-items:center}
.zh.t{top:0;border-bottom:1px solid var(--ink)}.zh.b{bottom:0;border-top:1px solid var(--ink)}
.zv{top:.2in;bottom:.2in;width:.2in;flex-direction:column;justify-content:space-around;align-items:center}
.zv.l{left:0;border-right:1px solid var(--ink)}.zv.r{right:0;border-left:1px solid var(--ink)}
.inner{position:absolute;inset:.62in .64in .6in .64in;display:grid;grid-template-columns:1fr 3.35in;grid-template-rows:.62in 1fr;gap:.12in}
.hbar{grid-column:1 / 3;display:flex;align-items:center;gap:16px;border-bottom:2px solid var(--gold);padding-bottom:6px}
.hbar img{height:.44in}
.hbar .disc{font-family:Montserrat;font-weight:800;font-size:10pt;letter-spacing:.14em;text-transform:uppercase;border-left:1.5px solid var(--line);padding-left:16px}
.hbar .dno{margin-left:auto;text-align:right;font-family:Montserrat;font-weight:700;font-size:7.5pt;letter-spacing:.12em;color:var(--kick);text-transform:uppercase}
.hbar .dno b{display:block;font-family:'JetBrains Mono',Montserrat,monospace;font-size:11pt;color:var(--ink);letter-spacing:0}
.views{display:grid;grid-template-columns:repeat(6,1fr);grid-template-rows:repeat(4,1fr);gap:.12in;min-height:0}
.view{border:1px solid var(--line);border-radius:4px;display:flex;flex-direction:column;min-height:0;padding:6px 8px 6px}
.view .art{flex:1;min-height:0;display:flex;align-items:center;justify-content:center}
.view .art svg{max-width:100%;max-height:100%;width:100%;height:100%}
.vlab{display:flex;align-items:center;gap:8px;border-top:1px solid var(--line);padding-top:5px;margin-top:4px}
.vlab .bub{width:24px;height:24px;border-radius:50%;border:1.6px solid var(--ink);font-family:Montserrat;font-weight:800;font-size:9pt;display:flex;align-items:center;justify-content:center;flex:none}
.vlab b{font-family:Montserrat;font-weight:800;font-size:8.5pt;letter-spacing:.08em;text-transform:uppercase;display:block}
.vlab small{font-family:'JetBrains Mono',Montserrat,monospace;font-size:7pt;color:var(--mute)}
.side{display:flex;flex-direction:column;gap:.1in;min-height:0}
.blk{border:1px solid var(--line);border-radius:4px;padding:6px 9px}
.blk .kicker{font-size:6.6pt;margin-bottom:4px}
.side table{width:100%;border-collapse:collapse;font-size:7.2pt}
.side th{font-family:Montserrat;font-weight:700;font-size:6pt;letter-spacing:.1em;text-transform:uppercase;text-align:left;padding:3px 4px;border-bottom:1.5px solid var(--gold)}
.side td{padding:2.5px 4px;border-bottom:1px solid var(--line);vertical-align:top}
.tag{display:inline-block;min-width:26px;text-align:center;font-family:Montserrat;font-weight:800;font-size:6.6pt;background:var(--gold);border-radius:8px;padding:1px 4px}
.notes ol{margin:0;padding-left:15px;font-size:7.2pt}.notes li{margin:1.5px 0}
.tb{margin-top:auto;border:1.6px solid var(--ink);border-radius:0;padding:0}
.tb .brandrow{display:flex;justify-content:space-between;align-items:center;padding:6px 9px;border-bottom:1px solid var(--ink)}
.tb .brandrow img{height:.3in}
.tb .brandrow span{font-family:Montserrat;font-weight:600;font-size:6.6pt;color:var(--mute);text-align:right;line-height:1.3}
.tb .proj{padding:6px 9px;border-bottom:1px solid var(--ink)}
.tb .proj .kicker{font-size:6pt}
.tb .proj h1{font-family:Montserrat;font-weight:800;font-size:15pt;line-height:1.05;margin:2px 0 1px}
.tb .proj p{margin:0;font-size:7.4pt;color:var(--mute)}
.tb .cells{display:grid;grid-template-columns:1fr 1fr 1fr}
.tb .cells div{padding:4px 8px;border-bottom:1px solid var(--ink);border-right:1px solid var(--ink)}
.tb .cells div:nth-child(3),.tb .cells div:nth-child(6),.tb .cells div:nth-child(8),.tb .cells div:nth-child(10){border-right:none}
.tb .cells .w2{grid-column:span 2}
.tb .cells b{display:block;font-family:Montserrat;font-weight:700;font-size:5.6pt;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
.tb .cells span{font-family:'JetBrains Mono',Montserrat,monospace;font-size:8pt;font-weight:700}
.tb .revs2{padding:4px 8px;font-size:6.8pt;border-bottom:1px solid var(--ink)}
.tb .revs2 .kicker{font-size:5.6pt;margin-bottom:1px}
.tb .revs2 div{display:flex;gap:6px}.tb .revs2 code{font-family:'JetBrains Mono',Montserrat,monospace;font-weight:700}
.tb .conf{background:var(--ink);color:#fff;display:flex;justify-content:space-between;align-items:center;padding:5px 9px;font-size:6.4pt}
.tb .conf em{font-style:normal;font-family:Montserrat;font-weight:700;color:var(--gold-light,#F5CD15)}
"""
class Drawing(Doc):
def __init__(self, number, rev, date, title, subtitle, project, client, slug, history,
location="", discipline="Mechanical drawing", drawn="JS", checked="—", approved="—",
classification=None, lang="en"):
super().__init__(number=number, rev=rev, date=date, title=title, lede=subtitle,
meta=[("Project", project), ("Client", client)], slug=slug, history=history,
customer=client, classification=classification, lang=lang)
if self.type != "DWG":
raise ValueError("Drawing numbers must start with DWG-")
import inspect
self.src_dir = Path(inspect.stack()[1].filename).resolve().parent
self.subtitle, self.project, self.client, self.location = subtitle, project, client, location
self.discipline, self.drawn, self.checked, self.approved = discipline, drawn, checked, approved
self.sheets = []
# ---- building blocks ----------------------------------------------------
def view(self, svg, n, title, scale="N.T.S.", area="1 / 1 / 5 / 7"):
sep = ":" if self.lang == "zh" else ": "
return (f'<div class="view" style="grid-area:{area}"><div class="art">{svg}</div>'
f'<div class="vlab"><div class="bub">{n}</div><div><b>{title}</b><small>{self.L("SCALE")}{sep}{self.L(scale)}</small></div></div></div>')
def sheet(self, views, schedule=None, notes=None, scale="As shown", schedule_title="Schedule"):
notes = list(notes or [])
if self.lang != "en" and not any("以英文版为准" in n for n in notes):
notes.append(i18n.governing_note(self.number, self.rev))
self.sheets.append(dict(views=views, schedule=schedule or [], notes=notes, scale=self.L(scale),
schedule_title=self.L(schedule_title)))
return self
def _logo(self, path):
return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode()
def _sheet_html(self, i, s, n_sheets):
L, D = self.L, self.fmt_date
zones_h = "".join(f"<span>{k}</span>" for k in range(8, 0, -1))
zones_v = "".join(f"<span>{k}</span>" for k in "DCBA")
sched = ""
if s["schedule"]:
rows = "".join(f'<tr><td><span class="tag">{t}</span></td><td><b>{d}</b></td><td>{desc}</td><td class="r">{q}</td></tr>'
for t, d, desc, q in s["schedule"])
sched = (f'<div class="blk"><div class="kicker">{s["schedule_title"]}</div><table>'
f'<tr><th>{L("Tag")}</th><th>{L("Designation")}</th><th>{L("Description")}</th><th>{L("Qty")}</th></tr>{rows}</table></div>')
notes = ""
if s["notes"]:
notes = (f'<div class="blk notes"><div class="kicker">{L("General notes")}</div><ol>'
+ "".join(f"<li>{x}</li>" for x in s["notes"]) + "</ol></div>")
revs = "".join(f'<div><code>{r}</code><span>{D(d)}</span><span>{t}</span></div>' for r, d, t in self.history)
mark = brand.CLASSIFICATIONS[self.classification] or L("Proprietary to Message Point Media")
if self.lang == "zh" and self.classification in i18n.MARK_ZH:
mark = f"{i18n.MARK_ZH[self.classification]} {mark}"
conf = f"{mark},{L('Do not distribute without authorization.')}" if self.lang == "zh" else f"{mark}. Do not distribute without authorization."
tb = f"""<div class="tb">
<div class="brandrow"><img src="{self._logo(brand.LOGO_HEADER)}"><span>Message Point Media<br>{L("Irondale, AL")} · messagepoint.tv</span></div>
<div class="proj"><div class="kicker">{L("Project")} · {self.project}</div><h1>{self.title}</h1><p>{self.subtitle}</p></div>
<div class="cells">
<div><b>{L("Client")}</b><span>{self.client}</span></div><div><b>{L("Location")}</b><span>{self.location or "—"}</span></div><div><b>{L("Scale")}</b><span>{s["scale"]}</span></div>
<div><b>{L("Drawn")}</b><span>{self.drawn}</span></div><div><b>{L("Checked")}</b><span>{self.checked}</span></div><div><b>{L("Approved")}</b><span>{self.approved}</span></div>
<div class="w2"><b>{L("Dwg no.")}</b><span>{self.number}</span></div><div><b>{L("Sheet")}</b><span>{i} {"/" if self.lang == "zh" else "of"} {n_sheets}</span></div>
<div><b>{L("Rev")}</b><span>{self.rev} · {L(self.status)}</span></div><div class="w2"><b>{L("Date ")}</b><span>{D(self.date)}</span></div>
</div>
<div class="revs2"><div class="kicker">{L("Revision history")}</div>{revs}</div>
<div class="conf"><span>{conf}</span><em>{brand.TAGLINE}</em></div>
</div>"""
return f"""<div class="sheet"><div class="frame"></div>
<div class="zones"><div class="zh t">{zones_h}</div><div class="zh b">{zones_h}</div><div class="zv l">{zones_v}</div><div class="zv r">{zones_v}</div></div>
<div class="inner">
<div class="hbar"><img src="{self._logo(brand.LOGO_HEADER)}"><div class="disc">{self.discipline}</div>
<div class="dno">{L("Dwg no. · Sheet")}<b>{self.number} · {i}/{n_sheets} · Rev {self.rev}</b></div></div>
<div class="views">{"".join(s["views"])}</div>
<div class="side">{sched}{notes}{tb}</div>
</div></div>"""
def html(self):
base = (Path(__file__).parent / "mpm.css").read_text()
fonts = brand.FONTS_CSS.read_text()
n = len(self.sheets)
body = "".join(self._sheet_html(i + 1, s, n) for i, s in enumerate(self.sheets))
css = base + CSS
hl = ""
if self.lang != "en":
css, body = i18n.add_fallback(css), i18n.add_fallback(body)
fonts += i18n.font_css(self.title + body)
hl = ' lang="zh-CN"'
return (f'<!doctype html><html{hl}><head><meta charset="utf-8"><title>{self.number} · {self.title}</title>'
f'<style>{fonts}{css}</style></head><body>{body}</body></html>')
def build(self, out_dir=None, html_dir=None):
import inspect
from playwright.sync_api import sync_playwright
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)
hp = html_dir / f"{self.number}.html"
hp.write_text(self.html())
self._check_english(out_dir)
out = out_dir / self.filename()
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page()
pg.goto(hp.resolve().as_uri(), wait_until="load")
pg.evaluate("() => document.fonts.ready")
pg.wait_for_timeout(150)
pg.pdf(path=str(out), width=f"{W_IN}in", height=f"{H_IN}in", print_background=True,
margin={"top": "0", "bottom": "0", "left": "0", "right": "0"})
b.close()
print(out)
return out
+49
View File
@@ -0,0 +1,49 @@
"""Number / unit / date formatters so every document writes values the same way.
See MPM_Document_Standard.md §5 (Units and numbers)."""
import datetime as _dt
from fractions import Fraction
def ftin(x):
"""Inches -> feet-inches to the nearest 1/16: 154 -> 12'-10", 153.5 -> 12'-9 1/2"."""
x = round(x * 16) / 16
ft = int(x // 12)
rem = x - ft * 12
whole = int(rem)
frac = Fraction(rem - whole).limit_denominator(16)
f = f" {frac.numerator}/{frac.denominator}" if frac else ""
return f"{ft}'-{whole}{f}\""
def inch(x):
"""Inches to the nearest 1/16: 7.25 -> 7 1/4"."""
x = round(x * 16) / 16
whole = int(x)
frac = Fraction(x - whole).limit_denominator(16)
if frac and whole:
return f"{whole} {frac.numerator}/{frac.denominator}\""
if frac:
return f"{frac.numerator}/{frac.denominator}\""
return f"{whole}\""
def mm_in(mm, digits=2):
"""'1448 mm (57.01 in.)' — metric primary with inch conversion."""
return f"{mm:g} mm ({mm / 25.4:.{digits}f} in.)"
def signed(s, value):
"""Prefix a true minus sign or plus: signed('13\"', -1) -> '−13\"'."""
return ("−" if value < 0 else "+" if value > 0 else "") + s
def parse_date(d):
if isinstance(d, _dt.date):
return d
return _dt.date.fromisoformat(d)
def show_date(d):
"""Display format used on every page: 'Nov 5, 2026'."""
d = parse_date(d)
return f"{d:%b} {d.day}, {d.year}"