
Frappe Syntax Print
- 23 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-syntax-print is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-syntax-print
- AI & Agent Building
- AI-coding skill
Frappe Syntax Print by the numbers
- 23 all-time installs (skills.sh)
- Ranked #10,032 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-syntax-printAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Print Formats & PDF Generation
Deterministic reference for print formats, Letter Head, and PDF generation in Frappe v14/v15/v16.
---
When to Use This Skill
USE when:
- Creating or modifying Print Formats (Jinja or JS)
- Generating PDFs programmatically (
get_pdf, download endpoints) - Configuring Letter Head (header/footer) for print output
- Working with Print Designer (v15+)
- Implementing page breaks, print CSS, or landscape layouts
- Building Report Print Formats ({%= %} syntax)
DO NOT USE for:
- General Jinja template syntax (emails, portals) -- see
frappe-syntax-jinja - Client Script UI logic -- see
frappe-syntax-clientscripts - Web views or portal pages -- see
frappe-syntax-jinja
---
Decision Tree: Which Print Format Type?
Need a printable/PDF document?
├─ YES → Is it a Query/Script Report?
│ ├─ YES → Use JS Template ({%= %} microtemplate)
│ │ Set print_format_for = "Report"
│ └─ NO → Need visual drag-and-drop editor?
│ ├─ YES → On v15+?
│ │ ├─ YES → Use Print Designer (WeasyPrint)
│ │ └─ NO → NOT available on v14. Use Jinja.
│ └─ NO → Need full layout control?
│ ├─ YES → Use Jinja Print Format (custom_format=1)
│ └─ NO → Use Standard Print Format (auto layout)
└─ NO → This skill does not apply.---
Print Format Types
| Type | Engine | Version | When to Use |
|---|---|---|---|
| Standard | Auto from DocType field layout | v14+ | No customization needed |
| Jinja | Server-side Jinja2 (wkhtmltopdf) | v14+ | Full layout control |
| JS Template | Client-side microtemplate | v14+ | Report print formats only |
| Print Designer | WeasyPrint / Chrome | v15+ | Visual drag-and-drop builder |
Standard Print Format
ALWAYS the default. Frappe auto-generates layout from DocType fields. No code needed. Controlled via Print Settings and field print_hide property.
Jinja Print Format
Set custom_format = 1 on the Print Format document. Full Jinja2 with server-side rendering.
Context variables available in every Jinja Print Format:
| Variable | Type | Content |
|---|---|---|
doc | Document | The document being printed |
meta | Meta | DocType metadata |
layout | list | Field layout sections |
letter_head | str | Rendered Letter Head HTML |
footer | str | Rendered footer HTML |
print_settings | dict | Print Settings configuration |
frappe | module | Full frappe module access |
Example — Minimal Jinja Print Format:
<h1>{{ doc.name }}</h1>
<p>Customer: {{ doc.customer_name }}</p>
<p>Date: {{ doc.posting_date | global_date_format }}</p>
<table class="table table-bordered">
<thead>
<tr><th>Item</th><th>Qty</th><th>Rate</th><th>Amount</th></tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ row.item_name }}</td>
<td>{{ row.qty }}</td>
<td>{{ frappe.utils.fmt_money(row.rate, currency=doc.currency) }}</td>
<td>{{ frappe.utils.fmt_money(row.amount, currency=doc.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>Grand Total:</strong> {{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}</p>JS Template (Report Print Formats)
ONLY for Query Reports and Script Reports. Uses {%= %} microtemplate syntax, NOT Jinja.
// In report's .js file
{%= row.item_name %}
{% if (row.qty > 10) { %}
<strong>Bulk order</strong>
{% } %}
{% for (var i = 0; i < rows.length; i++) { %}
<tr>
<td>{%= rows[i].item_name %}</td>
<td>{%= rows[i].qty %}</td>
</tr>
{% } %}CRITICAL: NEVER mix Jinja {{ }} and JS {%= %} syntax. They are completely separate template engines.
Print Designer (v15+ Only)
- Separate app:
bench get-app print_designer - Uses WeasyPrint (not wkhtmltopdf)
- Visual drag-and-drop builder in the browser
- NEVER attempt to use Print Designer on v14 -- it does not exist
---
Letter Head
Letter Head provides consistent header/footer across all print formats.
Configuration
| Field | Purpose |
|---|---|
source | "Image" or "HTML" |
content | Header HTML (Jinja-rendered with doc context) |
footer | Footer HTML (Jinja-rendered, PDF only) |
image | Header image (when source = "Image") |
align | Image alignment: Left, Center, Right |
IMPORTANT: The footer field only displays in PDF output, never in browser print preview.
Letter Head in Jinja Templates
# Server-side: render Letter Head programmatically
from frappe.utils.print_format import render_letterhead_for_print
letterhead_html = render_letterhead_for_print(
letter_head_name="My Company",
doc=doc
)Dynamic Letter Head Content
Letter Head content and footer fields support Jinja with doc context:
<!-- In Letter Head content field -->
<div style="text-align: right;">
<strong>{{ doc.company }}</strong><br>
Date: {{ doc.posting_date | global_date_format }}
</div>---
PDF Generation API
See references/pdf-api.md for complete API reference.Quick Reference
# Generate PDF bytes from HTML
from frappe.utils.pdf import get_pdf
pdf_bytes = get_pdf(html_string, options=None)
# Generate PDF from a specific document + print format
from frappe.utils.print_format import download_pdf
download_pdf(doctype, name, format=None, doc=None, no_letterhead=0)Download Endpoints
# Single document PDF
GET /api/method/frappe.utils.print_format.download_pdf
?doctype=Sales Invoice
&name=SINV-00001
&format=My Print Format
&no_letterhead=0
# Multiple documents in one PDF
GET /api/method/frappe.utils.print_format.download_multi_pdf
?doctype=Sales Invoice
&name=["SINV-00001","SINV-00002"]
&format=My Print FormatPDF Engine Selection (v15+)
| Engine | When | Config |
|---|---|---|
| wkhtmltopdf | Default on v14, fallback on v15+ | Default |
| Chrome | v15+ with Chromium installed | pdf_generator = "chrome" on Print Format |
| WeasyPrint | Print Designer formats only | Automatic for Print Designer |
ALWAYS use wkhtmltopdf on v14. On v15+, Chrome produces better CSS3 support.
---
Page Breaks & Print CSS
Page Break Classes
<!-- Force page break after this element -->
<div class="page-break"></div>
<!-- Or use CSS directly -->
<div style="page-break-after: always;"></div>
<!-- Page break before -->
<div style="page-break-before: always;"></div>Print CSS Classes (Frappe Built-in)
| Class | Effect |
|---|---|
.print-format | Container: max-width 8.3in, min-height 11.69in (A4 portrait) |
.print-format.landscape | Width 11.69in (A4 landscape) |
.page-break | page-break-after: always |
.print-heading | Print title styling |
.hidden-pdf | Hidden in PDF output only |
.visible-pdf | Visible in PDF output only |
PDF Header/Footer HTML
# In hooks.py — inject header/footer into every PDF
pdf_header_html = "myapp.utils.get_pdf_header"
pdf_body_html = "myapp.utils.get_pdf_body"
pdf_footer_html = "myapp.utils.get_pdf_footer"<!-- Header/footer elements in print format HTML -->
<div id="header-html">
<span class="page"></span> of <span class="topage"></span>
</div>
<div id="footer-html">
<p style="text-align: center; font-size: 9px;">
Printed on {{ frappe.utils.nowdate() }}
</p>
</div>Print CSS Best Practices
/* ALWAYS use relative units for print widths */
@media print {
.print-format {
max-width: 100%;
margin: 0;
padding: 15mm;
}
/* Prevent table rows from splitting across pages */
tr {
page-break-inside: avoid;
}
/* Constrain images */
img {
max-width: 100%;
height: auto;
}
}---
Custom App Print Formats
Ship a Print Format with Your App
myapp/
└── mymodule/
└── print_format/
└── my_custom_format/
├── my_custom_format.json # Print Format doc
└── my_custom_format.html # Jinja templateIn the JSON file, ALWAYS set:
{
"doctype": "Print Format",
"name": "My Custom Format",
"doc_type": "Sales Invoice",
"module": "My Module",
"standard": "Yes",
"custom_format": 1,
"print_format_type": "Jinja"
}ALWAYS set standard = "Yes" and module for app-shipped print formats. This ensures they are recognized as part of the app and not as site-level customizations.
---
Jinja Filters for Print Formats
| Filter | Purpose | Example |
|---|---|---|
global_date_format | Format date per system settings | `{{ doc.posting_date \ |
json | Serialize to JSON string | `{{ doc.items \ |
len | Get length | `{{ doc.items \ |
int | Cast to integer | `{{ value \ |
flt | Cast to float | `{{ value \ |
markdown | Render Markdown to HTML | `{{ doc.description \ |
abs | Absolute value | `{{ value \ |
Register Custom Jinja Filters/Methods
# In hooks.py
jinja = {
"methods": [
"myapp.utils.jinja.my_custom_method"
],
"filters": [
"myapp.utils.jinja.my_custom_filter"
]
}# myapp/utils/jinja.py
def my_custom_method(value):
"""Available as {{ my_custom_method(doc.field) }} in templates."""
return value.upper()
def my_custom_filter(value, arg=None):
"""Available as {{ doc.field | my_custom_filter }} in templates."""
return f"[{value}]"---
Version Compatibility Matrix
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Jinja Print Formats | Yes | Yes | Yes |
| JS Report Templates | Yes | Yes | Yes |
| Standard Print Formats | Yes | Yes | Yes |
| Letter Head (Image/HTML) | Yes | Yes | Yes |
| wkhtmltopdf | Default | Fallback | Fallback |
| Chrome PDF engine | No | Yes | Yes |
| WeasyPrint | No | Yes | Yes |
| Print Designer app | No | Yes | Yes |
pdf_header_html hook | Yes | Yes | Yes |
download_multi_pdf | Yes | Yes | Yes |
---
Common Anti-Patterns
See references/anti-patterns.md for the complete list with fixes.1. NEVER use {{ }} in Report Print Formats -- they use {%= %} (JS microtemplate) 2. NEVER call frappe.get_doc() inside a {% for %} loop in templates -- causes N+1 queries 3. NEVER put heavy business logic in Jinja templates -- move to Python and pass results 4. NEVER hardcode page dimensions in CSS -- use .print-format class or relative units 5. NEVER ignore the no_letterhead parameter when generating PDFs programmatically 6. NEVER use Print Designer on v14 -- it requires v15+ 7. NEVER embed large base64 images in print templates -- use URLs with max-width: 100%
---
Reference Files
- Jinja Print Formats -- Jinja syntax, variables, filters, macros
- PDF API -- get_pdf(), download endpoints, page breaks, hooks
- Anti-Patterns -- Common print format mistakes with fixes
Print Format Anti-Patterns — Complete Reference
Common mistakes when building print formats and generating PDFs in Frappe v14/v15/v16, with correct alternatives.
---
AP-1: Mixing Jinja and JS Template Syntax
The Mistake
Using {{ }} (Jinja) syntax in a Report Print Format, or {%= %} (JS microtemplate) in a Jinja Print Format.
<!-- WRONG: Jinja syntax in a Report Print Format -->
<td>{{ row.item_name }}</td>
<td>{{ row.qty }}</td>Why It Fails
Report Print Formats use JavaScript microtemplate engine ({%= %}), NOT Jinja. The {{ }} syntax is silently ignored or causes rendering errors.
The Fix
<!-- CORRECT: JS microtemplate in Report Print Format -->
<td>{%= row.item_name %}</td>
<td>{%= row.qty %}</td>
{% if (row.qty > 10) { %}
<strong>Bulk</strong>
{% } %}Rule: ALWAYS check the print_format_for field. If it says "Report", use {%= %}. For all other print formats, use Jinja {{ }}.
---
AP-2: N+1 Queries in Jinja Templates
The Mistake
<!-- WRONG: frappe.get_doc() inside a loop -->
{% for row in doc.items %}
{% set item = frappe.get_doc("Item", row.item_code) %}
<td>{{ item.item_group }}</td>
<td>{{ item.stock_uom }}</td>
{% endfor %}Why It Fails
If doc.items has 50 rows, this executes 50 separate database queries. For complex documents with hundreds of items, this causes severe performance degradation and can time out PDF generation.
The Fix
<!-- CORRECT: Batch fetch before the loop -->
{% set item_codes = doc.items | map(attribute='item_code') | list %}
{% set item_data = frappe.get_all("Item",
filters={"name": ["in", item_codes]},
fields=["name", "item_group", "stock_uom"]
) %}
{% set item_map = {} %}
{% for item in item_data %}
{% set _ = item_map.update({item.name: item}) %}
{% endfor %}
{% for row in doc.items %}
{% set item = item_map.get(row.item_code, {}) %}
<td>{{ item.get("item_group", "") }}</td>
<td>{{ item.get("stock_uom", "") }}</td>
{% endfor %}Rule: NEVER call frappe.get_doc() or frappe.db.get_value() inside a {% for %} loop. ALWAYS batch-fetch data before the loop.
---
AP-3: Heavy Business Logic in Jinja Templates
The Mistake
<!-- WRONG: Complex calculations in the template -->
{% set tax_rate = 0 %}
{% for tax in doc.taxes %}
{% if tax.charge_type == "On Net Total" %}
{% set tax_rate = tax_rate + tax.rate %}
{% endif %}
{% endfor %}
{% set adjusted_total = doc.net_total * (1 + tax_rate / 100) %}
{% if adjusted_total > doc.grand_total %}
{% set difference = adjusted_total - doc.grand_total %}
<!-- ... 50 more lines of calculation logic ... -->
{% endif %}Why It Fails
- Jinja templates are hard to debug (no breakpoints, limited error messages)
- Business logic in templates cannot be unit tested
- Makes the template unmaintainable and unreadable
- Jinja's scoping rules make complex state management error-prone
The Fix
Move logic to a Python method and call it from the template:
# myapp/utils/print_helpers.py
def get_invoice_summary(doc_name):
"""Calculate invoice summary for print format."""
doc = frappe.get_doc("Sales Invoice", doc_name)
tax_rate = sum(
tax.rate for tax in doc.taxes
if tax.charge_type == "On Net Total"
)
adjusted_total = doc.net_total * (1 + tax_rate / 100)
return {
"tax_rate": tax_rate,
"adjusted_total": adjusted_total,
"difference": adjusted_total - doc.grand_total
}# hooks.py
jinja = {
"methods": ["myapp.utils.print_helpers.get_invoice_summary"]
}<!-- CORRECT: Simple template, logic in Python -->
{% set summary = get_invoice_summary(doc.name) %}
<p>Tax Rate: {{ summary.tax_rate }}%</p>
<p>Adjusted Total: {{ frappe.utils.fmt_money(summary.adjusted_total, currency=doc.currency) }}</p>Rule: If your Jinja template has more than 5 lines of calculation or conditional logic, ALWAYS move it to a Python function registered via jinja.methods in hooks.py.
---
AP-4: Hardcoding Page Dimensions in CSS
The Mistake
/* WRONG: Hardcoded pixel dimensions */
.my-print-format {
width: 794px;
height: 1123px;
padding: 50px;
}
.my-table {
width: 694px;
}Why It Fails
- Pixel dimensions break on different DPI settings
- Does not adapt to different paper sizes (Letter vs A4)
- PDF engine may interpret pixel sizes differently than browser
- Ignores user's Print Settings (margins, paper size)
The Fix
/* CORRECT: Use relative/print-friendly units */
.print-format {
max-width: 100%;
padding: 0;
}
.my-table {
width: 100%;
}
@media print {
@page {
size: A4 portrait;
margin: 15mm;
}
}Rule: ALWAYS use %, mm, cm, or in for print dimensions. NEVER use px for page-level layout in print formats. Use Frappe's .print-format class which handles A4/Letter sizing automatically.
---
AP-5: Ignoring the no_letterhead Parameter
The Mistake
# WRONG: Hardcoded letterhead inclusion
def generate_pdf(doctype, name):
html = frappe.get_print(doctype, name, "My Format")
return get_pdf(html)Why It Fails
- Users may want PDFs without Letter Head (e.g., for embedding in other documents)
- Some workflows specifically require no Letter Head
- Ignores user preference from Print Settings
The Fix
# CORRECT: Respect no_letterhead parameter
def generate_pdf(doctype, name, no_letterhead=0):
html = frappe.get_print(
doctype, name, "My Format",
no_letterhead=no_letterhead
)
return get_pdf(html)Rule: ALWAYS accept and pass through the no_letterhead parameter in any PDF generation function. NEVER assume Letter Head should always be included.
---
AP-6: Using Print Designer on v14
The Mistake
Attempting to install or use Print Designer on Frappe v14.
# WRONG on v14:
bench get-app print_designerWhy It Fails
Print Designer is a v15+ application. It requires WeasyPrint and Frappe v15's Print Format architecture changes. Installing it on v14 will fail or produce broken output.
The Fix
- On v14: Use Jinja Print Formats with
custom_format=1for full layout control - On v15+: Print Designer is available as a separate app (
bench get-app print_designer)
Rule: ALWAYS check the Frappe version before recommending Print Designer. On v14, use Jinja-based custom print formats instead.
---
AP-7: Large Images Without Size Constraints
The Mistake
<!-- WRONG: No size constraints on images -->
<img src="{{ frappe.utils.get_url() }}/files/product_photo.jpg">
<!-- WRONG: Base64 inline image -->
<img src="data:image/jpeg;base64,{{ huge_base64_string }}">Why It Fails
- Unconstrained images overflow the page and break layout
- Large base64 images dramatically increase HTML size, causing wkhtmltopdf to run out of memory
- PDF generation time increases by 10-100x with inline images
The Fix
<!-- CORRECT: Constrained image with URL -->
<img src="{{ frappe.utils.get_url() }}/files/product_photo.jpg"
style="max-width: 100%; height: auto; max-height: 200px;">
<!-- For logos/small images only — limit base64 to under 50KB -->
<img src="{{ doc.image }}" style="max-width: 150px; height: auto;">Rule: ALWAYS set max-width: 100% and height: auto on images in print formats. NEVER embed large images as base64. Use absolute URLs for images and constrain dimensions with CSS.
---
AP-8: Not Using | safe for HTML Content
The Mistake
<!-- WRONG: HTML-encoded output -->
<div>{{ doc.terms }}</div>
<!-- Renders as: <p>Payment due in 30 days</p> -->Why It Fails
Jinja auto-escapes HTML by default. Fields containing HTML (like terms, description, letter_head) render as escaped text instead of formatted HTML.
The Fix
<!-- CORRECT: Mark as safe HTML -->
<div>{{ doc.terms | safe }}</div>
<!-- Renders as: <p>Payment due in 30 days</p> -->Rule: ALWAYS use | safe filter when outputting fields that contain HTML content (Text Editor fields, terms, descriptions). NEVER use | safe on user-input fields that should not contain HTML.
---
AP-9: Relative Image URLs in PDF
The Mistake
<!-- WRONG: Relative URL -->
<img src="/files/logo.png">Why It Fails
wkhtmltopdf runs as a separate process and cannot resolve relative URLs. The image will be missing in the generated PDF, even though it works in browser print preview.
The Fix
<!-- CORRECT: Absolute URL -->
<img src="{{ frappe.utils.get_url() }}/files/logo.png"
style="max-width: 200px; height: auto;">Rule: ALWAYS use {{ frappe.utils.get_url() }} prefix for image and asset URLs in print formats. Relative URLs work in browser preview but FAIL in PDF generation.
---
AP-10: Forgetting Jinja Variable Scoping in Loops
The Mistake
<!-- WRONG: Variable set inside loop does not update outer scope -->
{% set total = 0 %}
{% for row in doc.items %}
{% set total = total + row.amount %}
{% endfor %}
<p>Total: {{ total }}</p>
<!-- Always shows 0! -->Why It Fails
Jinja2's scoping rules mean that {% set %} inside a {% for %} block creates a new variable in the loop's scope. It does NOT update the outer variable.
The Fix
<!-- CORRECT: Use namespace for mutable state across scopes -->
{% set ns = namespace(total=0) %}
{% for row in doc.items %}
{% set ns.total = ns.total + row.amount %}
{% endfor %}
<p>Total: {{ frappe.utils.fmt_money(ns.total, currency=doc.currency) }}</p>Rule: ALWAYS use namespace() when you need to accumulate values across a {% for %} loop in Jinja2. Plain {% set %} inside loops does NOT update outer scope variables.
---
Summary Table
| # | Anti-Pattern | Key Rule |
|---|---|---|
| AP-1 | Mixing Jinja/JS syntax | Check print_format_for field |
| AP-2 | N+1 queries in loops | Batch-fetch before loops |
| AP-3 | Logic in templates | Move to Python, register via hooks |
| AP-4 | Hardcoded dimensions | Use relative units, .print-format class |
| AP-5 | Ignoring no_letterhead | ALWAYS pass through the parameter |
| AP-6 | Print Designer on v14 | Requires v15+ |
| AP-7 | Large unconstrained images | max-width: 100%, use URLs not base64 |
| AP-8 | Missing `\ | safe` filter |
| AP-9 | Relative image URLs | Use frappe.utils.get_url() prefix |
| AP-10 | Variable scoping in loops | Use namespace() for loop accumulators |
Jinja Print Formats — Complete Reference
Detailed reference for Jinja-based print formats in Frappe v14/v15/v16.
---
Template Context
Every Jinja Print Format receives these variables automatically:
| Variable | Type | Description |
|---|---|---|
doc | Document | The document being printed (fully loaded with child tables) |
meta | Meta | DocType metadata (field definitions, permissions) |
layout | list | Field layout sections from DocType definition |
letter_head | str | Pre-rendered Letter Head HTML |
footer | str | Pre-rendered footer HTML |
print_settings | dict | Print Settings document values |
frappe | module | Full frappe module — access to all utilities |
frappe.utils | module | All utility functions (nowdate, flt, fmt_money, etc.) |
no_letterhead | int | 1 if Letter Head should be suppressed |
---
Jinja Syntax Quick Reference
Output Expressions
<!-- Simple field output -->
{{ doc.name }}
{{ doc.customer_name }}
{{ doc.posting_date }}
<!-- Nested child table access -->
{% for item in doc.items %}
{{ item.item_name }} - {{ item.qty }}
{% endfor %}
<!-- Frappe utility functions -->
{{ frappe.utils.nowdate() }}
{{ frappe.utils.flt(doc.grand_total, 2) }}
{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}
{{ frappe.format_value(doc.posting_date, {"fieldtype": "Date"}) }}Control Flow
<!-- Conditionals -->
{% if doc.discount_amount > 0 %}
<p>Discount: {{ frappe.utils.fmt_money(doc.discount_amount, currency=doc.currency) }}</p>
{% endif %}
{% if doc.status == "Paid" %}
<span class="badge badge-success">PAID</span>
{% elif doc.status == "Overdue" %}
<span class="badge badge-danger">OVERDUE</span>
{% else %}
<span class="badge badge-warning">{{ doc.status }}</span>
{% endif %}
<!-- Loops -->
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td>{{ row.qty }}</td>
</tr>
{% endfor %}
<!-- Loop utilities -->
{{ loop.index }} {# 1-based counter #}
{{ loop.index0 }} {# 0-based counter #}
{{ loop.first }} {# True on first iteration #}
{{ loop.last }} {# True on last iteration #}
{{ loop.length }} {# Total number of items #}Setting Variables
{% set total_qty = 0 %}
{% for row in doc.items %}
{% set total_qty = total_qty + row.qty %}
{% endfor %}
{# NOTE: set inside a for loop does NOT update the outer variable in Jinja2. #}
{# Use namespace instead: #}
{% set ns = namespace(total_qty=0) %}
{% for row in doc.items %}
{% set ns.total_qty = ns.total_qty + row.qty %}
{% endfor %}
<p>Total Qty: {{ ns.total_qty }}</p>Macros (Reusable Components)
{% macro money(value, currency=doc.currency) %}
{{ frappe.utils.fmt_money(value, currency=currency) }}
{% endmacro %}
{% macro address_block(address_name) %}
{% set addr = frappe.get_doc("Address", address_name) %}
<div class="address">
{{ addr.address_line1 }}<br>
{% if addr.address_line2 %}{{ addr.address_line2 }}<br>{% endif %}
{{ addr.city }}, {{ addr.state }} {{ addr.pincode }}<br>
{{ addr.country }}
</div>
{% endmacro %}
<!-- Usage -->
<p>Total: {{ money(doc.grand_total) }}</p>
{{ address_block(doc.customer_address) }}---
Available Filters
Built-in Jinja Filters
| Filter | Purpose | Example |
|---|---|---|
default(value) | Fallback for None/empty | `{{ doc.po_no \ |
title | Title case | `{{ doc.status \ |
lower / upper | Case conversion | `{{ doc.name \ |
replace(old, new) | String replacement | `{{ doc.name \ |
truncate(length) | Truncate string | `{{ doc.description \ |
round(precision) | Round number | `{{ doc.rate \ |
join(sep) | Join list | `{{ tags \ |
safe | Mark as safe HTML | `{{ doc.terms \ |
Frappe-Specific Filters
| Filter | Purpose | Example |
|---|---|---|
global_date_format | System date format | `{{ doc.posting_date \ |
json | Serialize to JSON | `{{ doc.items \ |
len | Get length | `{{ doc.items \ |
int | Cast to int | `{{ value \ |
flt | Cast to float | `{{ value \ |
markdown | Markdown to HTML | `{{ doc.description \ |
abs | Absolute value | `{{ value \ |
---
Accessing Related Documents
Fetching Related Data
<!-- Get a linked document -->
{% set customer = frappe.get_doc("Customer", doc.customer) %}
<p>Customer Group: {{ customer.customer_group }}</p>
<!-- Get multiple records -->
{% set contacts = frappe.get_all("Contact",
filters={"link_doctype": "Customer", "link_name": doc.customer},
fields=["first_name", "last_name", "email_id"],
limit=5
) %}
{% for contact in contacts %}
<p>{{ contact.first_name }} {{ contact.last_name }} — {{ contact.email_id }}</p>
{% endfor %}
<!-- Get a single value (efficient) -->
{% set customer_group = frappe.db.get_value("Customer", doc.customer, "customer_group") %}IMPORTANT: NEVER use frappe.get_doc() inside a {% for %} loop iterating over child table rows. This creates N+1 queries. Instead, fetch all needed data BEFORE the loop:
{# CORRECT: Fetch once, use in loop #}
{% set item_groups = {} %}
{% for item_code in doc.items | map(attribute='item_code') | unique %}
{% set _ = item_groups.update({item_code: frappe.db.get_value("Item", item_code, "item_group")}) %}
{% endfor %}
{% for row in doc.items %}
<td>{{ item_groups.get(row.item_code, "") }}</td>
{% endfor %}---
Formatting Utilities
<!-- Money formatting -->
{{ frappe.utils.fmt_money(doc.grand_total, currency=doc.currency) }}
<!-- Number formatting -->
{{ frappe.utils.flt(value, 2) }}
{{ frappe.format_value(value, {"fieldtype": "Currency", "options": "currency"}) }}
<!-- Date formatting -->
{{ frappe.utils.formatdate(doc.posting_date) }}
{{ doc.posting_date | global_date_format }}
{{ frappe.utils.format_datetime(doc.creation) }}
<!-- Address formatting -->
{{ frappe.utils.get_formatted_address(frappe.get_doc("Address", doc.customer_address)) }}---
Including External Templates
<!-- Include another template -->
{% include "templates/includes/my_component.html" %}
<!-- Include from app path -->
{% include "myapp/templates/print/invoice_header.html" %}
<!-- Conditional include -->
{% if doc.doctype == "Sales Invoice" %}
{% include "myapp/templates/print/sales_header.html" %}
{% endif %}---
Complete Example: Sales Invoice Print Format
<style>
.print-format { font-family: Arial, sans-serif; font-size: 10pt; }
.invoice-header { margin-bottom: 20px; }
.items-table { width: 100%; border-collapse: collapse; }
.items-table th, .items-table td { border: 1px solid #ddd; padding: 6px 8px; }
.items-table th { background: #f5f5f5; text-align: left; }
.text-right { text-align: right; }
.totals { margin-top: 20px; float: right; width: 40%; }
.totals td { padding: 4px 8px; }
</style>
{% macro money(val) %}
{{ frappe.utils.fmt_money(val, currency=doc.currency) }}
{% endmacro %}
<div class="invoice-header">
<h2>{{ doc.name }}</h2>
<p>
<strong>Customer:</strong> {{ doc.customer_name }}<br>
<strong>Date:</strong> {{ doc.posting_date | global_date_format }}<br>
{% if doc.po_no %}
<strong>PO #:</strong> {{ doc.po_no }}<br>
{% endif %}
</p>
</div>
<table class="items-table">
<thead>
<tr>
<th>#</th>
<th>Item</th>
<th>Description</th>
<th class="text-right">Qty</th>
<th class="text-right">Rate</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{% for row in doc.items %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ row.item_name }}</td>
<td>{{ row.description | truncate(80) | default("") }}</td>
<td class="text-right">{{ row.qty }}</td>
<td class="text-right">{{ money(row.rate) }}</td>
<td class="text-right">{{ money(row.amount) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr>
<td><strong>Net Total</strong></td>
<td class="text-right">{{ money(doc.net_total) }}</td>
</tr>
{% if doc.discount_amount %}
<tr>
<td>Discount</td>
<td class="text-right">-{{ money(doc.discount_amount) }}</td>
</tr>
{% endif %}
{% for tax in doc.taxes %}
<tr>
<td>{{ tax.description }}</td>
<td class="text-right">{{ money(tax.tax_amount) }}</td>
</tr>
{% endfor %}
<tr style="border-top: 2px solid #333;">
<td><strong>Grand Total</strong></td>
<td class="text-right"><strong>{{ money(doc.grand_total) }}</strong></td>
</tr>
</table>
<div style="clear: both;"></div>
{% if doc.terms %}
<div style="margin-top: 30px;">
<h4>Terms & Conditions</h4>
{{ doc.terms | safe }}
</div>
{% endif %}---
Registering Custom Jinja Methods & Filters
Via hooks.py
# hooks.py
jinja = {
"methods": [
"myapp.utils.jinja_helpers.get_barcode_svg",
"myapp.utils.jinja_helpers.get_qr_code",
],
"filters": [
"myapp.utils.jinja_helpers.format_iban",
]
}Implementation
# myapp/utils/jinja_helpers.py
def get_barcode_svg(value, barcode_type="Code128"):
"""Generate barcode SVG. Use in templates: {{ get_barcode_svg(doc.name) }}"""
import barcode
from io import BytesIO
code = barcode.get(barcode_type, value, writer=barcode.writer.SVGWriter())
buffer = BytesIO()
code.write(buffer)
return buffer.getvalue().decode()
def format_iban(value):
"""Format IBAN with spaces. Use as: {{ bank_account | format_iban }}"""
if not value:
return ""
clean = value.replace(" ", "")
return " ".join([clean[i:i+4] for i in range(0, len(clean), 4)])Usage in Print Format
<!-- Method call -->
<div class="barcode">{{ get_barcode_svg(doc.name) | safe }}</div>
<!-- Filter -->
<p>Bank Account: {{ doc.bank_account | format_iban }}</p>PDF Generation API — Complete Reference
All PDF generation methods, download endpoints, and rendering configuration in Frappe v14/v15/v16.
---
Core API: get_pdf()
from frappe.utils.pdf import get_pdf
# Basic usage — returns PDF bytes
pdf_bytes = get_pdf(html_string)
# With wkhtmltopdf options
pdf_bytes = get_pdf(html_string, options={
"page-size": "A4",
"margin-top": "15mm",
"margin-right": "10mm",
"margin-bottom": "15mm",
"margin-left": "10mm",
"orientation": "Portrait",
"encoding": "UTF-8",
})Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
html | str | Required | HTML content to convert to PDF |
options | dict | None | wkhtmltopdf options (page-size, margins, orientation) |
Return Value
Returns bytes — the raw PDF binary content. ALWAYS handle as binary, never decode to string.
Common wkhtmltopdf Options
| Option | Values | Default |
|---|---|---|
page-size | A4, Letter, A3, Legal | A4 |
orientation | Portrait, Landscape | Portrait |
margin-top | e.g. 15mm, 0.5in | 15mm |
margin-right | e.g. 10mm | 15mm |
margin-bottom | e.g. 15mm | 15mm |
margin-left | e.g. 10mm | 15mm |
encoding | UTF-8 | UTF-8 |
quiet | (flag, no value) | Set by Frappe |
---
Download Endpoints
Single Document PDF
GET /api/method/frappe.utils.print_format.download_pdf
?doctype=Sales Invoice
&name=SINV-00001
&format=My Print Format # optional, uses default if omitted
&no_letterhead=0 # optional, 0=with letterhead, 1=without
&letterhead=My Letter Head # optional, specific Letter Head namePython: download_pdf()
from frappe.utils.print_format import download_pdf
# This sets frappe.response for file download — use in whitelisted methods
download_pdf(
doctype="Sales Invoice",
name="SINV-00001",
format="My Print Format", # optional
doc=None, # optional, pass pre-loaded doc
no_letterhead=0 # optional
)Multiple Documents in One PDF
GET /api/method/frappe.utils.print_format.download_multi_pdf
?doctype=Sales Invoice
&name=["SINV-00001","SINV-00002","SINV-00003"]
&format=My Print Format
&no_letterhead=0from frappe.utils.print_format import download_multi_pdf
# Downloads concatenated PDF of multiple documents
download_multi_pdf(
doctype="Sales Invoice",
name='["SINV-00001","SINV-00002"]', # JSON string of names
format="My Print Format",
no_letterhead=0
)---
Generating PDF Programmatically
In a Whitelisted API
@frappe.whitelist()
def get_invoice_pdf(invoice_name):
"""Return PDF as base64 for API consumption."""
import base64
from frappe.utils.print_format import download_pdf
html = frappe.get_print(
doctype="Sales Invoice",
name=invoice_name,
print_format="My Print Format",
no_letterhead=0
)
from frappe.utils.pdf import get_pdf
pdf_bytes = get_pdf(html)
return base64.b64encode(pdf_bytes).decode()Attach PDF to Document
def attach_pdf_to_doc(doctype, name, print_format=None):
"""Generate PDF and attach to document as a file."""
html = frappe.get_print(
doctype=doctype,
name=name,
print_format=print_format
)
from frappe.utils.pdf import get_pdf
pdf_bytes = get_pdf(html)
file_name = f"{name}.pdf"
file_doc = frappe.get_doc({
"doctype": "File",
"file_name": file_name,
"content": pdf_bytes,
"attached_to_doctype": doctype,
"attached_to_name": name,
"is_private": 1
})
file_doc.save(ignore_permissions=True)
return file_doc.file_urlSend PDF via Email
def email_with_pdf(doctype, name, recipients, print_format=None):
"""Send document PDF as email attachment."""
html = frappe.get_print(
doctype=doctype,
name=name,
print_format=print_format
)
from frappe.utils.pdf import get_pdf
pdf_bytes = get_pdf(html)
frappe.sendmail(
recipients=recipients,
subject=f"{doctype}: {name}",
message=f"Please find attached {doctype} {name}.",
attachments=[{
"fname": f"{name}.pdf",
"fcontent": pdf_bytes
}]
)---
frappe.get_print()
The primary function to render a document's print format to HTML.
html = frappe.get_print(
doctype="Sales Invoice",
name="SINV-00001",
print_format="My Print Format", # optional
doc=None, # optional, pass pre-loaded doc
no_letterhead=0, # optional
letterhead=None, # optional, Letter Head name
as_pdf=False # if True, returns PDF bytes directly
)Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
doctype | str | Required | DocType name |
name | str | Required | Document name |
print_format | str | None | Print Format name (uses default if None) |
doc | Document | None | Pre-loaded document (skips DB fetch) |
no_letterhead | int | 0 | 1 to suppress Letter Head |
letterhead | str | None | Specific Letter Head name |
as_pdf | bool | False | Return PDF bytes instead of HTML |
---
PDF Hooks
Inject HTML into PDF Header/Footer/Body
# hooks.py
pdf_header_html = "myapp.utils.pdf.get_pdf_header"
pdf_body_html = "myapp.utils.pdf.get_pdf_body"
pdf_footer_html = "myapp.utils.pdf.get_pdf_footer"# myapp/utils/pdf.py
def get_pdf_header(soup, head, content, styles):
"""Called during PDF generation. Return HTML string for header."""
return '<div style="text-align:center; font-size:8pt;">Company Name</div>'
def get_pdf_footer(soup, head, content, styles):
"""Called during PDF generation. Return HTML string for footer."""
return '''
<div style="text-align:center; font-size:8pt;">
Page <span class="page"></span> of <span class="topage"></span>
</div>
'''Hook Parameters
| Parameter | Type | Description |
|---|---|---|
soup | BeautifulSoup | Parsed HTML of the print format |
head | str | HTML <head> content |
content | str | HTML body content |
styles | str | CSS styles |
---
PDF Engine Configuration (v15+)
Per Print Format
Set pdf_generator field on the Print Format document:
| Value | Engine | Notes |
|---|---|---|
| (empty) | wkhtmltopdf | Default, compatible with v14 |
chrome | Chromium headless | Better CSS3/flexbox support |
Print Designer (v15+ Only)
Print Designer formats ALWAYS use WeasyPrint. No configuration needed — it is automatic.
Global Configuration
In site_config.json:
{
"pdf_generator": "chrome"
}This sets the default PDF engine for ALL print formats on the site. Individual Print Format settings override the global default.
---
Page Break Control
In HTML
<!-- Page break between sections -->
<div class="page-break"></div>
<!-- Page break before a specific section -->
<div style="page-break-before: always;">
<h2>Section 2</h2>
</div>
<!-- Prevent break inside an element -->
<div style="page-break-inside: avoid;">
<table>...</table>
</div>In Jinja Loops (e.g., Multi-Section Invoices)
{% for group in doc.item_groups %}
{% if not loop.first %}
<div class="page-break"></div>
{% endif %}
<h3>{{ group.name }}</h3>
<table>
{% for item in group.items %}
<tr><td>{{ item.item_name }}</td></tr>
{% endfor %}
</table>
{% endfor %}wkhtmltopdf Header/Footer with Page Numbers
<!-- Elements with these IDs are extracted by wkhtmltopdf -->
<div id="header-html">
<div style="font-size: 8pt; text-align: right; padding: 5mm;">
{{ doc.company }}
</div>
</div>
<div id="footer-html">
<div style="font-size: 8pt; text-align: center; padding: 5mm;">
Page <span class="page"></span> of <span class="topage"></span>
</div>
</div>IMPORTANT: <span class="page"> and <span class="topage"> are wkhtmltopdf-specific replacements. They ONLY work in PDF output, not in browser print preview.
---
Troubleshooting PDF Generation
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| Blank PDF | HTML has syntax errors | Validate HTML, check Frappe error log |
| Missing images | Relative URLs | Use absolute URLs: {{ frappe.utils.get_url() }}/files/logo.png |
| CSS not applied | External stylesheets blocked | Inline CSS or use <style> tags |
| Page breaks ignored | page-break-inside: avoid on parent | Check parent element CSS |
| Fonts not rendering | Custom fonts not available to wkhtmltopdf | Use system fonts or embed as base64 |
| Landscape not working | Option not passed | Set orientation: Landscape in options |
| Letter Head missing | no_letterhead=1 or no default | Check Letter Head configuration |
Debug PDF Rendering
# Get the HTML that would be sent to PDF engine
html = frappe.get_print("Sales Invoice", "SINV-00001", "My Format")
frappe.log_error(html, "PDF Debug HTML")
# Check wkhtmltopdf version
import subprocess
result = subprocess.run(["wkhtmltopdf", "--version"], capture_output=True, text=True)
print(result.stdout)