diff --git a/README.md b/README.md
index 18f85670fc..39f544d899 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,8 @@ addon | version | maintainers | summary
[report_partner_address](report_partner_address/) | 18.0.1.0.0 |
| Translatable partner address details for reports and portal
[report_pdf_form](report_pdf_form/) | 18.0.1.0.0 |
| Fill custom PDF form reports
[report_pdf_zip_download](report_pdf_zip_download/) | 18.0.1.0.0 | | Report PDF ZIP Download
-[report_py3o](report_py3o/) | 18.0.1.0.2 | | Reporting engine based on Libreoffice (ODT -> ODT, ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)
+[report_positioned_image](report_positioned_image/) | 18.0.1.0.0 | | Add positioned images to PDF reports.
+[report_py3o](report_py3o/) | 18.0.1.0.3 | | Reporting engine based on Libreoffice (ODT -> ODT, ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)
[report_py3o_fusion_server](report_py3o_fusion_server/) | 18.0.1.0.0 | | Let the fusion server handle format conversion.
[report_qr](report_qr/) | 18.0.1.0.0 | | Web QR Manager
[report_qweb_element_page_visibility](report_qweb_element_page_visibility/) | 18.0.1.0.0 | | Report Qweb Element Page Visibility
diff --git a/report_positioned_image/README.rst b/report_positioned_image/README.rst
new file mode 100644
index 0000000000..d55eb0d9cf
--- /dev/null
+++ b/report_positioned_image/README.rst
@@ -0,0 +1,136 @@
+.. image:: https://odoo-community.org/readme-banner-image
+ :target: https://odoo-community.org/get-involved?utm_source=readme
+ :alt: Odoo Community Association
+
+=======================
+Report Positioned Image
+=======================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:7492b81c95084617eacd2468003a6783881838c17e7ef230b84bf4d6733dc0e3
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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/license-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
+
+Images can be assigned to a specific company or left as shared records
+(without company assignment) for use across multiple companies
+
+**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
+ - *Company*: Automatically set to the current company when creating
+ from the company form. To create shared images, leave empty.
+
+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 `_.
+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 `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+-------
+
+* Quartile
+
+Contributors
+------------
+
+- Quartile
+
+ - 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 `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/report_positioned_image/__init__.py b/report_positioned_image/__init__.py
new file mode 100644
index 0000000000..0650744f6b
--- /dev/null
+++ b/report_positioned_image/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/report_positioned_image/__manifest__.py b/report_positioned_image/__manifest__.py
new file mode 100644
index 0000000000..683b0de2a9
--- /dev/null
+++ b/report_positioned_image/__manifest__.py
@@ -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,
+}
diff --git a/report_positioned_image/i18n/report_positioned_image.pot b/report_positioned_image/i18n/report_positioned_image.pot
new file mode 100644
index 0000000000..1a6ba219d2
--- /dev/null
+++ b/report_positioned_image/i18n/report_positioned_image.pot
@@ -0,0 +1,191 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * report_positioned_image
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: report_positioned_image
+#: model:ir.model,name:report_positioned_image.model_res_company
+msgid "Companies"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__company_id
+msgid "Company"
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid "Company Assignment"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_res_company__report_positioned_image_ids
+msgid "Company Images"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__create_uid
+msgid "Created by"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__create_date
+msgid "Created on"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__display_name
+msgid "Display Name"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__first_page_only
+msgid "First Page Only"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__height
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_act_report_form_positioned_image
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_company_form_positioned_image
+msgid "Height (mm)"
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid "Height must be greater than zero."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__id
+msgid "ID"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,help:report_positioned_image.field_ir_actions_report__include_company_images
+#: model:ir.model.fields,help:report_positioned_image.field_report_pdf_form__include_company_images
+msgid ""
+"If checked, company-level images will be shown in addition to report-"
+"specific images."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__image
+msgid "Image"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_ir_actions_report__include_company_images
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_pdf_form__include_company_images
+msgid "Include Company Images"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__write_uid
+msgid "Last Updated by"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__write_date
+msgid "Last Updated on"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,help:report_positioned_image.field_report_positioned_image__company_id
+msgid ""
+"Leave empty to apply to all companies. Set a specific company to restrict "
+"this image to that company only."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__pos_left
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_act_report_form_positioned_image
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_company_form_positioned_image
+msgid "Left (mm)"
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid "Left position must be a positive value."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__name
+msgid "Name"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model,name:report_positioned_image.model_ir_actions_report
+msgid "Report Action"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_ir_actions_report__report_positioned_image_ids
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_pdf_form__report_positioned_image_ids
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_act_report_form_positioned_image
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_company_form_positioned_image
+msgid "Report Images"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model,name:report_positioned_image.model_report_positioned_image
+msgid "Report Positioned Image"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__respect_image_ratio
+msgid "Respect Image Ratio"
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__pos_top
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_act_report_form_positioned_image
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_company_form_positioned_image
+msgid "Top (mm)"
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid "Top position must be a positive value."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,help:report_positioned_image.field_report_positioned_image__respect_image_ratio
+msgid ""
+"When enabled, changing width or height will automatically adjust the other "
+"dimension to maintain the original image aspect ratio."
+msgstr ""
+
+#. module: report_positioned_image
+#: model:ir.model.fields,field_description:report_positioned_image.field_report_positioned_image__width
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_act_report_form_positioned_image
+#: model_terms:ir.ui.view,arch_db:report_positioned_image.view_company_form_positioned_image
+msgid "Width (mm)"
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid "Width must be greater than zero."
+msgstr ""
+
+#. module: report_positioned_image
+#. odoo-python
+#: code:addons/report_positioned_image/models/report_positioned_image.py:0
+msgid ""
+"You cannot assign this image to a different company. Please use the "
+"dedicated wizard to assign images to other companies."
+msgstr ""
diff --git a/report_positioned_image/models/__init__.py b/report_positioned_image/models/__init__.py
new file mode 100644
index 0000000000..197c7163dd
--- /dev/null
+++ b/report_positioned_image/models/__init__.py
@@ -0,0 +1,3 @@
+from . import ir_actions_report
+from . import report_positioned_image
+from . import res_company
diff --git a/report_positioned_image/models/ir_actions_report.py b/report_positioned_image/models/ir_actions_report.py
new file mode 100644
index 0000000000..9fdca590fc
--- /dev/null
+++ b/report_positioned_image/models/ir_actions_report.py
@@ -0,0 +1,115 @@
+# 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",
+ )
+
+ @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''
+ f'

'
+ "
"
+ )
+ return Markup("".join(parts))
+
+ def _insert_html_into_header(self, header, html_to_inject):
+ if Markup("
") in header:
+ return header.replace(
+ Markup(""), Markup("") + html_to_inject, 1
+ )
+ return header + html_to_inject
+
+ 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 _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
+ ]
+
+ 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
+ header = self._inject_images_into_header(header, image_configs)
+ return bodies, res_ids, header, footer, specific_paperformat_args
+
+ 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 _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
+ )
diff --git a/report_positioned_image/models/report_positioned_image.py b/report_positioned_image/models/report_positioned_image.py
new file mode 100644
index 0000000000..edfb5f9dc8
--- /dev/null
+++ b/report_positioned_image/models/report_positioned_image.py
@@ -0,0 +1,116 @@
+# 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")
+
+ @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."))
+
+ 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.onchange("company_id")
+ def _onchange_company_id(self):
+ """Prevent assigning to a different company when created from company form."""
+ default_company_id = self.env.context.get("default_company_id")
+ if not default_company_id:
+ return
+ if self.company_id and self.company_id.id != default_company_id:
+ self.company_id = default_company_id
+ return {
+ "warning": {
+ "title": _("Company Assignment"),
+ "message": _(
+ "You cannot assign this image to a different company. "
+ "Please use the dedicated wizard to assign images to other "
+ "companies."
+ ),
+ }
+ }
diff --git a/report_positioned_image/models/res_company.py b/report_positioned_image/models/res_company.py
new file mode 100644
index 0000000000..21df82a227
--- /dev/null
+++ b/report_positioned_image/models/res_company.py
@@ -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(
+ comodel_name="report.positioned.image",
+ relation="res_company_positioned_image_rel",
+ column1="company_id",
+ column2="image_id",
+ string="Company Images",
+ )
diff --git a/report_positioned_image/pyproject.toml b/report_positioned_image/pyproject.toml
new file mode 100644
index 0000000000..4231d0cccb
--- /dev/null
+++ b/report_positioned_image/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/report_positioned_image/readme/CONFIGURE.md b/report_positioned_image/readme/CONFIGURE.md
new file mode 100644
index 0000000000..0342fbd98b
--- /dev/null
+++ b/report_positioned_image/readme/CONFIGURE.md
@@ -0,0 +1,33 @@
+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
+ - *Company*: Automatically set to the current company when creating from
+ the company form. To create shared images, leave empty.
+
+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.
diff --git a/report_positioned_image/readme/CONTRIBUTORS.md b/report_positioned_image/readme/CONTRIBUTORS.md
new file mode 100644
index 0000000000..c1911de180
--- /dev/null
+++ b/report_positioned_image/readme/CONTRIBUTORS.md
@@ -0,0 +1,3 @@
+- Quartile \<\>
+ - Tatsuki Kanda
+ - Aung Ko Ko Lin
diff --git a/report_positioned_image/readme/DESCRIPTION.md b/report_positioned_image/readme/DESCRIPTION.md
new file mode 100644
index 0000000000..0220a9c075
--- /dev/null
+++ b/report_positioned_image/readme/DESCRIPTION.md
@@ -0,0 +1,14 @@
+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
+
+Images can be assigned to a specific company or left as shared records
+(without company assignment) for use across multiple companies
diff --git a/report_positioned_image/security/ir.model.access.csv b/report_positioned_image/security/ir.model.access.csv
new file mode 100644
index 0000000000..419de3303f
--- /dev/null
+++ b/report_positioned_image/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_report_positioned_image_user,report.positioned.image user,model_report_positioned_image,base.group_user,1,0,0,0
+access_report_positioned_image_manager,report.positioned.image manager,model_report_positioned_image,base.group_system,1,1,1,1
diff --git a/report_positioned_image/security/report_positioned_image_security.xml b/report_positioned_image/security/report_positioned_image_security.xml
new file mode 100644
index 0000000000..aa22618b04
--- /dev/null
+++ b/report_positioned_image/security/report_positioned_image_security.xml
@@ -0,0 +1,12 @@
+
+
+
+ Report Positioned Image: multi-company
+
+ [
+ '|',
+ ('company_id', '=', False),
+ ('company_id', 'in', company_ids)
+ ]
+
+
diff --git a/report_positioned_image/static/description/icon.png b/report_positioned_image/static/description/icon.png
new file mode 100644
index 0000000000..1dcc49c24f
Binary files /dev/null and b/report_positioned_image/static/description/icon.png differ
diff --git a/report_positioned_image/static/description/index.html b/report_positioned_image/static/description/index.html
new file mode 100644
index 0000000000..a83b124585
--- /dev/null
+++ b/report_positioned_image/static/description/index.html
@@ -0,0 +1,486 @@
+
+
+
+
+
+README.rst
+
+
+
+
+
+
+
+
+
+
+
Report Positioned Image
+
+

+
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
+
+
Images can be assigned to a specific company or left as shared records
+(without company assignment) for use across multiple companies
+
Table of contents
+
+
+
+
To configure company-level images:
+
+- Go to Settings / Companies
+- Open your company record
+- Navigate to the Report Images tab
+- 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
+- Company: Automatically set to the current company when creating
+from the company form. To create shared images, leave empty.
+
+
+
+
To configure report-specific images:
+
+- Go to Settings / Technical / Actions / Reports
+- Open the report you want to customize
+- Navigate to the Report Images tab
+- Check Include Company Images if you want to show company-level
+images in addition to report-specific images
+- 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.
+
+
+
+
Bugs are tracked on GitHub 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.
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+
+
This module is maintained by the OCA.
+
+
+
+
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 project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
+
+
+
+
+
+
diff --git a/report_positioned_image/tests/__init__.py b/report_positioned_image/tests/__init__.py
new file mode 100644
index 0000000000..20a6a6863a
--- /dev/null
+++ b/report_positioned_image/tests/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2026 Quartile (https://www.quartile.co)
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+from . import test_report_positioned_image
diff --git a/report_positioned_image/tests/test_report_positioned_image.py b/report_positioned_image/tests/test_report_positioned_image.py
new file mode 100644
index 0000000000..e52e5302f8
--- /dev/null
+++ b/report_positioned_image/tests/test_report_positioned_image.py
@@ -0,0 +1,274 @@
+# 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 Command
+from odoo.exceptions import ValidationError
+from odoo.tests.common import TransactionCase
+
+
+class TestReportPositionedImage(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.company_a = cls.env.ref("base.main_company")
+ cls.company_b = cls.env["res.company"].create({"name": "Company B"})
+ cls.report = cls.env["ir.actions.report"].create(
+ {
+ "name": "Test Report",
+ "model": "res.partner",
+ "report_type": "qweb-pdf",
+ "report_name": "test_report",
+ }
+ )
+ # Create a simple 1x1 transparent PNG for testing (base64-encoded)
+ cls.test_image = (
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhg"
+ b"GAWjR9awAAAABJRU5ErkJggg=="
+ )
+ cls.image_a = cls.env["report.positioned.image"].create(
+ {
+ "name": "Company A Image",
+ "image": cls.test_image,
+ "pos_top": 10.0,
+ "pos_left": 15.0,
+ "width": 25.0,
+ "height": 30.0,
+ "first_page_only": False,
+ "company_id": cls.company_a.id,
+ }
+ )
+ cls.company_a.write(
+ {"report_positioned_image_ids": [Command.set([cls.image_a.id])]}
+ )
+ cls.image_b = cls.env["report.positioned.image"].create(
+ {
+ "name": "Company B Image",
+ "image": cls.test_image,
+ "pos_top": 50.0,
+ "pos_left": 60.0,
+ "width": 70.0,
+ "height": 80.0,
+ "first_page_only": True,
+ "company_id": cls.company_b.id,
+ }
+ )
+ cls.company_b.write(
+ {"report_positioned_image_ids": [Command.set([cls.image_b.id])]}
+ )
+ cls.global_image = cls.env["report.positioned.image"].create(
+ {
+ "name": "Global Image",
+ "image": cls.test_image,
+ "pos_top": 5.0,
+ "pos_left": 5.0,
+ "width": 10.0,
+ "height": 10.0,
+ "company_id": False,
+ }
+ )
+
+ def test_company_images_respects_company_context(self):
+ self.report.include_company_images = True
+ configs = self.report.with_company(
+ self.company_a
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs), 1)
+ self.assertEqual(configs[0]["pos_top"], 10.0)
+ self.assertEqual(configs[0]["pos_left"], 15.0)
+ self.assertFalse(configs[0]["first_page_only"])
+ configs = self.report.with_company(
+ self.company_b
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs), 1)
+ self.assertEqual(configs[0]["pos_top"], 50.0)
+ self.assertEqual(configs[0]["pos_left"], 60.0)
+ self.assertTrue(configs[0]["first_page_only"])
+
+ def test_report_images_filter_by_company(self):
+ self.report.write(
+ {
+ "include_company_images": False,
+ "report_positioned_image_ids": [
+ Command.set([self.image_a.id, self.image_b.id])
+ ],
+ }
+ )
+ configs = self.report.with_company(
+ self.company_a
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs), 1)
+ self.assertEqual(configs[0]["pos_top"], 10.0)
+ configs = self.report.with_company(
+ self.company_b
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs), 1)
+ self.assertEqual(configs[0]["pos_top"], 50.0)
+
+ def test_combined_company_and_report_images(self):
+ custom_image = self.env["report.positioned.image"].create(
+ {
+ "name": "Custom Report Image",
+ "image": self.test_image,
+ "pos_top": 100.0,
+ "pos_left": 110.0,
+ "width": 120.0,
+ "height": 130.0,
+ "first_page_only": False,
+ "company_id": self.company_a.id,
+ }
+ )
+ self.report.write(
+ {
+ "include_company_images": True,
+ "report_positioned_image_ids": [Command.set([custom_image.id])],
+ }
+ )
+ configs = self.report.with_company(
+ self.company_a
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs), 2)
+ self.assertEqual(configs[0]["pos_top"], 100.0)
+ self.assertEqual(configs[1]["pos_top"], 10.0)
+
+ def test_validation_negative_dimensions(self):
+ with self.assertRaises(ValidationError):
+ self.env["report.positioned.image"].create(
+ {
+ "name": "Invalid Image",
+ "image": self.test_image,
+ "width": -10.0,
+ "company_id": self.company_a.id,
+ }
+ )
+ with self.assertRaises(ValidationError):
+ self.image_a.write({"height": -5.0})
+
+ def test_build_image_html_positioning(self):
+ images = [
+ {
+ "image": self.test_image,
+ "pos_top": 5,
+ "pos_left": 10,
+ "width": 20,
+ "height": 15,
+ }
+ ]
+ html = self.report._build_image_html(images)
+ html_str = str(html)
+ self.assertIn("position: fixed", html_str)
+ self.assertIn("top: 5mm", html_str)
+ self.assertIn("left: 10mm", html_str)
+ self.assertIn("width: 20mm", html_str)
+ self.assertIn("height: 15mm", html_str)
+ self.assertIn('
")
+ result = self.report._inject_images_into_header(header, images)
+ result_str = str(result)
+ # Should contain the first-page class
+ self.assertIn('class="first-page"', result_str)
+
+ def test_global_images_appear_for_all_companies(self):
+ self.report.write(
+ {
+ "report_positioned_image_ids": [
+ Command.set([self.global_image.id, self.image_a.id])
+ ]
+ }
+ )
+ configs_a = self.report.with_company(
+ self.company_a
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs_a), 2)
+ # Company B sees: global only (not image_a)
+ configs_b = self.report.with_company(
+ self.company_b
+ )._get_positioned_image_configs()
+ self.assertEqual(len(configs_b), 1)
+
+ def test_company_id_onchange_with_context(self):
+ image = (
+ self.env["report.positioned.image"]
+ .with_context(default_company_id=self.company_a.id)
+ .new(
+ {
+ "name": "Test Image",
+ "image": self.test_image,
+ "width": 10.0,
+ "height": 10.0,
+ "company_id": self.company_a.id,
+ }
+ )
+ )
+ image.company_id = self.company_b
+ result = image._onchange_company_id()
+ self.assertIsNotNone(result)
+ self.assertIn("warning", result)
+ self.assertEqual(image.company_id, self.company_a)
+ image.company_id = self.company_a
+ result = image._onchange_company_id()
+ self.assertIsNone(result)
+ self.assertEqual(image.company_id, self.company_a)
+ image.company_id = False
+ result = image._onchange_company_id()
+ self.assertIsNone(result)
+ self.assertFalse(image.company_id)
+ image_no_context = self.env["report.positioned.image"].new(
+ {
+ "name": "Free Image",
+ "image": self.test_image,
+ "width": 10.0,
+ "height": 10.0,
+ "company_id": self.company_b.id,
+ }
+ )
+ result = image_no_context._onchange_company_id()
+ self.assertIsNone(result)
+ self.assertEqual(image_no_context.company_id, self.company_b)
diff --git a/report_positioned_image/views/ir_actions_report_views.xml b/report_positioned_image/views/ir_actions_report_views.xml
new file mode 100644
index 0000000000..ee27a39005
--- /dev/null
+++ b/report_positioned_image/views/ir_actions_report_views.xml
@@ -0,0 +1,37 @@
+
+
+
+ ir.actions.report.positioned.image
+ ir.actions.report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/report_positioned_image/views/report_positioned_image_views.xml b/report_positioned_image/views/report_positioned_image_views.xml
new file mode 100644
index 0000000000..25e21faf7b
--- /dev/null
+++ b/report_positioned_image/views/report_positioned_image_views.xml
@@ -0,0 +1,42 @@
+
+
+
+ report.positioned.image.view.form
+ report.positioned.image
+
+
+
+
+
+ report.positioned.image.view.tree
+ report.positioned.image
+
+
+
+
+
+
+
+
+
diff --git a/report_positioned_image/views/res_company_views.xml b/report_positioned_image/views/res_company_views.xml
new file mode 100644
index 0000000000..d69a373e48
--- /dev/null
+++ b/report_positioned_image/views/res_company_views.xml
@@ -0,0 +1,36 @@
+
+
+
+ res.company.form.positioned.image
+ res.company
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/report_py3o/README.rst b/report_py3o/README.rst
index 8f3c5f26dc..6a8375adb2 100644
--- a/report_py3o/README.rst
+++ b/report_py3o/README.rst
@@ -11,7 +11,7 @@ Py3o Report Engine
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
- !! source digest: sha256:5247a746d03835a38b3c79cc709156047927649c6f612f0ff0312619cfb57c48
+ !! source digest: sha256:e07081016b19fcddf4ee1a4bb69764055b0c6df19b4628a9fb8766d4777abc54
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
diff --git a/report_py3o/__manifest__.py b/report_py3o/__manifest__.py
index e15d2e5106..20ead67c3a 100644
--- a/report_py3o/__manifest__.py
+++ b/report_py3o/__manifest__.py
@@ -4,7 +4,7 @@
"name": "Py3o Report Engine",
"summary": "Reporting engine based on Libreoffice (ODT -> ODT, "
"ODT -> PDF, ODT -> DOC, ODT -> DOCX, ODS -> ODS, etc.)",
- "version": "18.0.1.0.2",
+ "version": "18.0.1.0.3",
"category": "Reporting",
"license": "AGPL-3",
"author": "XCG Consulting, ACSONE SA/NV, Odoo Community Association (OCA)",
diff --git a/report_py3o/static/description/index.html b/report_py3o/static/description/index.html
index e9b6ecda26..34b539f356 100644
--- a/report_py3o/static/description/index.html
+++ b/report_py3o/static/description/index.html
@@ -372,7 +372,7 @@ Py3o Report Engine
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
-!! source digest: sha256:5247a746d03835a38b3c79cc709156047927649c6f612f0ff0312619cfb57c48
+!! source digest: sha256:e07081016b19fcddf4ee1a4bb69764055b0c6df19b4628a9fb8766d4777abc54
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

The py3o reporting engine is a reporting engine for Odoo based on
diff --git a/report_py3o/views/ir_actions_report.xml b/report_py3o/views/ir_actions_report.xml
index 8669dce1fd..20bbf065fa 100644
--- a/report_py3o/views/ir_actions_report.xml
+++ b/report_py3o/views/ir_actions_report.xml
@@ -8,11 +8,13 @@
-
-
+
+
diff --git a/setup/_metapackage/pyproject.toml b/setup/_metapackage/pyproject.toml
index f6f8c36fce..945924d5cd 100644
--- a/setup/_metapackage/pyproject.toml
+++ b/setup/_metapackage/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "odoo-addons-oca-reporting-engine"
-version = "18.0.20260415.0"
+version = "18.0.20260421.0"
dependencies = [
"odoo-addon-base_comment_template==18.0.*",
"odoo-addon-bi_sql_editor==18.0.*",
@@ -14,6 +14,7 @@ dependencies = [
"odoo-addon-report_partner_address==18.0.*",
"odoo-addon-report_pdf_form==18.0.*",
"odoo-addon-report_pdf_zip_download==18.0.*",
+ "odoo-addon-report_positioned_image==18.0.*",
"odoo-addon-report_py3o==18.0.*",
"odoo-addon-report_py3o_fusion_server==18.0.*",
"odoo-addon-report_qr==18.0.*",