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,551 @@
|
||||
# MPM Document Standard
|
||||
|
||||
**Version 1.3 · Released · Nov 9, 2026 · Owner: Jason Stedwell**
|
||||
|
||||
This is the rule set for every technical document produced in the DOCUMENTATION PROJECT. It covers what each document type is for, how it is numbered, named and filed, how it looks, and how it reads. The engine in `mpmdoc/` enforces the mechanical parts (numbering, masthead, header/footer, palette, revision history); this document covers the judgement parts.
|
||||
|
||||
**MPM Documentation Kit edition.** Everything in the kit is Message Point Media. Replace your kit when MPM Technical Services releases a new version; the version line above tells you which one you have.
|
||||
|
||||
---
|
||||
|
||||
## 1. Document types
|
||||
|
||||
| Code | Type | Use it when | Audience | Default marking | Length | Template |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **INS** | Installation Guide | Someone has to physically install hardware, step by step | Customer facilities, installers, MPM field | Customer | 4–10 pp | **Ready** · `templates/INS/` |
|
||||
| **SOP** | Standard Operating Procedure | A repeatable internal task (setup, onboarding, process) | MPM staff | Internal | 2–6 pp | **Ready** · `templates/SOP/` |
|
||||
| **TN** | Technical Note | One engineering question needs a worked, checkable answer | Customer + MPM engineering | Customer | 1–3 pp | **Ready** · `templates/TN/` |
|
||||
| **BOM** | Bill of Materials | Pull, check and pack a set of parts | MPM shop / field | Internal | 1–2 pp | **Ready** · `templates/BOM/` |
|
||||
| **SVY** | Site Survey | Recording what was found on site and what it means for design | Part 1 customer, Part 2 MPM-internal | Customer | 4–12 pp | **Ready** · `templates/SVY/` |
|
||||
| **TAP** | Technical Action Plan | A planned engagement: scope, staffing, logistics, success criteria | Customer / OEM partner | Confidential | 5–12 pp | **Ready** · `templates/TAP/` |
|
||||
| **DWG** | Drawing sheet (11×17) | Mechanical, cable, mounting or fit-out drawings with title block | Fabrication, customer approval | Customer | 1–4 sheets | **Ready** · `templates/DWG/` |
|
||||
| **TSB** | Technical Service Bulletin | A known product issue and its fix for every affected unit | Customer edition: agencies, partners · Internal edition: MPM field and support | Customer (internal edition: Internal) | 2–5 pp | **Ready** · `templates/TSB/` |
|
||||
| **PRO** | Proposal | Before award: need, proposed solution, why MPM, investment | Agency / partner decision makers | Confidential | 3–8 pp | **Ready** · `templates/PRO/` |
|
||||
| **SOW** | Statement of Work | After award: contractual scope, deliverables, milestones | Customer / partner, under the MSA | Customer | 2–6 pp | **Ready** · `templates/SOW/` |
|
||||
| **WO** | Field Work Order | Dispatching one job at one site to an MPM tech or a subcontract installer | Technician / installer, site contact | Internal | 1–3 pp | **Ready** · `templates/WO/` |
|
||||
|
||||
Also standard but not a PDF: **`00_INDEX.md`**, a project index (see §4.3).
|
||||
|
||||
New types are assessed when they come up; don't improvise a code. Add it to `brand.TYPES` and this table first.
|
||||
|
||||
**Picking the type:**
|
||||
- Steps someone performs → INS (outside MPM) or SOP (inside MPM).
|
||||
- A number, a fit or a yes/no with working → TN.
|
||||
- A list of parts → BOM.
|
||||
- A record of what exists on site → SVY.
|
||||
- A plan for a visit or engagement → TAP.
|
||||
- Geometry someone will fabricate from → DWG.
|
||||
- A product defect and its fix across units → TSB. (One site's one-off problem is a TN or a TAP.)
|
||||
- Winning the work → PRO. Defining the awarded work → SOW. A PRO never turns into the SOW; the SOW gets its own number.
|
||||
- Sending someone to do one job → WO. How to do the job is the INS it references.
|
||||
|
||||
---
|
||||
|
||||
## 2. Brand selection
|
||||
|
||||
- **MPM** (gold/ink, Montserrat + Open Sans + JetBrains Mono) is used for every document.
|
||||
|
||||
---
|
||||
|
||||
## 3. Numbering
|
||||
|
||||
### 3.1 Format: `TYPE-CUST-SUBJ-NN`
|
||||
|
||||
| Field | Rule | Examples |
|
||||
|---|---|---|
|
||||
| TYPE | Code from §1 | `INS`, `TN`, `BOM` |
|
||||
| CUST | 2–4 letters: customer code, or MPM department for internal docs (`IT`, `ENG`, `OPS`, `QA`), or `MPM` for company-wide/product docs | `LT`, `NSH`, `CMT`, `OMT`, `SOR`, `GIL` |
|
||||
| SUBJ | 2–8 letters/digits naming the subject: site, product, bay, kit, job | `15R`, `B17`, `P25`, `HW`, `UNIFI`, `MON` |
|
||||
| NN | Two-digit sequence within TYPE-CUST-SUBJ, starting `01` | `01`, `02` |
|
||||
|
||||
- Uppercase, hyphens only, no spaces.
|
||||
- A number is issued once and never reused, even if the document is abandoned.
|
||||
- A new subject on the same project gets a new SUBJ. A second document of the same type on the same subject gets the next NN.
|
||||
- Register the customer code and the number in `registry.md` **before** the first build. The engine refuses unknown customer codes.
|
||||
- TSB: `MPM` + product family for a product-wide bulletin (`TSB-MPM-LX270-01`); a customer code only when the issue belongs to one deployment.
|
||||
- PRO / SOW: the customer the document is addressed to (the partner when MPM works through one, e.g. Vontas), SUBJ = project. A SOW that follows a PRO keeps the same SUBJ.
|
||||
- WO: the customer code of the job, SUBJ = site or job; one WO per dispatch.
|
||||
|
||||
### 3.2 Existing documents
|
||||
|
||||
| Current | Standard number | Action |
|
||||
|---|---|---|
|
||||
| `INS-LT-15R-01` | same | Done: Rev 0.6 on engine |
|
||||
| `SOP-IT-UNIFI-01` | same | Done: Rev 1.2 on engine; file renamed to its number |
|
||||
| `TN-NSH-B17-01`, `TN-CMT-P25-01` | same | Done: Rev 0.3 on engine |
|
||||
| `BOM-OMT-HW-01` | same | Done: Rev 0.2 on engine |
|
||||
| `MPM-TAP-2606-001` Rev A / B | `TAP-GIL-M37-01` | Done: Rev A → 1.0, Rev B → 1.1, rebuilt as 1.2; old number listed under *Supersedes* |
|
||||
| `MPM-TB-2604-001` (technical bulletin) | TBD | TB type not yet defined; assess when next needed |
|
||||
| SORTA Monument Square survey Rev 0.1 (.docx) | `SVY-SOR-MON-01` | Done: rebuilt as PDF, Rev 0.2 |
|
||||
| Power Whip Cable drawing (engineering, MWT) | TBD | Engineering-owned part drawings keep their part-number scheme; DWG numbers are for drawings produced in this project |
|
||||
|
||||
---
|
||||
|
||||
## 4. Revisions, files and folders
|
||||
|
||||
### 4.1 Revisions and status
|
||||
|
||||
- Revisions are numeric `MAJOR.MINOR` for every type, drawings included. Letter revisions (Rev A/B) belong to legacy and engineering part drawings only; when a lettered document is renumbered, issued letters map to 1.0, 1.1, … (Rev A → 1.0).
|
||||
- **0.1, 0.2, …** are drafts. Status shows as `Draft` (red in the info strip).
|
||||
- **1.0** is the first release. Anything given to a customer as final must be ≥ 1.0. Drafts may be shared but stay marked Draft.
|
||||
- After release: **1.1, 1.2** for corrections and clarifications, **2.0** when the substance (scope, values, procedure) changes.
|
||||
- Once a revision has been shared outside your machine, any change to the PDF bumps the revision, formatting-only changes included. An unshared draft may be rebuilt in place.
|
||||
- **Revision history is mandatory.** One line per revision: what changed, not "updates". The engine prints it as its own block at the end of the document (§6).
|
||||
- A document that replaces a different document number lists it under *Supersedes* (TAP, SVY).
|
||||
|
||||
### 4.2 File names
|
||||
|
||||
`{NUMBER}_{Customer}_{Short-Title}_Rev{X.Y}.pdf`
|
||||
|
||||
- Underscores separate fields; hyphens join words within a field. No spaces.
|
||||
- Example: `TN-NSH-B17-01_Nashville_Bay17_Sign-Height-Change_Rev0.3.pdf`
|
||||
- The engine builds this name from `slug="Nashville_Bay17_Sign-Height-Change"`.
|
||||
- A Chinese edition adds `_ZH` before the revision: `DWG-NSH-STAR-01_WeGo-Star_LED-Banner_Mounting-Options_ZH_Rev0.2.pdf` (§12).
|
||||
|
||||
### 4.3 Folder layout
|
||||
|
||||
```
|
||||
DOCUMENTATION PROJECT/
|
||||
_ASSETS/ ← shared fonts and logos (mpm/): the only copies
|
||||
_STANDARDS/ ← the standard, engine, templates, glossary, registry
|
||||
_INPUTS/ ← reference material not tied to one project (outside drawings, examples)
|
||||
<CUSTOMER> <PROJECT>/ ← one folder per project, e.g. "NASHVILLE BAY 17"
|
||||
00_INDEX.md ← optional: status, contents, key numbers, owed items, sources
|
||||
<current PDFs> ← latest revision of each document only
|
||||
_superseded/ ← older revisions (moved here when a new rev is built)
|
||||
_INPUTS/ ← what came in: site photos, screenshots, received drawings, quotes, field notes
|
||||
source/ ← build.py, figs.py, img/ (prepared crops used in the PDF), _build/ (disposable)
|
||||
```
|
||||
|
||||
- **The root holds folders only.** No PDFs, photos or loose files.
|
||||
- **`_INPUTS/` holds originals and is never edited.** Crops and annotated versions used in a document go in `source/img/`.
|
||||
- **Nothing is deleted from `_superseded/`.** It is the issued history.
|
||||
- **"Claude outputs" is where the Claude app drops copies of files sent in chat.** Treat it as transient. The project folder holds the record.
|
||||
|
||||
**`00_INDEX.md` sections**, as in Nashville Bay 17:
|
||||
- Status in one paragraph
|
||||
- What's in this folder
|
||||
- Key numbers
|
||||
- What's still owed
|
||||
- Could not open
|
||||
- Source threads
|
||||
|
||||
---
|
||||
|
||||
## 5. Units, numbers, dates
|
||||
|
||||
- **Heights and clearances above grade:** feet-inches, `12'-10"`, to the nearest 1/16 (`units.ftin`).
|
||||
- **Component and hardware dimensions:** use the units of the source drawing or part. Metric parts stay metric (`M8 × 1.25`, `107 × 465 mm`).
|
||||
- **Customer-facing documents:** the first mention of a metric dimension gets the inch conversion: `312 mm (about 12-5/16 in.)`.
|
||||
- **Characters:** use the true minus `−`, times `×`, diameter `Ø`, degree `°` and arrow `→`. Not `-`, `x` or `->`.
|
||||
- **Numbers and units:** in the mono font in tables and calcs. Put a space between value and unit (`51.0 W`, `0.53 A`), except feet/inches and `%`.
|
||||
- **Precision:** match the measurement; don't print 63.75 if you measured to ±0.1. One decimal for watts, two for amps.
|
||||
- **Dates:** ISO in source (`2026-11-05`); displayed `Nov 5, 2026` everywhere in the document.
|
||||
- **Unknowns:** use `TBC` (red) in a cell, never a guess. List each TBC as an open item.
|
||||
|
||||
---
|
||||
|
||||
## 6. Page anatomy
|
||||
|
||||
Every MPM document, top to bottom. The engine generates the items marked ⚙.
|
||||
|
||||
1. ⚙ **Running header (every page):** MPM logo left; document type (uppercase) and `NUMBER · REV X.Y` right; gold rule.
|
||||
2. ⚙ **Kicker:** `Type · Customer · Subject` in gold small caps.
|
||||
3. **Title (H1):**
|
||||
- TN: name the thing being checked ("Bay 17 Sign: Mounting Height Change").
|
||||
- INS/SOP: name the outcome ("15" Reflective Sign: Shelter Post Mount").
|
||||
4. **Lede:** 2–3 sentences; the situation and what the document does.
|
||||
5. ⚙ **Info strip:** five cells: Document · Revision · Date (*Effective* for SOP) plus **two type-specific cells** (§9).
|
||||
6. **Opening:**
|
||||
- TN: KPI tiles, then the dark *Result* box
|
||||
- INS/SOP: dark *Before you start* box
|
||||
- SVY: control table, dark *About this document* box, *Status of this revision* note
|
||||
- TAP: document-control table and the confidentiality notice (no dark box)
|
||||
- BOM: straight into the item tables
|
||||
- TSB: compliance pill, applicability table, dark *Result* box
|
||||
- PRO: parties, then KPI tiles (one `key` tile: the investment)
|
||||
- SOW: the opening paragraph naming the parties, the MSA, the end customer and the quotation
|
||||
- WO: status pill, parties (site · site contact · MPM dispatch), job table, *Safety* note
|
||||
7. **Body:** sections with gold-ruled uppercase headings (`doc.section`). Figures and tables are numbered in order.
|
||||
8. **Close:**
|
||||
- TN: *Assumptions and open items*
|
||||
- SVY: *To verify* list at the end of Part 2, then the appendices
|
||||
- INS: *Final check*
|
||||
- SOP: *Setup checklist*, *Troubleshooting*, *Need help?*
|
||||
- BOM: *items to confirm* note and sign-off lines
|
||||
- TAP: contacts, then the *Prepared and approved by* block
|
||||
- TSB: *Compliance record*, then Part 2 in the internal edition
|
||||
- PRO: *Next steps*, then a *Submitted by* block
|
||||
- SOW: milestones, assumptions and exclusions, optional *Acceptance* signatures
|
||||
- WO: *Completion record* and technician + site-contact signatures
|
||||
9. ⚙ **Revision history block:** the last thing in every document, in its own cream block with a gold top rule. On the last page it sits at the **bottom** when it shares the page with content, or at the top when it lands on a page of its own.
|
||||
10. ⚙ **Running footer (every page):** `MESSAGE POINT MEDIA · Born to Innovate. Built to Last.` left (tagline in Dark Gold, exactly as written); `CUSTOMER · SUBJECT [· INTERNAL USE | CONFIDENTIAL] · NUMBER · Page x of y` right. Override the context with `footer=` when it runs long; keep it under about 45 characters.
|
||||
|
||||
**Page setup:** US Letter portrait, margins 1.0 in. top, 0.7 in. bottom, 0.62 in. sides. DWG uses its own 11×17 landscape sheet (§9.7) instead of this anatomy.
|
||||
|
||||
**Page breaks:** start a new page (`new_page=True`) for a full-width figure page or a major part. Never leave a heading at the foot of a page. Headings are styled to stay with what follows, but check the PDF.
|
||||
|
||||
**Classification:**
|
||||
- *Public / Customer*: no marking.
|
||||
- *Internal*: footer says INTERNAL USE.
|
||||
- *Confidential*: footer says CONFIDENTIAL, and page 1 carries a confidentiality notice naming the recipient and the governing NDA (TAP).
|
||||
|
||||
---
|
||||
|
||||
## 7. Visual system
|
||||
|
||||
### 7.1 Colour: each colour has one job
|
||||
|
||||
| Token | Hex | Job |
|
||||
|---|---|---|
|
||||
| Ink | `#232022` | Text, dark boxes, primary linework |
|
||||
| Gold | `#DCBB4F` | Brand accent: rules, step numbers, header underline |
|
||||
| Dark Gold | `#998643` | Kickers, labels, FIG/TABLE tags, centrelines |
|
||||
| Light Accent | `#849698` | Ledes, captions, secondary text, running footer |
|
||||
| Cream | `#F5F1EC` | Info strip, notes, calc blocks |
|
||||
| Line | `#E6E0D8` | Table rules, figure frames |
|
||||
| Key | `#FBF6E6` | The one highlighted row or tile |
|
||||
| Red | `#A42D20` | Warnings, failing values, limits, minimums, Draft status |
|
||||
| Green | `#3F6B3A` | PASS, passing values |
|
||||
| Blue / Grey | `#2F5A8A` / `#B8B1A8` | Data series only, never UI |
|
||||
|
||||
Colours are the official MPM colour system (`_ASSETS/README.md`). Green PASS and the blue data series are functional additions, not brand colours, so keep them to status marks and data.
|
||||
|
||||
### 7.2 Type
|
||||
|
||||
- **Montserrat:** headings, kickers, labels, captions, table headers.
|
||||
- **Open Sans 9.6 pt:** body. The brand's 11 pt applies to Word documents; dense technical PDFs stay at 9.6 pt (decided Nov 6, 2026).
|
||||
- **JetBrains Mono:** numbers, dimensions, part numbers, calculations.
|
||||
- All fonts come from `_ASSETS/mpm/fonts/` and are embedded in every PDF. Never substitute Arial, Calibri or a system font.
|
||||
|
||||
### 7.2a Logo
|
||||
- Running header: `Logo-Gold-Black.png` (gold icon, dark text) on white.
|
||||
- Dark title blocks: `Logo-Gold-White.png`.
|
||||
- Drawings: the SVG equivalent.
|
||||
- The full selection guide is in `_ASSETS/README.md`.
|
||||
|
||||
### 7.3 Components: use these, don't invent new ones
|
||||
|
||||
| Component | Engine call | Rules |
|
||||
|---|---|---|
|
||||
| Headline numbers | `doc.kpis()` | TN (and SVY summary): 3–4 tiles. Exactly one `key` tile (the answer); verdict tiles `ok`/`bad`. |
|
||||
| Dark box | `doc.result()`, `doc.before_you_start()`, `doc.dark()` | Once per document, right after the info strip / KPIs. 2–6 bullets, key fact in bold. |
|
||||
| Section | `doc.section()` | Short noun phrase. Uppercase is automatic. |
|
||||
| Table | `doc.table()` | Header row is uppercase Montserrat with a **gold underline**, not the brand guide's dark header bar (decided Nov 6, 2026; reads better in dense numeric tables). Caption required if referenced. At most one `KeyRow`. Right-align numbers (`r`), mono for values (`m`, `d`). |
|
||||
| Calculation | `doc.calc()` | Comment line, then `name = symbolic = numeric = result`. PASS/FAIL pill on the line that decides. |
|
||||
| Figure | `doc.figure()` | Every drawn figure numbered and captioned; refer to it in text with `[[fig:key]]`. |
|
||||
| Screenshot / photo | `doc.shot()`, `doc.shots()` | Files in `source/img/`. Captioned, **not numbered**, because each belongs to its step. Crop to the relevant part and mark the control to tap with a gold box. |
|
||||
| On-screen control | `ui('Label')` | Any button, tile or link the reader taps, named exactly as it appears on screen. |
|
||||
| Process at a glance | `doc.glance()` | INS/SOP. 4–8 tiles, two-word label + detail. A step done by someone else is a greyed ✓ tile. |
|
||||
| Step | `doc.step()` | INS/SOP. A side figure keeps the step whole. `wide=True` puts the figure below; the text stays whole and only the figure may move to the next page. |
|
||||
| Special case | `doc.special()` | SOP/INS. A gold-framed panel for a situation only some readers hit (e.g. outside the U.S.). At most one or two per document. |
|
||||
| BOM table | `doc.bom()` | Line numbers continue across sections; subtotal row per section; Pulled/Packed checkboxes. |
|
||||
| Note | `doc.note(kicker, body, tone)` | See §7.4. |
|
||||
| Checklist | `doc.checklist()`, `doc.bullets(check=True)` | *Final check* (INS, two columns), *Setup checklist* (SOP, one column), open items. |
|
||||
| Assumptions / open items | `doc.assumptions()` | TN and SVY close with this. Open items start with a verb. |
|
||||
| Sign-off | `doc.signoff()`, `doc.approval()` | Sign lines (BOM); *Prepared and approved by* block (TAP). |
|
||||
| Control table | `doc.kv()` | Front matter of SVY and TAP: label / value rows (prepared for, supersedes, distribution …). |
|
||||
| Part divider | `doc.part()` | SVY Part 1 / Part 2 / Appendices. `internal=True` marks an MPM-internal part. |
|
||||
| Site photo | `doc.photo()`, `doc.photos()` | Numbered FIG, two side by side, max 2.7 in. tall. Every photo also appears in the photo log. |
|
||||
| To-verify tag | `verify()` | Inline red tag on a point of record to confirm with the customer; each one also listed in the *To verify* note. |
|
||||
| Field notes | `doc.transcript()` | Verbatim notebook transcription: spelling preserved, editorial text in [brackets]. |
|
||||
| Drawing sheet | `Drawing(...)`, `dwg.view()`, `dwg.sheet()` | DWG only (§9.7). |
|
||||
| Priced BOM | `doc.priced()` | PRO / SOW. SKU · description · qty · extended price, subtotal per group, grand total. Prices are numbers from the quotation; totals are computed (`doc.price_total`), never typed. |
|
||||
| Milestones | `doc.milestones(rows, expect=doc.price_total)` | SOW (and PRO payment terms). The build fails if the milestones don't add up to the priced total. |
|
||||
| Parties | `doc.parties()` | PRO / WO (SOW names them in the opening paragraph). MPM's block is `brand.PROVIDER`. |
|
||||
| Status / compliance pill | `doc.pill(text, colour)` | TSB compliance: Mandatory `red` · Recommended `gold` · Informational `grey`. WO status: Dispatched `gold` · On site `ink` · Complete `green` · On hold `red`. One per document. |
|
||||
| Fill-in record | `doc.fields()`, `doc.table(..., cls="fill")` | WO completion record; TSB compliance record (blank ruled rows). |
|
||||
| Signatures | `doc.signatures([(role, who)])` | WO (technician + site contact); SOW *Acceptance* when the customer signs the SOW itself. |
|
||||
|
||||
### 7.4 Note kickers (fixed vocabulary)
|
||||
|
||||
| Tone | Kickers |
|
||||
|---|---|
|
||||
| `info` (cream/gold) | GOOD TO KNOW · CHECK · CHECK YOUR MARKS · TWO WAYS TO … · ON ANDROID · NEED HELP? · STATUS OF THIS REVISION · WHAT CHANGED IN THIS REVISION · *topic-specific short label* |
|
||||
| `warn` (red) | WARNING · LINE VOLTAGE · CHECK POLARITY · WATCH FOR … · DON'T … · DRAFT · ITEMS TO CONFIRM BEFORE RELEASE · PROPRIETARY & CONFIDENTIAL |
|
||||
| `ok` (green) | CONFIRMED · RESOLVED · CLOSED |
|
||||
| `verify` (dashed) | TO VERIFY WITH {CUSTOMER} BEFORE RELEASE: points of record still to confirm (SVY) |
|
||||
|
||||
*Action required* (TAP) uses `doc.special()`, not a note.
|
||||
|
||||
- Use a warn note only when getting it wrong can hurt someone or damage equipment.
|
||||
- At most one warn note per step.
|
||||
|
||||
---
|
||||
|
||||
## 8. Writing
|
||||
|
||||
**Voice:**
|
||||
- Plain, direct, specific.
|
||||
- Short sentences.
|
||||
- Active voice.
|
||||
- No marketing language in technical documents.
|
||||
|
||||
**Answer first:**
|
||||
- A TN's Result box must stand on its own.
|
||||
- A reader who stops after the Result should have the right answer and its conditions.
|
||||
|
||||
**Procedures (INS/SOP):**
|
||||
- Each step starts with a verb: *Mark the hole locations*. One step is one outcome.
|
||||
- Numbered sub-steps: 3–6, one action each.
|
||||
- Bold the object or value the reader must get right: **plumb**, **1/2 in. cobalt**, **breaker off**.
|
||||
- The step kicker names the tool, part or place for that step: `Ø1/2 IN. COBALT BIT`, `UNIFI ENDPOINT`.
|
||||
- Every step that changes something physical gets a figure.
|
||||
- Say what success looks like: a CHECK note, or a closing *Final check* list.
|
||||
|
||||
**Numbers:**
|
||||
- Every number traces to a measurement, a document or a calculation shown in the document.
|
||||
- Keep source numbers in one place (`figs.py`) and derive the rest in code. Never retype a derived value.
|
||||
|
||||
**Names:** use the customer's name for itself on first use in the kicker or lede (*Nashville MCC (WeGo)*, *SORTA / Metro*); short form after that.
|
||||
|
||||
**Unknowns:**
|
||||
- `TBC` in tables.
|
||||
- *Open items* in TN/SVY.
|
||||
- *Draft · items to confirm* warn note on BOM.
|
||||
- Never paper over an unknown.
|
||||
|
||||
**Attribution:** when a requirement came from a person or email, the source is recorded in `00_INDEX.md`, not in the customer document.
|
||||
|
||||
### 8.1 MPM brand voice in technical documents
|
||||
|
||||
Technical documents sit at the **high formality · low energy · very high technical depth** end of the MPM tone matrix (hardware/maintenance documentation). The MPM voice still applies:
|
||||
|
||||
- **Expert partner, not vendor.** Explain the why behind a step or a limit. Don't lecture and don't sell.
|
||||
- **Precise and purposeful.** Exact names and numbers. No superlatives ("cutting-edge", "best-in-class", "revolutionary").
|
||||
- **Riders and passengers, not users,** when the end audience is the public. Use **transit agencies / agencies** for customers in transit contexts.
|
||||
- **Accessibility is a design principle, not a checkbox.** State ADA requirements as what makes the sign work for riders (reach range, audio), never as a compliance burden.
|
||||
- **Own the complexity.** When something is hard, say so, then show the way through it. Proactive wording ("preventative check") rather than firefighting.
|
||||
- **Terminology:**
|
||||
- *native* API integration (never wrapper or middleware)
|
||||
- *MP.TV CMS* in customer-facing documents
|
||||
- *RDMC*
|
||||
- *GTFS-RT*
|
||||
- *publishing rules*
|
||||
- hardware product names exactly as in the brand settings (e.g. *MR Series*, *MR27*)
|
||||
- **No conference-theme language** (MPower motorsports metaphors) in documents.
|
||||
|
||||
Brand source: the mpm-brand-voice plugin (`settings/brand-voice.local.md`).
|
||||
|
||||
---
|
||||
|
||||
## 9. Per-type specifications
|
||||
|
||||
The two type-specific info-strip cells and the required section order for each type. Every type has an engine template in `templates/<TYPE>/` with a preview PDF.
|
||||
|
||||
### 9.1 INS: Installation Guide *(ready: `templates/INS/`)*
|
||||
- **Info cells:** Audience · Mount/Install type
|
||||
- **Sections:**
|
||||
1. Before you start (dark box)
|
||||
2. Process at a glance (4–8 tiles)
|
||||
3. Hardware table + Tools table, side by side, kept together as one block
|
||||
4. Reference figure (template / drill schedule) if needed
|
||||
5. Walkthrough (numbered steps with figures)
|
||||
6. Final check (two-column checklist; last item "Photo of the finished install sent to MPM")
|
||||
|
||||
### 9.2 SOP: Standard Operating Procedure *(ready: `templates/SOP/`)*
|
||||
- **Info cells:** Audience · Time needed
|
||||
- **Sections:**
|
||||
1. Before you start
|
||||
2. Process at a glance (tiles labelled with who does it: IT / YOU)
|
||||
3. Walkthrough (numbered steps, screenshots as side figures)
|
||||
4. Special cases (e.g. *Outside the U.S.*), placed after the step they relate to
|
||||
5. Setup checklist (one column)
|
||||
6. Troubleshooting table (Problem · What to do)
|
||||
7. *Need help?* note
|
||||
- Marked INTERNAL USE.
|
||||
|
||||
### 9.3 TN: Technical Note *(ready: `templates/TN/`)*
|
||||
- **Info cells:** context (Site / Measured / Module) · criterion (Requirement / Deployment)
|
||||
- **Sections:**
|
||||
1. KPI tiles (3–4)
|
||||
2. Result
|
||||
3. Evidence: comparison table → calculation → figures
|
||||
4. Assumptions and open items
|
||||
- 1–3 pages. If it needs more, it is probably two notes or a report.
|
||||
|
||||
### 9.4 BOM: Bill of Materials *(ready: `templates/BOM/`)*
|
||||
- **Info cells:** Customer · Prepared by
|
||||
- **Columns:** # · Item · Specification · Qty · UOM · Part no. · Pulled · Packed
|
||||
- **Grouping:** by category (Fasteners, Sealing & consumables, …), each with a subtotal row.
|
||||
- **Close:** *Draft · items to confirm before release* warn note while < 1.0, then sign-off (Pulled by · Checked by · Date).
|
||||
|
||||
### 9.5 SVY: Site Survey *(ready: `templates/SVY/`)*
|
||||
- **Info cells:** Survey date · Surveyor
|
||||
- **Front matter:**
|
||||
- Prepared for / by
|
||||
- Basis (notes, photos, drawings received)
|
||||
- About this document
|
||||
- **Part 1, Summary of Findings (customer):**
|
||||
- Purpose
|
||||
- Scope
|
||||
- What we found (table by stop or family)
|
||||
- What it means
|
||||
- **Part 2, Engineering Detail (MPM-internal):**
|
||||
- Survey record
|
||||
- Inventory
|
||||
- One section per mount family (observations → design constraints)
|
||||
- Power & data
|
||||
- TO VERIFY list
|
||||
- **Appendices:** A, photo log (# · time · cluster · file ID · subject, citing the figure numbers); B, verbatim field-notes transcription with the notebook pages.
|
||||
- **Release to the customer:** the PDF is marked INTERNAL USE while it carries Part 2. Release Part 1 + appendices as a customer edition (to be added to the engine when the first survey is released).
|
||||
|
||||
### 9.6 TAP: Technical Action Plan *(ready: `templates/TAP/`)*
|
||||
- **Info cells:** Customer · Engagement dates
|
||||
- **Front matter:**
|
||||
- Document-control table: Supersedes / References · Distribution · Affected products · Personnel · Site
|
||||
- Confidentiality notice
|
||||
- **Sections** (numbered in the heading text):
|
||||
1. Purpose and scope (with *What changed in this revision* from 1.1 on)
|
||||
2. Background: known-issue register and failure groups
|
||||
3. Information required from the customer (*Action required* panel)
|
||||
4. Personnel: field technician, coordination, oversight
|
||||
5. Parts and equipment kit
|
||||
6. Timeline and daily schedule
|
||||
7. Procedures by failure group, then disposition
|
||||
8. Open items and decisions
|
||||
9. Acceptance criteria (per unit, engagement)
|
||||
10. Daily reporting
|
||||
11. Escalation and contacts
|
||||
- **Close:** *Prepared and approved by* block.
|
||||
|
||||
### 9.7 DWG: Drawing sheet *(ready: `templates/DWG/`)*
|
||||
- **Sheet:** 11×17 landscape, zone grid (8–1 across, D–A down), header bar with logo, discipline and `DWG NO · SHEET · REV`.
|
||||
- **View area:** a 6 × 4 grid. Each view has a numbered bubble, a title and a scale.
|
||||
- **Right column, top to bottom:**
|
||||
- Schedule: tag · designation · description · qty, with tags as gold bubbles that match the callouts on the views
|
||||
- General notes, numbered
|
||||
- Title block: project, title, client, location, scale, drawn / checked / approved, dwg no., sheet n of N, rev, date
|
||||
- Revision history
|
||||
- Confidentiality strip with the tagline
|
||||
- **Drafting conventions:**
|
||||
- Ink linework; centrelines Dark Gold, long-dash-dot; hidden lines dashed
|
||||
- Dimensions in mm (JetBrains Mono) with extension lines
|
||||
- "Do not scale from drawing" in the general notes
|
||||
- **Revisions:** numeric like every other type (§4.1). Multi-sheet sets share one number and revision.
|
||||
|
||||
### 9.8 TSB: Technical Service Bulletin *(ready: `templates/TSB/`)*
|
||||
- **Info cells:** Compliance · Effective
|
||||
- **Two editions from one `build.py`:**
|
||||
- Customer edition: classification customer; slug ends `_TSB`.
|
||||
- Internal edition: classification internal; slug ends `_TSB-Internal`; adds Part 2 *Diagnostic detail* (root cause, field data, support notes).
|
||||
- Same number, same revision, issued together. The customer edition never contains Part 2.
|
||||
- **Front:** compliance pill; applicability table (Applies to · Affected units · Not affected · Labor estimate · Supersedes · Distribution); dark *Result* box (what happens, what to do, by when).
|
||||
- **Sections:** 1 Symptom · 2 Cause · 3 Corrective action (steps; reference an INS for anything long) · 4 Verification · 5 Parts.
|
||||
- **Close:** *Compliance record*, a blank table (unit serial, site, date, technician, done) the field fills in.
|
||||
- **Compliance levels:** Mandatory (failure or safety; fix by a stated date) · Recommended (fix at the next visit) · Informational (no action).
|
||||
|
||||
### 9.9 PRO: Proposal *(ready: `templates/PRO/`)*
|
||||
- **Info cells:** Prepared for · Valid until
|
||||
- **Front:** parties (Prepared for · Prepared by = `brand.PROVIDER`); KPI tiles with the investment as the one `key` tile.
|
||||
- **Sections** (numbered in the heading text): 1 Your situation · 2 Proposed solution · 3 Why Message Point Media · 4 Scope summary (phase · MPM provides · agency provides) · 5 Investment (`doc.priced`, with a *Pricing* note naming the quotation and validity) · 6 Timeline · 7 Assumptions and exclusions · 8 Next steps.
|
||||
- **Close:** *Submitted by* block (`doc.approval(..., label="Submitted by")`).
|
||||
- **Rules:** every price comes from a quotation; §3 holds facts you can back up (deployments, agencies, years, uptime), never superlatives. Marked CONFIDENTIAL. After award, write the SOW; don't revise the PRO into one.
|
||||
|
||||
### 9.10 SOW: Statement of Work *(ready: `templates/SOW/`)*
|
||||
- **Info cells:** Customer · Quotation
|
||||
- **Opening paragraph:** made by and between Customer and Message Point Media of Alabama, Inc (MPM); subject to the Master Services Agreement; for the benefit of the End Customer; pricing per Quotation S#####, its date and expiry.
|
||||
- **Sections** (numbered `1.0`, `2.1`, `2.1.1` in the heading text):
|
||||
1. 1.0 Scope overview (say what is *not* included when a reader might assume it is)
|
||||
2. 2.0 Project deliverables: 2.1 Hardware (list, then 2.1.1 priced BOM per the quotation) · 2.2 Documentation (document · purpose)
|
||||
3. 3.0 Scope of work: 3.1 Initiation and scheduling · 3.2 Site confirmation · 3.3 Procurement and provisioning · 3.4 Delivery · 3.5 Installation · 3.6 Operational verification · 3.7 Warranty (name the warranty policy)
|
||||
4. 4.0 Project milestones (`doc.milestones(..., expect=doc.price_total)`)
|
||||
5. 5.0 Assumptions and exclusions
|
||||
6. 6.0 Acceptance: signatures, only when the customer signs the SOW itself
|
||||
- **Revisions:** 0.x while negotiating; 1.0 = executed. Reference: `SOW_Nashville_Replacement_Sign_S01012` (Aug 2026), in `NASHVILLE BAY 17/_INPUTS/04_Commercial/`.
|
||||
|
||||
### 9.11 WO: Field Work Order *(ready: `templates/WO/`)*
|
||||
- **Info cells:** Scheduled · Assigned to
|
||||
- **Front:** status pill; parties (Site with coordinates · Site contact · MPM dispatch); job table (Customer / project · Installer · Access · Reference documents); *Safety* note.
|
||||
- **Sections:** Work to be performed (checkboxes, each an action) · Parts and materials (`doc.bom`, Staged / Used checks) · Return with this work order (photos, serials, measured deviations).
|
||||
- **Close:** *Completion record* (`doc.fields`: arrival, departure, serials installed and removed, online on MP.TV, issues) and technician + site-contact signatures.
|
||||
- **Rules:** marked INTERNAL USE (contacts, access). Issued at 1.0; a re-dispatch with a changed scope or date is 1.1. The signed paper copy is scanned into the project's `_INPUTS/` and never edited; the PDF stays as issued. The how-to lives in the referenced INS, not in the WO.
|
||||
|
||||
---
|
||||
|
||||
## 10. Before you issue (checklist)
|
||||
|
||||
- [ ] Number registered in `registry.md`; file name matches §4.2
|
||||
- [ ] Revision bumped; history line says what changed
|
||||
- [ ] Result / Before-you-start box stands on its own
|
||||
- [ ] Every figure and table numbered, captioned and referred to in the text
|
||||
- [ ] Every number traceable; no retyped derived values; TBCs listed as open items
|
||||
- [ ] Units and characters per §5 (− × Ø °)
|
||||
- [ ] Classification correct; confidential documents carry the NDA notice
|
||||
- [ ] PRO / SOW: prices match the quotation line for line; milestones add up (the build checks); validity date set
|
||||
- [ ] TSB: customer edition opened and checked to contain no Part 2
|
||||
- [ ] Opened the PDF and looked at every page: no orphan headings, no clipped figures, no stray near-empty last page
|
||||
- [ ] Previous revision moved to `_superseded/`
|
||||
- [ ] Chinese edition (if any) rebuilt at the same revision, its previous revision also moved to `_superseded/`
|
||||
|
||||
---
|
||||
|
||||
## 11. Asking Claude for a document
|
||||
|
||||
Say the type and the subject; Claude reads this standard and the template:
|
||||
|
||||
> "TN for CommTran: per-sign power roll-up for the 4×2 Pingo layout"
|
||||
> "Rev the Nashville TN — hanging hardware is 2 in. taller than the old"
|
||||
> "INS for LakeTran: 15-inch sign on a round pole"
|
||||
|
||||
Claude asks for the number you claimed in the shared registry, builds in `<PROJECT>/source/` with `mpmdoc`, and puts the PDF in the project folder. Anything it can't source becomes a TBC or an open item, never a guess.
|
||||
|
||||
**How builds run:**
|
||||
- Claude needs your kit folder: open it in the Claude desktop app (or link the computer that holds it), and install the **mpm-technical-documents** plugin from `_PLUGIN/`.
|
||||
- Builds need Python 3.10+ with Playwright/Chromium and poppler. Run `setup.sh` once (see `START_HERE.md`); then `python3 build.py` in a project's `source/` folder builds the PDF. When Claude can't run Python on your computer, it builds in its cloud workspace and writes the PDF back to your folder.
|
||||
|
||||
**Numbers are claimed in the shared registry.** The `registry.md` in your kit is a snapshot. The master registry is kept in one shared place set by MPM Technical Services: claim a number there before your first build, then copy the row into your own `registry.md`. A number is never reused, even for an abandoned draft.
|
||||
|
||||
**Keeping documents.** Issued PDFs go to the shared project location for the job, together with their `source/` folder, so the next revision starts from the same source.
|
||||
|
||||
The **mpm-technical-documents** plugin carries this workflow, so any Claude session follows it.
|
||||
|
||||
---
|
||||
|
||||
## 12. Chinese editions (简体中文) for manufacturers
|
||||
|
||||
For drawings and technical notes going to a Chinese manufacturer, MPM issues a **Chinese edition** alongside the English one.
|
||||
|
||||
**Scope.** TN and DWG only (`mpmdoc.i18n.TYPES`). Other types are assessed when needed.
|
||||
|
||||
**What it is.**
|
||||
- A second PDF of the same document: same number, slug, revision, figures and geometry.
|
||||
- Body text is in Simplified Chinese.
|
||||
- Frame labels are bilingual (`图号 Dwg no.`, `部件表 · Schedule`), so MPM staff can find their way around it.
|
||||
- File name: `{NUMBER}_{slug}_ZH_Rev{X.Y}.pdf`, kept in the project folder next to the English edition.
|
||||
- **The English edition governs.** Every Chinese edition says so:
|
||||
- TN: a line under the masthead.
|
||||
- DWG: a general note on every sheet.
|
||||
- The engine adds the statement automatically unless a note already carries it.
|
||||
- Revisions move together: a new English revision means a new Chinese edition at the same revision. The history lines are translated; the revision numbers and dates are identical.
|
||||
|
||||
**How it's built.**
|
||||
- `source/build_zh.py` sits next to the English `build.py`; start from `templates/TN/build_zh.py` or `templates/DWG/build_zh.py`. It shares `figs.py` with the English build and passes `lang="zh"` to `Doc` / `Drawing`.
|
||||
- Build the English edition first. The engine refuses to build a Chinese edition unless the English PDF at the same revision is already in the project folder.
|
||||
- Write the Chinese strings in the same order as `build.py`, so the two files can be compared side by side.
|
||||
- **Figure text:** goes through `i18n.Translator`.
|
||||
- Lookup order: the document's own `TERMS` table, then the master glossary `_STANDARDS/glossary_zh.md`.
|
||||
- `tr.check()` fails the build on any English left in a figure.
|
||||
- Numbers with units, tags (`S-1`, `M3`), part numbers and standards pass through unchanged.
|
||||
- **Terms:** one English term, one Chinese term.
|
||||
- A term that will recur goes in `glossary_zh.md`; a sentence specific to one document goes in its `TERMS`.
|
||||
- When a manufacturer corrects a term, change the glossary and rebuild the Chinese editions that use it.
|
||||
- **Units:** mm first, imperial in [ ]; mass in kg, with lb in [ ] where the source is lb. If `figs.py` formats dimensions through a function `F`, `build_zh.py` replaces it before drawing the figures.
|
||||
- **Keep in Latin script:** part and model numbers, tags, units, standards (ADA 307, IP66, UL 48), place and station names, brand names, the tagline.
|
||||
- **Dates:** shown as `2026年11月9日`.
|
||||
|
||||
**Font.**
|
||||
- Chinese text is set in **MPM SC**: Noto Sans CJK SC (SIL OFL), cut to GB2312 and converted to TrueType in `_ASSETS/mpm/fonts/cjk/` by `make_cjk.py`.
|
||||
- The engine embeds only the characters each document uses, and adds MPM SC as the fallback to every font stack. Latin text and numbers stay in Montserrat / Open Sans / JetBrains Mono.
|
||||
- No system font is used, so a build looks the same on any machine.
|
||||
|
||||
---
|
||||
|
||||
## Standard revision history
|
||||
|
||||
| Version | Date | Change |
|
||||
|---|---|---|
|
||||
| 1.3 | Nov 9, 2026 | Chinese editions for TN and DWG (§12): `lang="zh"` in the engine, MPM SC font, master glossary `glossary_zh.md`, `build_zh.py` templates; `_ZH` file names (§4.2); checklist line (§10). |
|
||||
| 1.2 | Nov 6, 2026 | Added TSB, PRO, SOW and WO (§1, §3.1, §6, §7.3, §9.8–9.11, §10) with templates; new components `priced`, `milestones`, `fields`; MPM styling for parties, pills and signatures. |
|
||||
| 1.1 | Nov 6, 2026 | Brand references updated. |
|
||||
| 1.0 | Nov 6, 2026 | Released. MPM document types TN, INS, SOP, BOM, SVY, TAP and DWG with engine templates; shared `_ASSETS`; folder layout with `_superseded/` and `_INPUTS/`. |
|
||||
@@ -0,0 +1,76 @@
|
||||
# _STANDARDS
|
||||
|
||||
Standards, engine and templates for documents produced in this project.
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `MPM_Document_Standard.md` | **The rules**: types, numbering, revisions, files, layout, writing. Read this first. |
|
||||
| `glossary_zh.md` | Master English → Simplified Chinese terms for Chinese editions (standard §12). |
|
||||
| `registry.md` | Customer codes and every issued document number (a snapshot: claim numbers in the shared master registry, standard §11). |
|
||||
| `mpmdoc/` | The build engine: stylesheet, header/footer, components, PDF renderer. Fonts and logos come from `../_ASSETS/mpm/`. |
|
||||
| `templates/<TYPE>/` | Starter `build.py` + `figs.py` per MPM document type (TN, INS, SOP, BOM, SVY, TAP, DWG, TSB, PRO, SOW, WO), with a `_preview_*.pdf`. TN and DWG also have `build_zh.py` (Chinese edition). |
|
||||
|
||||
## Start a new document
|
||||
|
||||
1. Register the number in `registry.md` (and the customer code in `mpmdoc/brand.py` if new).
|
||||
2. Copy `templates/<TYPE>/` to `<PROJECT FOLDER>/source/`.
|
||||
3. Fill in `build.py` (content) and `figs.py` (source numbers + figures).
|
||||
4. Run `python3 build.py` from `source/`. The PDF lands in the project folder.
|
||||
|
||||
`build.py` finds `_STANDARDS` by walking up from its own folder, so a project folder
|
||||
can sit anywhere under DOCUMENTATION PROJECT.
|
||||
|
||||
## Requirements
|
||||
|
||||
Python 3.10+, `pip install playwright && playwright install chromium`.
|
||||
Run `setup.sh` in the kit root once to install them, plus poppler (see `START_HERE.md`).
|
||||
|
||||
## Engine cheat sheet
|
||||
|
||||
```python
|
||||
doc = Doc(number, rev, date, title, lede, meta=[(k, v), (k, v)], slug, history,
|
||||
customer=None, subject="", kicker=None, footer=None, classification=None)
|
||||
doc.add(
|
||||
doc.kpis([(label, value, sub, tone)]), # tone: None|key|strong|ok|bad
|
||||
doc.result([...]) / doc.before_you_start([...]),
|
||||
doc.section("Title", new_page=False),
|
||||
doc.table(cols, rows, caption=, ref=), # KeyRow([...]), TotalRow([...]), up(), dn(), TBC
|
||||
doc.calc(["# comment", (name, working, result, "PASS")]),
|
||||
doc.figure(svg, caption, ref=), # cite as [[fig:ref]] / [[tab:ref]]
|
||||
doc.note(kicker, body, tone="info|warn|ok|verify"),
|
||||
doc.two(left_html, right_html), doc.bullets([...], check=True),
|
||||
doc.assumptions([...], [...]), doc.signoff(),
|
||||
# procedures (INS / SOP)
|
||||
doc.before_you_start([...]), doc.glance([(label, sub[, mark, other])]),
|
||||
doc.step(n, kicker, title, doc.steps([...]) + doc.note(...), figure, wide=False, side="35%"),
|
||||
doc.shot("img/x.jpg", caption, width), doc.shots(...), ui("Button"), doc.chain([...]),
|
||||
doc.special(kicker, title, body, figure), doc.checklist(title, [...], cols=2),
|
||||
# BOM
|
||||
doc.bom([(section, [(item, spec, qty, uom[, part_no])], subtotal_label)]),
|
||||
# surveys / plans (SVY / TAP)
|
||||
doc.kv([(label, value)]), doc.part(label, title, internal=False), doc.photo("img/x.jpg", caption, ref),
|
||||
doc.photos(...), verify(), doc.transcript(heading, [lines]), doc.approval(name, role, date),
|
||||
# commercial and field (PRO / SOW / WO / TSB); MPM provider block: from mpmdoc.brand import PROVIDER
|
||||
doc.parties([(heading, {name, contact, address, phone, email, web, coordinates})]),
|
||||
doc.priced([(subtotal_label, [(sku, description, qty, extended)])]), # totals computed -> doc.price_total
|
||||
doc.milestones([(milestone, criteria, share, amount)], expect=doc.price_total), doc.money(v),
|
||||
doc.pill(text, "red|gold|grey|ink|green"), doc.fields([label | (label, hint)], cols=2, title=),
|
||||
doc.table(cols, [[""] * n] * 6, cls="fill"), doc.signatures([(role, who)]),
|
||||
)
|
||||
# Chinese edition (TN / DWG): source/build_zh.py next to build.py, same figs.py; build English first
|
||||
# tr = i18n.Translator({doc phrases}); F = {k: tr.svg(f()) for k, f in FIGS.items()}; tr.check()
|
||||
# Doc(..., lang="zh") / Drawing(..., lang="zh") -> {NUMBER}_{slug}_ZH_Rev{X.Y}.pdf
|
||||
# TSB: one build.py loops over editions ("customer", slug "_TSB") and ("internal", slug "_TSB-Internal")
|
||||
|
||||
# drawings (DWG): see templates/DWG/build.py
|
||||
dwg = Drawing(number, rev, date, title, subtitle, project, client, slug, history, location, discipline, drawn, checked, approved)
|
||||
dwg.sheet(views=[dwg.view(svg, n, title, scale, area="1 / 1 / 5 / 3")], schedule=[(tag, name, desc, qty)], notes=[...])
|
||||
dwg.build()
|
||||
doc.build()
|
||||
|
||||
```
|
||||
|
||||
## Changing the standard
|
||||
|
||||
Edit `MPM_Document_Standard.md` and the engine together, bump the standard's version line,
|
||||
and rebuild one document of each affected type to check.
|
||||
@@ -0,0 +1,218 @@
|
||||
# MPM English → Simplified Chinese glossary
|
||||
|
||||
The master vocabulary for Chinese editions (MPM_Document_Standard.md §12). Every `build_zh.py` uses it
|
||||
through `mpmdoc.i18n.Translator`: a figure or table string that matches an English term here, as a whole,
|
||||
is translated (case-insensitive), so the same English always gives the same Chinese.
|
||||
|
||||
Rules:
|
||||
- Add a term here when it will recur across documents. Phrases and sentences specific to one document go in that document's own table in `build_zh.py`.
|
||||
- One English term, one Chinese term. If a manufacturer corrects a term, change it here and rebuild the Chinese editions that use it.
|
||||
- Keep in Latin script: part numbers, model numbers, tags (S-1, M3), units, standards (ADA 307, IP66, UL 48), station and place names, brand names.
|
||||
- Units in Chinese editions: mm first, imperial in [ ]; mass in kg (lb in brackets where the source is lb).
|
||||
|
||||
## Document and drawing frame
|
||||
|
||||
| English | 中文 | Note |
|
||||
|---|---|---|
|
||||
| TBC | 待确认 | to be confirmed |
|
||||
| To verify | 待核实 | |
|
||||
| Ref. | 参考 | |
|
||||
| Reference | 参考 | |
|
||||
| Existing | 现有 | |
|
||||
| New | 新增 | |
|
||||
| Option | 方案 | |
|
||||
| Baseline | 基准方案 | |
|
||||
| Alternate | 备选 | |
|
||||
| Not to scale | 不按比例 | |
|
||||
| N.T.S. | 不按比例 | |
|
||||
| Scale | 比例 | |
|
||||
| Do not scale from drawing | 请勿从图纸量取尺寸 | |
|
||||
| Front | 正视 | view |
|
||||
| Side | 侧视 | view |
|
||||
| Rear | 后视 | view |
|
||||
| Top | 顶边 | dimension label: top edge |
|
||||
| Underside | 底边 | dimension label: bottom edge |
|
||||
| Elevation | 立面 | |
|
||||
| Plan | 平面 | |
|
||||
| Section | 剖面 | |
|
||||
| Detail | 详图 | |
|
||||
| Hidden | 隐藏线 | |
|
||||
| CL | 中心线 | centreline |
|
||||
| Centreline | 中心线 | |
|
||||
| Dimension | 尺寸 | |
|
||||
| Tolerance | 公差 | |
|
||||
| Schedule | 部件表 | |
|
||||
| General notes | 总说明 | |
|
||||
| Tag | 编号 | |
|
||||
| Designation | 名称 | |
|
||||
| Description | 说明 | |
|
||||
| Qty | 数量 | |
|
||||
| Each | 每件 | |
|
||||
| Set | 套 | |
|
||||
| Total | 合计 | |
|
||||
| Subtotal | 小计 | |
|
||||
| Assumptions | 假设条件 | |
|
||||
| Open items | 待办事项 | |
|
||||
| Result | 结论 | |
|
||||
| Manufacturer | 厂家 | |
|
||||
| Engineer | 结构工程师 | structural engineer |
|
||||
| Owner | 业主 | |
|
||||
| Customer | 客户 | |
|
||||
| Approval | 批准 | |
|
||||
| Measured | 实测 | |
|
||||
| Limit | 限值 | |
|
||||
| Margin | 余量 | |
|
||||
| Requirement | 要求 | |
|
||||
| By others | 他方供货 | supplied by others |
|
||||
| See schedule | 见部件表 | |
|
||||
|
||||
## Sign and electronics
|
||||
|
||||
| English | 中文 | Note |
|
||||
|---|---|---|
|
||||
| Sign | 显示屏 | an LED passenger-information sign |
|
||||
| LED sign | LED 显示屏 | |
|
||||
| LED banner | LED 条形显示屏 | |
|
||||
| Face | 显示面 | |
|
||||
| Active face | 有效显示面 | |
|
||||
| Display area | 显示区域 | |
|
||||
| Module | 模组 | |
|
||||
| LED module | LED 模组 | |
|
||||
| Cabinet | 箱体 | |
|
||||
| Enclosure | 机箱 | |
|
||||
| Frame | 框架 | |
|
||||
| Pixel pitch | 像素间距 | |
|
||||
| Resolution | 分辨率 | |
|
||||
| Brightness | 亮度 | nits = cd/m² |
|
||||
| Viewing angle | 可视角度 | |
|
||||
| Refresh rate | 刷新率 | |
|
||||
| Full colour | 全彩 | |
|
||||
| Single-sided | 单面显示 | |
|
||||
| Double-sided | 双面显示 | |
|
||||
| Receiving card | 接收卡 | |
|
||||
| RX card | 接收卡 | |
|
||||
| Sending card | 发送卡 | |
|
||||
| Controller | 控制器 | |
|
||||
| Media player | 播放器 | |
|
||||
| Power supply | 电源 | |
|
||||
| PSU | 电源 | |
|
||||
| Cellular router | 蜂窝路由器 | |
|
||||
| Speaker | 扬声器 | |
|
||||
| Amplifier | 功放 | |
|
||||
| Text-to-speech | TTS 语音合成 | |
|
||||
| Light sensor | 光感探头 | |
|
||||
| Heat sink | 散热片 | |
|
||||
| Fan | 风扇 | |
|
||||
| Front service | 前维护 | |
|
||||
| Rear service | 后维护 | |
|
||||
| Power budget | 功率预算 | |
|
||||
| Max power | 最大功耗 | |
|
||||
| Typical power | 典型功耗 | |
|
||||
| Input voltage | 输入电压 | |
|
||||
| Current | 电流 | |
|
||||
| Power | 功率 | |
|
||||
| Breaker | 断路器 | |
|
||||
| Ground | 接地 | |
|
||||
| Cable | 线缆 | |
|
||||
| Power cable | 电源线 | |
|
||||
| Data cable | 数据线 | |
|
||||
| Connector | 连接器 | |
|
||||
| Cable gland | 防水接头 | |
|
||||
| Ingress protection | 防护等级 | IP rating |
|
||||
| Operating temperature | 工作温度 | |
|
||||
| Ambient temperature | 环境温度 | |
|
||||
| Conformal coating | 三防漆 | |
|
||||
| Gasket | 密封垫 | |
|
||||
| Seal | 密封 | |
|
||||
|
||||
## Structure and hardware
|
||||
|
||||
| English | 中文 | Note |
|
||||
|---|---|---|
|
||||
| Post | 立柱 | |
|
||||
| Column | 立柱 | an existing building column |
|
||||
| Pole | 灯杆 | round pole |
|
||||
| Square tube | 方钢管 | |
|
||||
| Round tube | 圆管 | |
|
||||
| Wall thickness | 壁厚 | |
|
||||
| Bracket | 支架 | |
|
||||
| Mounting bracket | 安装支架 | |
|
||||
| Face-mount bracket | 安装支架 | |
|
||||
| Rail | 横梁 | a support rail; never "railway" |
|
||||
| Support rail | 支撑横梁 | |
|
||||
| Clamp | 夹具 | |
|
||||
| Clamp plate | 夹板 | |
|
||||
| Base plate | 底板 | |
|
||||
| Foundation | 基础 | |
|
||||
| Concrete pier | 混凝土墩 | |
|
||||
| Anchor bolt | 地脚螺栓 | |
|
||||
| Anchor | 锚栓 | |
|
||||
| Wedge anchor | 膨胀锚栓 | |
|
||||
| Thru | 通孔 | through hole |
|
||||
| Wall | 墙体 | |
|
||||
| Wall plate | 墙板 | |
|
||||
| Arm | 悬臂 | |
|
||||
| Bolt | 螺栓 | |
|
||||
| Through-bolt | 对穿螺栓 | |
|
||||
| Nut | 螺母 | |
|
||||
| Washer | 垫圈 | |
|
||||
| Lock washer | 弹簧垫圈 | |
|
||||
| Screw | 螺钉 | |
|
||||
| Fastener | 紧固件 | |
|
||||
| Torque | 扭矩 | |
|
||||
| Weld | 焊接 | |
|
||||
| No welding | 不得焊接 | |
|
||||
| Drill | 钻孔 | |
|
||||
| Cosmetic cover | 装饰罩板 | |
|
||||
| Cover allowance | 罩板余量 | |
|
||||
| Steel | 钢 | |
|
||||
| Stainless steel | 不锈钢 | |
|
||||
| Aluminium | 铝 | |
|
||||
| Hot-dip galvanized | 热镀锌 | |
|
||||
| Powder coat | 粉末喷涂 | |
|
||||
| Finish | 表面处理 | |
|
||||
| Weight | 重量 | |
|
||||
| Mass | 质量 | |
|
||||
| Width | 宽度 | |
|
||||
| Height | 高度 | |
|
||||
| Depth | 深度 | |
|
||||
| Thickness | 厚度 | |
|
||||
| Clearance | 净距 | |
|
||||
| Setback | 退距 | |
|
||||
| Spacing | 间距 | |
|
||||
| Post spacing | 立柱间距 | |
|
||||
| Overhang | 悬挑 | |
|
||||
| Headroom | 净空 | |
|
||||
| Mounting height | 安装高度 | |
|
||||
|
||||
## Site
|
||||
|
||||
| English | 中文 | Note |
|
||||
|---|---|---|
|
||||
| Finished grade | 完成地面 | |
|
||||
| Grade | 地面 | |
|
||||
| Platform | 站台 | |
|
||||
| Track | 轨道 | |
|
||||
| Station | 车站 | |
|
||||
| Stop | 站点 | bus stop |
|
||||
| Shelter | 候车亭 | |
|
||||
| Existing shelter | 现有候车亭 | |
|
||||
| Pavilion | 站房 | |
|
||||
| Canopy | 雨棚 | |
|
||||
| Roofline | 屋檐线 | |
|
||||
| Eave | 檐口 | |
|
||||
| Bollard | 护柱 | |
|
||||
| Bollard CL | 护柱中心线 | |
|
||||
| Post CL | 立柱中心线 | |
|
||||
| Curb | 路缘石 | |
|
||||
| Sidewalk | 人行道 | |
|
||||
| Walkway | 人行通道 | |
|
||||
| Pedestrian walkway | 人行通道 | |
|
||||
| Parking lot | 停车场 | |
|
||||
| Lot | 停车场 | |
|
||||
| Drive aisle | 行车道 | |
|
||||
| Approach | 进站方向 | |
|
||||
| Bus bay | 公交停靠位 | |
|
||||
| Header sign | 门头标识 | |
|
||||
| Header | 门头标识 | the existing header sign |
|
||||
| Light bar | 灯条 | |
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Document Registry
|
||||
|
||||
Numbers are `TYPE-CUST-SUBJ-NN`. **This copy is a snapshot.** Claim new numbers in the shared master registry first (standard §11), then copy the row here.
|
||||
|
||||
Record the number here **before** the first build. Numbers are never reused.
|
||||
Customer codes must also exist in `mpmdoc/brand.py → CUSTOMERS`.
|
||||
|
||||
## Customer and department codes
|
||||
|
||||
| Code | Name | Notes |
|
||||
|---|---|---|
|
||||
| LT | LakeTran | |
|
||||
| NSH | Nashville MCC (WeGo) | |
|
||||
| CMT | CommTran | |
|
||||
| OMT | Omnitrans | |
|
||||
| SOR | SORTA / Metro (Cincinnati) | |
|
||||
| COTA | COTA (Columbus) | |
|
||||
| GIL | GILLIG | OEM; Vontas is the integrator |
|
||||
| MPM | Message Point Media | Company-wide / product documents |
|
||||
| IT | MPM IT | Internal |
|
||||
| ENG | MPM Engineering | Internal |
|
||||
| OPS | MPM Field Operations | Internal |
|
||||
| QA | MPM Quality | Internal |
|
||||
|
||||
Partners and integrators (e.g. Vontas, TripSpark) get a code the first time a PRO or SOW is addressed to them.
|
||||
|
||||
## Issued numbers
|
||||
|
||||
| Number | Title | Latest rev | Status | Folder | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| INS-LT-15R-01 | 15" Reflective Sign: Shelter Post Mount | 0.6 | Draft | LAKETRAN 15in SIGN | On engine |
|
||||
| SOP-IT-UNIFI-01 | Softphone Setup with UniFi Endpoint | 1.2 | Released | UNIFI TALK | On engine; file renamed from MPM_UniFi-Talk_Softphone-Setup_SOP_Rev1.1.pdf |
|
||||
| TN-NSH-B17-01 | Bay 17 Sign: Mounting Height Change | 0.3 | Draft | NASHVILLE BAY 17 | On engine |
|
||||
| TN-CMT-P25-01 | Pingo P2.5 Per-Module Power Budget | 0.3 | Draft | COMMTRAN PINGO P2.5 | On engine |
|
||||
| BOM-OMT-HW-01 | Fasteners & Hardware | 0.2 | Draft | OMNITRANS | On engine |
|
||||
| TAP-GIL-M37-01 | GILLIG Factory On-Site Service Engagement: Follow-Up Visit | 1.2 | Released | GILLIG | Supersedes MPM-TAP-2606-001 Rev A/B (→ 1.0/1.1) |
|
||||
| SVY-SOR-MON-01 | Monument Square Wayside Signage | 0.2 | Draft | SORTA SITE SURVEY | Replaces the Rev 0.1 .docx; internal until Part 2 is split out |
|
||||
| DWG-NSH-STAR-01 | Parking-Lot LED Banner: sign assembly and mounting options | 0.2 | Draft | NASHVILLE WEGO STAR | WeGo Star rail; 4 sheets for 3D renders. NSH code used for WeGo. Simplified Chinese edition (_ZH) issued with 0.2 |
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
"""TEMPLATE — Bill of Materials (BOM). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A list of parts to pull, check and pack. Marked INTERNAL USE automatically. 1–2 pages.
|
||||
See MPM_Document_Standard.md §9.4. Reference: BOM-OMT-HW-01 (Omnitrans).
|
||||
|
||||
Order: masthead (automatic) → item sections (doc.bom) → Draft items to confirm (while < 1.0)
|
||||
→ sign-off → Revision history (automatic)
|
||||
Unknown values are TBC (red), never guessed; every TBC appears in the 'items to confirm' note.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc, TBC
|
||||
|
||||
doc = Doc(
|
||||
number="BOM-MPM-SUBJ-01", rev="0.1", date="2026-11-06",
|
||||
customer=None, subject="Project hardware",
|
||||
title="What Is Being Packed",
|
||||
lede="What the list covers and what to do with it (pull, check off, pack).",
|
||||
meta=[("Customer", "Customer"), ("Prepared by", "Jason · MPM")],
|
||||
slug="Customer_Short-Title_BOM",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
# (item, specification, qty, uom[, part no.]) · group by category · subtotal per section
|
||||
doc.bom([
|
||||
("Fasteners", [
|
||||
("Hex bolt, M8 × 30 mm", f"M8 × 1.25 · 30 mm L · hex head<br>Material/grade {TBC}", 150, "ea"),
|
||||
("Flange nut, M8", "M8 × 1.25 · serrated flange", 150, "ea", "PN-0000"),
|
||||
], "Fastener subtotal"),
|
||||
("Consumables", [
|
||||
("Sealing tape", f"Foil tape<br>Width × length {TBC}", 6, "roll"),
|
||||
], "Consumables subtotal"),
|
||||
]),
|
||||
doc.note("Draft · items to confirm before release", [
|
||||
"<b>Material and grade</b> for all fasteners.",
|
||||
"<b>Tape size</b> (width × roll length).",
|
||||
], tone="warn"),
|
||||
doc.signoff(("Pulled by", "Checked by", "Date")),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
"""TEMPLATE — Drawing sheet (DWG), 11×17 landscape. Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
Mechanical, mounting, cable or fit-out drawings someone will fabricate or approve from.
|
||||
See MPM_Document_Standard.md §9.7. Each dwg.sheet(...) is one page; the title block, sheet numbers,
|
||||
zone grid, revision history and confidentiality strip are automatic.
|
||||
|
||||
View area = 6 columns × 4 rows. Place each view with area="row-start / col-start / row-end / col-end"
|
||||
(e.g. "1 / 1 / 5 / 3" = full height, left third).
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Drawing
|
||||
from figs import FIGS
|
||||
|
||||
F = {k: f() for k, f in FIGS.items()}
|
||||
|
||||
dwg = Drawing(
|
||||
number="DWG-MPM-SUBJ-01", rev="0.1", date="2026-11-06",
|
||||
title="Wall-Plate Sign Bracket",
|
||||
subtitle="Mounting bracket, general arrangement",
|
||||
project="Project name", client="Customer", location="City, ST",
|
||||
discipline="Mechanical drawing · Mounting",
|
||||
drawn="JS", checked="—", approved="—",
|
||||
slug="Customer_Sign-Bracket_GA",
|
||||
history=[("0.1", "2026-11-06", "Initial issue for review.")],
|
||||
)
|
||||
|
||||
dwg.sheet(
|
||||
views=[
|
||||
dwg.view(F["elevation"], 1, "Elevation · wall plate", "1:4", area="1 / 1 / 5 / 3"),
|
||||
dwg.view(F["section"], 2, "Section A · side", "1:4", area="1 / 3 / 4 / 7"),
|
||||
dwg.view(F["detail"], 3, "Detail B · anchor", "1:1", area="4 / 3 / 5 / 5"),
|
||||
],
|
||||
schedule=[ # (tag, designation, description, qty)
|
||||
("B-1", "Wall plate", "6 mm HDG steel, 200 × 300", 1),
|
||||
("A-1", "Arm", "60 × 60 × 4 SHS, HDG, 400 long", 1),
|
||||
("F-1", "Anchor", "M12 wedge anchor, A4 stainless", 4),
|
||||
],
|
||||
notes=[
|
||||
"All dimensions in mm unless noted. Do not scale from drawing.",
|
||||
"Hot-dip galvanize all steel after fabrication.",
|
||||
"Confirm wall / post construction before drilling.",
|
||||
"Sign and its hanging hardware supplied by others.",
|
||||
],
|
||||
scale="As shown",
|
||||
)
|
||||
|
||||
dwg.build()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""TEMPLATE — Drawing sheet, Simplified Chinese edition (图纸 · 中文版).
|
||||
Copy next to the English build.py in <PROJECT>/source/. See MPM_Document_Standard.md §12.
|
||||
|
||||
Rules:
|
||||
* Same number, slug, revision, sheets and views as build.py; figs.py is shared. Build English first:
|
||||
the engine refuses to build the _ZH file without the English PDF at this revision.
|
||||
* Dimensions mm first, imperial in [ ]: if figs.py formats dimensions through a function F, replace it
|
||||
here before drawing the figures (figs functions read F at call time).
|
||||
* Figure text goes through i18n.Translator (TERMS, then _STANDARDS/glossary_zh.md); tr.check() fails the
|
||||
build on any English left. Tags (B-1), part numbers, standards and place names stay in Latin script.
|
||||
* The frame (title block, schedule and notes headings, dates) is translated by the engine; the
|
||||
"英文版为准" note is added to each sheet's notes unless you place it yourself.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from mpmdoc import Drawing, i18n
|
||||
from figs import FIGS
|
||||
|
||||
TERMS = { # this document's figure phrases (single terms: glossary)
|
||||
"WALL / POST": "墙体 / 立柱",
|
||||
"SIGN (BY OTHERS)": "显示屏(他方供货)",
|
||||
"M12 ANCHOR · SEE SCHEDULE": "M12 锚栓 · 见部件表",
|
||||
}
|
||||
tr = i18n.Translator(TERMS)
|
||||
F = {k: tr.svg(f()) for k, f in FIGS.items()}
|
||||
tr.check()
|
||||
|
||||
dwg = Drawing(
|
||||
number="DWG-MPM-SUBJ-01", rev="0.1", date="2026-11-06", lang="zh",
|
||||
title="墙板式显示屏支架",
|
||||
subtitle="安装支架总图",
|
||||
project="项目名称", client="Customer", location="城市, 州",
|
||||
discipline="机械图纸 · 安装",
|
||||
drawn="JS", checked="—", approved="—",
|
||||
slug="Customer_Sign-Bracket_GA", # identical to the English slug
|
||||
history=[("0.1", "2026-11-06", "首次发布,供审核。")],
|
||||
)
|
||||
|
||||
dwg.sheet(
|
||||
views=[
|
||||
dwg.view(F["elevation"], 1, "立面 · 墙板", "1:4", area="1 / 1 / 5 / 3"),
|
||||
dwg.view(F["section"], 2, "剖面 A · 侧视", "1:4", area="1 / 3 / 4 / 7"),
|
||||
dwg.view(F["detail"], 3, "详图 B · 锚栓", "1:1", area="4 / 3 / 5 / 5"),
|
||||
],
|
||||
schedule=[
|
||||
("B-1", "墙板", "6 mm 热镀锌钢板,200 × 300", 1),
|
||||
("A-1", "悬臂", "60 × 60 × 4 方钢管,热镀锌,长 400", 1),
|
||||
("F-1", "锚栓", "M12 膨胀锚栓,A4 不锈钢", 4),
|
||||
],
|
||||
notes=[
|
||||
"除注明外尺寸单位为 mm。请勿从图纸量取尺寸。",
|
||||
"所有钢件加工完成后热镀锌。",
|
||||
"钻孔前确认墙体 / 立柱结构。",
|
||||
"显示屏及其吊挂件由他方供货。",
|
||||
],
|
||||
scale="As shown",
|
||||
)
|
||||
|
||||
dwg.build()
|
||||
@@ -0,0 +1,78 @@
|
||||
"""TEMPLATE figures for a DWG sheet: a generic wall-plate sign bracket in three views.
|
||||
Drafting conventions: ink linework, centrelines in Dark Gold (long-dash-dot), hidden lines dashed,
|
||||
dimensions in mm (JetBrains Mono), schedule tags as gold bubbles. One mm = SCALE px."""
|
||||
from mpmdoc.figkit import T, svg_open, dim_h, dim_v, tag, INK, KICK, MUTE, CREAM
|
||||
|
||||
S = 0.9 # px per mm in these views
|
||||
PLATE_W, PLATE_H, T_PL = 200, 300, 10
|
||||
HOLE_X, HOLE_Y, HOLE_D = 150, 250, 14
|
||||
ARM_L, ARM_H = 400, 60
|
||||
|
||||
|
||||
def _cl(x1, y1, x2, y2):
|
||||
return f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{KICK}" stroke-width=".8" stroke-dasharray="14 3 2 3"/>'
|
||||
|
||||
|
||||
def elevation():
|
||||
"""Front view of the wall plate with the four anchor holes."""
|
||||
W, H = 360, 420
|
||||
ox, oy = 80, 50
|
||||
pw, ph = PLATE_W * S, PLATE_H * S
|
||||
o = [svg_open(W, H, grid=True)]
|
||||
o.append(f'<rect x="{ox}" y="{oy}" width="{pw}" height="{ph}" fill="{CREAM}" stroke="{INK}" stroke-width="1.4"/>')
|
||||
cx, cy = ox + pw / 2, oy + ph / 2
|
||||
for dx in (-HOLE_X / 2, HOLE_X / 2):
|
||||
for dy in (-HOLE_Y / 2, HOLE_Y / 2):
|
||||
o.append(f'<circle cx="{cx + dx * S}" cy="{cy + dy * S}" r="{HOLE_D / 2 * S}" fill="#fff" stroke="{INK}" stroke-width="1.1"/>')
|
||||
o.append(_cl(cx, oy - 14, cx, oy + ph + 14) + _cl(ox - 14, cy, ox + pw + 14, cy))
|
||||
o.append(f'<rect x="{cx - ARM_H / 2 * S}" y="{cy - ARM_H / 2 * S}" width="{ARM_H * S}" height="{ARM_H * S}" fill="none" stroke="{INK}" stroke-width="1" stroke-dasharray="4 3"/>')
|
||||
o.append(dim_h(ox, ox + pw, oy + ph + 34, f"{PLATE_W}", ext_from=oy + ph))
|
||||
o.append(dim_h(cx - HOLE_X / 2 * S, cx + HOLE_X / 2 * S, oy - 22, f"{HOLE_X}", ext_from=oy + (PLATE_H - HOLE_Y) / 2 * S))
|
||||
o.append(dim_v(ox - 30, oy, oy + ph, f"{PLATE_H}", ext_from=ox))
|
||||
o.append(dim_v(ox + pw + 30, cy - HOLE_Y / 2 * S, cy + HOLE_Y / 2 * S, f"{HOLE_Y}", ext_from=cx + HOLE_X / 2 * S))
|
||||
o.append(tag(ox + pw + 52, oy + 18, "B-1"))
|
||||
o.append(T(ox + pw - 4, oy + ph - 8, f"4× Ø{HOLE_D} THRU", size=7.5, w=700, anchor="end", ls=.6))
|
||||
o.append("</svg>")
|
||||
return "".join(o)
|
||||
|
||||
|
||||
def section():
|
||||
"""Side section: plate, arm and sign hanging point."""
|
||||
W, H = 640, 420
|
||||
ox, oy = 70, 50
|
||||
ph = PLATE_H * S
|
||||
o = [svg_open(W, H, grid=True)]
|
||||
o.append(f'<rect x="{ox - 18}" y="{oy - 30}" width="18" height="{ph + 60}" fill="url(#fh)" stroke="{INK}" stroke-width="1"/>')
|
||||
o.append(T(ox - 26, oy - 36, "WALL / POST", size=7, w=700, fill=MUTE, ls=1))
|
||||
o.append(f'<rect x="{ox}" y="{oy}" width="{T_PL * S}" height="{ph}" fill="{CREAM}" stroke="{INK}" stroke-width="1.4"/>')
|
||||
ay = oy + ph / 2 - ARM_H / 2 * S
|
||||
o.append(f'<rect x="{ox + T_PL * S}" y="{ay}" width="{ARM_L * S}" height="{ARM_H * S}" fill="#fff" stroke="{INK}" stroke-width="1.4"/>')
|
||||
ex = ox + T_PL * S + ARM_L * S
|
||||
o.append(f'<line x1="{ex - 30}" y1="{ay + ARM_H * S}" x2="{ex - 30}" y2="{ay + ARM_H * S + 60}" stroke="{INK}" stroke-width="1.2"/>')
|
||||
o.append(f'<rect x="{ex - 130}" y="{ay + ARM_H * S + 60}" width="200" height="70" rx="3" fill="{INK}"/>')
|
||||
o.append(T(ex - 30, ay + ARM_H * S + 100, "SIGN (BY OTHERS)", size=7.5, w=700, fill="#fff", anchor="middle", ls=1))
|
||||
o.append(_cl(ox - 30, oy + ph / 2, ex + 30, oy + ph / 2))
|
||||
o.append(dim_h(ox, ex, oy - 16, f"{T_PL + ARM_L}", ext_from=ay))
|
||||
o.append(dim_v(ex + 26, ay, ay + ARM_H * S, f"{ARM_H}", ext_from=ex))
|
||||
o.append(tag(ox + 40, ay - 16, "A-1") + tag(ox + 4, oy + ph + 16, "B-1"))
|
||||
o.append("</svg>")
|
||||
return "".join(o)
|
||||
|
||||
|
||||
def detail():
|
||||
"""Detail: anchor at the plate, enlarged."""
|
||||
W, H = 300, 220
|
||||
o = [svg_open(W, H, grid=True)]
|
||||
o.append(f'<rect x="40" y="30" width="40" height="160" fill="url(#fh)" stroke="{INK}"/>')
|
||||
o.append(f'<rect x="80" y="40" width="22" height="140" fill="{CREAM}" stroke="{INK}" stroke-width="1.3"/>')
|
||||
o.append(f'<rect x="20" y="102" width="150" height="16" fill="#fff" stroke="{INK}" stroke-width="1.1"/>')
|
||||
o.append(f'<rect x="102" y="92" width="8" height="36" fill="{CREAM}" stroke="{INK}"/>')
|
||||
o.append(f'<polygon points="110,96 126,96 126,124 110,124" fill="#fff" stroke="{INK}"/>')
|
||||
o.append(_cl(10, 110, 190, 110))
|
||||
o.append(tag(150, 70, "F-1") + f'<line x1="142" y1="76" x2="122" y2="98" stroke="{INK}" stroke-width=".7"/>')
|
||||
o.append(T(150, 200, "M12 ANCHOR · SEE SCHEDULE", size=7, w=700, anchor="middle", fill=MUTE, ls=.8))
|
||||
o.append("</svg>")
|
||||
return "".join(o)
|
||||
|
||||
|
||||
FIGS = {"elevation": elevation, "section": section, "detail": detail}
|
||||
Binary file not shown.
@@ -0,0 +1,68 @@
|
||||
"""TEMPLATE — Installation Guide (INS). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
Step-by-step physical installation for someone outside MPM (customer facilities, installer, field crew).
|
||||
4–10 pages. See MPM_Document_Standard.md §9.1. Reference: INS-LT-15R-01 (LakeTran).
|
||||
|
||||
Order (do not reorder):
|
||||
masthead (automatic) → Before you start → Process at a glance → Hardware + Tools
|
||||
→ reference figure (template / drill schedule), optional → Walkthrough (one doc.step per step)
|
||||
→ Final check → Revision history (automatic)
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
from figs import FIGS
|
||||
|
||||
F = {k: f() for k, f in FIGS.items()}
|
||||
|
||||
doc = Doc(
|
||||
number="INS-MPM-SUBJ-01", rev="0.1", date="2026-11-06",
|
||||
customer=None, subject="Product",
|
||||
title="Product Name:<br>Mount Type", # the outcome, not "Installation Guide"
|
||||
lede="One sentence per phase of the job: mount it, wire it, hang it, lock it.",
|
||||
meta=[("Audience", "Who does the work"), ("Mount type", "What it mounts to")],
|
||||
slug="Customer_Product_Install",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
# Safety, permits, who must do what, and anything to confirm BEFORE the first hole. Bold the critical fact.
|
||||
doc.before_you_start([
|
||||
"<b>Confirm the location and mounting height</b> with your MPM project contact before drilling.",
|
||||
"<b>Wear eye protection and gloves.</b>",
|
||||
"<b>Line-voltage wiring must be done by a qualified electrician</b> per NEC and local code.",
|
||||
]),
|
||||
doc.section("Process at a glance"),
|
||||
doc.glance([("Mark<br>holes", "3 marks"), ("Drill", "Ø1/2\""), ("Mount<br>bracket", "3 bolts"),
|
||||
("Wire", "8 splices"), ("Hang<br>& test", "on bracket")]), # 4–8 tiles, ≤ 2 words + detail
|
||||
'<div class="keep">' + doc.two(
|
||||
doc.section("Hardware") + doc.table([("Item", ""), ("Spec", ""), ("Qty", "q")],
|
||||
[["Bracket", '<span class="mono">107 × 465 mm</span>', "1"]], cls="tight"),
|
||||
doc.section("Tools") + doc.table([("Tool", ""), ("Notes", "")], [["Drill", "Corded or 18 V+, low speed"]], cls="tight"),
|
||||
ratio=(11, 9)) + "</div>",
|
||||
|
||||
doc.section("Walkthrough", new_page=True),
|
||||
# One outcome per step. Kicker = the tool / part / place. 3–6 numbered actions. Say what success looks like.
|
||||
doc.step(1, "Layout", "Mark the hole locations", doc.steps([
|
||||
"Hold the bracket against the mounting face at the approved height.",
|
||||
"Set a level on the bracket edge and adjust until it's <b>plumb</b>.",
|
||||
"Mark through holes <b>A</b>, <b>B</b> and <b>C</b>.",
|
||||
]) + doc.note("Check your marks", "A to C should measure <b>312 mm</b>."),
|
||||
doc.figure(F["part"], "Caption: what the reader should see")), # side figure (35%)
|
||||
doc.step(2, "12 V DC · WAGO 221", "Connect power", doc.steps([
|
||||
"Confirm the breaker is <b>off</b>.",
|
||||
"Splice <b>brown</b> to <b>red (+)</b> and <b>blue</b> to <b>black (−)</b>.",
|
||||
]) + doc.note("Check polarity", "Reversing + and − can damage the sign.", tone="warn"),
|
||||
doc.figure(F["part"], "Wide figures go below the text", style="width:45%;margin:0 auto"), wide=True), # wide=True for diagrams
|
||||
|
||||
doc.checklist("Final check", [
|
||||
"Bracket plumb, all bolts tight",
|
||||
"Every splice tug-tested",
|
||||
"Sign powers up and every function works",
|
||||
"Photo of the finished install sent to MPM",
|
||||
]),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""TEMPLATE figures for an INS. Wireframe style: ink linework on a faint grid, gold for the part
|
||||
being installed, red for warnings, dimensions in JetBrains Mono. See INS-LT-15R-01/source/figs.py
|
||||
for a full set (drill template, sections, wiring diagrams)."""
|
||||
from mpmdoc.figkit import T, svg_open, INK, KICK, GOLD, MUTE, CREAM
|
||||
|
||||
|
||||
def part():
|
||||
W, H = 300, 260
|
||||
o = [svg_open(W, H, grid=True)]
|
||||
o.append(f'<rect x="110" y="30" width="80" height="200" rx="3" fill="{CREAM}" stroke="{INK}" stroke-width="1.4"/>')
|
||||
for i, (lab, y) in enumerate([("A", 60), ("B", 140), ("C", 200)]):
|
||||
o.append(f'<circle cx="150" cy="{y}" r="6" fill="#fff" stroke="{INK}" stroke-width="1.2"/>')
|
||||
o.append(f'<circle cx="86" cy="{y}" r="9" fill="{GOLD}"/>')
|
||||
o.append(T(86, y + 3.5, lab, size=9, w=800, anchor="middle", ls=0))
|
||||
o.append(f'<line x1="150" y1="18" x2="150" y2="244" stroke="{KICK}" stroke-width=".9" stroke-dasharray="14 3 2 3"/>')
|
||||
o.append(T(150, 14, "CL", size=8, w=700, fill=KICK, anchor="middle"))
|
||||
o.append(T(214, 135, "PLUMB", size=8, w=700))
|
||||
o.append(T(214, 147, "level on edge", size=7.5, w=500, fill=MUTE, fam="Open Sans", ls=0))
|
||||
o.append("</svg>")
|
||||
return "".join(o)
|
||||
|
||||
|
||||
FIGS = {"part": part}
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
"""TEMPLATE — Proposal (PRO). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
Pre-award: the customer's need, MPM's proposed solution, why MPM, scope, investment, timeline, next
|
||||
steps. It persuades with facts, not adjectives. Marked CONFIDENTIAL by default (pricing). 3–8 pages.
|
||||
After award, the agreed scope becomes a SOW with its own number.
|
||||
See MPM_Document_Standard.md §9.9.
|
||||
|
||||
Order: masthead (Prepared for · Valid until) → parties → At a glance (KPI tiles) → 1 Your situation
|
||||
→ 2 Proposed solution → 3 Why Message Point Media → 4 Scope summary → 5 Investment (priced BOM)
|
||||
→ 6 Timeline → 7 Assumptions and exclusions → 8 Next steps → approval → Revision history
|
||||
Rules: every price comes from the quotation (never estimated); every proof point in §3 is a fact
|
||||
(deployments, agencies, years, uptime) you can back up. No superlatives.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
from mpmdoc.brand import PROVIDER
|
||||
|
||||
doc = Doc(
|
||||
number="PRO-MPM-SUBJ-01", rev="0.1", date="2026-11-06", customer="Agency name", subject="Project",
|
||||
title="Wayside Real-Time Signage for Agency",
|
||||
lede="What MPM proposes, for whom, in one line.",
|
||||
meta=[("Prepared for", "Agency name"), ("Valid until", "Dec 6, 2026")],
|
||||
slug="Customer_Project_Proposal",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
price = doc.priced([ # extended prices from Quotation S00000; totals are computed
|
||||
("Hardware subtotal", [("SKU-0000", "Outdoor LED sign, model", 4, 40000.00), ("MNT-0000", "Mounting kit", 4, 2000.00)]),
|
||||
("Services subtotal", [("SVC-INST", "Installation, per sign", 4, 8000.00), ("SVC-PM", "Project management", 1, 3000.00)]),
|
||||
])
|
||||
|
||||
doc.add(
|
||||
doc.parties([("Prepared for", {"name": "Agency name", "contact": "Contact, title", "address": "City, ST"}),
|
||||
("Prepared by", PROVIDER)]),
|
||||
doc.kpis([("Signs", "4", "Wayside, outdoor", "strong"), ("Investment", doc.money(doc.price_total), "plus applicable tax", "key"),
|
||||
("Install", "8 weeks", "from award", "")]),
|
||||
doc.section("1. Your situation"),
|
||||
doc.p("The agency's need in its own terms: riders, stops, current system, what is not working."),
|
||||
doc.section("2. Proposed solution"),
|
||||
doc.p("What MPM will install and how it works for the rider. One paragraph, then the parts."),
|
||||
doc.bullets(["Signs: model, size, brightness, accessibility (audio, contrast).", "Content: MP.TV platform, real-time feed.",
|
||||
"Support: monitoring, warranty, service."]),
|
||||
doc.section("3. Why Message Point Media"),
|
||||
doc.bullets(["Fact: comparable deployments, with agency names where permitted.", "Fact: years in transit signage.",
|
||||
"Fact: platform uptime or support response, from records."]),
|
||||
doc.section("4. Scope summary"),
|
||||
doc.table([("Phase", "", "22%"), ("MPM provides", ""), ("Agency provides", "", "30%")],
|
||||
[["Survey and design", "Site survey, mounting design", "Site access"], ["Install", "Hardware, installation, configuration", "Power and network at each pole"]]),
|
||||
doc.section("5. Investment"),
|
||||
price,
|
||||
doc.note("Pricing", "Per MPM Quotation S00000, dated Nov 6, 2026. Valid until Dec 6, 2026. Sales tax not included."),
|
||||
doc.section("6. Timeline"),
|
||||
doc.table([("Phase", ""), ("Duration", "", "20%"), ("Starts", "", "25%")], [["Survey and design", "2 weeks", "Award"], ["Production", "4 weeks", "Design approval"], ["Install", "2 weeks", "Delivery"]]),
|
||||
doc.section("7. Assumptions and exclusions"),
|
||||
doc.two(doc.sub("Assumptions") + doc.bullets(["Power and network available at each location."]),
|
||||
doc.sub("Exclusions") + doc.bullets(["Permits, unless listed in §5.", "Civil work and trenching."])),
|
||||
doc.section("8. Next steps"),
|
||||
doc.steps(["Review this proposal and send questions to MPM.", "Issue a purchase order or notice of award.",
|
||||
"MPM issues the Statement of Work and schedules the site survey."]),
|
||||
doc.approval("Jason Stedwell", "Director of Technical Services, Message Point Media | Irondale, AL", "2026-11-06", label="Submitted by"),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
"""TEMPLATE — Standard Operating Procedure (SOP). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A repeatable task for MPM staff (setup, onboarding, process). Marked INTERNAL USE automatically.
|
||||
2–6 pages. See MPM_Document_Standard.md §9.2. Reference: SOP-IT-UNIFI-01 (UniFi Talk).
|
||||
|
||||
Order (do not reorder):
|
||||
masthead (automatic) → Before you start → Process at a glance (who does each part)
|
||||
→ Walkthrough (doc.step per step, screenshots in img/) → special cases (doc.special)
|
||||
→ Setup checklist → Troubleshooting → Need help? → Revision history (automatic)
|
||||
|
||||
Screenshots: crop to the relevant part, mark the control to tap with a gold box, save as JPG in img/.
|
||||
They are captioned but not numbered. Name on-screen controls with ui('Label').
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc, ui
|
||||
|
||||
doc = Doc(
|
||||
number="SOP-IT-SUBJ-01", rev="0.1", date="2026-11-06", # CUST = department: IT, ENG, OPS, QA
|
||||
customer=None, subject="System",
|
||||
kicker="End-user walkthrough · System",
|
||||
title="Do the Thing with the App",
|
||||
lede="One sentence: what the reader will have done and be able to do at the end.",
|
||||
meta=[("Audience", "All MPM staff"), ("Time needed", "About 10 minutes")],
|
||||
slug="MPM_System_Task",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
PHONE, SIDE = "92px", "20%" # one phone screenshot beside a step
|
||||
|
||||
doc.add(
|
||||
doc.before_you_start([
|
||||
"<b>What has already been done for you</b>, and by whom.",
|
||||
"What to have in hand before you start.",
|
||||
"Anything that expires or needs a special case (point to it by name).",
|
||||
]),
|
||||
doc.section("Process at a glance"),
|
||||
doc.glance([
|
||||
("Account<br>created", "IT", "✓", True), # done by someone else: tick + greyed tile
|
||||
("Open<br>email", "You"), ("Install<br>app", "You"), ("Sign in", "You"),
|
||||
]),
|
||||
|
||||
doc.section("Walkthrough", new_page=True),
|
||||
doc.step(1, "Your inbox", "Open the invitation email", doc.steps([
|
||||
f"Open the email from <b>Vendor</b> and tap {ui('Get Started')}.",
|
||||
"Continue to Step 2.",
|
||||
]) + doc.note("Good to know", "One short fact that prevents a support call."),
|
||||
doc.shot("img/placeholder.jpg", "Caption: what to look for", PHONE), side=SIDE),
|
||||
|
||||
doc.special("Some staff only", "Outside the U.S.? Do this first",
|
||||
"<p>Who this applies to and how they'll know.</p>"
|
||||
+ doc.steps([f"Tap {ui('VPN')}.", "Leave it on for your whole shift."])),
|
||||
|
||||
doc.checklist("Setup checklist", ["Email opened and link tapped", "App shows my name", "Test completed"], cols=1),
|
||||
doc.section("Troubleshooting"),
|
||||
doc.table([("Problem", "", "28%"), ("What to do", "")],
|
||||
[["<b>Email is missing</b>", "Check Spam/Junk. If it isn't there, ask IT to resend it."]]),
|
||||
doc.note("Need help?", "Contact MPM IT."),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
"""TEMPLATE — Statement of Work (SOW). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
The contractual scope after award: what MPM delivers, how, and the payment milestones. Follows the
|
||||
layout of SOW_Nashville_Replacement_Sign_S01012 (Aug 2026). 2–6 pages.
|
||||
See MPM_Document_Standard.md §9.10.
|
||||
|
||||
Order: masthead (Customer · Quotation) → opening paragraph (parties, MSA, end customer, quotation)
|
||||
→ 1.0 Scope overview → 2.0 Project deliverables (2.1 hardware + 2.1.1 priced BOM, 2.2 documentation)
|
||||
→ 3.0 Scope of work (3.1 initiation … 3.n warranty) → 4.0 Project milestones → 5.0 Assumptions and
|
||||
exclusions → [6.0 Acceptance: signatures, when the customer signs the SOW itself] → Revision history
|
||||
Rules: prices come from the quotation; milestones(expect=doc.price_total) fails the build if the milestones
|
||||
don't add up. Draft 0.x while negotiating; 1.0 = executed.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
|
||||
doc = Doc(
|
||||
number="SOW-MPM-SUBJ-01", rev="0.1", date="2026-11-06", customer="Partner", subject="Project",
|
||||
title="Project Name",
|
||||
lede="Statement of work for the scope, at the site, for the end customer.",
|
||||
meta=[("Customer", "Partner, for End Customer"), ("Quotation", "S00000")],
|
||||
slug="Customer_Project_SOW",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
doc.p("This Statement of Work (SOW) is made by and between <b>Partner</b> (Customer) and <b>Message Point Media of "
|
||||
"Alabama, Inc</b> (MPM), and is subject to the Master Services Agreement between Customer and MPM, whose "
|
||||
"provisions are incorporated except as amended here. The work is for the benefit of <b>End Customer</b>. "
|
||||
"Pricing is per MPM Quotation S00000, dated Nov 6, 2026 (expires Feb 4, 2027)."),
|
||||
doc.section("1.0 Scope overview"),
|
||||
doc.p("What MPM will provide, in one paragraph. Say what is <i>not</i> included when a reader might assume it is."),
|
||||
doc.section("2.0 Project deliverables"),
|
||||
doc.sub("2.1 Hardware deliverables"),
|
||||
doc.p("MPM will procure, provision and deliver all hardware needed for the work, including:"),
|
||||
doc.bullets(["Item, with the specification that matters (pitch, size, rating)."]),
|
||||
doc.sub("2.1.1 Bill of materials (per Quotation S00000)"),
|
||||
doc.priced([
|
||||
("Hardware subtotal", [("SKU-0000", "Hardware item", 1, 10000.00), ("MNT-0000", "Mounting and bracket budget", 1, 500.00)]),
|
||||
("Installation subtotal", [("SVC-INST", "Installation", 1, 2500.00)]),
|
||||
]),
|
||||
doc.sub("2.2 Documentation deliverables"),
|
||||
doc.table([("Document", "", "32%"), ("Purpose", "")],
|
||||
[["Installation Completion Report", "Confirms installation, configuration and operational verification"],
|
||||
["As-built update", "Records serial numbers and redline changes"]]),
|
||||
doc.section("3.0 Scope of work"),
|
||||
doc.sub("3.1 Project initiation and scheduling"),
|
||||
doc.p("Within ten (10) business days of award, MPM confirms points of contact, site access and the installation window."),
|
||||
doc.sub("3.2 Site confirmation"), doc.p("How site conditions are confirmed, and what happens if they differ."),
|
||||
doc.sub("3.3 Hardware procurement and provisioning"), doc.p("Procurement, provisioning and QA testing before delivery."),
|
||||
doc.sub("3.4 Hardware delivery"), doc.p("Shipment as directed by Customer."),
|
||||
doc.sub("3.5 Installation"), doc.p("Installation, configuration, content assignment. Responsibilities for power and network."),
|
||||
doc.sub("3.6 Operational verification"), doc.p("Online, reporting to MP.TV, displaying assigned content."),
|
||||
doc.sub("3.7 Warranty"), doc.p("MPM standard Equipment Warranty Policy (name the policy), starting at End Customer approval."),
|
||||
doc.section("4.0 Project milestones"),
|
||||
doc.milestones([("Production deposit", "Due on contract execution", "25% of hardware", 2625.00),
|
||||
("Equipment delivery", "All hardware delivered to site", "75% of hardware", 7875.00),
|
||||
("Installation completion", "Installed, verified operational, accepted", "100% of installation", 2500.00)],
|
||||
expect=doc.price_total),
|
||||
doc.section("5.0 Assumptions and exclusions"),
|
||||
doc.bullets(["Existing power and network remain in place; changes to them are the End Customer's responsibility.",
|
||||
"Excludes permits, civil work and licensing unless listed in §2."]),
|
||||
doc.section("6.0 Acceptance"),
|
||||
doc.signatures([("Customer", "Name, title · date"), ("Message Point Media", "Jason Stedwell, Director of Technical Services · date")]),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
@@ -0,0 +1,61 @@
|
||||
"""TEMPLATE — Site Survey (SVY). Copy this folder to <PROJECT>/source/ and fill it in. Photos go in img/.
|
||||
|
||||
A record of what exists on site and what it means for design. 4–12 pages.
|
||||
See MPM_Document_Standard.md §9.5. Reference: SVY-SOR-MON-01 (SORTA Monument Square).
|
||||
|
||||
Order: masthead → control table (kv) → About this document (dark) → Status note
|
||||
→ PART 1 Summary of Findings (customer) → PART 2 Engineering Detail (MPM internal)
|
||||
→ To verify list → Appendix A Photo log → Appendix B Field notes (verbatim) → Revision history
|
||||
Rules: dimensions as recorded in the field (convert, never round away the original); anything not
|
||||
measured is TBC or a To-verify item; photos are numbered figures and every one is in the photo log.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc, verify
|
||||
|
||||
doc = Doc(
|
||||
number="SVY-MPM-SITE-01", rev="0.1", date="2026-11-06",
|
||||
customer=None, subject="Site name",
|
||||
title="Site Name Wayside Signage",
|
||||
lede="What was surveyed, for which project, and when the field visit took place.",
|
||||
meta=[("Survey date", "Nov 6, 2026"), ("Surveyor", "Jason Stedwell · MPM")],
|
||||
slug="Customer_Site_Site-Survey",
|
||||
classification="internal", # internal until Part 2 is split out for the customer
|
||||
history=[("0.1", "2026-11-06", "Preliminary draft from field notes and photographs.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
doc.kv([("Prepared for", "Customer, City, ST"), ("Prepared by", "Message Point Media (MPM): name, role"),
|
||||
("Basis", "N pages of field notes · N site photographs · drawings received (or none)")]),
|
||||
doc.dark("About this document", [
|
||||
"<b>Part 1, Summary of Findings</b>, is written for the customer.",
|
||||
"<b>Part 2, Engineering Detail</b>, is MPM-internal.",
|
||||
"<b>Appendices</b> hold the photo log and a verbatim transcription of the field notes.",
|
||||
]),
|
||||
doc.note("Status of this revision", f"Preliminary draft. Items marked {verify()} are points of record to confirm with the customer."),
|
||||
|
||||
doc.part("Part 1", "Summary of Findings"),
|
||||
doc.section("1.1 Purpose"), doc.p("Why the survey was done, in rider/agency terms."),
|
||||
doc.section("1.2 Scope"), doc.p("What was in and out of scope, and why."),
|
||||
doc.section("1.3 What we found"),
|
||||
doc.table([("Stops", ""), ("Structure", ""), ("Existing mount", ""), ("Governing clearance", "")],
|
||||
[["A, B", "Steel column", "Side / cantilever", "Member <b>870 mm</b> from mount"]], caption="Families found on site."),
|
||||
doc.p("<b>Lead with the conclusion.</b> One bold sentence per finding, then what it means for the design."),
|
||||
|
||||
doc.part("Part 2", "Engineering Detail", internal=True),
|
||||
doc.section("2.1 Survey record"),
|
||||
doc.kv([("Survey date / time", "From photo EXIF"), ("Site name", f"As recorded {verify()}")]),
|
||||
doc.section("2.2 Observations and design constraints"),
|
||||
doc.sub("Field observations"), doc.bullets(["What was seen or measured, with the value in bold."]),
|
||||
doc.sub("Design constraints and implications"), doc.bullets(["What the observation forces in the design."]),
|
||||
doc.note("To verify with the customer before release", ["Every point of record still open, with its section number."], tone="verify"),
|
||||
|
||||
doc.part("Appendix A", "Photo Log"),
|
||||
doc.table([("#", "q"), ("Time", "m"), ("File ID", "m"), ("Subject", "")], [["1", "08:46:17", "D2C5E8C7", "What the photo shows"]], cls="tight"),
|
||||
doc.part("Appendix B", "Field Notes: Verbatim Transcription"),
|
||||
doc.transcript("Notebook heading", ["Spelling and abbreviations preserved.", "• bullet as written"]),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
"""TEMPLATE — Technical Action Plan (TAP). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A planned engagement with a customer or OEM partner: scope, staffing, logistics, procedures, success
|
||||
criteria. Marked CONFIDENTIAL automatically. 5–12 pages.
|
||||
See MPM_Document_Standard.md §9.6. Reference: TAP-GIL-M37-01 (GILLIG).
|
||||
|
||||
Order: masthead → document-control table → confidentiality notice → 1 Purpose & scope (what changed)
|
||||
→ 2 Background & register → 3 Information required (Action required) → 4 Personnel
|
||||
→ 5 Kit → 6 Timeline → 7 Procedures by group → 8 Open items → 9 Acceptance criteria
|
||||
→ 10 Reporting → 11 Contacts → approval → Revision history
|
||||
Sections are numbered in the heading text ("3. Information required …"; "7.1 Group A …").
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
|
||||
doc = Doc(
|
||||
number="TAP-MPM-SUBJ-01", rev="0.1", date="2026-11-06",
|
||||
customer=None, subject="Product",
|
||||
footer="Customer · NDA",
|
||||
title="Customer On-Site Service Engagement",
|
||||
lede="What is being resolved, for which products, where.",
|
||||
meta=[("Customer", "Customer · Partner"), ("Engagement", "Dates")],
|
||||
slug="Customer_Subject_Action-Plan",
|
||||
history=[("0.1", "2026-11-06", "Initial draft.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
doc.kv([("Supersedes / references", "Prior plans, bulletins, RMAs"), ("Distribution", "Customer | Partner"),
|
||||
("Affected products", "Product names and part numbers"), ("Field technician", "Name, role"),
|
||||
("Technical oversight", "Jason Stedwell, Director of Technical Services"), ("Engagement site", "Facility, City, ST")]),
|
||||
doc.note("Proprietary & confidential", "Provided exclusively to the named recipients under the existing NDA. "
|
||||
"Reproduction or disclosure to any third party without written consent from MPM is prohibited.", tone="warn"),
|
||||
|
||||
doc.section("1. Purpose and scope", new_page=True),
|
||||
doc.p("What this plan covers and the shared objective."),
|
||||
doc.note("What changed in this revision", "Only from 1.1 on: the scope change in one paragraph."),
|
||||
doc.section("2. Background and known-issue summary"),
|
||||
doc.table([("Reject #", "m"), ("Issue", ""), ("Group", ""), ("Status", "")], [["00000", "Reported issue", "A", "On-site"]],
|
||||
caption="Known-issue register.", cls="tight"),
|
||||
doc.section("3. Information required prior to engagement"),
|
||||
doc.special("Action required", "Customer response needed to finalize the visit",
|
||||
doc.table([("#", "q"), ("Information needed", ""), ("Why it matters", "")], [["1", "Units staged", "Work starts on arrival"]], cls="tight")),
|
||||
doc.section("4. Personnel"), doc.p("Who is on site, who oversees, who coordinates."),
|
||||
doc.section("7. Procedures by group"), doc.sub("7.1 Group A"), doc.steps(["Step.", "Step."]),
|
||||
doc.section("9. Acceptance criteria"), doc.bullets(["Pass criterion."], check=True),
|
||||
doc.approval("Jason Stedwell", "Director of Technical Services, Message Point Media | Irondale, AL", "2026-11-06"),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
"""TEMPLATE — Technical Note (TN). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A TN answers ONE engineering question (does it fit, how much power, is a cut needed)
|
||||
and puts the answer first. 1–3 pages. See MPM_Document_Standard.md §9.3.
|
||||
|
||||
Order (do not reorder):
|
||||
masthead (automatic) → KPI tiles → Result → evidence sections (tables, calc, figures)
|
||||
→ Assumptions and open items → Revision history (automatic)
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc, KeyRow, up, dn
|
||||
from figs import FIGS, MEASURED, LIMIT # keep every number in ONE place (figs.py) and derive the rest
|
||||
|
||||
F = {k: f() for k, f in FIGS.items()}
|
||||
MARGIN = LIMIT - MEASURED
|
||||
|
||||
doc = Doc(
|
||||
number="TN-MPM-SUBJ-01", # TYPE-CUST-SUBJ-NN · register CUST + number in registry.md
|
||||
rev="0.1", # 0.x = Draft · 1.0 = first release
|
||||
date="2026-11-05", # ISO in source; displayed as "Nov 5, 2026"
|
||||
customer=None, # None = name from the CUST code; override for a fuller name
|
||||
subject="Subject", # short: the thing studied ("Bay 17", "Pingo P2.5 LED module")
|
||||
title="Question-Shaped Title: The Thing Being Checked",
|
||||
lede=("Two to three sentences: the situation, and what this note works out. "
|
||||
"Write it so someone who reads only this paragraph knows why the note exists."),
|
||||
meta=[("Site", "Where / what"), ("Requirement", "The criterion · e.g. ≤ 60 W")], # exactly two cells
|
||||
slug="Customer_Short-Title", # → TN-MPM-SUBJ-01_Customer_Short-Title_Rev0.1.pdf
|
||||
history=[("0.1", "2026-11-05", "Initial draft.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
# 3–4 tiles: the input(s), THE ANSWER (tone="key"), and the verdict (tone="ok"/"bad")
|
||||
doc.kpis([
|
||||
("Measured", f"{MEASURED:.1f} W", "How it was measured"),
|
||||
("Limit", f"{LIMIT:.1f} W", "Where the limit comes from", "strong"),
|
||||
("Margin", f"{MARGIN:+.1f} W", "Limit − measured", "ok" if MARGIN >= 0 else "bad"),
|
||||
]),
|
||||
# 2–4 bullets. First bullet = the answer, with the number in bold. Then conditions / what to do.
|
||||
doc.result([
|
||||
f"The answer in one sentence, with the key value in <b>bold ({MEASURED:.1f} W)</b>.",
|
||||
"The condition the answer depends on, and what changes if it doesn't hold.",
|
||||
]),
|
||||
|
||||
doc.section("Comparison"),
|
||||
doc.table(
|
||||
[("Item", ""), ("Value", "m r"), ("Change", "d r"), ("Notes", "")],
|
||||
[
|
||||
["Row", "1.0", up("+1.0"), "Source of the number"],
|
||||
KeyRow(["<b>The row that matters</b>", "2.0", dn("−0.5"), "Highlight max. one row"]),
|
||||
],
|
||||
caption="What the table shows, units, and the datum.", ref="cmp"),
|
||||
|
||||
doc.section("Calculation"),
|
||||
doc.calc([
|
||||
"# One comment line per step, saying why",
|
||||
("Margin", "limit − measured = {:.1f} − {:.1f}".format(LIMIT, MEASURED), f"{MARGIN:+.1f} W",
|
||||
"PASS" if MARGIN >= 0 else "FAIL"),
|
||||
]),
|
||||
|
||||
doc.section("Figure"),
|
||||
doc.figure(F["bars"], "Caption says what to look at, not just what it is. Refer to it in text as [[fig:bars]].", ref="bars"),
|
||||
|
||||
doc.assumptions(
|
||||
["Every assumption the answer rests on, one per bullet."],
|
||||
["Everything still to confirm, phrased as an action (Confirm …, Add …)."],
|
||||
),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""TEMPLATE — Technical Note, Simplified Chinese edition (技术说明 · 中文版).
|
||||
Copy next to the English build.py in <PROJECT>/source/. See MPM_Document_Standard.md §12.
|
||||
|
||||
Rules:
|
||||
* Same number, slug, revision and figures as build.py; the engine adds _ZH to the file name and refuses
|
||||
to build until the English PDF at this revision is in the project folder. Build English first.
|
||||
* Translate every string here, in the same order as build.py, so the two can be compared side by side.
|
||||
* Figure text goes through i18n.Translator: this document's TERMS first, then _STANDARDS/glossary_zh.md.
|
||||
tr.check() fails the build on any English left in a figure.
|
||||
* Numbers come from figs.py exactly as in English. Metric first, imperial in [ ].
|
||||
* The masthead carries "英文版为准" (English governs) automatically.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from mpmdoc import Doc, KeyRow, up, dn, i18n
|
||||
from figs import FIGS, MEASURED, LIMIT
|
||||
|
||||
MARGIN = LIMIT - MEASURED
|
||||
|
||||
TERMS = { # this document's figure phrases (single terms: glossary)
|
||||
f"LIMIT · {LIMIT:.1f} W": f"限值 · {LIMIT:.1f} W",
|
||||
}
|
||||
tr = i18n.Translator(TERMS)
|
||||
F = {k: tr.svg(f()) for k, f in FIGS.items()}
|
||||
tr.check()
|
||||
|
||||
doc = Doc(
|
||||
number="TN-MPM-SUBJ-01", rev="0.1", date="2026-11-05", lang="zh",
|
||||
customer="客户名称", subject="主题",
|
||||
title="以问题为标题:被核查的对象",
|
||||
lede="两到三句话:现状,以及本说明要解决的问题。只读这一段的人也应知道本说明的目的。",
|
||||
meta=[("地点", "位置 / 对象"), ("要求", "判定标准 · 例如 ≤ 60 W")],
|
||||
slug="Customer_Short-Title", # identical to the English slug
|
||||
history=[("0.1", "2026-11-05", "初稿。")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
doc.kpis([
|
||||
("实测", f"{MEASURED:.1f} W", "测量方法"),
|
||||
("限值", f"{LIMIT:.1f} W", "限值来源", "strong"),
|
||||
("余量", f"{MARGIN:+.1f} W", "限值 − 实测", "ok" if MARGIN >= 0 else "bad"),
|
||||
]),
|
||||
doc.result([
|
||||
f"一句话结论,关键数值加粗 <b>({MEASURED:.1f} W)</b>。",
|
||||
"结论成立的前提条件,以及条件不满足时的变化。",
|
||||
]),
|
||||
|
||||
doc.section("对比"),
|
||||
doc.table(
|
||||
[("项目", ""), ("数值", "m r"), ("变化", "d r"), ("备注", "")],
|
||||
[
|
||||
["行", "1.0", up("+1.0"), "数据来源"],
|
||||
KeyRow(["<b>关键行</b>", "2.0", dn("−0.5"), "最多突出一行"]),
|
||||
],
|
||||
caption="表格内容、单位及基准。", ref="cmp"),
|
||||
|
||||
doc.section("计算"),
|
||||
doc.calc([
|
||||
"# 每步一行注释,说明原因",
|
||||
("余量", "限值 − 实测 = {:.1f} − {:.1f}".format(LIMIT, MEASURED), f"{MARGIN:+.1f} W",
|
||||
"PASS" if MARGIN >= 0 else "FAIL"),
|
||||
]),
|
||||
|
||||
doc.section("图示"),
|
||||
doc.figure(F["bars"], "图注说明应关注的内容。正文中引用为 [[fig:bars]]。", ref="bars"),
|
||||
|
||||
doc.assumptions(
|
||||
["结论所依据的每项假设,每条一项。"],
|
||||
["所有待确认事项,以动作表述(确认……、补充……)。"],
|
||||
),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""TEMPLATE figures for a TN. Keep the source numbers here; build.py imports them.
|
||||
Each figure is a function returning an SVG string; register it in FIGS."""
|
||||
from mpmdoc.figkit import T, svg_open, INK, KICK, MUTE, LINE, RED, GRY
|
||||
|
||||
# ---- source numbers (one place only) ----
|
||||
MEASURED = 51.0
|
||||
LIMIT = 63.8
|
||||
|
||||
|
||||
def bars():
|
||||
W, L, R, top, rowh = 700, 150, 640, 30, 30
|
||||
rows = [("Measured", MEASURED, INK), ("Reference", 20.4, GRY)]
|
||||
vmax = 70.0
|
||||
x = lambda v: L + v * (R - L) / vmax
|
||||
bot = top + rowh * len(rows)
|
||||
o = [svg_open(W, bot + 30)]
|
||||
for v in range(0, 71, 10):
|
||||
o.append(f'<line x1="{x(v):.1f}" y1="{top-6}" x2="{x(v):.1f}" y2="{bot}" stroke="{LINE}" stroke-width="0.8"/>')
|
||||
o.append(T(x(v), bot + 14, v, size=7.4, w=500, fill=MUTE, anchor="middle", fam="JetBrains Mono", ls=0))
|
||||
o.append(f'<line x1="{x(LIMIT):.1f}" y1="{top-14}" x2="{x(LIMIT):.1f}" y2="{bot}" stroke="{RED}" stroke-width="1.4" stroke-dasharray="5 3"/>')
|
||||
o.append(T(x(LIMIT), top - 18, f"LIMIT · {LIMIT:.1f} W", size=6.8, w=800, fill=RED, anchor="middle", ls=1.0))
|
||||
for i, (name, v, c) in enumerate(rows):
|
||||
cy = top + i * rowh + rowh / 2
|
||||
o.append(T(L - 10, cy + 3, name, size=8.2, anchor="end"))
|
||||
o.append(f'<rect x="{L}" y="{cy-8.5:.1f}" width="{x(v)-L:.1f}" height="17" rx="2" fill="{c}"/>')
|
||||
o.append(T(x(v) + 6, cy + 3.2, f"{v:.1f} W", size=8.6, w=700, fam="JetBrains Mono", ls=0, halo=True))
|
||||
o.append("</svg>")
|
||||
return "".join(o)
|
||||
|
||||
|
||||
FIGS = {"bars": bars}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
"""TEMPLATE — Technical Service Bulletin (TSB). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A known product issue and its fix, for every unit it affects. One number, two editions from this one
|
||||
script: the CUSTOMER edition (agencies, partners) and the INTERNAL edition (MPM field and support), which
|
||||
adds a Part 2 with diagnostic detail. The customer edition never contains Part 2. 2–5 pages.
|
||||
See MPM_Document_Standard.md §9.8.
|
||||
|
||||
Order: masthead (Compliance · Effective) → compliance pill → applicability (kv) → In short (dark)
|
||||
→ 1 Symptom → 2 Cause → 3 Corrective action → 4 Verification → 5 Parts → Compliance record
|
||||
→ [internal: PART 2 Diagnostic detail] → Revision history
|
||||
Compliance: Mandatory (red) = safety/failure, fix by a date · Recommended (gold) = fix at next visit
|
||||
· Informational (grey) = no action, awareness only.
|
||||
Numbers: customer code MPM for product-wide bulletins (TSB-MPM-LX270-01); a customer code when the
|
||||
issue is specific to one deployment. Subject = product family.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
|
||||
REV, DATE = "0.1", "2026-11-06"
|
||||
HISTORY = [("0.1", DATE, "Initial draft.")]
|
||||
COMPLIANCE, PILL = "Recommended", "gold" # Mandatory/red · Recommended/gold · Informational/grey
|
||||
|
||||
for edition, slug in (("customer", "MPM_Product_Issue_TSB"), ("internal", "MPM_Product_Issue_TSB-Internal")):
|
||||
internal = edition == "internal"
|
||||
doc = Doc(
|
||||
number="TSB-MPM-SUBJ-01", rev=REV, date=DATE, subject="Product family",
|
||||
title="Product: Issue in a Few Words",
|
||||
lede="One line: what goes wrong, on which units, and what to do about it.",
|
||||
meta=[("Compliance", COMPLIANCE), ("Effective", "Nov 6, 2026")],
|
||||
slug=slug, classification=edition, history=HISTORY,
|
||||
)
|
||||
body = [
|
||||
doc.pill(COMPLIANCE, PILL),
|
||||
doc.kv([("Applies to", "Product, model, part number"),
|
||||
("Affected units", "Serial range, build dates or firmware versions"),
|
||||
("Not affected", "Units outside that range; how to tell"),
|
||||
("Labor estimate", "0.5 h per unit, one technician"),
|
||||
("Supersedes", "—"),
|
||||
("Distribution", "MPM field and support" if internal else "Customers and partners operating affected units")]),
|
||||
doc.result(["What happens, in one line.", "What to do, in one line.", "By when (Mandatory only)."]),
|
||||
doc.section("1. Symptom"),
|
||||
doc.p("What the rider, the agency or the platform sees. Exact: which LEDs, which message, how often."),
|
||||
doc.section("2. Cause"),
|
||||
doc.p("The cause in plain words. Customer edition: enough to trust the fix, no more."),
|
||||
doc.section("3. Corrective action"),
|
||||
doc.steps(["Power down the sign at the breaker and confirm it is dark.", "Replace the part (see §5).",
|
||||
"Power up and confirm the sign reports online on MP.TV."]),
|
||||
doc.section("4. Verification"),
|
||||
doc.bullets(["Sign reports online on MP.TV within 5 minutes.", "No dropout over a 10-minute content loop."], check=True),
|
||||
doc.section("5. Parts"),
|
||||
doc.table([("Part no.", "m", "22%"), ("Description", ""), ("Qty per unit", "r", "14%")],
|
||||
[["PN-0000", "Replacement part", "1"]]),
|
||||
doc.section("Compliance record"),
|
||||
doc.table([("Unit serial", "", "24%"), ("Site", ""), ("Date", "", "16%"), ("Technician", "", "20%"), ("Done", "c", "8%")],
|
||||
[["", "", "", "", '<span class="box"></span>']] * 6, cls="fill"),
|
||||
]
|
||||
if internal:
|
||||
body += [
|
||||
doc.part("Part 2", "Diagnostic detail", "MPM field and support only. Not for distribution.", internal=True),
|
||||
doc.section("Root cause"), doc.p("Failure analysis, test results, supplier findings."),
|
||||
doc.section("Field data"),
|
||||
doc.table([("Site", ""), ("Units", "r", "12%"), ("Failures", "r", "12%"), ("First seen", "", "18%")],
|
||||
[["Site name", "0", "0", "Nov 6, 2026"]]),
|
||||
doc.section("Support notes"),
|
||||
doc.bullets(["How to recognise the issue on a support call.", "Remote checks before dispatch."]),
|
||||
]
|
||||
doc.add(*body)
|
||||
doc.build()
|
||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
||||
"""TEMPLATE — Field Work Order (WO). Copy this folder to <PROJECT>/source/ and fill it in.
|
||||
|
||||
A dispatch ticket for one job at one site, for MPM technicians or a subcontract installer. It says where,
|
||||
when, what to do, what to bring and what to send back; the technician fills in the completion record and
|
||||
both parties sign. Marked INTERNAL USE (it carries contacts and access details). 1–3 pages.
|
||||
See MPM_Document_Standard.md §9.11.
|
||||
|
||||
Order: masthead (Scheduled · Assigned to) → status pill → parties (Site · Site contact · MPM dispatch)
|
||||
→ job details (kv) → Safety → Work to be performed → Parts and materials → Return with this work order
|
||||
→ Completion record → signatures → Revision history
|
||||
Status pill: Dispatched (gold) · On site (ink) · Complete (green) · On hold (red).
|
||||
The signed, completed copy is scanned into the project's _INPUTS/ (never edited); this PDF stays as issued.
|
||||
Issue at 1.0; a re-dispatch with changed scope or date is 1.1.
|
||||
"""
|
||||
import sys, pathlib
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(next(p / "_STANDARDS" for p in HERE.parents if (p / "_STANDARDS").is_dir())))
|
||||
|
||||
from mpmdoc import Doc
|
||||
|
||||
doc = Doc(
|
||||
number="WO-MPM-SITE-01", rev="1.0", date="2026-11-06", customer="Agency", subject="Site",
|
||||
title="Install: Site Name, Sign Type",
|
||||
lede="One line: the job, the site, the outcome.",
|
||||
meta=[("Scheduled", "Nov 10, 2026 · 8:00 AM"), ("Assigned to", "Technician · Company")],
|
||||
slug="Customer_Site_Work-Order",
|
||||
history=[("1.0", "2026-11-06", "Issued.")],
|
||||
)
|
||||
|
||||
doc.add(
|
||||
doc.pill("Dispatched", "gold"),
|
||||
doc.parties([("Site", {"name": "Stop or facility name", "address": "Street, City, ST", "coordinates": "00.00000, -00.00000"}),
|
||||
("Site contact", {"name": "Name", "contact": "Role, agency", "phone": "000-000-0000"}),
|
||||
("MPM dispatch", {"name": "Jason Stedwell", "contact": "Director of Technical Services", "phone": "000-000-0000"})]),
|
||||
doc.kv([("Customer / project", "Agency · project name"), ("Installer", "MPM, or subcontractor company and lead"),
|
||||
("Access", "Hours, badge, escort, keys, lift or traffic control"),
|
||||
("Reference documents", "INS-…, DWG-…, SVY-… (bring or have on a device)")]),
|
||||
doc.note("Safety", "PPE, work at height, traffic control, lockout at the breaker before opening any enclosure.", tone="warn"),
|
||||
doc.section("Work to be performed"),
|
||||
doc.bullets(["Confirm the mounting point matches the drawing.", "Mount the sign; torque fasteners to spec.",
|
||||
"Terminate power and network; confirm the sign reports online on MP.TV."], check=True),
|
||||
doc.bom([("Parts and materials", [("Sign assembly", "Model, size", 1, "ea", "PN-0000"), ("Mounting kit", "Per drawing", 1, "kit", "PN-0000")],
|
||||
"Parts")], checks=("Staged", "Used")),
|
||||
doc.section("Return with this work order"),
|
||||
doc.bullets(["Photos: before, mounted, wired, powered with content.", "Serial number of every unit installed or removed.",
|
||||
"Any difference from the drawing, measured."], check=True),
|
||||
doc.fields([("Arrival", "Date and time"), ("Departure", "Date and time"), ("Serial(s) installed", ""), ("Serial(s) removed", ""),
|
||||
("Online on MP.TV", "Time confirmed"), ("Issues or deviations", "")], cols=2, title="Completion record"),
|
||||
doc.signatures([("Technician", "Name · company · date"), ("Site contact", "Name · date")]),
|
||||
)
|
||||
|
||||
doc.build()
|
||||
Reference in new issue
Block a user