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.

📋 Overview

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:

RegionClass / IDPurpose
Header.pdf-headerRepeats on every page
Content.pdf-content (optional)Main document body
Footer.pdf-footerRepeats on every page
ℹ️ If no .pdf-content is found, everything outside .pdf-header and .pdf-footer is treated as content automatically.

🏗️ HTML Structure

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 Numbers

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);
}
EngineHow It Works
WeasyPrintCSS counter(page) / counter(pages) fills the ::before pseudo-element natively
CDPDetects .page-number / .total-pages classes, generates a separate header/footer PDF per page with numbers injected
ℹ️ Use the same HTML and CSS for both engines. WeasyPrint uses CSS counters; CDP replaces the span content directly. Page numbers reset per copy in multi-copy mode.

↕️ Page Breaks

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>

🖼️ Images

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">
Always wrap image paths with 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.

QR Codes

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.

📝 Markdown / Rich Text Fields

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>

🎨 CSS Tips & Overrides

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

FieldAccepted Values
Page SizeA4, A3, A5, Letter, Legal, Tabloid
Orientationportrait, landscape
Margin (mm)10mm (all sides) or 10mm 15mm (top/bottom left/right)

📑 Multi-Copy & Copy Labels

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

FieldValue
Number of Copiese.g. 3
Copy ValuesJSON 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

PrefixTargets
.classnameAll elements with that class
#idElement with that ID
plain-nameElements with that class (same as .classname)

⚙️ WeasyPrint vs CDP (Chrome)

Choose the right engine for your template's requirements. Set the engine per template in the pdf_engine field.

FeatureWeasyPrintCDP (Chrome)
CSS Paged Media (@page)✅ Full❌ No
CSS Grid / FlexboxPartial✅ Full
CSS counters (page numbers)✅ Native✅ Injected
Gradients / Transforms / FiltersPartial✅ Full
True 0mm margins✅ Yes✅ Yes
Full CSS3 in header/footer
Private file images✅ Embedded✅ Embedded
Header/footer repeat✅ CSS running()✅ 3-PDF overlay
SpeedFastModerate
Dependenciesweasyprint + playwrightwebsockets + Chrome (auto-downloads)
💡
Recommendation:
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.
⚠️ No fallback between engines. If the selected engine is unavailable, you get an error — not a silent switch. Install the required dependencies for your chosen engine.

🌐 Remote PDF Service

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

StepAction
1Install FrapPDFy on the remote PDF service site
2Create API keys on the service site: User → API Access → Generate Keys
3On production site, go to FrapPDFy Settings and fill in Service URL, API Key, API Secret
4Generate any PDF — it automatically routes to the remote site

FrapPDFy Settings

FieldValue
Service URLhttps://pdf.yourcompany.com
API KeyFrom remote site's user API access
API SecretFrom remote site (stored encrypted in Frappe)
ℹ️ When a Service URL is configured, all PDF generation routes through the remote site. The engine selection (WeasyPrint or CDP) is sent to the remote site and executed there.
Both engines work with remote delegation. Private images are embedded as base64 before sending, so no authentication issues on the remote side.

🚧 Common Pitfalls
ProblemCauseFix
Images not showingRelative URLUse frappe.utils.get_url(path)
Header/footer missingWrong class nameMust be exactly pdf-header / pdf-footer
Page numbers show 0Testing in browserCSS counters only work in actual PDF output, not browser preview
Extra padding in rich textQuill defaultsAdd .ql-editor { padding: 0; margin: 0; }
Table rows split across pagesMissing CSSAdd tr { break-inside: avoid; }
QR code too large/smallDefault sizeOverride with .qrcode { width: 90px; }
Content cut offHeader/footer too tallIncrease top/bottom margin in settings
CDP engine not availableMissing websocketsRun pip install websockets — Chrome auto-downloads
WeasyPrint measurement errorPlaywright not installedRun pip install playwright && playwright install chromium
Engine error instead of fallbackBy designNo silent fallback — install dependencies for your chosen engine

Quick Reference

A cheat-sheet for everyday use:

Images frappe.utils.get_url(doc.field)
Page nos. .page-number::before { content: counter(page); }
Header <div class="pdf-header">...</div>
Footer <div class="pdf-footer">...</div>
Content <div class="pdf-content">...</div>
Engine weasyprint | cdp
QR code {{ web_block('e-Invoice QR', values={...}) }}
Rich text .ql-editor { padding: 0; margin: 0; }
Page break <div class="page-break"></div>
No break <div class="no-break">...</div>
Copy label <span class="copy-label"></span>
Total pages .total-pages::before { content: counter(pages); }