---
name: flyer-print-pipeline
description: Build single-page print-ready marketing flyers as HTML/CSS rendered to PDF via weasyprint on the VPS — per-client brand scraping (logo/colors/fonts), page-fit discipline, signature/offer blocks, and Drive delivery. Trigger when producing printed sales collateral (flyers, one-pagers, leave-behinds) for local-business walk-ins or any client-facing print piece. For the bail-bonds sales content itself load bail-bonds-vertical-playbook; for Drive mechanics load composio-mcp-ops.
---

# Flyer Print Pipeline — HTML/CSS → PDF for local-business collateral

The working system built for the bail-bonds walk-in flyers (2026-08-08). Generalizes to any printed one-pager RRR produces for a local business.

## When to load

- Building or editing print flyers/one-pagers/leave-behinds for local businesses
- Per-client personalized collateral (their logo, their colors, their name on the page)
- Any "render HTML to PDF and get it to Rob" task

## The stack (verified working on the VPS)

- **Renderer:** `/root/.hermes/venv/bin/weasyprint` (in the hermes venv; pymupdf is also there for page-count/preview checks)
- **Preview loop:** `pymupdf` render page to PNG at 110–120 dpi → `vision_analyze` to verify layout before shipping
- **Fonts available:** Liberation Sans/Serif, DejaVu. Use `Liberation Serif` for Georgia-ish mastheads, `Liberation Sans` for Arial-ish body. Do NOT spec Google Fonts for print — embed-risk + render fragility; system stack only.
- **Delivery (codified 2026-08-09 — the folder method is the default):** create a **dedicated Drive folder** per campaign (`GOOGLEDRIVE_CREATE_FOLDER`, e.g. "Bail Bonds — Premium Line Flyers"), then upload each flyer as an **individual PDF with `parent_id` set to the folder id** (`GOOGLEDRIVE_UPLOAD_FROM_URL`). Hand Rob the folder link. Rationale: zips force Drive's extraction prompt or a download step; individual PDFs in a folder open in one click and print straight from the browser. Number the filenames in walk order (flyer-01-, flyer-02-...). Also upload supporting docs (dossier PDF, call-log CSV) as separate top-level files. Keep the zip ONLY as an archival/backup artifact, never the primary delivery.
- Mechanics per upload: nginx /stage/ block + GOOGLEDRIVE_UPLOAD_FROM_URL per composio-mcp-ops (helper: `python3 /tmp/nginx-stage-block.py add|remove`; stage dir is `/var/www/html/stage/` — create it if missing, the helper only edits nginx). Verify bytes match, remove staged files + block after. Multi-upload in batches of 3 per COMPOSIO_MULTI_EXECUTE_TOOL call. Google Sheets toolkit is NOT connected (Drive only) — deliver spreadsheets as CSV on Drive (opens in Sheets, one-click convert).

## Per-client brand scraping (the personalization system)

One template, personalized ink — the flyer carries the CLIENT's brand so it reads as built-for-them:

1. `curl` their homepage with a browser UA; grep for logo img tags, Google Fonts links, and hex colors (`grep -oP '#[0-9a-fA-F]{6}' | sort | uniq -c | sort -rn` — the dominant brand color surfaces at the top of the count).
2. Header logo gotcha that cost a full iteration: **check the image's real aspect ratio before sizing it.** Site logos are often exported on padded square canvases (ADAN's "logo" was 512×512 with the actual artwork at 468×118 inside transparent padding). Displaying the square at a wide width makes the header 3× too tall and silently breaks page fit. Fix: PIL `getbbox()` crop to the alpha bounding box, save a `-tight.png`, use that.
3. White-on-transparent header logos (built for dark site headers) won't print — look for the dark variant (favicon/app-icon PNGs are often the dark version).
4. **Scrape-resilience order (2026-08-08 pour):** plain browser-UA curl gets 403/blocked on some small-business hosts — escalate to `User-Agent: Googlebot` (bypasses most hotlink/403 protection); tolerate expired SSL certs with verification off when the target is just an image asset; if the "logo" turns out to be an unusable graphic (Mary Ellen's site logo = a bare Statue-of-Liberty photo, no wordmark) or the site is down entirely (Rapid Release was SUSPENDED — which is itself pitch intel; UPDATE 2026-08-10: Rob's 3:11 a.m. test call got a disconnected number → agency CONFIRMED CLOSED, dropped from the v3 batch entirely — dead prospects leave the route, not just the logo slot), fall back to a styled-text wordmark in their brand color so the layout system stays consistent.
5. Hybrid color discipline — ⚠️ UPDATED 2026-08-08 (v9): the final ADAN flyer went TWO-COLOR (client blue + navy) — Rob killed ALL amber mid-revision ("change all orange text color to the blue that is the check box blue"). The amber-does-selling/brand-does-belonging split was the EARLIER scheme; the landed system is: client brand blue = the energy/action color (demo number, payoff box, scoreboard, "Free"s, UNCLAIMED), navy = structure/seriousness (masthead, offer border, payoff intro). Present both options for future verticals, but know his revealed preference: the client's own color doing the selling makes the flyer feel like it was born in their office.

## Page-fit discipline (hard-won, in order)

A content-rich one-pager (offer block + signature lines) takes ~4 compression passes. Work this order, cheapest first:

1. Margins: `@page { size: letter; margin: 0.33in 0.55in; }` is about the floor.
2. Section margins/paddings before font sizes.
3. Body font floor: ~9.4pt / line-height 1.18 — below that it stops reading as a flyer.
4. Structural wins beat shrinkage: flatten bullet lists to single lines (a 4-item list → one "a · b · c · d" line saves ~30pt), merge footer contact into the bio line.
5. **`page-break-inside: avoid` on the offer/signature block is mandatory** — without it the scoreboard checkbox splits from its labels across the break. Side effect: the whole block jumps to page 2 if it doesn't fit whole; measure free space vs block height instead of guessing (`pymupdf get_text('blocks')` y-coords: page height 792pt, check p1 content bottom vs remaining).
5b. **weasyprint table-cell `padding-bottom` is ignored** — for vertical separation between signature rows use an explicit spacer row (`<tr><td colspan="2" style="height:22px"></td></tr>`), not cell padding. Verified: padding change produced zero y-shift; spacer row produced the gap.
6. Verify final: page count == 1, then vision-check the render for clipped/overlapping text before delivering.

## Copy rules Rob enforced (print collateral for trade/local-business owners)

- **No internal SKU names on the page.** ("Missed Call Only Agent" died; product = "The Premium Line.") If we'd say it to each other but not to the client's face, it doesn't print.
- **Match the trade's word for their business** — bail bondsmen say "agency" or "office," NEVER "shop" (Rob's correction, verified against industry usage). Scrape this per vertical before printing.
- **No behavior claims we haven't verified.** "Goes to voicemail" died because caller behavior is hang-up-and-dial-next, documented in the clients' own Google reviews. Print evidence, not assumptions.
- **The witnessed field report beats adjectives.** Rob cold-called the targets at 5 a.m. and printed what he heard (a "we are closed" recording, a 3-minute ukulele hold). Dated, specific, unanswerable. Build the Problem section around it.
- **But field evidence needs a physics + context check before print.** Two corrections this session: (1) a glowing review anecdote ("bonded out in under an hour, 3 hours away") was physically impossible by car — it was e-filed, and the geography-based hook built on it died. Check distance/time/feasibility on any load-bearing story. (2) A 5 a.m. test call result (3-min hold, human answered) only meant something once cross-checked against the target's site — the same phone number on "Call Now" AND "Text Now" revealed single-cell operation, and the hold-music pattern exposed a paid answering service, not the owner. Reinterpret raw evidence against the target's own site before it becomes a printed claim; the same observation can flip a prospect's whole profile (303 went from "tiny solo" to "growth-stage, 2 offices" on that one check).
- **Don't teach them their own math.** The pricing/ROI block is a two-liner that respects their expertise, not a spreadsheet lecture.
- **The flyer can BE the contract stub (v2) or the QR bridge (v3):** v2 flyers carried the scoreboard checkbox + signature/date rows for BOTH parties (print 2 copies — one signed leave-behind showing ☑ CLAIMED, one for Rob's records). v3 (2026-08-10) RETIRED the signature table — signup moved to the landing page (go.realresultsready.com/bail-bonds-page); the offer block now carries a QR code + "Claim Brighton online — right now" CTA instead. Keep the scoreboard + UNCLAIMED visual either way — it's the scarcity device. Pick v2 vs v3 per whether signup happens in the room (pen) or online (QR).
- Triple-emphasis (`<b><i><u>`) is Rob's house style for the single load-bearing phrase per page ("leave a message", "sets the phone down!").
- **No jargon with Rob.** He asked what "lede" meant mid-session (2026-08-10) — say "opening paragraph" instead. Same rule as collateral copy: if we wouldn't say it to the client's face, don't say it to Rob.

## The v3 batch system (2026-08-10 — QR + personalized field reports)

The second-generation bail flyer run (5 flyers, walk-in day). Learnings:

1. **Build ONE test flyer first, get Rob's go, then batch.** Rob explicitly asked for this ("build on Lucky Lucero's as a test, before expanding"). Don't pour all N variants before the first one is approved — structural changes (QR block, sig-table removal) are batch-cheap, but a rejected layout is expensive to redo N times.
2. **Field-report personalization rule (Rob's call):** each flyer's Field Report leads with THAT agency's own test-call result ("Lucky Lucero's (your line) — 3:07 a.m., 5 rings, then voicemail"), then ADAN's "we are closed" recording as entry #2 on EVERY flyer (it's the 800-lb-gorilla hypocrisy anchor), then a third agency as filler. The flyer recipient sees their own failure first, then the market leader's.
3. **QR code mechanics:** `qrcode` pip package (install into hermes venv: `/root/.hermes/venv/bin/pip install qrcode[pil]`), generate at box_size=10, border=1 → 350×350 PNG, embed at ~1.05in square in the offer block via a table-cell row (QR left, "Claim [Town] online — right now" + typed URL right). Always print the URL in text next to the QR — some owners will type, not scan.
4. **Footer-spill compression recipe (verified on 2 flyers this run):** when only the footer lands on page 2 (content bottom ~725-730/770), the winning pass is: body 9.2→9.0pt, line-height 1.16→1.14, @page margin 0.33in→0.3in, stack li 9.6→9.2pt + padding 3px→2px, offer-block padding 5px/6px→4px/5px, footer .about 7.4→7.0pt + margins tightened. For a LONGER opening paragraph (Mary Ellen rewrite), additionally drop .lede 10.5→10pt and field-report font 8.1→7.8pt. Measure with pymupdf blocks y-coords after each pass — don't guess.
5. **Per-flyer batch variables** (the full swap list): title tag, --their-blue + --tint vars, logo file + width, header stat lines (rating/reviews/address), salutation names, opening paragraph, field-report entry #1, guarantee bucket number, sig/QR label text. Grep the template for each before rendering — a stale variable (e.g. ADAN's title on every flyer) is the classic batch bug.

## Reference: the bail flyer session

Full decision log (18 items, all closed) at `/root/.hermes/work/ghl/FLYER-REBUILD-PUNCHLIST.md`; ADAN master HTML at `/root/.hermes/work/ghl/flyers/html/flyer-adan.html` (use as the pour template for the remaining 5 agencies: swap brand color var, tight logo, salutation names, guarantee bucket, personal hook).

**`references/bail-vertical-2026-08-08.md`** — the offer model (locked name since 2026-08-09: **"One Town. One Agency."** — formerly "Solo"/"Brighton Solo"), vocabulary corrections ("agency" never "shop", "next listing in Google"), ADAN org-chart intel + brand assets, the "sets the phone down" payoff line, and the client-build notes (junk-call filter, no IVR gate, conditional forwarding). The bail playbook and ghl-voice-agent-build skills were patch-blocked (manually-authored guard) at update time; where they disagree with this reference, the reference wins.

**Satellite-listing sweep (v8 lesson):** for any multi-office prospect, search "[shop] + [other metro cities]" for satellite GBP listings before trusting review counts or volume buckets — 303's Brighton listing showed 15 reviews; her Greenwood Village listing shows 423 (off by 28x). One sweep pass also surfaced Mary Ellen's Pollack-family + Dog the Bounty Hunter TV credit and M&M owner Marcia Carden.
