Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions report_positioned_image/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
=======================
Report Positioned Image
=======================

..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8bc2f08c57ac7bd7e62467501b1ac95394b9e6047b1a4fa48e08a4a99a760e2e
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Freporting--engine-lightgray.png?logo=github
:target: https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image
:alt: OCA/reporting-engine
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/reporting-engine-18-0/reporting-engine-18-0-report_positioned_image
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/reporting-engine&target_branch=18.0
:alt: Try me on Runboat

|badge1| |badge2| |badge3| |badge4| |badge5|

This module allows you to add positioned images (such as watermarks,
logos, or stamps) to PDF reports. Images can be precisely positioned
using millimeter coordinates (top, left) and you can control whether
they appear on all pages or only the first page.

The module supports two types of images:

- *Company-level Images*: Define images at the company level that can
be included in reports by enabling the *Include Company Images*
option
- *Report-specific Images*: Configure specific images for individual
reports, filtered by company context and always shown when configured

**Table of contents**

.. contents::
:local:

Configuration
=============

To configure company-level images:

1. Go to *Settings / Companies*
2. Open your company record
3. Navigate to the *Report Images* tab
4. Add images with position settings:

- Upload an image - width defaults to 50mm and height is
automatically calculated to maintain the original aspect ratio
- *Top (mm)*: Distance from the top of the page
- *Left (mm)*: Distance from the left edge of the page
- *Width (mm)*: Width of the image (changing this auto-adjusts
height)
- *Height (mm)*: Height of the image (changing this auto-adjusts
width)
- *Respect Image Ratio*: When enabled (default), changing width or
height automatically adjusts the other dimension to maintain
aspect ratio. Uncheck for manual control of both dimensions.
- *First Page Only*: Check to show only on the first page

To configure report-specific images:

1. Go to *Settings / Technical / Actions / Reports*
2. Open the report you want to customize
3. Navigate to the *Report Images* tab
4. Check *Include Company Images* if you want to show company-level
images in addition to report-specific images
5. Add report-specific images in the list with the same position
settings as above

**Note**: By default, images maintain their aspect ratio. When you
upload an image, it's automatically sized to 50mm width with
proportional height. You can then adjust either dimension and the other
will update automatically to prevent distortion.

Bug Tracker
===========

Bugs are tracked on `GitHub Issues <https://github.com/OCA/reporting-engine/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/reporting-engine/issues/new?body=module:%20report_positioned_image%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

Do not contact contributors directly about support or help with technical issues.

Credits
=======

Authors
-------

* Quartile

Contributors
------------

- Quartile <https://www.quartile.co>

- Tatsuki Kanda
- Aung Ko Ko Lin

Maintainers
-----------

This module is maintained by the OCA.

.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org

OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.

This module is part of the `OCA/reporting-engine <https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions report_positioned_image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions report_positioned_image/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Report Positioned Image",
"summary": "Add positioned images to PDF reports.",
"version": "18.0.1.0.0",
"category": "Reporting",
"author": "Quartile, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/reporting-engine",
"license": "AGPL-3",
"depends": ["web", "report_qweb_element_page_visibility"],
"data": [
"security/ir.model.access.csv",
"security/report_positioned_image_security.xml",
"views/report_positioned_image_views.xml",
"views/res_company_views.xml",
"views/ir_actions_report_views.xml",
],
"installable": True,
}
3 changes: 3 additions & 0 deletions report_positioned_image/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import ir_actions_report
from . import report_positioned_image
from . import res_company
116 changes: 116 additions & 0 deletions report_positioned_image/models/ir_actions_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from markupsafe import Markup

from odoo import fields, models
from odoo.tools.image import image_data_uri


class IrActionsReport(models.Model):
_inherit = "ir.actions.report"

include_company_images = fields.Boolean(
help="If checked, company-level images will be shown in addition to "
"report-specific images.",
)
report_positioned_image_ids = fields.Many2many(
comodel_name="report.positioned.image",
relation="ir_actions_report_positioned_image_rel",
column1="report_id",
column2="image_id",
string="Report Images",
)

def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
"""Set company context so _get_positioned_image_configs uses the
correct company.
"""
company = self._get_report_company(res_ids)
return super(IrActionsReport, self.with_company(company))._render_qweb_pdf(
report_ref, res_ids, data
)

def _prepare_html(self, html, report_model=False):
image_configs = self._get_positioned_image_configs()
if not image_configs:
return super()._prepare_html(html, report_model=report_model)
result = super()._prepare_html(html, report_model=report_model)
if not isinstance(result, tuple):
return result
bodies, res_ids, header, footer, specific_paperformat_args = result
if image_configs:
header = self._inject_images_into_header(header, image_configs)
return bodies, res_ids, header, footer, specific_paperformat_args

def _inject_images_into_header(self, header, image_configs):
image_html = self._build_image_html(image_configs)
return self._insert_html_into_header(header, image_html)

def _insert_html_into_header(self, header, html_to_inject):
if Markup("</body>") in header:
return header.replace(
Markup("</body>"), html_to_inject + Markup("</body>"), 1
)
if Markup("<body>") in header:
return header.replace(
Markup("<body>"), Markup("<body>") + html_to_inject, 1
)
return header + html_to_inject

@staticmethod
def _build_image_html(images):
parts = []
for image in images:
image_content = image.get("image")
if not image_content:
continue
style_parts = [
"position: fixed",
f"top: {image.get('pos_top', 5)}mm",
f"left: {image.get('pos_left', 5)}mm",
f"width: {image.get('width', 20)}mm",
f"height: {image.get('height', 20)}mm",
]
style = "; ".join(style_parts) + ";"
data_uri = image_data_uri(image_content)
# Use 'first-page' class from report_qweb_element_page_visibility
# for images that should only appear on the first page
css_class = "first-page" if image.get("first_page_only") else ""
class_attr = f' class="{css_class}"' if css_class else ""
parts.append(
f'<div{class_attr} style="{style}">'
f'<img src="{data_uri}" style="width: 100%; height: 100%;"/>'
"</div>"
)
return Markup("".join(parts))

def _get_report_company(self, res_ids):
if not res_ids or not self.model:
return self.env.company
model = self.env[self.model]
if "company_id" not in model._fields:
return self.env.company
records = model.browse(res_ids).exists()
companies = records.mapped("company_id")
return companies[0] if len(companies) == 1 else self.env.company

def _get_positioned_image_configs(self):
company = self.env.company
images = self.report_positioned_image_ids.filtered(
lambda img: img.company_id == company or not img.company_id
)
if self.include_company_images:
images |= company.report_positioned_image_ids
return [
{
"image": img.image,
"pos_top": img.pos_top,
"pos_left": img.pos_left,
"width": img.width,
"height": img.height,
"first_page_only": img.first_page_only,
}
for img in images
if img.image
]
97 changes: 97 additions & 0 deletions report_positioned_image/models/report_positioned_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import base64
from io import BytesIO

from PIL import Image

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class ReportPositionedImage(models.Model):
_name = "report.positioned.image"
_description = "Report Positioned Image"

name = fields.Char(required=True)
image = fields.Binary(attachment=True, required=True)
pos_top = fields.Float(string="Top (mm)", default=5.0)
pos_left = fields.Float(string="Left (mm)", default=5.0)
width = fields.Float(string="Width (mm)")
height = fields.Float(string="Height (mm)")
respect_image_ratio = fields.Boolean(
default=True,
help="When enabled, changing width or height will automatically adjust "
"the other dimension to maintain the original image aspect ratio.",
)
first_page_only = fields.Boolean()
company_id = fields.Many2one(
comodel_name="res.company",
default=lambda self: self._default_company_id(),
help="Leave empty to apply to all companies. Set a specific company to "
"restrict this image to that company only.",
)

def _default_company_id(self):
return self.env.context.get("default_company_id")

def _get_aspect_ratio(self):
"""Get image aspect ratio (width/height)."""
if not self.image:
return None
try:
img = Image.open(BytesIO(base64.b64decode(self.image)))
return img.width / img.height
except Exception:
return None

@api.onchange("image")
def _onchange_image(self):
if not self.image:
return
ratio = self._get_aspect_ratio()
if not ratio:
return
# Set default width to 50mm and calculate height maintaining aspect ratio
self.width = 50.0
self.height = round(50.0 / ratio, 2)

@api.onchange("width", "respect_image_ratio")
def _onchange_width(self):
if self._context.get("from_height_onchange"):
return
if not (self.respect_image_ratio and self.width):
return
ratio = self._get_aspect_ratio()
if ratio and self.width > 0:
# Set context flag to prevent circular onchange
self.with_context(from_width_onchange=True).height = round(
self.width / ratio, 2
)

@api.onchange("height")
def _onchange_height(self):
if self._context.get("from_width_onchange"):
return
if not (self.respect_image_ratio and self.height):
return
ratio = self._get_aspect_ratio()
if ratio and self.height > 0:
# Set context flag to prevent circular onchange
self.with_context(from_height_onchange=True).width = round(
self.height * ratio, 2
)

@api.constrains("pos_top", "pos_left", "width", "height")
def _check_positive_values(self):
"""Ensure position and dimension fields have positive values."""
for record in self:
if record.pos_top < 0:
raise ValidationError(_("Top position must be a positive value."))
if record.pos_left < 0:
raise ValidationError(_("Left position must be a positive value."))
if record.width <= 0:
raise ValidationError(_("Width must be greater than zero."))
if record.height <= 0:
raise ValidationError(_("Height must be greater than zero."))
16 changes: 16 additions & 0 deletions report_positioned_image/models/res_company.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Copyright 2026 Quartile (https://www.quartile.co)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

from odoo import fields, models


class ResCompany(models.Model):
_inherit = "res.company"

report_positioned_image_ids = fields.Many2many(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be one2many. It looks inconsistent with how company_id is defined as many2one in report.positioned.image.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally avoid using a One2many field because it can lead to unintended behavior.

For example, when we add a positioned image to an Order/Quotation report for a specific company, that image is also assigned to the company’s report images. As a result, every time we create a positioned image for a specific report, all of those images are added to the company as well.

Then, if we remove the report images from the company, they are also unlinked from the specific report, which is not the intended behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, we may add a help text to company_id of the image model to make the intent clear.

comodel_name="report.positioned.image",
relation="res_company_positioned_image_rel",
column1="company_id",
column2="image_id",
string="Company Images",
)
3 changes: 3 additions & 0 deletions report_positioned_image/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"
Loading
Loading