Print Format Developer Guide
Everything you need to build production-quality PDF print formats with FrapPDFy — from images and QR codes to multi-copy labels, page numbering, and remote PDF generation.
FrapPDFy renders your Jinja2 HTML template against a Frappe document, then generates a PDF using one of two engines: WeasyPrint (CSS paged media) or CDP (Chrome headless via raw DevTools Protocol). Your template is stored in the Print Format Template doctype as a single HTML string, automatically split into three regions:
| Region | Class / ID | Purpose |
|---|---|---|
| Header | .pdf-header | Repeats on every page |
| Content | .pdf-content (optional) | Main document body |
| Footer | .pdf-footer | Repeats on every page |
.pdf-content is found, everything outside .pdf-header and .pdf-footer is treated as content automatically.
A complete template skeleton showing all three regions:
<!-- HEADER — repeats on every page -->
<div class="pdf-header">
<img src="{{ frappe.utils.get_url(doc.company_logo) }}" height="40">
<h2>{{ doc.company }}</h2>
</div>
<!-- MAIN CONTENT -->
<div class="pdf-content">
<h1>{{ doc.name }}</h1>
<p>Date: {{ doc.posting_date }}</p>
<table>
<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>{{ row.rate }}</td>
<td>{{ row.amount }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- FOOTER — repeats on every page -->
<div class="pdf-footer">
<span>{{ doc.company }} | {{ doc.company_address }}</span>
<span>Page <span class="page-number"></span> of <span class="total-pages"></span></span>
</div>
Page numbering works in both engines using the same HTML structure. Add these spans in your header or footer:
<!-- HTML — works in both engines -->
<span>Page <span class="page-number"></span> of <span class="total-pages"></span></span>
/* Custom CSS — required for WeasyPrint engine */
.page-number::before {
content: counter(page);
}
.total-pages::before {
content: counter(pages);
}
| Engine | How It Works |
|---|---|
| WeasyPrint | CSS counter(page) / counter(pages) fills the ::before pseudo-element natively |
| CDP | Detects .page-number / .total-pages classes, generates a separate header/footer PDF per page with numbers injected |
These utility classes are pre-defined by both engines — do not redefine them in Custom CSS.
<!-- Force a page break AFTER this element -->
<div class="page-break"></div>
<!-- Prevent this block from splitting across pages -->
<div class="no-break">
... keep this section together on one page ...
</div>
FrapPDFy automatically embeds images as base64 data URIs before rendering. This means both public and private files work in PDFs — no authentication issues.
<!-- From an Attach field on the document -->
<img src="{{ frappe.utils.get_url(doc.company_logo) }}" height="50">
<!-- From a hardcoded file path -->
<img src="{{ frappe.utils.get_url('/files/letterhead.png') }}" height="80">
<!-- Private files also work -->
<img src="{{ frappe.utils.get_url('/private/files/stamp.png') }}" height="60">
frappe.utils.get_url(). Both public and private files are supported — FrapPDFy reads them from disk and embeds as base64 before sending to the PDF engine.
Use the web_block helper to render an e-Invoice QR code from a registered Web Template.
<!-- Render the QR code block -->
{{ web_block('e-Invoice QR', values={'e_invoice_qr_text': e_invoice_log.signed_qr_code}) }}
/* Size the QR image via Custom CSS */
.qrcode {
width: 90px;
height: 90px;
}
e_invoice_log must be available in your template context. Fetch it using frappe.get_doc("e-Invoice Log", doc.name) in a Jinja context or pass it via the calling code.
Frappe stores rich text (Long Text with editor) as HTML with Quill editor classes. Override default Quill padding to avoid unwanted spacing in your PDF.
/* Add to Custom CSS */
.ql-editor {
padding: 0;
margin: 0;
}
<!-- Render a rich text field — it's already HTML -->
<div class="ql-editor">{{ doc.terms_and_conditions }}</div>
All custom CSS goes in the Custom CSS field of the Print Format Template. Page size and orientation are set via the doctype fields — not CSS.
/* Recommended base CSS for every template */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
}
tr {
break-inside: avoid;
page-break-inside: avoid;
}
.ql-editor {
padding: 0;
margin: 0;
}
/* Page number counters (WeasyPrint) */
.page-number::before { content: counter(page); }
.total-pages::before { content: counter(pages); }
/* QR code size */
.qrcode { width: 90px; height: 90px; }
Page Size & Orientation Fields
| Field | Accepted Values |
|---|---|
| Page Size | A4, A3, A5, Letter, Legal, Tabloid |
| Orientation | portrait, landscape |
| Margin (mm) | 10mm (all sides) or 10mm 15mm (top/bottom left/right) |
Print multiple copies (e.g. Original, Duplicate, Triplicate) in one PDF, each with different labels. Page numbers reset per copy.
Step 1 — Set fields in Print Format Template
| Field | Value |
|---|---|
| Number of Copies | e.g. 3 |
| Copy Values | JSON array — one object per copy |
Step 2 — Copy Values JSON
[
{ ".copy-label": "Original" },
{ ".copy-label": "Duplicate" },
{ ".copy-label": "Triplicate" }
]
Step 3 — Template placeholder
<div class="pdf-header">
<span class="copy-label"></span> <!-- filled per copy -->
<h2>Tax Invoice</h2>
</div>
Selector Syntax
| Prefix | Targets |
|---|---|
.classname | All elements with that class |
#id | Element with that ID |
plain-name | Elements with that class (same as .classname) |
Choose the right engine for your template's requirements. Set the engine per template in the pdf_engine field.
| Feature | WeasyPrint | CDP (Chrome) |
|---|---|---|
CSS Paged Media (@page) | ✅ Full | ❌ No |
| CSS Grid / Flexbox | Partial | ✅ Full |
| CSS counters (page numbers) | ✅ Native | ✅ Injected |
| Gradients / Transforms / Filters | Partial | ✅ Full |
| True 0mm margins | ✅ Yes | ✅ Yes |
| Full CSS3 in header/footer | ✅ | ✅ |
| Private file images | ✅ Embedded | ✅ Embedded |
| Header/footer repeat | ✅ CSS running() | ✅ 3-PDF overlay |
| Speed | Fast | Moderate |
| Dependencies | weasyprint + playwright | websockets + Chrome (auto-downloads) |
Use WeasyPrint for document-style reports with running headers/footers and CSS page counters.
Use CDP for modern layouts requiring full CSS3 Grid, Flexbox, gradients, and browser-accurate rendering.
Offload PDF generation to a separate Frappe site — useful when your production server can't run Chrome or has memory constraints.
How it works
Production Site PDF Service Site
(frappdfy installed) (frappdfy installed)
│ │
│ Renders template, extracts │
│ header/footer/content │
│ │
│── POST (HTML + settings) ───────▶│
│ │ Generates PDF
│◀── { pdf_base64: "..." } ────────│
│ │
│ Decodes + streams to browser │
Setup
| Step | Action |
|---|---|
| 1 | Install FrapPDFy on the remote PDF service site |
| 2 | Create API keys on the service site: User → API Access → Generate Keys |
| 3 | On production site, go to FrapPDFy Settings and fill in Service URL, API Key, API Secret |
| 4 | Generate any PDF — it automatically routes to the remote site |
FrapPDFy Settings
| Field | Value |
|---|---|
| Service URL | https://pdf.yourcompany.com |
| API Key | From remote site's user API access |
| API Secret | From remote site (stored encrypted in Frappe) |
| Problem | Cause | Fix |
|---|---|---|
| Images not showing | Relative URL | Use frappe.utils.get_url(path) |
| Header/footer missing | Wrong class name | Must be exactly pdf-header / pdf-footer |
| Page numbers show 0 | Testing in browser | CSS counters only work in actual PDF output, not browser preview |
| Extra padding in rich text | Quill defaults | Add .ql-editor { padding: 0; margin: 0; } |
| Table rows split across pages | Missing CSS | Add tr { break-inside: avoid; } |
| QR code too large/small | Default size | Override with .qrcode { width: 90px; } |
| Content cut off | Header/footer too tall | Increase top/bottom margin in settings |
| CDP engine not available | Missing websockets | Run pip install websockets — Chrome auto-downloads |
| WeasyPrint measurement error | Playwright not installed | Run pip install playwright && playwright install chromium |
| Engine error instead of fallback | By design | No silent fallback — install dependencies for your chosen engine |
A cheat-sheet for everyday use: