
Create Pdf
- 1 installs
- 18 repo stars
- Updated July 28, 2026
- aws-samples/sample-ai-assistant-on-agentcore
create-pdf is a Claude skill that generates polished, themed PDF reports using a PDFReport helper class and matplotlib with a defined modern design system.
About
This skill provides guidelines for creating polished, themed PDF reports using a PDFReport helper class and matplotlib. A developer or agent uses it when generating long-form PDF reports that need consistent design, charts, and readable typography. It defines a design system covering color distribution, whitespace, chart styling, and WCAG contrast, plus reference files for components, charts, and technical troubleshooting.
- Creates polished themed PDF reports via a PDFReport helper and matplotlib
- Encodes a modern design system: 60/30/10 color, two font weights, one visual motif
- Includes WCAG contrast minimums and reference files for components, charts, and troubleshooting
Create Pdf by the numbers
- 1 all-time installs (skills.sh)
- Ranked #565 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
create-pdf capabilities & compatibility
- Capabilities
- presentations · documentation
- Use cases
- presentations · documentation · pdf parsing
- Pricing
- Free
What create-pdf says it does
Guidelines for creating polished, themed PDF reports with the code execution tool
Guidelines for creating professional, visually distinctive PDF reports via python-pptx's `PDFReport` helper class and matplotlib.
60/30/10 color distribution.
npx skills add https://github.com/aws-samples/sample-ai-assistant-on-agentcore --skill create-pdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 18 |
| Last updated | July 28, 2026 |
| Repository | aws-samples/sample-ai-assistant-on-agentcore ↗ |
What it does
Generate polished, themed PDF reports with charts and a consistent modern design system using a PDFReport helper and matplotlib.
Who is it for?
Developers or agents generating themed, long-form PDF reports with charts.
When should I use this skill?
When creating polished, themed PDF reports or generating report documents with the code execution tool.
What you get
A polished long-form PDF report with themed styling, harmonized charts, and WCAG-compliant contrast.
- themed PDF report
- harmonized matplotlib charts
By the numbers
- 60/30/10 color distribution rule
- two font weights only
- 3 reference files (components, charts, technical)
Files
Guidelines for creating professional, visually distinctive PDF reports via python-pptx's PDFReport helper class and matplotlib.
The design language borrows from modern presentation aesthetics — clean, spacious, intentional — adapted for the long-form reading experience. Light body pages for readability, bold title treatment for impact, and strategic color throughout.
Reference Files
Read these from /tmp/skills/pdf/references/ when you need implementation details.
| File | When to load | Contents |
|---|---|---|
components.md | Before building the report | Helper method reference, code block implementation, custom component patterns, visual motif ideas |
charts.md | Before creating any chart | Matplotlib styling, palette integration, chart type selection, chart + context composition |
technical.md | When troubleshooting or building custom layouts | Page-break safety, write/multi_cell pitfalls, FPDF encoding, common bugs |
---
1. Design Philosophy
Every element earns its place on the page. If it doesn't communicate, clarify, or guide the eye — remove it.
1.1 Core Principles
Content is king. Typography, spacing, and strategic color create hierarchy. Decoration is the enemy. A well-structured report with generous whitespace communicates more authority than one stuffed with borders and ornaments.
Light body, bold accents. Reports are read at length — light backgrounds with dark text for body content. Save bold, dark treatment for the title page and section openers. The title page sets the mood; body pages prioritize readability.
60/30/10 color distribution. 60% neutral (white page background, dark body text), 30% complementary (card/callout fills, section header colors, table header bands), 10% accent (KPI values, chart highlights, key stats, accent bars on callout boxes). If accent color covers more than ~10% of a page's visual area, pull back.
Topic-driven palette. The palette should feel designed for THIS report's subject. If swapping colors into a completely different report would still "work," the choices aren't specific enough. One color dominates (titles, section headers), 1–2 supporting tones for callout fills and tables, one accent for highlights.
Generous whitespace. Let content breathe. When a section feels cramped, split it across two pages rather than shrinking fonts. Space between sections signals confidence, not waste.
Two font weights only. Regular for body, Bold for headings and emphasis. Helvetica covers both. Reserve Italic for quotes and captions only — it's a third voice, use it sparingly.
Flat and clean. No heavy borders, drop shadows, 3D chart effects, or decorative clip art. Subtle background fills, minimal or no borders, and clean lines. Hierarchy comes from size, weight, color, and spatial position — in that order.
Visual motif. Commit to ONE distinctive element and carry it through every page: a colored left-margin bar on callout boxes, accent lines under headings, numbered circle badges for key items, or a consistent card styling. One motif, applied everywhere, creates more design coherence than multiple decorative ideas applied inconsistently.
Layout variety. No two consecutive sections should use the same visual format. Rotate through prose, callout boxes, charts, tables, KPI strips, quotes, and numbered/labeled items. Plan the visual treatment for each section before writing code.
1.2 What Looks Outdated
| Outdated | Modern replacement |
|---|---|
| Heavy borders around every element | Subtle background fills, minimal or no borders |
| Decorative clip art or icons | Purposeful whitespace |
| Underlined headings | Size + color contrast + spacing below |
| Multiple font families (3+) | Max 2: Helvetica for everything, or pair with one serif for titles |
| Centered body text | Left-aligned body; center only title page and captions |
| Dense walls of text with no breaks | Whitespace between paragraphs, callout boxes, charts |
| Rainbow color schemes | Topic-driven palette: 1 dominant + 1–2 supporting |
| 3D chart effects, gradient fills on charts | Flat, clean chart style with palette-harmonized colors |
| Same layout pattern every section | Rotate through the helper methods |
| Drop shadows on boxes | Flat fill or very subtle border only |
1.3 Accessibility & Contrast
WCAG minimums: Body text (<18pt): 4.5:1 contrast ratio. Headings (≥18pt or ≥14pt bold): 3:1.
Safe on white/light: #000000, #333333, #1B2A4A, #4A4A4A. On dark backgrounds (title page): #FFFFFF, #E0E0E0.
Avoid: light gray text on white, pastel text on light backgrounds, red (#FF0000) on white (only 4:1 — fails for small text). Never use color as the sole information channel.
---
2. Theme & Color System
2.1 Built-In Themes
Each theme defines title/heading color, accent color, card/callout background, and body text. Apply via pdf.set_theme('name').
| Theme | Title | Accent | Card BG | Best for |
|---|---|---|---|---|
| midnight | #1E2761 | #CADCFC | #EDF1FA | Executive, strategy |
| forest | #2C5F2D | #97BC62 | #F0F5E8 | Sustainability, nature |
| coral | #F96167 | #2F3C7E | #FFF5F0 | Creative, marketing |
| terracotta | #B85042 | #A7BEAE | #F5F0ED | Architecture, culture |
| ocean | #065A82 | #1C7293 | #E8F2F7 | Technology, research |
| charcoal | #36454F | #212121 | #F2F2F2 | Minimal, professional |
| teal | #028090 | #02C39A | #E6F5F2 | Healthcare, fintech |
| berry | #6D2E46 | #A26769 | #F5EDE8 | Fashion, premium |
| sage | #84B59F | #50808E | #EDF3F0 | Wellness, consulting |
| cherry | #990011 | #2F3C7E | #FFF5F5 | Impact, bold analysis |
2.2 Custom Palettes
For subjects that don't fit a built-in theme, set colors directly:
pdf.COLORS['title'] = (30, 39, 97)
pdf.COLORS['accent'] = (100, 140, 200)
pdf.COLORS['card_bg'] = (240, 244, 248)
pdf.COLORS['body'] = (51, 51, 51)
pdf.COLORS['border'] = (200, 210, 220)Derive card_bg from the title color at ~5–10% opacity over white. The accent should complement but not match the title color — a different hue or a lighter/brighter version of the same family.
2.3 Chart Palette Integration
Always harmonize charts with the report palette:
pal = pdf.get_chart_palette()
# pal['primary'] — hex, main chart color
# pal['secondary'] — hex, accent chart color
# pal['series'] — list of 6 harmonized hex colors
# pal['title_hex'] — for chart titles
# pal['body_hex'] — for axis labels
# pal['card_bg_hex'] — for chart background fillsPass pal['series'] to bar colors, line colors, pie slices, etc. Charts that use default matplotlib colors instead of the report palette look disconnected.
---
3. Script Toolkit
3.1 Loading
import sys, importlib
sys.path.insert(0, '/tmp/skills/pdf/scripts')
from pdf_helpers import PDFReport, THEMES, render_formula3.2 Initialization
pdf = PDFReport(orientation='P', unit='mm', format='A4')
pdf.set_auto_page_break(auto=True, margin=25)
pdf.set_margins(left=20, top=20, right=20)
pdf.set_theme('ocean')
pdf.alias_nb_pages()3.3 Helper Method Overview
| Method | Purpose |
|---|---|
set_theme(name) | Apply a named palette |
get_chart_palette(n=6) | Theme-harmonized hex colors for matplotlib |
ensure_space(min_mm) | Page break if not enough room |
add_title_page(title, subtitle, author, date, org) | Dark header-band title page |
add_section(title) | Section heading with accent underline |
add_subsection(title) | Smaller sub-heading |
add_body(text) | Standard body paragraph |
add_callout_box(text, fill_color, min_height) | Tinted box with left accent bar |
add_quote_strip(text, attribution) | Centered italic band |
add_kpi_strip(kpis, cols) | Row of stat cards |
add_indented_rich_text(indent_x, bold_text, regular_text, ...) | Bold + regular inline at indent |
add_labeled_item(indent_x, indicator_color, label, description) | Color bar + label/description |
add_numbered_item(indent_x, number, badge_color, label, description) | Badge + label/description |
add_chart(img_path, caption, width) | Centered image + sequential caption |
add_table(headers, rows, col_widths, row_height) | Simple table (clips long text) |
add_table_wrapped(headers, rows, col_widths, line_height, padding) | Table with multi-line wrapping |
add_formula(img_path, max_w) | Formula image in tinted strip |
For detailed signatures, usage notes, and the add_code_block() standalone function, see references/components.md.
3.4 Font Size Guide
| Element | Size | Weight |
|---|---|---|
| Report title (title page) | 26–32pt | Bold |
| Section headings | 16–20pt | Bold |
| Sub-headings | 13–15pt | Bold |
| Body text | 10–12pt (11pt recommended) | Regular |
| Captions | 8–10pt | Italic |
| Table text | 9–11pt | Regular |
| KPI values | 24–30pt | Bold, in accent color |
| KPI labels | 9–10pt | Regular, muted |
Use Helvetica (built into fpdf2). One font family is enough — hierarchy comes from size and weight.
---
4. Report Planning & Structure
4.1 Before Writing Code
Clarify purpose (informational, analytical, persuasive, status update) and audience (executives want brevity and conclusions first, technical readers want methodology and detail). Outline the sections, decide which visual treatment each gets (prose, callout, chart, table, KPI strip, badges), and plan a target page count.
4.2 Recommended Section Order
1. Title Page — add_title_page() with dark header band, subtitle, author, date 2. Executive Summary — Purpose, key findings, conclusions, recommendations. Write LAST, place FIRST. 3. Introduction / Background — Context, scope, objectives 4. Methodology (if applicable) — Data gathering approach, kept brief 5. Findings / Analysis — Core content with charts, organized by theme/chronology/priority 6. Discussion (if applicable) — Interpret findings, address limitations 7. Conclusions — Main takeaways 8. Recommendations (if applicable) — Specific, actionable next steps 9. Appendix / References (if applicable)
Lead with conclusions (inverted pyramid). One idea per section. Executive summary should stand alone at ~10–15% of total text content.
4.3 Special Report Types
Executive / Status: 3–5 sections, lead with KPI strip, outcomes and next steps. Keep it short.
Analytical / Research: Detailed, includes methodology and discussion, line charts and tables, addresses limitations.
Comparison / Benchmark: Structured around dimensions, uses grouped bar/radar charts, ends with clear recommendation.
Incident / Post-Mortem: Chronological, factual, blameless tone.
---
5. Writing Style
Clear, direct language. Sentences under 25 words, paragraphs under 75 words. Active voice preferred.
Be specific: "Revenue increased 18% YoY to $4.2M" communicates more than "Revenue increased significantly."
Professional, objective tone — confident but measured. Consistent formatting for numbers, currencies, and percentages. Always provide context for numbers: comparisons, benchmarks, or trends.
---
6. Chart Sections — Tell the Story, Not Just the Data
<chart_section_composition> A chart without interpretation is a missed opportunity. Every chart section in a report pairs the visualization with a clear takeaway — the chart is evidence for an insight, and the insight should be stated explicitly.
Every chart section includes:
1. An insight-driven heading — frames what the data reveals. "Revenue Grew 18% as Enterprise Segment Doubled" communicates far more than "Revenue Overview."
2. Brief context — 1–2 sentences before the chart that set up what the reader should look for.
3. The chart itself — styled to match the report palette, saved at dpi=150+, with descriptive axis labels.
4. A "Figure N:" caption — sequentially numbered, placed via add_chart(img_path, caption).
5. A takeaway paragraph after the chart — what the data means, why it matters, what action it implies. This is where the analysis lives.
The pattern is: heading → context sentence → chart → caption → analysis paragraph. A section with just a heading and a chart leaves the reader to draw their own conclusions, which defeats the purpose of the report. </chart_section_composition>
For chart type selection, matplotlib styling, and palette integration details, see references/charts.md.
---
7. Layout & Pagination
7.1 Content Density
One key idea per section. Prefer a single clear chart over three small ones. Generous whitespace between sections. When a section feels cramped, split across two pages.
7.2 Page-Break Safety
Composite elements (callout boxes, KPI strips, charts, tables) that draw background fills at absolute Y positions produce rendering artifacts when multi_cell() triggers an auto page break mid-element. All built-in helpers handle this via ensure_space(). When building custom composite elements, call pdf.ensure_space(estimated_height) before drawing.
For detailed technical rules on page breaks, the write/multi_cell overflow problem, code blocks, and FPDF encoding limitations, see references/technical.md.
7.3 Layout Variety
Plan the visual treatment for each section before writing code. Use this as a rotation guide:
| Section type | Good visual treatment |
|---|---|
| Key findings / executive summary | KPI strip + callout boxes |
| Narrative context | Body prose, maybe a callout for the main point |
| Data analysis | Chart + takeaway paragraph |
| Comparisons | Table or side-by-side chart |
| Steps / process | Numbered items with badges |
| Pros/cons / categories | Labeled items with colored indicators |
| Key quotes / takeaways | Quote strip |
| Code / technical detail | Code block |
No two consecutive sections should use the same format. If the last section was a chart, make the next one a callout box or table.
---
8. QA Checklist
1. Theme chosen — topic-driven palette applied via set_theme() or custom COLORS 2. Chart palette used — get_chart_palette() colors passed to all matplotlib charts 3. Visual motif consistent — same accent treatment on every page 4. Layout variety — no two consecutive sections use the same visual format 5. Title page present with dark header band, title, author, date 6. Executive summary stands alone 7. Logical section flow with coherent narrative arc 8. All data/numbers verified for accuracy 9. Each chart has dpi=150+, palette-harmonized colors, and sequential "Figure N:" caption 10. Every chart section includes context before and takeaway after the chart 11. Consistent fonts, colors, and spacing throughout 12. Tables have themed headers and alternating row shading 13. Page numbers via alias_nb_pages() 14. set_auto_page_break enabled 15. matplotlib.use('Agg') set before pyplot import 16. plt.close() called after each chart save 17. No write() + multi_cell(fixed_width) patterns (use add_indented_rich_text) 18. No manual rect + multi_cell combos without ensure_space() (use built-in helpers) 19. Code/preformatted text uses add_code_block() — never add_body() for multiline code 20. WCAG contrast met — body text ≥4.5:1, headings ≥3:1 21. No outdated patterns — no heavy borders, 3D charts, clip art, rainbow colors 22. File saved to /tmp/ and generate_download_link called
Chart Creation for PDF Reports
1. Chart Section Composition
A chart in a report is evidence for a claim. Every chart section follows this pattern:
Heading → Context → Chart → Caption → Analysis
1. Insight-driven heading: "Revenue Grew 18% as Enterprise Deals Doubled" rather than "Revenue Chart" 2. Context sentence(s): 1–2 lines before the chart that set up what the reader should look for 3. The chart: matplotlib image, palette-harmonized, clean styling 4. Figure caption: sequential numbering via add_chart(path, caption='Figure N: ...') 5. Analysis paragraph: what the data means, why it matters, what action it implies
pdf.add_section('Revenue Performance')
pdf.add_body('After restructuring the sales team in Q2, enterprise revenue began accelerating. '
'The chart below shows quarterly performance by segment.')
pdf.add_chart('/tmp/revenue.png', caption='Figure 1: Revenue by segment, Q1–Q4 2024')
pdf.add_body('Enterprise revenue grew 18% YoY, driven primarily by expansion into the APAC region. '
'SMB remained flat, suggesting the new pricing model has not yet gained traction in that segment.')A section with just a heading and a chart image leaves the reader to draw their own conclusions, which undermines the report's analytical value.
---
2. Choosing the Right Chart Type
| Goal | Chart type | Notes |
|---|---|---|
| Trends over time | Line or Area | ≤5 series for readability |
| Comparing categories | Bar (vertical) | ≤7 categories |
| Proportions | Pie / Donut | ≤5 categories |
| Multi-dimensional comparison | Radar | ≤8 axes |
| Stacked compositions | Stacked bar or area | Parts contributing to totals |
| Ranked values | Horizontal bar | Good for long category labels |
| Distribution | Histogram | Continuous data |
| Correlation | Scatter | Two numeric variables |
When in doubt, use a bar chart — it's the most universally understood.
---
3. Matplotlib Styling for PDF
3.1 Palette Integration
Always use the report's theme colors for charts. Disconnect between chart colors and report colors looks unprofessional.
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
pal = pdf.get_chart_palette()
# pal['primary'] — hex, title/heading color
# pal['secondary'] — hex, accent color
# pal['series'] — list of 6 hex colors, harmonized with theme
# pal['title_hex'] — for chart title text
# pal['body_hex'] — for axis labels and tick text
# pal['card_bg_hex'] — for chart background fill3.2 Clean Chart Template
fig, ax = plt.subplots(figsize=(7, 4))
# Style
ax.set_facecolor('#FFFFFF')
fig.patch.set_facecolor('#FFFFFF')
# Remove unnecessary spines
for sp in ['top', 'right']:
ax.spines[sp].set_visible(False)
for sp in ['left', 'bottom']:
ax.spines[sp].set_color('#CCCCCC')
# Light grid
ax.grid(axis='y', alpha=0.3, color='#CCCCCC')
ax.set_axisbelow(True)
# Tick styling
ax.tick_params(colors='#555555', labelsize=9)
# Title and labels
ax.set_title('Insight-Driven Title', fontsize=13, fontweight='bold',
color=pal['title_hex'], pad=12)
ax.set_ylabel('Y Label', fontsize=10, color='#666666')
# ... plot data using pal['series'] colors ...
plt.tight_layout()
plt.savefig('/tmp/chart.png', dpi=150, bbox_inches='tight', facecolor='#FFFFFF')
plt.close()3.3 Rules
Always call matplotlib.use('Agg') BEFORE importing pyplot. Always plt.close() after saving. Save at dpi=150 or higher. Use figsize=(7, 4) for good A4 fit. Always tight_layout(). No 3D effects.
Descriptive, insight-driven titles on charts. Label axes clearly. Abbreviate long labels ("Jan" not "January", "$4.2M" not "$4,200,000").
3.4 Chart Type Notes
Bar charts: Space bars with width=0.6 or less. Add value labels on top of bars for key values. Use zorder=3 on bars, zorder=0 on grid.
Line charts: Use markers (marker='o', markersize=5) for data points. 2–3 series max for clarity. Add fill_between at low alpha for area emphasis.
Pie/Donut: startangle=90, counterclock=False for consistent orientation. ≤5 slices. For donut: wedgeprops=dict(width=0.4). Use autopct='%1.0f%%'.
Horizontal bar: ax.invert_yaxis() so highest value is at top. Add inline value labels with ax.text().
Stacked bar: Use bottom parameter to stack. Include a clear legend — ax.legend(loc='upper left', frameon=False).
---
4. Legend Placement
Place legends where they don't overlap data.
Preferred (below chart): ax.legend(loc='upper center', bbox_to_anchor=(0.5, -0.12), ncol=3, frameon=False, fontsize=9)
Alternative (right): ax.legend(bbox_to_anchor=(1.02, 1), loc='upper left', frameon=False)
Always frameon=False for clean look.
---
5. Mathematical Formula Rendering
from pdf_helpers import render_formula
render_formula(r'\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}', '/tmp/formula.png')
pdf.add_formula('/tmp/formula.png')Uses Computer Modern font, 15pt, 250 DPI, transparent background by default. Override with fontsize and dpi params. Note: matplotlib's mathtext does NOT support \begin{pmatrix}, \begin{align}, or other LaTeX environments — only inline math expressions.
Component Reference & Visual Patterns
This reference covers the PDFReport helper methods in detail, the standalone add_code_block() function, and custom component patterns for building modern, visually distinctive reports.
---
1. Content Methods — Detailed Reference
add_title_page(title, subtitle="", author="", date="", org="")
Dark header-band title page with white text. The band covers the top ~130mm. Subtitle, author, org, and date are optional — meta fields appear below the band.
pdf.add_title_page(
'Q4 2024 Performance Report',
subtitle='Enterprise Division Analysis',
author='Strategy Team',
date='January 2025'
)add_section(title) / add_subsection(title)
Section: 18pt bold in title color, accent underline from x=20 to x=80. Subsection: 14pt bold, no underline. Both reset text color to body afterward.
Use add_section() for major report divisions. add_subsection() for topics within a section.
add_body(text)
Standard body paragraph, 11pt regular, body color. Uses multi_cell(0, 6, text) — safe for single-block prose. Do NOT use for multiline code (FPDF drops \n in multi_cell).
add_callout_box(text, fill_color=None, min_height=30)
Tinted background box with a 2.5mm accent-colored bar on the left edge. Page-break safe via ensure_space(). Default fill is the theme's card_bg color.
Good for: key findings, important notes, summary takeaways, warnings.
pdf.add_callout_box("Key finding: Customer retention improved 12% after implementing the new onboarding flow.")For a custom fill color (e.g. a warning box):
pdf.add_callout_box("Warning: Data for Q3 is preliminary.", fill_color=(255, 245, 238))add_quote_strip(text, attribution="")
Centered italic text in a full-width tinted band. Optional attribution line in smaller muted text. Good for key quotes, executive summaries, or section-closing takeaways.
pdf.add_quote_strip(
"The best time to plant a tree was twenty years ago. The second best time is now.",
attribution="— Chinese Proverb"
)add_kpi_strip(kpis, cols=None)
Row of stat callout cards. Each card shows a large bold value in title color and a small muted label. cols defaults to the number of KPIs.
pdf.add_kpi_strip([
{'label': 'Revenue', 'value': '$4.2M'},
{'label': 'Growth', 'value': '+18%'},
{'label': 'NPS Score', 'value': '72'},
])Best at the top of a section or right after the executive summary heading. 3–4 KPIs per strip works best; more gets cramped.
add_indented_rich_text(indent_x, bold_text, regular_text, ...)
Bold + regular text inline at an indented position. Uses write() for both parts with a temporary left-margin shift. This is the safe pattern for mixing font weights at an indent — see references/technical.md for why write() + multi_cell() doesn't work.
pdf.add_indented_rich_text(28, "Key Insight: ", "The enterprise segment drove 73% of total growth.")add_labeled_item(indent_x, indicator_color, label, description, ...)
Colored indicator bar (2mm × 6mm) + bold label + regular description. Good for pros/cons, categorized points, or feature lists.
pdf.add_labeled_item(28, (44, 95, 45), "Strength", "Market-leading retention rates in the 25-34 demographic.")
pdf.add_labeled_item(28, (200, 60, 60), "Risk", "Supply chain dependency on a single vendor.")add_numbered_item(indent_x, number, badge_color, label, description, ...)
Numbered badge (8mm × 7mm rectangle with white number) + bold label + description. Good for step-by-step processes, ranked items, or numbered recommendations.
pdf.add_numbered_item(32, 1, (6, 90, 130), "Audit Existing Infrastructure",
"Map all current services and their dependencies before migration planning.")add_chart(img_path, caption="", width=160)
Centers a chart image with optional "Figure N:" caption below. Estimates image height and calls ensure_space(). Default width 160mm (centered on A4).
pdf.add_chart('/tmp/revenue_chart.png', caption='Figure 1: Revenue by segment, Q1-Q4 2024')add_table(headers, rows, col_widths=None, row_height=7)
Themed table with header band in title color, alternating row shading. Uses cell() — long text is clipped, not wrapped. For wrapping, use add_table_wrapped().
pdf.add_table(
headers=['Metric', 'Q3', 'Q4', 'Change'],
rows=[['Revenue', '$3.6M', '$4.2M', '+18%'], ['Users', '12.4K', '14.1K', '+14%']],
col_widths=[50, 35, 35, 30]
)add_table_wrapped(headers, rows, col_widths=None, line_height=5.5, padding=1.5)
Table with automatic multi-line cell wrapping. Row height adjusts to the tallest cell. Use this when any cell might contain more than a few words.
add_formula(img_path, max_w=90)
Embeds a rendered formula image (from render_formula()) in a tinted strip, centered.
---
2. Code Block Implementation
add_code_block() is a standalone function — not built into PDFReport. Paste this into your script and call it on the pdf instance for any code, shell commands, JSON, config files, or preformatted text.
def add_code_block(pdf, code_text: str, font_size: int = 8, line_height: float = 4.5):
"""
Render monospace code with a dark background, preserving all line breaks.
FPDF2 does not respect \\n in multi_cell/write — this function splits on \\n
and renders each line individually with cell(), which is the only safe approach.
"""
lines = code_text.strip('\n').split('\n')
estimated_h = len(lines) * line_height + 6
pdf.ensure_space(estimated_h)
pdf.set_fill_color(30, 41, 59) # dark slate background
pdf.set_text_color(226, 232, 240) # light text
pdf.set_font('Courier', '', font_size)
pdf.ln(1)
for line in lines:
display = line.replace(' ', '\u00a0') # preserve indentation
pdf.cell(0, line_height, display, ln=True, fill=True)
pdf.ln(3)
# Restore body font and color
pdf.set_font('Helvetica', '', 11)
pdf.set_text_color(51, 51, 51)Usage:
add_code_block(pdf, """
def review_router(state):
if state["review_report"].status == "PASS":
return "finalize"
return "builder_agent"
""")Rules:
Do not pass multiline strings to add_body(), write(), or multi_cell() — FPDF2 silently drops all \n characters, collapsing the block into one continuous line. Always use add_code_block() for preformatted content. If a code block exceeds ~40 lines, split into labeled parts rather than letting it run across pages.
---
3. Visual Motif Ideas
Pick ONE motif and apply it consistently across every page. Mixing motifs looks scattered.
Accent-bar callouts (default): The add_callout_box() left-bar pattern. Extend this by using the same left-bar treatment on other custom elements — e.g., adding a 2.5mm accent bar beside important paragraphs or beside chart annotations.
Numbered badges: Use add_numbered_item() for key points, recommendations, or steps. The badge color should match the theme's title color. Creates a strong visual rhythm when 3–5 items appear in sequence.
Indicator-bar categorization: Use add_labeled_item() with consistent color coding (green for strengths, amber for opportunities, red for risks). Apply the same colors in charts and tables.
Accent-underlined sections: The default add_section() draws an accent line under the heading. Carry a similar short accent line under subsections or key terms for consistency.
Full-width tinted bands: Use add_quote_strip() for section-closing takeaways, not just quotes. A tinted band with centered text creates visual punctuation between major sections.
---
4. Custom Component Patterns
When the built-in helpers don't cover what you need, build custom elements using FPDF primitives. Key rules: always call ensure_space() first, always reset font/color after, and keep the styling consistent with the theme.
Custom two-column layout:
pdf.ensure_space(50)
col_w = 80
gap = 10
y0 = pdf.get_y()
# Left column
pdf.set_xy(20, y0)
pdf.set_font('Helvetica', 'B', 14)
pdf.set_text_color(*pdf.COLORS['title'])
pdf.cell(col_w, 8, 'Before', ln=True)
pdf.set_x(20)
pdf.set_font('Helvetica', '', 10)
pdf.set_text_color(*pdf.COLORS['body'])
old_l = pdf.l_margin
pdf.set_left_margin(20)
pdf.multi_cell(col_w, 5.5, 'Description of the before state...')
pdf.set_left_margin(old_l)
# Right column
pdf.set_xy(20 + col_w + gap, y0)
pdf.set_font('Helvetica', 'B', 14)
pdf.set_text_color(*pdf.COLORS['title'])
pdf.cell(col_w, 8, 'After', ln=True)
pdf.set_x(20 + col_w + gap)
pdf.set_font('Helvetica', '', 10)
pdf.set_text_color(*pdf.COLORS['body'])
pdf.set_left_margin(20 + col_w + gap)
pdf.multi_cell(col_w, 5.5, 'Description of the after state...')
pdf.set_left_margin(old_l)
pdf.set_y(max(pdf.get_y(), y0 + 40))Section divider page (for long reports):
pdf.add_page()
pdf.set_fill_color(*pdf.COLORS['title'])
pdf.rect(0, 0, 210, 297, 'F')
pdf.set_y(120)
pdf.set_text_color(255, 255, 255)
pdf.set_font('Helvetica', 'B', 28)
pdf.cell(0, 14, 'Part II', align='C', ln=True)
pdf.set_font('Helvetica', '', 16)
pdf.set_text_color(210, 225, 240)
pdf.cell(0, 10, 'Market Analysis', align='C', ln=True)Highlight stat inline (accent-colored number in body text):
pdf.set_font('Helvetica', '', 11)
pdf.set_text_color(*pdf.COLORS['body'])
pdf.write(6, 'Customer retention improved by ')
pdf.set_font('Helvetica', 'B', 13)
pdf.set_text_color(*pdf.COLORS['title'])
pdf.write(6, '12%')
pdf.set_font('Helvetica', '', 11)
pdf.set_text_color(*pdf.COLORS['body'])
pdf.write(6, ' in the first quarter after deployment.')
pdf.ln(8)Technical Reference — FPDF Pitfalls & Rules
This reference covers the technical gotchas that cause the most common bugs in PDF generation with fpdf2. Load this when building custom layouts or troubleshooting rendering issues.
---
1. Page-Break Safety
The Problem
Composite elements (callout boxes, KPI strips, charts, tables) draw background fills or accent bars at absolute Y positions. If multi_cell() triggers an auto page break mid-element, the Y reference captured before the break becomes invalid on the new page. This causes fills and bars to stretch incorrectly or appear in the wrong position.
The Fix
Call pdf.ensure_space(estimated_height_mm) before drawing any composite element. This checks remaining vertical space and forces a clean page break BEFORE the element starts, so no element ever straddles a page boundary.
pdf.ensure_space(50) # ensure 50mm of space before drawing
y0 = pdf.get_y()
# ... draw background rect, write text, etc. ...All built-in helpers (add_callout_box, add_kpi_strip, add_chart, add_table, add_table_wrapped, add_numbered_item, add_labeled_item, add_quote_strip, add_formula) already call ensure_space() internally. You only need to call it manually when building custom composite elements.
Rules
Do not draw a background rect, then write text that may trigger a page break, then calculate rect height from Y positions — the Y delta is invalid across pages. Always ensure_space() first, or pre-calculate the element's total height and check against available space.
---
2. write() + multi_cell() Overflow
The Problem
When combining bold titles with regular descriptions at an indented position, a common pattern is:
pdf.write(6, "Bold Title: ") # advances cursor rightward
pdf.multi_cell(fixed_width, 6, "Long description text...") # starts from cursor XThis overflows because write() advances the X cursor, and multi_cell(fixed_width) adds that fixed width starting from the new X position — extending past the right page margin. Text gets clipped.
The Fix
Use add_indented_rich_text(), which uses write() for BOTH the bold and regular parts with a temporary set_left_margin():
pdf.add_indented_rich_text(28, "Bold Title: ", "Long description text that wraps correctly.")Rules
Do not follow write() with multi_cell(fixed_width) — it overflows the page. Use write() for both bold and regular parts when combining inline. Set a temporary left_margin to the indent position and restore it afterward. multi_cell(0, h, text) (width=0, meaning "fill to right margin") is safe for non-indented body text.
---
3. Code Block Line Breaks
The Problem
FPDF2's multi_cell() and write() silently drop all \n characters. Passing a multiline code string to add_body() or any multi_cell()-based method collapses the entire block into one continuous line of text.
The Fix
Use add_code_block() (standalone function — see references/components.md for the implementation). It splits on \n and renders each line individually with cell(), which is the only reliable approach.
Rules
Never pass multiline strings to add_body(), write(), or multi_cell() when line breaks matter. Always use add_code_block() for code samples, shell commands, JSON, config files, or any preformatted text. Call ensure_space() before rendering (the function does this internally). If a block exceeds ~40 lines, split it into labeled parts — cell() does not auto-paginate mid-block.
---
4. FPDF Encoding Limitations
FPDF2 with the default built-in fonts (Helvetica, Courier, Times) uses latin-1 encoding. This means:
Safe: Standard ASCII characters, Western European accented characters (é, ñ, ü, etc.)
Unsafe: Unicode characters beyond latin-1 — Greek letters (α, β), mathematical symbols (∞, ≤), CJK characters, emoji. These will raise errors or render as garbage.
Workarounds:
- Spell out Greek letters in descriptions: "alpha" instead of α
- Use
render_formula()for mathematical expressions - Use
\u00a0(non-breaking space) to preserve indentation incell()calls - For Unicode-heavy content, consider using
pdf.add_font()with a TTF that covers the needed glyphs, but this adds complexity
---
5. Other Common Pitfalls
Forgetting `matplotlib.use('Agg')`: Must be called before importing pyplot. Without it, matplotlib tries to use a GUI backend that doesn't exist in headless environments, causing crashes.
Forgetting `plt.close()`: Each unclosed figure consumes memory. With many charts, this causes memory issues.
Forgetting `pdf.add_page()`: Content written without a page call won't appear. Always add a page before writing content (the title page method handles this internally).
Using `cell()` for long text: cell() clips text that exceeds the cell width. Use multi_cell() or add_table_wrapped() when text may be long.
Chart images too small or blurry: Use dpi=150 minimum and figsize=(7, 4). Smaller images look pixelated in the PDF.
Not resetting font/color: FPDF carries font and color state forward. After any custom styling (bold, colored text), explicitly reset to body defaults:
pdf.set_font('Helvetica', '', 11)
pdf.set_text_color(*pdf.COLORS['body'])Filenames with spaces: Use underscores in /tmp/ paths. Some environments have issues with spaces.
Not calling `generate_download_link`: The user can't download without it. Always call it after saving.
"""
pdf_helpers.py - Reusable PDF report generation toolkit.
Auto-loaded with the pdf-report-guidelines skill.
Usage:
from pdf_helpers import PDFReport, THEMES, render_formula
"""
from fpdf import FPDF
from PIL import Image
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# ═══════════════════════════════════════════════════════════════════════════════
# THEME PALETTES: (title_rgb, accent_rgb, card_bg_rgb, body_rgb)
# ═══════════════════════════════════════════════════════════════════════════════
THEMES = {
"midnight": ((30, 39, 97), (202, 220, 252), (237, 241, 250), (51, 51, 51)),
"forest": ((44, 95, 45), (151, 188, 98), (240, 245, 232), (51, 51, 51)),
"coral": ((249, 97, 103), (47, 60, 126), (255, 245, 240), (51, 51, 51)),
"terracotta": ((184, 80, 66), (167, 190, 174), (245, 240, 237), (61, 61, 61)),
"ocean": ((6, 90, 130), (28, 114, 147), (232, 242, 247), (51, 51, 51)),
"charcoal": ((54, 69, 79), (33, 33, 33), (242, 242, 242), (54, 69, 79)),
"teal": ((2, 128, 144), (2, 195, 154), (230, 245, 242), (51, 51, 51)),
"berry": ((109, 46, 70), (162, 103, 105), (245, 237, 232), (61, 61, 61)),
"sage": ((132, 181, 159), (80, 128, 142), (237, 243, 240), (51, 51, 51)),
"cherry": ((153, 0, 17), (47, 60, 126), (255, 245, 245), (51, 51, 51)),
}
def _rgb_to_hex(rgb):
"""Convert (R, G, B) tuple to '#RRGGBB' hex string."""
return "#{:02x}{:02x}{:02x}".format(*rgb)
def _luminance(rgb):
"""Perceived brightness 0-1 (NTSC formula)."""
return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255
def _blend(c1, c2, t=0.5):
"""Linearly blend two RGB tuples. t=0 -> c1, t=1 -> c2."""
return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2))
# ═══════════════════════════════════════════════════════════════════════════════
# FORMULA RENDERING
# ═══════════════════════════════════════════════════════════════════════════════
def render_formula(latex_str, filepath, fontsize=15, dpi=250):
"""Render a LaTeX math string to PNG via matplotlib mathtext.
Limitations: does NOT support \\begin{pmatrix}, \\begin{align}, etc.
"""
plt.rcParams["mathtext.fontset"] = "cm"
plt.rcParams["font.family"] = "serif"
fig = plt.figure(figsize=(0.1, 0.1))
fig.text(
0.5,
0.5,
f"${latex_str}$",
fontsize=fontsize,
ha="center",
va="center",
color="#000000",
)
fig.savefig(
filepath, dpi=dpi, bbox_inches="tight", pad_inches=0.1, transparent=True
)
plt.close(fig)
# ═══════════════════════════════════════════════════════════════════════════════
# MAIN REPORT CLASS
# ═══════════════════════════════════════════════════════════════════════════════
class PDFReport(FPDF):
"""Feature-rich FPDF subclass for modern, themed PDF reports."""
COLORS = {
"title": (26, 60, 110),
"body": (51, 51, 51),
"accent": (46, 125, 155),
"card_bg": (240, 244, 248),
"white": (255, 255, 255),
"border": (200, 200, 200),
}
# ── Theme ────────────────────────────────────────────────────────────
def set_theme(self, name):
"""Apply a named theme palette from THEMES dict."""
if name in THEMES:
t = THEMES[name]
self.COLORS["title"] = t[0]
self.COLORS["accent"] = t[1]
self.COLORS["card_bg"] = t[2]
self.COLORS["body"] = t[3]
self.COLORS["border"] = _blend(t[1], (255, 255, 255), 0.6)
def get_chart_palette(self, n=6):
"""Return theme-derived hex colors for matplotlib charts.
Returns dict with 'primary', 'secondary', 'series' (list[str]),
'title_hex', 'body_hex', 'card_bg_hex'.
"""
title = self.COLORS["title"]
accent = self.COLORS["accent"]
white = (255, 255, 255)
series = [
title,
accent,
_blend(title, white, 0.40),
_blend(accent, white, 0.35),
_blend(title, accent, 0.50),
_blend(title, (0, 0, 0), 0.25),
]
while len(series) < n:
idx = len(series) % 3
series.append(_blend(series[idx], white, 0.2 * (len(series) // 3)))
return {
"primary": _rgb_to_hex(title),
"secondary": _rgb_to_hex(accent),
"series": [_rgb_to_hex(c) for c in series[:n]],
"title_hex": _rgb_to_hex(title),
"body_hex": _rgb_to_hex(self.COLORS["body"]),
"card_bg_hex": _rgb_to_hex(self.COLORS["card_bg"]),
}
# ── Layout Utilities ─────────────────────────────────────────────────
def ensure_space(self, min_mm):
"""Add a page break if fewer than min_mm mm remain above bottom margin."""
if self.h - self.b_margin - self.get_y() < min_mm:
self.add_page()
def footer(self):
self.set_y(-15)
self.set_font("Helvetica", "", 8)
self.set_text_color(150, 150, 150)
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C")
# ── Title Page ───────────────────────────────────────────────────────
def add_title_page(self, title, subtitle="", author="", date="", org=""):
"""Dark header-band title page with white text."""
self.add_page()
band_h = 130
# Dark header band
self.set_fill_color(*self.COLORS["title"])
self.rect(0, 0, 210, band_h, "F")
# Title
self.set_y(38)
self.set_text_color(255, 255, 255)
self.set_font("Helvetica", "B", 30)
self.multi_cell(0, 14, title, align="C")
# Subtitle
if subtitle:
self.ln(3)
self.set_font("Helvetica", "", 15)
self.set_text_color(210, 225, 240)
self.cell(0, 10, subtitle, ln=True, align="C")
# Accent divider — light color on dark band
self.ln(4)
acc = self.COLORS["accent"]
line_clr = acc if _luminance(acc) > 0.40 else (255, 255, 255)
self.set_draw_color(*line_clr)
self.set_line_width(0.8)
self.line(70, self.get_y(), 140, self.get_y())
# Meta below band
self.set_y(band_h + 20)
self.set_text_color(*self.COLORS["body"])
self.set_font("Helvetica", "", 13)
meta = [p for p in [author, org, date] if p]
if meta:
self.cell(0, 8, " | ".join(meta), ln=True, align="C")
# ── Sections ─────────────────────────────────────────────────────────
def add_section(self, title):
self.ln(8)
self.set_text_color(*self.COLORS["title"])
self.set_font("Helvetica", "B", 18)
self.cell(0, 12, title, ln=True)
self.set_draw_color(*self.COLORS["accent"])
self.set_line_width(0.5)
self.line(20, self.get_y(), 80, self.get_y())
self.ln(6)
self.set_text_color(*self.COLORS["body"])
def add_subsection(self, title):
self.ln(4)
self.set_text_color(*self.COLORS["title"])
self.set_font("Helvetica", "B", 14)
self.cell(0, 10, title, ln=True)
self.ln(2)
self.set_text_color(*self.COLORS["body"])
# ── Body Text ────────────────────────────────────────────────────────
def add_body(self, text):
self.set_font("Helvetica", "", 11)
self.set_text_color(*self.COLORS["body"])
self.multi_cell(0, 6, text)
self.ln(4)
# ── Callout Box (page-break safe) ────────────────────────────────────
def add_callout_box(self, text, fill_color=None, min_height=30):
"""Tinted box with left accent bar. Page-break safe."""
self.ensure_space(min_height)
fill = fill_color or self.COLORS["card_bg"]
y0 = self.get_y()
x_text = 27
old_l = self.l_margin
self.set_left_margin(x_text)
self.set_x(x_text)
self.set_font("Helvetica", "", 11)
self.set_text_color(*self.COLORS["body"])
self.set_fill_color(*fill)
self.multi_cell(160, 6, text, fill=True)
box_h = self.get_y() - y0
self.set_fill_color(*self.COLORS["accent"])
self.rect(20, y0, 2.5, box_h, "F")
self.set_left_margin(old_l)
self.ln(6)
# ── Quote / Highlight Strip ──────────────────────────────────────────
def add_quote_strip(self, text, attribution=""):
"""Centered italic text in a tinted band with optional attribution."""
self.ensure_space(35)
y0 = self.get_y()
self.set_font("Helvetica", "I", 12)
line_count = max(1, len(text) // 70 + 1)
text_h = line_count * 7
strip_h = text_h + (14 if attribution else 6) + 10
# Band
self.set_fill_color(*self.COLORS["card_bg"])
self.rect(20, y0, 170, strip_h, "F")
# Quote
self.set_xy(35, y0 + 5)
self.set_text_color(*self.COLORS["title"])
old_l = self.l_margin
self.set_left_margin(35)
self.multi_cell(140, 7, text, align="C")
self.set_left_margin(old_l)
# Attribution
if attribution:
self.set_x(35)
self.set_font("Helvetica", "", 9)
self.set_text_color(120, 120, 120)
self.cell(140, 6, attribution, align="C")
self.set_y(y0 + strip_h + 6)
# ── KPI Strip ────────────────────────────────────────────────────────
def add_kpi_strip(self, kpis, cols=None):
"""Row of large stat callouts. kpis: [{'label': str, 'value': str}]"""
self.ensure_space(35)
n = len(kpis)
cols = cols or n
col_w = 170 / cols
y0 = self.get_y()
for i, kpi in enumerate(kpis):
x = 20 + i * col_w
self.set_fill_color(*self.COLORS["card_bg"])
self.rect(x + 1, y0, col_w - 2, 22, "F")
self.set_xy(x + 1, y0 + 2)
self.set_font("Helvetica", "B", 24)
self.set_text_color(*self.COLORS["title"])
self.cell(col_w - 2, 12, str(kpi["value"]), align="C")
self.set_xy(x + 1, y0 + 14)
self.set_font("Helvetica", "", 9)
self.set_text_color(120, 120, 120)
self.cell(col_w - 2, 6, kpi["label"], align="C")
self.set_y(y0 + 28)
# ── Indented Rich Text (write-safe pattern) ──────────────────────────
def add_indented_rich_text(
self,
indent_x,
bold_text,
regular_text,
bold_font_size=11,
regular_font_size=11,
line_height=6,
spacing=8,
bold_color=None,
regular_color=None,
):
"""Bold + regular inline text at an indented position.
Uses write() for both parts to avoid overflow.
"""
bold_color = bold_color or self.COLORS["body"]
regular_color = regular_color or self.COLORS["body"]
old_l = self.l_margin
self.set_left_margin(indent_x)
self.set_x(indent_x)
self.set_font("Helvetica", "B", bold_font_size)
self.set_text_color(*bold_color)
self.write(line_height, bold_text)
self.set_font("Helvetica", "", regular_font_size)
self.set_text_color(*regular_color)
self.write(line_height, regular_text)
self.ln(spacing)
self.set_left_margin(old_l)
# ── Labeled Item ─────────────────────────────────────────────────────
def add_labeled_item(
self,
indent_x,
indicator_color,
label,
description,
label_font_size=10,
desc_font_size=10,
line_height=5.5,
):
"""Colored indicator bar + bold label + description."""
self.ensure_space(15)
y0 = self.get_y()
self.set_fill_color(*indicator_color)
self.rect(indent_x - 6, y0, 2, 6, "F")
self.add_indented_rich_text(
indent_x,
label + ": ",
description,
label_font_size,
desc_font_size,
line_height,
8,
indicator_color,
self.COLORS["body"],
)
# ── Numbered Item ────────────────────────────────────────────────────
def add_numbered_item(
self,
indent_x,
number,
badge_color,
label,
description,
label_font_size=11,
desc_font_size=10,
line_height=5.5,
):
"""Numbered badge + bold label + description."""
self.ensure_space(15)
y0 = self.get_y()
badge_x = indent_x - 11
self.set_fill_color(*badge_color)
self.rect(badge_x, y0, 8, 7, "F")
self.set_xy(badge_x, y0)
self.set_text_color(255, 255, 255)
self.set_font("Helvetica", "B", 9)
self.cell(8, 7, str(number), align="C")
self.add_indented_rich_text(
indent_x,
label + ": ",
description,
label_font_size,
desc_font_size,
line_height,
9,
self.COLORS["title"],
self.COLORS["body"],
)
# ── Chart Embedding ──────────────────────────────────────────────────
def add_chart(self, img_path, caption="", width=160):
"""Embed a chart image centered, with optional caption.
Estimates image height and ensures space before embedding.
"""
try:
img = Image.open(img_path)
aspect = img.size[1] / img.size[0]
est_h = width * aspect
est_h = max(est_h, 50)
except Exception:
est_h = 80
self.ensure_space(est_h + 15)
x = (210 - width) / 2
self.image(img_path, x=x, w=width)
if caption:
self.ln(3)
self.set_font("Helvetica", "I", 9)
self.set_text_color(120, 120, 120)
self.cell(0, 5, caption, ln=True, align="C")
self.ln(6)
# ── Table (simple) ───────────────────────────────────────────────────
def add_table(self, headers, rows, col_widths=None, row_height=7):
"""Themed table with header band and alternating rows.
Uses cell() — text exceeding column width is clipped.
For wrapping cells use add_table_wrapped().
"""
if not col_widths:
w = (210 - 40) / len(headers)
col_widths = [w] * len(headers)
hdr_h = row_height + 1
self.ensure_space(min(hdr_h + row_height * len(rows) + 8, 60))
self.set_font("Helvetica", "B", 10)
self.set_fill_color(*self.COLORS["title"])
self.set_text_color(*self.COLORS["white"])
for i, h in enumerate(headers):
self.cell(col_widths[i], hdr_h, h, border=1, fill=True, align="C")
self.ln()
self.set_font("Helvetica", "", 10)
self.set_text_color(*self.COLORS["body"])
for ri, row in enumerate(rows):
self.ensure_space(row_height + 2)
bg = self.COLORS["card_bg"] if ri % 2 == 0 else self.COLORS["white"]
self.set_fill_color(*bg)
for i, cv in enumerate(row):
self.cell(
col_widths[i], row_height, str(cv), border=1, fill=True, align="C"
)
self.ln()
self.ln(4)
def add_table_wrapped(
self, headers, rows, col_widths=None, line_height=5.5, padding=1.5
):
"""Table with automatic multi-line cell wrapping.
Row height adjusts to the tallest cell per row.
"""
if not col_widths:
w = (210 - 40) / len(headers)
col_widths = [w] * len(headers)
x_start = self.l_margin
self.ensure_space(40)
# Header
self.set_font("Helvetica", "B", 10)
self.set_fill_color(*self.COLORS["title"])
self.set_text_color(*self.COLORS["white"])
for i, h in enumerate(headers):
self.cell(col_widths[i], 8, h, border=1, fill=True, align="C")
self.ln()
# Rows
self.set_font("Helvetica", "", 10)
for ri, row in enumerate(rows):
max_lines = 1
for i, cv in enumerate(row):
inner_w = col_widths[i] - 2 * padding
if inner_w > 0:
tw = self.get_string_width(str(cv))
max_lines = max(max_lines, int(tw / inner_w) + 1)
rh = max(max_lines * line_height + 2 * padding, line_height + 2 * padding)
self.ensure_space(rh + 2)
bg = self.COLORS["card_bg"] if ri % 2 == 0 else self.COLORS["white"]
y_row = self.get_y()
for i in range(len(row)):
x = x_start + sum(col_widths[:i])
self.set_fill_color(*bg)
self.set_draw_color(*self.COLORS["border"])
self.rect(x, y_row, col_widths[i], rh, "DF")
for i, cv in enumerate(row):
x = x_start + sum(col_widths[:i])
self.set_xy(x + padding, y_row + padding)
old_l = self.l_margin
self.set_left_margin(x + padding)
self.set_text_color(*self.COLORS["body"])
self.multi_cell(col_widths[i] - 2 * padding, line_height, str(cv))
self.set_left_margin(old_l)
self.set_y(y_row + rh)
self.ln(4)
# ── Formula Embedding ────────────────────────────────────────────────
def add_formula(self, img_path, max_w=90):
"""Embed a rendered formula image in a tinted strip."""
self.ensure_space(20)
img = Image.open(img_path)
pw, ph = img.size
img_w = pw / 250 * 25.4
img_h = ph / 250 * 25.4
if img_w > max_w:
s = max_w / img_w
img_w *= s
img_h *= s
img_h = max(img_h, 7)
pad = 3
strip_h = img_h + 2 * pad
y0 = self.get_y()
self.set_fill_color(*self.COLORS["card_bg"])
self.set_draw_color(*self.COLORS["border"])
self.set_line_width(0.25)
self.rect(20, y0, 170, strip_h, "DF")
x = (210 - img_w) / 2
self.image(img_path, x=x, y=y0 + (strip_h - img_h) / 2, w=img_w, h=img_h)
self.set_y(y0 + strip_h + 4)
Related skills
FAQ
What design rules does this skill enforce?
It uses a 60/30/10 color distribution, two font weights (Regular and Bold via Helvetica), generous whitespace, and one consistent visual motif carried through every page.
Does it handle accessibility?
Yes. It specifies WCAG minimums: 4.5:1 contrast for body text under 18pt and 3:1 for headings.