Build premier landscape PDF proposals for Metis Strategy business development. Use whenever the user asks to create, build, draft, rebuild, refine, or iterate on a proposal, BD follow-up document, pitch document, or client-facing document to be sent to an external prospect after a discovery call. Output is a 16:9 landscape PDF (13.33" x 7.5") combining full-bleed photography, branded graphic devices, and coordinate-based ReportLab layout. Do NOT use for PowerPoint decks (use metis-pptx), whitepapers (use metis-whitepaper), one-pagers or internal reports (use metis-pdf-creator), or SOWs/MSAs (use metis-legal-drafting).
94
94%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
This document captures the core technical decisions of the proposal PDF skill and the empirical reasoning behind each. Every decision here was learned the hard way during the Citi CRS Proposal engagement.
HTML + CSS + Playwright. The assumption was that a web layout engine could produce a fixed-page proposal.
Vertical spacing was chronically broken. Over five iterations of the Citi proposal, content consistently crowded at the top of each page with 100–300pt of dead whitespace at the bottom. Multiple attempted fixes failed:
display: flex; flex-direction: column; justify-content: space-between — works in browsers, unreliable in Playwright's print renderer when child heights are variableflex: 1 on inner grids — does not force child cells to growmin-height on containers — adds height without distributing contentflex: 1 — works on some pages, not others, depends on nesting depthThe root cause is that CSS was designed for reflowable web content, not fixed-page documents. Flexbox distributes free space based on flex factors, but only works reliably when:
position: absolute breaks out of normal flowIn a proposal PDF, you have mixed content types (images, text blocks, callouts, grids) with variable heights, and the "right" distribution is not mechanical — it is design judgment. CSS cannot encode that judgment.
ReportLab is a PDF-native library where every element is placed at an explicit (x, y) coordinate. There is no layout engine, no reflow, no "what should the browser do?" Every coordinate is a decision you made.
Two capabilities make the measure-first pattern possible:
Paragraph.wrap(width, height) — returns the actual wrapped height of a text block before you draw it. You can size containers based on measured content.canvas.stringWidth(text, font, size) — returns the pixel width of a string. You can auto-shrink fonts to fit cards.With these two primitives you can write code that predicts the final layout before drawing anything, then computes positions that eliminate both overflow and whitespace.
metis-whitepaper)For proposals, executive briefs, and sales decks where every page is a hand-designed artifact, use ReportLab.
# Set container height first, then pour content in
card_h = (available_height - gutters) / rows
for item in items:
p = Paragraph(item, style)
p.wrap(card_w, 300) # measure AFTER sizing
p.drawOn(c, x, y) # may overflow cardWhat breaks:
# Measure all content first, size container to fit tallest
heights = []
for item in items:
p = Paragraph(item, style)
pw, ph = p.wrap(card_w, 500) # measure FIRST
heights.append(ph + padding)
card_h = max(heights) # size AFTER measuring
# Now place cards at calculated positions
for i, item in enumerate(items):
cy = top_y - (i + 1) * card_h - i * gap
draw_card(item, cx, cy, card_w, card_h)Why it works:
Apply measure-first to:
item_gap to fill vertical space)distribute_y_positions to spread evenly)Helper functions in scripts/helpers.py:
measure_bullet_list(items, width, ...) — returns total height without drawingmeasure_body_text(text, width, ...) — returns wrapped heightmeasure_callout_height(text, width, ...) — predicts callout box heightdistribute_y_positions(top_y, bottom_y, block_heights, min_gap) — even distributionfitted_card(x, y_top, w, title, body, ...) — card sized to measured contentstringWidth for font auto-scalingBig numbers on metric cards have variable width ("3" vs "Hyper Personalization"). Without auto-scaling, long strings overflow the card.
def metric_card(x, y, w, h, number, label):
num_str = str(number)
inner_w = w - 16
num_font_size = 24
while num_font_size > 11:
text_w = c.stringWidth(num_str, 'Calibri-Bold', num_font_size)
if text_w <= inner_w:
break
num_font_size -= 1
c.setFont('Calibri-Bold', num_font_size)
c.drawCentredString(x + w/2, y + h - 8 - num_font_size, num_str)Every component that accepts user-authored text should be robust to length variance.
During the Citi build, the user (Andrew) wanted to tweak copy throughout the document. Every change required editing Python strings, which is:
Separate files:
<project>-copy.yaml — all editable text, structured as page_N_<slug>: { eyebrow, title, subtitle, activities, deliverables, callouts, metrics }build_proposal.py — imports yaml, loads the file at the top, references COPY['page_N_slug']['eyebrow'] everywhere<b>text</b> HTML tags inside strings. ReportLab's Paragraph renders these.content-rules.md.page_N_<slug> format. The N makes ordering explicit; the slug makes the key self-documenting.Sample structure:
page_5_module_a_overview:
eyebrow: "MODULE A | OPERATING MODEL DESIGN"
title: "Product Operating Model for CRS"
subtitle: "Define the target-state operating model..."
activities:
- "First activity..."
- "<b>Bold emphasis</b> on key phrase..."
deliverables:
- "First deliverable..."
callouts:
- label: "KEY INSIGHT"
body: "The operating model is the unlock..."At the top of the build script:
import yaml
COPY_PATH = "G:/My Drive/Vault/_working/<project>/<project>-copy.yaml"
with open(COPY_PATH, 'r', encoding='utf-8') as _f:
COPY = yaml.safe_load(_f)Then in each page builder:
_p5 = COPY['page_5_module_a_overview']
activities_deliverables_page(
_p5['eyebrow'], _p5['title'], _p5['subtitle'],
_p5['activities'], _p5['deliverables'],
bottom_callouts=[(c['label'], c['body']) for c in _p5['callouts']],
)Pattern: load once, reference by structured path.
The vault's global CLAUDE.md enforces this, but it matters here specifically:
G:\My Drive\Vault\_working\<project>\G:\Shared drives\BizDev Collaboration\<Client>\<Year>\Why: Shared drives represent the firm's finished work. Intermediate artifacts are clutter and confuse other consultants who see them. Clients occasionally get access to shared drive folders — unpacked source files are embarrassing to be found.
Additional rule: The build script should write its PDF output to the _working folder first, then the deploy step copies to the shared drive. If the shared drive copy is locked (user has the PDF open), the build still succeeds.
Bbox analysis catches overlaps but misses:
The rule: after every build, render each meaningful page to PNG at 1.5x or 2x zoom and use the Read tool to view it. This takes 30 seconds and catches issues that cost hours to discover after deployment.
Implementation in scripts/verify.py:
for idx, label in problem_pages:
pix = doc[idx].get_pixmap(matrix=fitz.Matrix(1.5, 1.5))
pix.save(f'{out_dir}/{label}.png')Then read each PNG and assess. Do not declare done without this step.