115 lines
5.9 KiB
Python
115 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)
|