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); }