#!/usr/bin/env python3
"""Generate the printable suburb posters from one template.
Two orientations per suburb — A4 landscape and A4 portrait (vertical) — each
carrying that suburb's reserved house referral code as a plaque, on two QR codes
(one per audience), and across the tear-off tabs along the bottom. That code is
the same one the weekly summary email tabulates.
The generated .html files are fully self-contained (QR codes are inlined as SVG
paths, the logo is inline SVG, fonts are web-safe), so they need nothing at view
time. Print from a browser at A4 in the matching orientation, "Background
graphics" ON, margins "None" — the page box already carries its own margin.
Regenerating needs `segno` (QR encoding), which is NOT a project dependency:
python3 -m pip install --target /tmp/pylibs segno
PYTHONPATH=/tmp/pylibs python3 docs/posters/build_posters.py
Adding a suburb: reserve its code (payments/migrations/0017_reserve_campaign_codes.py),
add an entry to POSTERS below, and re-run.
"""
import pathlib
from string import Template
import segno
BASE = "https://tutorsfirst.com.au"
OUT_DIR = pathlib.Path(__file__).resolve().parent
FEE = "9.90"
# Site palette (templates/base.html :root) plus the lockup's lighter cell navy.
# Three roles, kept strictly separate so the poster reads at a glance:
# navy = structure (edge, masthead rule, the families card)
# green = the tutor side, and the brand's "free/go" positives
# red = the code, and only the code (plaque + tear-off tabs)
NAVY = "#1f3a60"
GREEN = "#1f7a44"
SLATE = "#33507a" # the mark's non-TF cells, as in static/tf-lockup-og
RED = "#c0392b" # the site's map-slider red
INK = "#1a1a1a"
GREY = "#6b7280" # the muted "(done fairly)" subtitle
CREAM = "#f7efe1" # warm paper, carried over from the original poster
RULE = "#d9cbb4"
POSTERS = [
{"slug": "west-ryde-poster", "suburb": "West Ryde", "code": "WR2114"},
{"slug": "meadowbank-poster", "suburb": "Meadowbank", "code": "MB2114"},
]
# The 2x2 "confusion matrix" mark, in the same colours as static/tf-lockup-og:
# a navy plate with three slate cells and the TF cell picked out in green. The
# cell labels are dropped — at 14mm they'd be sub-millimetre and print as mud.
LOGO_SVG = f""""""
FAMILY_BULLETS = f"""
Search & message tutors for free. Primary, NAPLAN, OC,
Selective, HSC; by subject, level and suburb.
One ${FEE} connection fee, only when you want a tutor's contact
details. No subscription.
Tutors are all ID and Qualification verified, with WWCC where
appropriate.
One free connection on us with the code below, plus one more for
every friend you refer. They stack.
"""
TUTOR_BULLETS = """
List your ad free and keep 100% of your lesson fees. We
never take a cut of what you teach.
Get found by families near you — search, map and suburb pages.
Earn trust with verified marks, ID and WWCC badges on your ad.
Refer a tutor and you both get a free week of Featured
placement. Weeks stack.
"""
def qr_svg(data, colour=NAVY, label=""):
"""A QR code as a compact, scalable inline SVG (one path, runs of dark modules
merged per row). No border — the surrounding CSS supplies the quiet zone."""
qr = segno.make(data, error="m")
matrix = list(qr.matrix)
n = len(matrix)
parts = []
for y, row in enumerate(matrix):
x = 0
while x < n:
if row[x]:
run = 0
while x + run < n and row[x + run]:
run += 1
parts.append(f"M{x} {y}h{run}v1h-{run}z")
x += run
else:
x += 1
aria = f' role="img" aria-label="{label}"' if label else ' aria-hidden="true"'
return (f'')
TEMPLATE = Template("""
tutorsfirst — $suburb poster ($code, $orient_name)
$logo
tutorsfirst
An efficient & fair marketplace
tutorsfirst.com.au
Tutoring in $suburb(done fairly)
Browse local, verified tutors for free. Tutors keep 100% of what they earn.
No subscriptions, no lesson commissions.
Looking for a tutor
$family_bullets
$qr_family
Scan to register
as a student or parent — the code is applied for you.
Are you a tutor?
$tutor_bullets
$qr_tutor
Scan to list
your tutoring — free, reviewed within a business day.
Use code
$code
Students & parents: your first connection is free. Tutors: a free week of Featured placement once you're approved.
$tabs
""")
# Per-orientation geometry. Landscape sits the two audience cards side by side;
# portrait stacks them and drops the headline kicker onto its own flush-right line.
ORIENTATIONS = {
"landscape": dict(
suffix="", orient_css="landscape", orient_name="landscape",
sheet_w="297mm", sheet_h="210mm", pad="10mm",
wordmark_fs="30pt", h1_fs="33pt", li_fs="11.5pt", qr_size="23mm",
code_fs="30pt", cols_tpl="1fr 1fr", cols_gap="5mm", n_tabs=14,
kicker_fs="15pt",
tabs_h="30mm",
h1_flow="align-items: baseline; justify-content: space-between;",
kicker_align="",
),
"portrait": dict(
suffix="-portrait", orient_css="portrait", orient_name="portrait (vertical)",
sheet_w="210mm", sheet_h="297mm", pad="12mm",
wordmark_fs="27pt", h1_fs="31pt", li_fs="11.5pt", qr_size="23mm",
code_fs="32pt", cols_tpl="1fr", cols_gap="5mm", n_tabs=10,
kicker_fs="15pt",
tabs_h="28mm",
# Portrait is too narrow to sit the aside beside the suburb, so it drops to
# its own line, flush right. (Landscape shares the headline's baseline.)
h1_flow="flex-direction: column;",
kicker_align="align-self: flex-end;",
),
}
def build(poster, orient):
o = ORIENTATIONS[orient]
code, suburb = poster["code"], poster["suburb"]
tab = (f'
{code}'
f'tutorsfirst.com.au
')
html = TEMPLATE.substitute(
suburb=suburb, code=code, logo=LOGO_SVG,
navy=NAVY, green=GREEN, ink=INK, cream=CREAM, rule=RULE,
red=RED, grey=GREY, slate=SLATE,
family_bullets=FAMILY_BULLETS, tutor_bullets=TUTOR_BULLETS,
qr_family=qr_svg(f"{BASE}/accounts/signup/student/?ref={code}",
label=f"Register as a student or parent with code {code}"),
qr_tutor=qr_svg(f"{BASE}/accounts/signup/tutor/?ref={code}",
label=f"List as a tutor with code {code}"),
tabs="\n ".join([tab] * o["n_tabs"]),
**{k: v for k, v in o.items() if k not in ("suffix", "n_tabs")},
)
path = OUT_DIR / f"{poster['slug']}{o['suffix']}.html"
path.write_text(html, encoding="utf-8")
return path
# Headless Chrome is the only renderer here that honours @page + print colours,
# so the PDFs are produced from the very same HTML rather than a parallel layout.
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
]
def _chrome():
import shutil
for c in CHROME_CANDIDATES:
if pathlib.Path(c).exists():
return c
return shutil.which("google-chrome") or shutil.which("chromium")
def to_pdf(html_path):
"""Print a poster to PDF beside its HTML. Returns None if Chrome isn't around."""
import subprocess
chrome = _chrome()
if chrome is None:
return None
pdf_path = html_path.with_suffix(".pdf")
subprocess.run(
[chrome, "--headless", "--disable-gpu", "--no-sandbox",
"--no-pdf-header-footer", f"--print-to-pdf={pdf_path}",
html_path.as_uri()],
check=True, capture_output=True)
return pdf_path
if __name__ == "__main__":
import sys
want_pdf = "--no-pdf" not in sys.argv
for p in POSTERS:
for orient in ORIENTATIONS:
path = build(p, orient)
print("wrote", path)
if want_pdf:
pdf = to_pdf(path)
print("wrote", pdf) if pdf else print(
" (skipped PDF — no Chrome/Chromium found)")