在報銷、費用單等業務裏,明細行常常會掛一個「發票附件」字段:

  • 有時候是 圖片(JPG/PNG 等);
  • 有時候是 PDF,而且可能還是 多頁 PDF。

在 QWeb 報表裏,我們通常希望:

  • HTML 預覽裏能直接看到發票;
  • 導出 PDF 報表時,每一頁 PDF 的每一頁發票能 清晰、單獨地 展示;
  • 最好一套代碼同時支持「單張圖片」和「多頁 PDF」。

通過一個模型方法 _get_invoice_image_pages,把一份附件統一抽象成「若干張 PNG 頁面」,在 QWeb 裏循環渲染即可。

一、業務場景與目標

模型(以海外報銷明細行為例)大致如下:

# 發票附件:可能是圖片,也可能是 PDF(二進制 + 文件名)
invoice_image = fields.Binary("Invoice Image", readonly=True)
invoice_image_filename = fields.Char("Invoice Filename")

目標:

  1. 如果附件是 圖片:直接按圖片展示即可;
  2. 如果附件是 PDF(多頁):
  • 拆成多張 PNG,每頁一張;
  • 在 QWeb 報表中,每張 PNG 單獨一頁展示(page-break-after: always)。

二、核心思路:統一抽象為「圖片列表」

關鍵是寫一個模型方法:

def _get_invoice_image_pages(self, dpi=150) -> list[bytes]

返回值是一個 list,每個元素都是 一張 PNG 的 base64(二進制):

  • 如果是圖片:返回 [原圖片]
  • 如果是 PDF:用 pdf2image 按頁轉換成多張 PNG,依次放進列表。

這樣,QWeb 模板完全不需要關心「圖片還是 PDF」——

只管循環 pages,一張圖片一頁地渲染就行了。

三、模型代碼:_get_invoice_image_pages

  1. 依賴導入
# -*- coding: utf-8 -*-
import base64
import io
import logging

from odoo import models, fields

_logger = logging.getLogger(__name__)

try:
    from pdf2image import convert_from_bytes
    PDF2IMAGE_AVAILABLE = True
except Exception as e:
    PDF2IMAGE_AVAILABLE = False
    _logger.warning("pdf2image 未安裝,PDF 轉圖片功能不可用: %s", e)

依賴説明:

  • pdf2image:把 PDF 轉成 PIL Image 對象
  • 安裝:pip install pdf2image
  • 系統還需安裝 poppler-utils(Linux 下:yum install poppler-utils / apt-get install poppler-utils)
  1. 方法實現
class OverseasExpenseLine(models.Model):
    _name = "overseas.expense.line"
    _description = "Overseas Expense Reimbursement Line"
    _order = "sequence, id desc"

    # ... 上面已有的字段略 ...

    def _get_invoice_image_pages(self, dpi=150):
        """
        返回一個 list,每個元素是 base64 的 PNG 圖片(一頁一個元素)
        - 如果是圖片:只返回 [原圖片]
        - 如果是 PDF:按頁拆成多張 PNG
        """
        self.ensure_one()

        # 沒附件,直接空列表
        if not self.invoice_image:
            return []

        # 1)判斷是否為 PDF(通過文件名後綴)
        is_pdf = bool(self.invoice_image_filename and
                      self.invoice_image_filename.lower().endswith('.pdf'))

        # 2)不是 PDF,當圖片用:直接返回一張
        if not is_pdf:
            return [self.invoice_image]

        # 3)是 PDF,但沒裝 pdf2image
        if not PDF2IMAGE_AVAILABLE:
            _logger.warning(
                "記錄 %s 為 PDF,但未安裝 pdf2image,無法拆分頁面。",
                self.sequence or self.id
            )
            return []

        # 4)是 PDF,嘗試拆頁
        try:
            pdf_bytes = base64.b64decode(self.invoice_image)
            pages = convert_from_bytes(pdf_bytes, dpi=dpi)
            if not pages:
                return []

            images_b64 = []
            for page in pages:
                buf = io.BytesIO()
                page.save(buf, format='PNG')
                buf.seek(0)
                images_b64.append(base64.b64encode(buf.getvalue()))
            return images_b64

        except Exception as e:
            _logger.error(
                "記錄 %s PDF 拆頁失敗: %s",
                self.sequence or self.id, str(e), exc_info=True
            )
            return []

四、QWeb 模板:明細行圖片 + PDF 的統一展示

假設你的報銷單報表模板裏有 o.expense_line(One2many 明細行)。

我們希望 每條明細的每一頁發票 佔用 一整頁 報表。

<t t-foreach="o.expense_line" t-as="line">
    <!-- 取出當前明細行的所有「頁面圖片」 -->
    <t t-set="pages" t-value="line._get_invoice_image_pages()"/>

    <t t-if="pages">
        <t t-foreach="pages" t-as="img">
            <!-- 每張圖片單獨一頁 -->
            <p style="page-break-after:always;"/>

            <!-- 可選:顯示明細序號等信息 -->
            <h4 t-field="line.sequence" class="pt-4"/>

            <!-- 渲染圖片(img 是 base64) -->
            <img t-attf-src="data:image/png;base64,{{ img.decode('utf-8') if isinstance(img, bytes) else img }}"
                 class="pt-3"
                 style="max-width: 100%; height: auto;"/>
        </t>
    </t>
</t>