All posts
Developer8 min read

Odoo QWeb PDF reports: custom design, subreports, and report context variables

Odoo generates every PDF from a QWeb template compiled to HTML and rendered via wkhtmltopdf. Mastering the report template structure, subreport calls, and context variable injection lets you produce any document format the business needs.

Every document Odoo prints - sales orders, invoices, delivery slips, manufacturing work orders - is a QWeb template that Odoo compiles to HTML and passes to wkhtmltopdf for PDF rendering. Custom report development follows the same inheritance model as website templates but adds a reporting context, paper formats, and subreport composition.

Report definition#

Reports are defined in two parts: an ir.actions.report record and a QWeb template.

xml
<!-- mymodule/report/sale_order_report.xml -->
<odoo>
  <record id="action_report_sale_order_custom" model="ir.actions.report">
    <field name="name">Custom Sale Order</field>
    <field name="model">sale.order</field>
    <field name="report_type">qweb-pdf</field>
    <field name="report_name">mymodule.report_sale_order_custom_document</field>
    <field name="report_file">mymodule.report_sale_order_custom_document</field>
    <field name="binding_model_id" ref="sale.model_sale_order"/>
    <field name="binding_type">report</field>
  </record>
</odoo>

The binding_model_id and binding_type make the report appear in the "Print" action menu on the model's form view.

QWeb report template structure#

Every PDF report wraps its content in a standard structure:

xml
<template id="report_sale_order_custom_document">
  <t t-call="web.html_container">
    <t t-foreach="docs" t-as="doc">
      <t t-call="web.external_layout">
        <div class="page">
          <!-- report content here -->
          <h2 t-field="doc.name"/>
          <table class="table">
            <thead>
              <tr>
                <th>Product</th>
                <th>Quantity</th>
                <th>Unit Price</th>
                <th>Subtotal</th>
              </tr>
            </thead>
            <tbody>
              <t t-foreach="doc.order_line" t-as="line">
                <tr>
                  <td><span t-field="line.product_id.name"/></td>
                  <td><span t-field="line.product_uom_qty"/></td>
                  <td><span t-field="line.price_unit"/></td>
                  <td><span t-field="line.price_subtotal"/></td>
                </tr>
              </t>
            </tbody>
          </table>
          <div class="row mt32">
            <div class="col-4 offset-8">
              <table class="table table-sm">
                <tr>
                  <td>Subtotal</td>
                  <td class="text-end"><span t-field="doc.amount_untaxed"/></td>
                </tr>
                <tr>
                  <td>Tax</td>
                  <td class="text-end"><span t-field="doc.amount_tax"/></td>
                </tr>
                <tr class="fw-bold">
                  <td>Total</td>
                  <td class="text-end"><span t-field="doc.amount_total"/></td>
                </tr>
              </table>
            </div>
          </div>
        </div>
      </t>
    </t>
  </t>
</template>

Key elements:

  • web.html_container - sets the HTML document structure.
  • web.external_layout - adds the company header and footer.
  • docs - the recordset passed to the report (can be multiple records).
  • t-field - renders the field value with Odoo's field widget (handles currencies, dates, many2one names).

Context variables available in reports#

VariableDescription
docsRecordset of records being printed
doc_idsList of record IDs
doc_modelModel name string
userCurrent user
companyCurrent company
timePython time module
datetimePython datetime module
langCurrent language code

Access related fields directly: doc.partner_id.country_id.name, doc.company_id.logo.

Injecting custom context#

Override the _get_report_values method in a custom report parser to add variables:

python
from odoo import models, api

class SaleOrderReport(models.AbstractModel):
    _name = 'report.mymodule.report_sale_order_custom_document'
    _description = 'Custom Sale Order Report'

    @api.model
    def _get_report_values(self, docids, data=None):
        docs = self.env['sale.order'].browse(docids)
        return {
            'doc_ids': docids,
            'doc_model': 'sale.order',
            'docs': docs,
            'company': self.env.company,
            'custom_footer': 'All sales subject to our standard terms.',
            'show_internal_notes': self.env.user.has_group('mymodule.group_internal'),
        }

In the template, use the injected variable:

xml
<p t-if="show_internal_notes" t-field="doc.note"/>
<p class="text-muted"><t t-esc="custom_footer"/></p>

Subreport composition#

For shared blocks (signatures, terms and conditions, payment details) across multiple reports, extract them into subreport templates and call them with t-call:

xml
<template id="report_payment_terms_block">
  <div class="terms-block">
    <h5>Payment Terms</h5>
    <p t-field="doc.payment_term_id.note"/>
    <p>Bank: <t t-esc="doc.company_id.bank_ids[:1].acc_number"/></p>
  </div>
</template>

<!-- In the main report -->
<t t-call="mymodule.report_payment_terms_block"/>

Pass variables into subreports:

xml
<t t-call="mymodule.report_payment_terms_block">
  <t t-set="show_iban" t-value="True"/>
</t>

Paper format configuration#

Paper formats are defined in Settings → Technical → Reporting → Paper Formats. Create one for a non-standard document:

xml
<record id="paperformat_a5_landscape" model="report.paperformat">
  <field name="name">A5 Landscape</field>
  <field name="default" eval="False"/>
  <field name="format">custom</field>
  <field name="page_width">210</field>
  <field name="page_height">148</field>
  <field name="orientation">Landscape</field>
  <field name="margin_top">10</field>
  <field name="margin_bottom">10</field>
  <field name="margin_left">10</field>
  <field name="margin_right">10</field>
  <field name="header_line" eval="False"/>
  <field name="header_spacing">10</field>
</record>

Link the paper format to the report action:

xml
<field name="paperformat_id" ref="mymodule.paperformat_a5_landscape"/>

Common wkhtmltopdf issues#

Page breaks: Force a page break with CSS:

. This is more reliable than CSS page-break-after on block elements in wkhtmltopdf.

Images not rendering: t-att-src on must use the base64 data URI for images stored in Odoo binary fields. Use t-att-src="'data:image/png;base64,' + doc.image_1920.decode()" - or use the web.image helper template.

Fonts not loading: wkhtmltopdf is a headless browser with its own font rendering. Install fonts system-wide on the Odoo server: apt install fonts-noto. Web fonts loaded from external URLs (Google Fonts) fail in offline environments.

Page numbers: In the header/footer, use the page and topage JavaScript variables that wkhtmltopdf injects:

xml
<t t-call="web.internal_layout">
  <t t-set="o" t-value="docs[0]"/>
  <!-- Page number in footer: uses --footer-html -->
  <div class="footer">
    Page <span class="page"/> of <span class="topage"/>
  </div>
</t>

The web.internal_layout template wires up the header and footer HTML files that wkhtmltopdf processes.

Try ERPeek on your own Odoo module - ask questions, scaffold tests, and explore your codebase in plain language.

Get started free