Files
Jason StedwellandClaude Opus 5.5 5b52636003 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
2026-11-09 11:51:56 -06:00

116 lines
5.9 KiB
Python

"""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)