本文目前仅提供英文版本。翻译即将推出。
QWeb reports are how every Odoo invoice, sales order, picking list, and customer-facing PDF gets generated. The basic mechanism is simple — write QWeb (an HTML template language), Odoo converts it to PDF via wkhtmltopdf — but the details of page layout, multi-page header/footer rendering, multilingual reports, and the…
QWeb reports are how every Odoo invoice, sales order, picking list, and customer-facing PDF gets generated. The basic mechanism is simple — write QWeb (an HTML template language), Odoo converts it to PDF via wkhtmltopdf — but the details of page layout, multi-page header/footer rendering, multilingual reports, and the web.external_layout inheritance pattern catch developers off guard. This article distills the patterns you actually need.
Key Takeaways
- QWeb is QWeb — same template language used in views, but rendered server-side for PDF
web.external_layoutis the canonical letterhead wrapper — extend it, don't replace- wkhtmltopdf renders the header/footer separately, with its own context — gotcha #1
- Multi-language reports need
t-options="{'lang': record.partner_id.lang}"on<t t-call>- For complex layouts (precise positioning), Chromium-based rendering beats wkhtmltopdf
- Page breaks:
style="page-break-before: always"on a div forces a break- Debugging: render as HTML first (
?report_type=qweb-html) before PDF
Anatomy of a QWeb report
A report in Odoo has three pieces:
- The
ir.actions.reportrecord that ties the report to a model and action button - The QWeb template that defines the layout
- (Optional) Python helpers in a custom report controller
<!-- ir.actions.report definition -->
<record id="action_report_my_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_my_invoice</field>
<field name="report_file">my_module.report_my_invoice</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="base.paperformat_us"/>
</record>
<!-- The QWeb template -->
<template id="report_my_invoice">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<h2>Invoice <span t-field="o.name"/></h2>
<div class="row">
<div class="col-6">
<strong>Customer:</strong>
<div t-field="o.partner_id"
t-options='{"widget": "contact"}'/>
</div>
<div class="col-6">
<strong>Date:</strong> <span t-field="o.invoice_date"/>
</div>
</div>
<table class="table table-sm">
<thead>
<tr>
<th>Description</th>
<th class="text-end">Qty</th>
<th class="text-end">Price</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="o.invoice_line_ids" t-as="line">
<td t-field="line.name"/>
<td class="text-end" t-field="line.quantity"/>
<td class="text-end" t-field="line.price_unit"/>
<td class="text-end" t-field="line.price_subtotal"/>
</tr>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
Key observations:
web.html_containeris the outer HTML scaffold (DOCTYPE, head, body)web.external_layoutwraps with the company letterhead (header, footer, page borders)<div class="page">defines the content area within the letterhead
The web.external_layout pattern
Odoo ships several "external layout" variants — the company's branded letterhead. The default is web.external_layout which dispatches to one of:
web.external_layout_standard— minimal header/footerweb.external_layout_background— full background imageweb.external_layout_boxed— bordered designweb.external_layout_clean— modern minimalweb.external_layout_striped— accent striping
Configurable per company: Settings > Companies > External Layout.
For most custom reports, calling web.external_layout is the right move — it inherits the company-level letterhead choice automatically. Only override if your report needs a fundamentally different layout (e.g., a half-page receipt or a specialized form).
Customizing the letterhead
<template id="external_layout_custom" inherit_id="web.external_layout_standard">
<xpath expr="//div[hasclass('header')]" position="replace">
<div class="header">
<img t-att-src="image_data_uri(company.logo)"
style="max-height: 80px;"/>
<div class="text-end">
<strong t-field="company.name"/><br/>
<span t-field="company.partner_id"
t-options='{"widget": "contact", "no_marker": True}'/>
</div>
</div>
</xpath>
</template>
wkhtmltopdf vs Chromium rendering
Odoo historically uses wkhtmltopdf (a wrapper around WebKit) for PDF generation. It's fast, lightweight, but renders an older HTML/CSS spec — flexbox is partial, CSS Grid is missing, modern fonts can be tricky.
Odoo 17+ added optional Chromium-based rendering via chromium system parameter. Trade-offs:
| Aspect | wkhtmltopdf | Chromium |
|---|---|---|
| HTML/CSS support | WebKit ~2014 era | Full modern |
| Speed | Fast (~500ms typical) | Slower (~2s typical) |
| Memory | Low | High (~300MB per process) |
| Header/footer | Separate render context (gotcha) | Same context |
| Font rendering | Variable | Consistent |
| Page-break control | CSS only | Better support |
For simple reports, wkhtmltopdf is fine. For complex layouts (precise positioning, modern CSS, multi-column), Chromium wins. Configure via report.url system parameter to use Chromium.
The wkhtmltopdf header/footer gotcha
wkhtmltopdf renders the header and footer in separate processes from the body. They don't share variables. If your header references o.name, you need to pass it explicitly — otherwise the header renders with empty values.
The pattern:
<template id="external_layout">
<t t-call="web.html_container">
<!-- This part rendered as the body -->
<div class="article">
<t t-out="0"/> <!-- inserts the calling template's content -->
</div>
<!-- This part rendered as the header (separate process) -->
<div class="header">
<span t-out="company.name"/>
</div>
</t>
</template>
When you pass company/document context, ensure it's accessible in both renders. Use data-oe-model and data-oe-id attributes on the body, then your header template can re-fetch from the URL parameters.
For complex headers/footers, switch to Chromium rendering — single context, no gotchas.
Multi-language reports
A report sent to a French customer should render in French. Odoo handles this via t-options="{'lang': lang}":
<t t-call="web.external_layout"
t-options='{"lang": o.partner_id.lang or "en_US"}'>
<div class="page">
...
</div>
</t>
The lang option flows through to all t-field and translatable strings inside. For reports invoked via the report engine, you can also set the language at the action level via context.
For multi-language strings in the template itself:
<span><t t-translation="off">Invoice #</t><span t-field="o.name"/></span>
<span t-esc="_t('Total')" />: <span t-field="o.amount_total"/>
_t() is the translation marker; t-translation="off" excludes a span from translation.
Page breaks
Force a page break before an element:
<div style="page-break-before: always">
<h2>New Page Section</h2>
...
</div>
Avoid breaking inside an element:
<div style="page-break-inside: avoid">
<h3>Keep this whole section together</h3>
<p>...</p>
</div>
For more control (Chromium only), use the modern break-before, break-inside, break-after properties.
Tables across pages
Tables that span multiple pages need explicit <thead> (rendered on every page) and <tbody>. wkhtmltopdf is sometimes inconsistent here — test with at least 50 rows of data to verify page-break behavior.
For complex tables (totals row, subtotals per group), Chromium rendering is more reliable.
Debugging QWeb reports
Three techniques that save hours:
1. Render as HTML first
Append ?report_type=qweb-html to the report URL. Odoo renders the QWeb to HTML in your browser. You can view source, inspect with devtools, and iterate fast — no PDF generation latency.
2. Inline raw values
Inside a template, dump variables to see what's available:
<pre t-esc="docs[0].read()"/>
<pre t-esc="env.context"/>
Then view as HTML.
3. Debug logger
In the Python report controller (if you have one):
import logging
_logger = logging.getLogger(__name__)
def _render_qweb_pdf(self, ...):
_logger.debug("Report context: %s", self.env.context)
return super()._render_qweb_pdf(...)
Common report tasks
Show company logo
<img t-att-src="image_data_uri(company.logo)"
style="max-height: 60px;"/>
Format currency
<span t-field="o.amount_total"
t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
Format date in user's locale
<span t-field="o.invoice_date"
t-options='{"widget": "date"}'/>
Show address block
<div t-field="o.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name", "phone"]}'/>
Conditional rendering
<t t-if="o.amount_due > 0">
<p style="color: red;">Payment overdue!</p>
</t>
Show subtotal and tax breakdown
<table>
<tr>
<td>Subtotal:</td>
<td t-field="o.amount_untaxed"/>
</tr>
<tr t-foreach="o.amount_by_group" t-as="tax_group">
<td><span t-esc="tax_group[0]"/></td>
<td><span t-esc="tax_group[1]"/></td>
</tr>
<tr>
<td><strong>Total:</strong></td>
<td><strong t-field="o.amount_total"/></strong></td>
</tr>
</table>
Print 1-of-3 pagination
<div class="text-end">
Page <span class="page"/> of <span class="topage"/>
</div>
The page and topage elements are filled in by wkhtmltopdf at render time. They only work inside the header or footer template, not the body.
Frequently Asked Questions
How do I make a report download with a specific filename?
Set print_report_name on the ir.actions.report to a Python expression:
<field name="print_report_name">'Invoice - %s' % object.name</field>
object is the document; the result is used as the PDF filename.
Can I generate Excel/CSV reports instead of PDF?
Yes. For Excel, use the xlsxwriter library in a Python report controller. For CSV, set report_type to qweb-text and emit comma-separated values. There are also OCA modules (report_xlsx, report_xls) that provide Excel-specific helpers.
How do I add a watermark like "DRAFT" on unconfirmed records?
Add a positioned div to the layout template, conditional on state:
<t t-if="o.state == 'draft'">
<div style="position: fixed; top: 50%; left: 50%;
transform: rotate(-30deg); font-size: 100px;
color: rgba(255,0,0,0.2); z-index: -1;">
DRAFT
</div>
</t>
Why does my report look different in the browser preview vs the downloaded PDF?
Browser preview uses your browser's HTML rendering; PDF uses wkhtmltopdf (older WebKit) or Chromium. The most common discrepancy is CSS — modern flex/grid renders differently. Test the actual PDF, not just the HTML preview.
How do I print multiple records into one PDF?
Pass multiple records via docs. The template's t-foreach="docs" t-as="o" loops over them, each in its own page. Combine with <div style="page-break-before: always"> between records to ensure clean page breaks.
How do I render a barcode or QR code in a QWeb report?
Use Odoo's barcode controller as the image source:
<img t-att-src="'/report/barcode/?type=QR&value=%s&width=200&height=200' % o.name"/>
The barcode controller supports QR, Code128, EAN13, and several others. For high-density QR with custom error correction, use a Python library (qrcode) and embed as base64 data URI.
Can I add a digital signature to a generated PDF?
Yes, with a post-processing step. Generate the PDF normally, then run it through a Python library (pyHanko, reportlab's signature module) before delivering. Some clients use the signature placeholder pattern: include a styled signature block in the QWeb (with line, name, date) and have the recipient sign separately via DocuSign-or-equivalent.
QWeb reports are how Odoo's customer-facing documents take shape. ECOSIRE's Odoo customization service builds custom reports for invoices, contracts, statements, and industry-specific documents. See our Odoo developer hire service for senior Python+QWeb developers, or browse our Odoo modules catalog for examples of well-structured report modules.
作者
ECOSIRE Research and Development Team技术写作
ECOSIRE 技术写作团队的内容涵盖 Odoo ERP、Shopify 电子商务、AI 智能体、Power BI 分析、GoHighLevel 自动化以及企业软件最佳实践。我们的指南帮助企业做出明智的技术决策。
相关文章
AI 支持的客户细分:从 RFM 到预测聚类
了解 AI 如何将客户细分从静态 RFM 分析转变为动态预测聚类。使用 Python、Odoo 和真实 ROI 数据的实施指南。
用于供应链优化的人工智能:可见性、预测和自动化
利用人工智能改变供应链运营:需求感知、供应商风险评分、路线优化、仓库自动化和中断预测。 2026年指南。
B2B 批发电商指南:买家门户、账期与客户专属定价
打造买家真正愿意用的 B2B 批发门户:客户专属定价、账期与信用额度、采购单与审批结账,以及基于订单历史的一键复购。