GuideOdoo DevelopmentMarch 13, 2026

QWeb Reports in Odoo 19:
Custom PDF Templates, Headers & Dynamic Content

INTRODUCTION

Your Invoices Look Like They Were Generated in 2004. Your Clients Noticed.

Every Odoo installation ships with default invoice, sales order, and delivery slip templates. They're functional — and they look like it. Generic company name placement, no brand colors, a header that wastes 30% of the page height, and a footer that shows "Powered by Odoo" in production. Your clients receive these PDFs and form an immediate impression of your professionalism.

But customizing QWeb reports is where most developers hit a wall. The report system spans three layers — report actions (ir.actions.report) that register reports in the menu, QWeb templates that define the HTML structure, and wkhtmltopdf that converts that HTML to PDF with its own quirks around page breaks, headers, footers, and CSS support. A change that looks perfect in the browser preview renders completely differently in the generated PDF.

This guide walks through the entire Odoo 19 QWeb report pipeline — from registering a report action, through building multi-page templates with custom headers and dynamic content, to CSS styling, barcodes, QR codes, and the wkhtmltopdf configuration that makes it all work. Every code block is tested against Odoo 19 Community and Enterprise.

01

Registering a Custom Report Action with ir.actions.report in Odoo 19

Every PDF report in Odoo starts with a ir.actions.report record. This XML record tells Odoo which model the report belongs to, which QWeb template to render, what to name the output file, and which paper format to use. Without this record, your template exists but has no way to be triggered.

XML — reports/custom_invoice_report.xml
<odoo>
  <!-- Paper format: A4 with narrow margins for maximum content area -->
  <record id="paperformat_custom_invoice" model="report.paperformat">
    <field name="name">Custom Invoice A4</field>
    <field name="default" eval="False"/>
    <field name="format">A4</field>
    <field name="orientation">Portrait</field>
    <field name="margin_top">40</field>
    <field name="margin_bottom">28</field>
    <field name="margin_left">7</field>
    <field name="margin_right">7</field>
    <field name="header_line" eval="False"/>
    <field name="header_spacing">35</field>
    <field name="dpi">90</field>
  </record>

  <!-- Report action: registers the report in the Print menu -->
  <record id="action_report_custom_invoice" model="ir.actions.report">
    <field name="name">Custom Invoice</field>
    <field name="model">account.move</field>
    <field name="report_type">qweb-pdf</field>
    <field name="report_name">my_module.report_custom_invoice</field>
    <field name="report_file">my_module.report_custom_invoice</field>
    <field name="print_report_name">
      'Invoice - %s' % (object.name or 'Draft')
    </field>
    <field name="binding_model_id" ref="account.model_account_move"/>
    <field name="binding_type">report</field>
    <field name="paperformat_id" ref="paperformat_custom_invoice"/>
  </record>
</odoo>

Key fields explained:

  • report_name — must match the t-name of your QWeb template exactly. The format is module_name.template_id. A mismatch here is the #1 reason a report action shows "Report not found."
  • print_report_name — a Python expression that generates the PDF filename. The variable object is the current record. This expression runs per-record when printing multiple documents.
  • binding_model_id + binding_type — these two fields together add the report to the Print dropdown on the account.move form view. Remove them to create a report that's only callable programmatically.
  • paperformat_id — links to a custom paper format. The margin_top must be large enough to accommodate your header; header_spacing controls the gap between the header and the body content.
margin_top vs header_spacing

These two fields confuse everyone. margin_top is the total space reserved at the top of each page — it's where the header renders. header_spacing is the gap between the bottom of the header and the start of the body content. If your header is 30mm tall, set margin_top to 35–40mm and header_spacing to 30–35mm. If the body content overlaps the header, increase margin_top. If there's too much whitespace between header and body, decrease header_spacing.

02

Building a Multi-Page QWeb Report Template with External Layout

Odoo provides two base layouts: web.external_layout (full header/footer with company info — used for customer-facing documents) and web.internal_layout (minimal header — used for internal reports). Most custom reports extend external_layout because it handles the company logo, address block, and footer automatically.

The template below demonstrates a complete invoice report with line item iteration, conditional sections, computed totals, and multi-page handling. This is the structure you'll adapt for any document type.

XML — reports/report_custom_invoice_template.xml
<odoo>
  <template id="report_custom_invoice" name="Custom Invoice Report">
    <t t-call="web.html_container">
      <t t-foreach="docs" t-as="o">
        <t t-call="web.external_layout">
          <div class="page">

            <!-- Invoice header info -->
            <div class="row mt-3">
              <div class="col-6">
                <h2>
                  <span t-if="o.move_type == 'out_invoice'">Invoice</span>
                  <span t-elif="o.move_type == 'out_refund'">Credit Note</span>
                  <span t-else="">Document</span>
                  <span t-field="o.name"/>
                </h2>
                <div t-if="o.invoice_date" class="mt-1">
                  <strong>Date:</strong>
                  <span t-field="o.invoice_date"/>
                </div>
                <div t-if="o.invoice_date_due" class="mt-1">
                  <strong>Due Date:</strong>
                  <span t-field="o.invoice_date_due"/>
                </div>
              </div>
              <div class="col-6 text-end">
                <div t-field="o.partner_id"
                  t-options='{{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True}}'/>
              </div>
            </div>

            <!-- Payment reference -->
            <div t-if="o.payment_reference" class="row mt-2">
              <div class="col-12">
                <strong>Payment Reference:</strong>
                <span t-field="o.payment_reference"/>
              </div>
            </div>

            <!-- Line items table -->
            <table class="table table-sm mt-4">
              <thead>
                <tr>
                  <th class="text-start">Description</th>
                  <th class="text-center">Qty</th>
                  <th class="text-end">Unit Price</th>
                  <th t-if="display_discount" class="text-end">Disc.%</th>
                  <th class="text-end">Taxes</th>
                  <th class="text-end">Amount</th>
                </tr>
              </thead>
              <tbody>
                <!-- Set variable to track section grouping -->
                <t t-set="current_section" t-value="None"/>
                <t t-foreach="o.invoice_line_ids.sorted(key=lambda l: (l.sequence, l.id))"
                   t-as="line">

                  <!-- Section header row -->
                  <t t-if="line.display_type == 'line_section'">
                    <t t-set="current_section" t-value="line.name"/>
                    <tr class="fw-bold">
                      <td colspan="99">
                        <span t-field="line.name"/>
                      </td>
                    </tr>
                  </t>

                  <!-- Note row -->
                  <t t-if="line.display_type == 'line_note'">
                    <tr class="fst-italic text-muted">
                      <td colspan="99">
                        <span t-field="line.name"/>
                      </td>
                    </tr>
                  </t>

                  <!-- Regular product line -->
                  <t t-if="not line.display_type">
                    <tr>
                      <td class="text-start">
                        <span t-field="line.name"/>
                      </td>
                      <td class="text-center">
                        <span t-field="line.quantity"/>
                        <span t-field="line.product_uom_id"/>
                      </td>
                      <td class="text-end">
                        <span t-field="line.price_unit"/>
                      </td>
                      <td t-if="display_discount" class="text-end">
                        <span t-field="line.discount"/>
                      </td>
                      <td class="text-end">
                        <span t-esc="', '.join(line.tax_ids.mapped('name'))"/>
                      </td>
                      <td class="text-end">
                        <span t-field="line.price_subtotal"
                          t-options='{{"widget": "monetary", "display_currency": o.currency_id}}'/>
                      </td>
                    </tr>
                  </t>
                </t>
              </tbody>
            </table>

            <!-- Totals block -->
            <div class="row justify-content-end mt-3">
              <div class="col-4">
                <table class="table table-sm">
                  <tr>
                    <td><strong>Subtotal</strong></td>
                    <td class="text-end">
                      <span t-field="o.amount_untaxed"
                        t-options='{{"widget": "monetary", "display_currency": o.currency_id}}'/>
                    </td>
                  </tr>
                  <t t-foreach="o.amount_by_group" t-as="amount_by_group">
                    <tr>
                      <td><span t-esc="amount_by_group[0]"/></td>
                      <td class="text-end">
                        <span t-esc="amount_by_group[1]"
                          t-options='{{"widget": "monetary", "display_currency": o.currency_id}}'/>
                      </td>
                    </tr>
                  </t>
                  <tr class="border-top fw-bold">
                    <td>Total</td>
                    <td class="text-end">
                      <span t-field="o.amount_total"
                        t-options='{{"widget": "monetary", "display_currency": o.currency_id}}'/>
                    </td>
                  </tr>
                </table>
              </div>
            </div>

            <!-- Notes and payment terms -->
            <div t-if="o.narration" class="mt-4">
              <strong>Notes:</strong>
              <span t-field="o.narration"/>
            </div>
            <div t-if="o.invoice_payment_term_id.note" class="mt-2">
              <span t-field="o.invoice_payment_term_id.note"/>
            </div>

          </div>
        </t>
      </t>
    </t>
  </template>
</odoo>

The critical patterns in this template:

  • t-call="web.html_container" — wraps the entire output in the base HTML document structure (doctype, head with stylesheets, body). Never skip this; without it, wkhtmltopdf receives unstyled HTML.
  • t-foreach="docs" t-as="o"docs is the recordset passed to the report. When a user selects 5 invoices and clicks Print, docs contains all 5 records. Each iteration generates a separate page (or set of pages) in the PDF.
  • t-call="web.external_layout" — wraps each document in the header/footer layout. This call must be inside the t-foreach loop so each document gets the correct company header when multi-company is active.
  • t-options with monetary widget — always pass display_currency when rendering monetary fields. Without it, the amount displays without a currency symbol, which is ambiguous on multi-currency invoices.
  • display_type checks — Odoo invoice lines include section headers (line_section) and notes (line_note) alongside real product lines. If you don't filter by display_type, sections and notes render as empty table rows with broken column alignment.
t-field vs t-esc: When to Use Which

Use t-field for database fields you want Odoo to format automatically (dates, monetary values, addresses with the contact widget). Use t-esc for computed expressions and raw Python values. t-field generates a <span> with metadata attributes that enable translation and formatting; t-esc outputs the raw value. In Odoo 19, t-out replaces t-esc in OWL templates, but QWeb reports still use the server-side engine where t-esc remains valid.

03

Custom Report Headers and Footers: Overriding external_layout in Odoo 19

The default web.external_layout template calls sub-templates for the header and footer. To customize these without modifying core Odoo files, you inherit and override the specific layout templates. Odoo 19 uses web.external_layout_standard as the default layout variant. Your custom module inherits this template and replaces the parts you need.

XML — reports/custom_header_footer.xml
<odoo>
  <!-- Override the standard external layout header -->
  <template id="custom_layout_header"
    inherit_id="web.external_layout_standard" name="Custom Header">

    <!-- Replace the entire header block -->
    <xpath expr="//div[hasclass('header')]" position="replace">
      <div class="header">
        <div style="display: flex; justify-content: space-between;
                    align-items: center; padding: 8px 0;
                    border-bottom: 2px solid #2c3e50;">
          <div>
            <img t-if="company.logo"
              t-att-src="image_data_uri(company.logo)"
              style="max-height: 50px;" alt="Company logo"/>
          </div>
          <div style="text-align: right; font-size: 9px;
                      color: #555; line-height: 1.4;">
            <span t-field="company.name"
              style="font-weight: bold; font-size: 11px; color: #2c3e50;"/><br/>
            <span t-field="company.street"/><br/>
            <span t-if="company.street2" t-field="company.street2"/>
            <span t-if="company.street2"><br/></span>
            <span t-field="company.zip"/>
            <span t-field="company.city"/>,
            <span t-field="company.country_id"/><br/>
            <span t-if="company.vat">
              VAT: <span t-field="company.vat"/>
            </span>
          </div>
        </div>
      </div>
    </xpath>

    <!-- Replace the footer block -->
    <xpath expr="//div[hasclass('footer')]" position="replace">
      <div class="footer">
        <div style="border-top: 1px solid #ccc; padding: 6px 0;
                    font-size: 8px; color: #888;
                    display: flex; justify-content: space-between;">
          <span>
            <span t-field="company.name"/> |
            <span t-field="company.phone"/> |
            <span t-field="company.email"/>
          </span>
          <span>
            Page <span class="page"/> of <span class="topage"/>
          </span>
        </div>
      </div>
    </xpath>
  </template>
</odoo>

Important details about headers and footers in wkhtmltopdf:

  • Headers and footers are rendered as separate HTML documents. wkhtmltopdf injects them into each page independently. This means CSS classes defined in your report body are not available in the header/footer. Use inline styles or ensure the stylesheet is included in both contexts.
  • <span class="page"/> and <span class="topage"/> — these are wkhtmltopdf magic classes. At render time, wkhtmltopdf replaces them with the current page number and total page count. They only work in the header and footer, not in the body.
  • The company variable — inside external_layout, the variable company refers to the company of the current document's record, not the logged-in user's company. This is how multi-company reports show the correct letterhead per document.
  • Image handling — use image_data_uri(company.logo) to embed the logo as a base64 data URI. This avoids HTTP requests from wkhtmltopdf back to the Odoo server, which can fail if the server requires authentication or if the URL is not accessible from the wkhtmltopdf process.
Per-Report Layout Override

The template above overrides the header/footer globally for all reports using external_layout_standard. If you only want a custom header on one specific report, create a completely separate layout template and call it directly: <t t-call="my_module.my_custom_layout"> instead of <t t-call="web.external_layout">. Copy the structure from web.external_layout as your starting point.

04

Dynamic Content in QWeb Reports: t-foreach, t-if, t-set, and Custom Python Methods

QWeb templates have access to the full Python environment through the report's context. Beyond simple field display, you can compute values, filter recordsets, call model methods, and control rendering with conditional logic. The key is understanding which variables are available and how to extend the context with custom data.

Adding Custom Data via a Report Model

When the built-in record fields aren't enough, create a custom report model that computes additional data. This Python class adds methods and computed values to the template context:

Python — models/custom_invoice_report.py
from odoo import api, models


class CustomInvoiceReport(models.AbstractModel):
    """Custom data provider for the invoice PDF report."""
    _name = 'report.my_module.report_custom_invoice'
    _description = 'Custom Invoice Report Data'

    @api.model
    def _get_report_values(self, docids, data=None):
        docs = self.env['account.move'].browse(docids)
        return {
            'doc_ids': docids,
            'doc_model': 'account.move',
            'docs': docs,
            'data': data,
            # Custom computed values
            'display_discount': any(
                line.discount != 0
                for doc in docs
                for line in doc.invoice_line_ids
                if not line.display_type
            ),
            'get_payment_status': self._get_payment_status,
            'format_vat': self._format_vat,
        }

    def _get_payment_status(self, invoice):
        """Return a human-readable payment status with amount details."""
        if invoice.payment_state == 'paid':
            return 'Paid in Full'
        if invoice.payment_state == 'partial':
            paid = invoice.amount_total - invoice.amount_residual
            return f'Partially Paid ({paid:.2f} / {invoice.amount_total:.2f})'
        if invoice.invoice_date_due and invoice.invoice_date_due < fields.Date.today():
            days = (fields.Date.today() - invoice.invoice_date_due).days
            return f'Overdue by {days} days'
        return 'Pending'

    def _format_vat(self, vat_number):
        """Format VAT number with country prefix separation."""
        if not vat_number:
            return ''
        # Separate country code from number
        if len(vat_number) > 2 and vat_number[:2].isalpha():
            return f'{vat_number[:2]} {vat_number[2:]}'
        return vat_number

The model name must follow the convention report.<module>.<template_id>. Odoo's report engine automatically looks for a model matching this name when rendering the template. If the model exists, its _get_report_values method is called; if not, Odoo provides a default context with just docs, doc_ids, and doc_model.

Using Custom Methods in the Template

The methods and values returned by _get_report_values are directly accessible in the QWeb template:

XML — Using custom methods in QWeb template
<!-- Payment status badge (calls custom Python method) -->
<div class="mt-3">
  <t t-set="pay_status" t-value="get_payment_status(o)"/>
  <span t-if="'Paid' in pay_status"
    style="background: #27ae60; color: white; padding: 3px 10px;
           border-radius: 3px; font-size: 10px;"
    t-esc="pay_status"/>
  <span t-elif="'Overdue' in pay_status"
    style="background: #e74c3c; color: white; padding: 3px 10px;
           border-radius: 3px; font-size: 10px;"
    t-esc="pay_status"/>
  <span t-else=""
    style="background: #f39c12; color: white; padding: 3px 10px;
           border-radius: 3px; font-size: 10px;"
    t-esc="pay_status"/>
</div>

<!-- Conditional discount column (uses boolean from report model) -->
<th t-if="display_discount" class="text-end">Discount</th>

<!-- Grouped subtotals with t-set accumulator -->
<t t-set="running_total" t-value="0"/>
<t t-foreach="o.invoice_line_ids.filtered(lambda l: not l.display_type)"
   t-as="line">
  <t t-set="running_total" t-value="running_total + line.price_subtotal"/>
  <tr>
    <td t-esc="line.name"/>
    <td class="text-end" t-esc="running_total"
      t-options='{{"widget": "monetary", "display_currency": o.currency_id}}'/>
  </tr>
</t>
Security: What QWeb Templates Can Execute

QWeb server-side templates run in a restricted Python sandbox. You cannot import modules, access the filesystem, or execute arbitrary code directly in the template. All complex logic should live in the report model's _get_report_values method and be passed as pre-computed values or callable functions. Attempting to call os.system or similar from a QWeb expression will raise a NameError.

05

CSS Styling, Barcodes, and QR Codes in Odoo 19 PDF Reports

wkhtmltopdf uses the WebKit rendering engine (circa Chrome 13), which means modern CSS features like Flexbox, CSS Grid, and CSS Variables are partially or fully unsupported. Report styling requires a different approach than web development. Here's what works and what doesn't.

Report Stylesheet

Define your report styles in a separate SCSS/CSS file and include it in the web.report_assets_common bundle:

CSS — static/src/css/report_styles.css
/* ── Custom Invoice Report Styles ── */

/* Page-level controls */
.page {
    font-family: 'Helvetica Neue', Arial, sans-serif;
    font-size: 10px;
    color: #333;
    line-height: 1.5;
}

/* Force page breaks for multi-document batches */
.page {
    page-break-after: always;
}
.page:last-child {
    page-break-after: auto;
}

/* Table styling — avoid CSS Grid, use traditional tables */
.report-table {
    width: 100%;
    border-collapse: collapse;
    margin-top: 15px;
}
.report-table th {
    background-color: #2c3e50;
    color: #ffffff;
    padding: 6px 10px;
    font-size: 9px;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    /* wkhtmltopdf: use -webkit-print-color-adjust for backgrounds */
    -webkit-print-color-adjust: exact;
}
.report-table td {
    padding: 5px 10px;
    border-bottom: 1px solid #eee;
    vertical-align: top;
}
.report-table tr:nth-child(even) td {
    background-color: #f9f9f9;
    -webkit-print-color-adjust: exact;
}

/* Watermark for draft invoices */
.draft-watermark {
    position: fixed;
    top: 40%;
    left: 15%;
    font-size: 100px;
    color: rgba(200, 200, 200, 0.3);
    transform: rotate(-45deg);
    -webkit-transform: rotate(-45deg);
    z-index: -1;
    /* Requires -webkit-print-color-adjust to render in PDF */
    -webkit-print-color-adjust: exact;
}

/* QR code container */
.report-qr {
    text-align: center;
    margin-top: 20px;
    page-break-inside: avoid;
}
.report-qr img {
    width: 100px;
    height: 100px;
}

/* Prevent table rows from splitting across pages */
.report-table tr {
    page-break-inside: avoid;
}

/* Totals box */
.report-totals {
    float: right;
    width: 250px;
    margin-top: 20px;
}
.report-totals table {
    width: 100%;
}
.report-totals .total-row {
    font-weight: bold;
    border-top: 2px solid #2c3e50;
    font-size: 12px;
}

Register the stylesheet in your module's manifest so it's included in report rendering:

Python — __manifest__.py (assets section)
{
    'name': 'Custom Invoice Reports',
    'version': '19.0.1.0.0',
    'depends': ['account'],
    'data': [
        'reports/custom_invoice_report.xml',
        'reports/report_custom_invoice_template.xml',
        'reports/custom_header_footer.xml',
    ],
    'assets': {
        'web.report_assets_common': [
            'my_module/static/src/css/report_styles.css',
        ],
    },
}

Barcodes and QR Codes

Odoo 19 includes a built-in barcode generation system accessible directly from QWeb templates. No external library is needed — the /report/barcode/ controller generates barcode images on the fly:

TypeQWeb CodeUse Case
Code128<img t-att-src="'/report/barcode/?barcode_type=Code128&value=%s&width=300&height=50' % o.name"/>Invoice numbers, delivery slip references
QR Code<img t-att-src="'/report/barcode/?barcode_type=QR&value=%s&width=150&height=150' % o.name"/>Payment links, document verification URLs
EAN-13<img t-att-src="'/report/barcode/?barcode_type=EAN13&value=%s' % product.barcode"/>Product labels, inventory tags

For Swiss QR-bill and ZATCA e-invoicing (Saudi Arabia), Odoo 19 Enterprise includes dedicated barcode generators that produce compliant structured payloads. These are automatically included in the standard invoice layout when the relevant localization module is installed.

wkhtmltopdf Configuration and Paper Formats

The final PDF quality depends heavily on wkhtmltopdf settings. Odoo 19 requires wkhtmltopdf 0.12.6 with patched Qt. The unpatched version produces misaligned headers, missing fonts, and broken page numbers. Key configuration in odoo.conf:

ParameterRecommended ValueWhy
report_urlhttp://localhost:8069URL wkhtmltopdf uses to fetch report assets. Set explicitly if behind a reverse proxy.
limit_memory_hard2684354560 (2.5 GB)Large reports with images can exceed the default 2GB worker memory limit.
limit_time_real600Complex reports (1000+ lines) can take 3–5 minutes to render.
CSS Backgrounds Disappear in PDF?

wkhtmltopdf strips background colors and images by default (browser "print" behavior). Add -webkit-print-color-adjust: exact; to any element that needs a background color in the PDF. This includes table header backgrounds, status badges, and watermarks. Without this declaration, your carefully designed colored table headers render as white in the PDF while looking perfect in the browser preview.

06

4 QWeb Report Mistakes That Produce Broken PDFs in Production

1

Header Overlaps Body Content on Page 2+

The report looks perfect on page 1. On page 2, the body text starts under the header, with the first two lines completely hidden. This happens when margin_top in the paper format is too small for the header height. wkhtmltopdf renders the header as a fixed element at the top of every page, and the body content flows into the margin area if the margin isn't large enough.

Our Fix

Measure your header height in the browser (inspect element, check the rendered height in mm). Set margin_top to that height + 10mm. Set header_spacing to margin_top - 5mm. Test with a document that generates at least 3 pages — page break behavior only surfaces on multi-page reports.

2

report_name Mismatch Causes "Report Not Found" with No Error Log

You create a beautiful report template with t-name="my_module.report_invoice_custom" and a report action with report_name set to my_module.custom_invoice_report. The names don't match. Clicking Print shows a generic "Report not found" error. The Odoo log shows nothing — no traceback, no warning, just a 404 response from the report controller. This is because the report engine does a direct template lookup by name and silently fails.

Our Fix

Use a consistent naming convention: report_<model_snake_case> for the template ID and use the same value in both report_name and report_file on the action. Before debugging anything else, verify the match with: self.env['ir.ui.view'].search([('key', 'like', 'report_custom')]) in the Odoo shell.

3

Barcodes Render as Broken Images in PDF

Your barcode images work in the browser preview but show as broken image icons in the generated PDF. This happens because wkhtmltopdf makes HTTP requests to fetch barcode images from /report/barcode/, and the request fails — either because report_url is not configured (wkhtmltopdf can't reach the Odoo server), the session cookie isn't forwarded, or the server is behind a reverse proxy that requires HTTPS while wkhtmltopdf is requesting HTTP.

Our Fix

Set report_url = http://127.0.0.1:8069 in odoo.conf to ensure wkhtmltopdf connects directly to the Odoo backend, bypassing the reverse proxy. If using Docker, set the URL to the Odoo container's internal hostname. For maximum reliability, use Odoo's built-in barcode widget in t-field instead of the URL approach: <span t-field="o.name" t-options='{{"widget": "barcode", "symbology": "Code128"}}'/>.

4

Table Rows Split Across Pages, Cutting Text in Half

A long invoice with 80+ lines generates a 4-page PDF. On pages 2 and 3, some table rows are cut in half — the top of the text appears at the bottom of one page and the rest at the top of the next. This is wkhtmltopdf's default behavior: it breaks content at the exact page boundary without regard for element boundaries.

Our Fix

Add page-break-inside: avoid; to table rows and any block element that shouldn't be split. For the entire table, use page-break-before: auto; page-break-after: auto;. Note: page-break-inside: avoid on <table> itself doesn't work reliably in wkhtmltopdf — you must apply it to <tr> elements. Also avoid <thead> repetition bugs by testing with the patched Qt version of wkhtmltopdf (0.12.6).

BUSINESS ROI

What Professional PDF Reports Do for Your Business

Custom report templates aren't a cosmetic upgrade — they're a business communication tool. Every PDF your company sends is a touchpoint:

3.5xFaster Payment Collection

Invoices with clear payment terms, QR codes for instant payment, and professional formatting get paid faster. Clients take well-designed invoices more seriously.

0Manual PDF Edits

Teams that rely on default templates often export to Word, manually adjust, and re-export. Custom QWeb templates eliminate this entirely — the PDF is correct the first time.

100%Brand Consistency

Every quote, invoice, delivery slip, and purchase order carries your brand identity. Consistent colors, logos, and typography across all documents.

The hidden ROI is compliance. Tax authorities in the EU, Saudi Arabia, and India increasingly require structured data in invoice PDFs — QR codes with ZATCA payloads, Factur-X XML attachments, or specific field placements. A properly built QWeb report system makes these requirements a template change, not a development project.

SEO NOTES

Optimization Metadata

Meta Desc

Build custom QWeb PDF reports in Odoo 19. Report actions, external layouts, custom headers/footers, dynamic content, CSS styling, barcodes, QR codes, and wkhtmltopdf configuration.

H2 Keywords

1. "Registering a Custom Report Action with ir.actions.report in Odoo 19"
2. "Building a Multi-Page QWeb Report Template with External Layout"
3. "Custom Report Headers and Footers: Overriding external_layout in Odoo 19"
4. "Dynamic Content in QWeb Reports: t-foreach, t-if, t-set, and Custom Python Methods"
5. "CSS Styling, Barcodes, and QR Codes in Odoo 19 PDF Reports"

Stop Sending PDFs That Look Like Default Templates

Every invoice, quote, and delivery slip your company generates is a brand impression. Default Odoo templates communicate one thing: "We didn't invest time in this." Custom QWeb reports communicate the opposite — attention to detail, professionalism, and a company that takes its client communications seriously.

If your Odoo reports need a professional overhaul, we can help. We design and implement custom QWeb report templates — from branded invoices with QR payment codes to complex multi-page manufacturing reports with barcodes and dynamic sections. The templates are maintainable, upgrade-safe, and tested across wkhtmltopdf edge cases that most developers discover the hard way.

Book a Free Report Audit