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:
143 files changed
+4097
No files matched your search
@@ -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"
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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))))
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
Reference in new issue
Block a user