Skip to content

Commit 25d602b

Browse files
[ADD] report_positioned_image
1 parent 35a9cd5 commit 25d602b

19 files changed

Lines changed: 1346 additions & 0 deletions

report_positioned_image/README.rst

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
=======================
2+
Report Positioned Image
3+
=======================
4+
5+
..
6+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
7+
!! This file is generated by oca-gen-addon-readme !!
8+
!! changes will be overwritten. !!
9+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
10+
!! source digest: sha256:8bc2f08c57ac7bd7e62467501b1ac95394b9e6047b1a4fa48e08a4a99a760e2e
11+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
12+
13+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
14+
:target: https://odoo-community.org/page/development-status
15+
:alt: Beta
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
17+
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
18+
:alt: License: AGPL-3
19+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Freporting--engine-lightgray.png?logo=github
20+
:target: https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image
21+
:alt: OCA/reporting-engine
22+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
23+
:target: https://translation.odoo-community.org/projects/reporting-engine-18-0/reporting-engine-18-0-report_positioned_image
24+
:alt: Translate me on Weblate
25+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
26+
:target: https://runboat.odoo-community.org/builds?repo=OCA/reporting-engine&target_branch=18.0
27+
:alt: Try me on Runboat
28+
29+
|badge1| |badge2| |badge3| |badge4| |badge5|
30+
31+
This module allows you to add positioned images (such as watermarks,
32+
logos, or stamps) to PDF reports. Images can be precisely positioned
33+
using millimeter coordinates (top, left) and you can control whether
34+
they appear on all pages or only the first page.
35+
36+
The module supports two types of images:
37+
38+
- *Company-level Images*: Define images at the company level that can
39+
be included in reports by enabling the *Include Company Images*
40+
option
41+
- *Report-specific Images*: Configure specific images for individual
42+
reports, filtered by company context and always shown when configured
43+
44+
Images can be assigned to a specific company or left as shared records
45+
(without company assignment) for use across multiple companies
46+
47+
**Table of contents**
48+
49+
.. contents::
50+
:local:
51+
52+
Configuration
53+
=============
54+
55+
To configure company-level images:
56+
57+
1. Go to *Settings / Companies*
58+
2. Open your company record
59+
3. Navigate to the *Report Images* tab
60+
4. Add images with position settings:
61+
62+
- Upload an image - width defaults to 50mm and height is
63+
automatically calculated to maintain the original aspect ratio
64+
- *Top (mm)*: Distance from the top of the page
65+
- *Left (mm)*: Distance from the left edge of the page
66+
- *Width (mm)*: Width of the image (changing this auto-adjusts
67+
height)
68+
- *Height (mm)*: Height of the image (changing this auto-adjusts
69+
width)
70+
- *Respect Image Ratio*: When enabled (default), changing width or
71+
height automatically adjusts the other dimension to maintain
72+
aspect ratio. Uncheck for manual control of both dimensions.
73+
- *First Page Only*: Check to show only on the first page
74+
- *Company*: Automatically set to the current company when creating
75+
from the company form. To create shared images, leave empty.
76+
77+
To configure report-specific images:
78+
79+
1. Go to *Settings / Technical / Actions / Reports*
80+
2. Open the report you want to customize
81+
3. Navigate to the *Report Images* tab
82+
4. Check *Include Company Images* if you want to show company-level
83+
images in addition to report-specific images
84+
5. Add report-specific images in the list with the same position
85+
settings as above
86+
87+
**Note**: By default, images maintain their aspect ratio. When you
88+
upload an image, it's automatically sized to 50mm width with
89+
proportional height. You can then adjust either dimension and the other
90+
will update automatically to prevent distortion.
91+
92+
Bug Tracker
93+
===========
94+
95+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/reporting-engine/issues>`_.
96+
In case of trouble, please check there if your issue has already been reported.
97+
If you spotted it first, help us to smash it by providing a detailed and welcomed
98+
`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**>`_.
99+
100+
Do not contact contributors directly about support or help with technical issues.
101+
102+
Credits
103+
=======
104+
105+
Authors
106+
-------
107+
108+
* Quartile
109+
110+
Contributors
111+
------------
112+
113+
- Quartile <https://www.quartile.co>
114+
115+
- Tatsuki Kanda
116+
- Aung Ko Ko Lin
117+
118+
Maintainers
119+
-----------
120+
121+
This module is maintained by the OCA.
122+
123+
.. image:: https://odoo-community.org/logo.png
124+
:alt: Odoo Community Association
125+
:target: https://odoo-community.org
126+
127+
OCA, or the Odoo Community Association, is a nonprofit organization whose
128+
mission is to support the collaborative development of Odoo features and
129+
promote its widespread use.
130+
131+
This module is part of the `OCA/reporting-engine <https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image>`_ project on GitHub.
132+
133+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import models
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
{
4+
"name": "Report Positioned Image",
5+
"summary": "Add positioned images to PDF reports.",
6+
"version": "18.0.1.0.0",
7+
"category": "Reporting",
8+
"author": "Quartile, Odoo Community Association (OCA)",
9+
"website": "https://github.com/OCA/reporting-engine",
10+
"license": "AGPL-3",
11+
"depends": ["web", "report_qweb_element_page_visibility"],
12+
"data": [
13+
"security/ir.model.access.csv",
14+
"security/report_positioned_image_security.xml",
15+
"views/report_positioned_image_views.xml",
16+
"views/res_company_views.xml",
17+
"views/ir_actions_report_views.xml",
18+
],
19+
"installable": True,
20+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import ir_actions_report
2+
from . import report_positioned_image
3+
from . import res_company
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from markupsafe import Markup
5+
6+
from odoo import fields, models
7+
from odoo.tools.image import image_data_uri
8+
9+
10+
class IrActionsReport(models.Model):
11+
_inherit = "ir.actions.report"
12+
13+
include_company_images = fields.Boolean(
14+
help="If checked, company-level images will be shown in addition to "
15+
"report-specific images.",
16+
)
17+
report_positioned_image_ids = fields.Many2many(
18+
comodel_name="report.positioned.image",
19+
relation="ir_actions_report_positioned_image_rel",
20+
column1="report_id",
21+
column2="image_id",
22+
string="Report Images",
23+
)
24+
25+
@staticmethod
26+
def _build_image_html(images):
27+
parts = []
28+
for image in images:
29+
image_content = image.get("image")
30+
if not image_content:
31+
continue
32+
style_parts = [
33+
"position: fixed",
34+
f"top: {image.get('pos_top', 5)}mm",
35+
f"left: {image.get('pos_left', 5)}mm",
36+
f"width: {image.get('width', 20)}mm",
37+
f"height: {image.get('height', 20)}mm",
38+
]
39+
style = "; ".join(style_parts) + ";"
40+
data_uri = image_data_uri(image_content)
41+
# Use 'first-page' class from report_qweb_element_page_visibility
42+
# for images that should only appear on the first page
43+
css_class = "first-page" if image.get("first_page_only") else ""
44+
class_attr = f' class="{css_class}"' if css_class else ""
45+
parts.append(
46+
f'<div{class_attr} style="{style}">'
47+
f'<img src="{data_uri}" style="width: 100%; height: 100%;"/>'
48+
"</div>"
49+
)
50+
return Markup("".join(parts))
51+
52+
def _insert_html_into_header(self, header, html_to_inject):
53+
if Markup("</body>") in header:
54+
return header.replace(
55+
Markup("</body>"), html_to_inject + Markup("</body>"), 1
56+
)
57+
if Markup("<body>") in header:
58+
return header.replace(
59+
Markup("<body>"), Markup("<body>") + html_to_inject, 1
60+
)
61+
return header + html_to_inject
62+
63+
def _inject_images_into_header(self, header, image_configs):
64+
image_html = self._build_image_html(image_configs)
65+
return self._insert_html_into_header(header, image_html)
66+
67+
def _get_positioned_image_configs(self):
68+
company = self.env.company
69+
images = self.report_positioned_image_ids.filtered(
70+
lambda img: img.company_id == company or not img.company_id
71+
)
72+
if self.include_company_images:
73+
images |= company.report_positioned_image_ids
74+
return [
75+
{
76+
"image": img.image,
77+
"pos_top": img.pos_top,
78+
"pos_left": img.pos_left,
79+
"width": img.width,
80+
"height": img.height,
81+
"first_page_only": img.first_page_only,
82+
}
83+
for img in images
84+
if img.image
85+
]
86+
87+
def _prepare_html(self, html, report_model=False):
88+
image_configs = self._get_positioned_image_configs()
89+
if not image_configs:
90+
return super()._prepare_html(html, report_model=report_model)
91+
result = super()._prepare_html(html, report_model=report_model)
92+
if not isinstance(result, tuple):
93+
return result
94+
bodies, res_ids, header, footer, specific_paperformat_args = result
95+
header = self._inject_images_into_header(header, image_configs)
96+
return bodies, res_ids, header, footer, specific_paperformat_args
97+
98+
def _get_report_company(self, res_ids):
99+
if not res_ids or not self.model:
100+
return self.env.company
101+
model = self.env[self.model]
102+
if "company_id" not in model._fields:
103+
return self.env.company
104+
records = model.browse(res_ids).exists()
105+
companies = records.mapped("company_id")
106+
return companies[0] if len(companies) == 1 else self.env.company
107+
108+
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
109+
"""Set company context so _get_positioned_image_configs uses the
110+
correct company.
111+
"""
112+
company = self._get_report_company(res_ids)
113+
return super(IrActionsReport, self.with_company(company))._render_qweb_pdf(
114+
report_ref, res_ids, data
115+
)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
import base64
5+
from io import BytesIO
6+
7+
from PIL import Image
8+
9+
from odoo import _, api, fields, models
10+
from odoo.exceptions import ValidationError
11+
12+
13+
class ReportPositionedImage(models.Model):
14+
_name = "report.positioned.image"
15+
_description = "Report Positioned Image"
16+
17+
name = fields.Char(required=True)
18+
image = fields.Binary(attachment=True, required=True)
19+
pos_top = fields.Float(string="Top (mm)", default=5.0)
20+
pos_left = fields.Float(string="Left (mm)", default=5.0)
21+
width = fields.Float(string="Width (mm)")
22+
height = fields.Float(string="Height (mm)")
23+
respect_image_ratio = fields.Boolean(
24+
default=True,
25+
help="When enabled, changing width or height will automatically adjust "
26+
"the other dimension to maintain the original image aspect ratio.",
27+
)
28+
first_page_only = fields.Boolean()
29+
company_id = fields.Many2one(
30+
comodel_name="res.company",
31+
default=lambda self: self._default_company_id(),
32+
help="Leave empty to apply to all companies. Set a specific company to "
33+
"restrict this image to that company only.",
34+
)
35+
36+
def _default_company_id(self):
37+
return self.env.context.get("default_company_id")
38+
39+
@api.constrains("pos_top", "pos_left", "width", "height")
40+
def _check_positive_values(self):
41+
"""Ensure position and dimension fields have positive values."""
42+
for record in self:
43+
if record.pos_top < 0:
44+
raise ValidationError(_("Top position must be a positive value."))
45+
if record.pos_left < 0:
46+
raise ValidationError(_("Left position must be a positive value."))
47+
if record.width <= 0:
48+
raise ValidationError(_("Width must be greater than zero."))
49+
if record.height <= 0:
50+
raise ValidationError(_("Height must be greater than zero."))
51+
52+
def _get_aspect_ratio(self):
53+
"""Get image aspect ratio (width/height)."""
54+
if not self.image:
55+
return None
56+
try:
57+
img = Image.open(BytesIO(base64.b64decode(self.image)))
58+
return img.width / img.height
59+
except Exception:
60+
return None
61+
62+
@api.onchange("image")
63+
def _onchange_image(self):
64+
if not self.image:
65+
return
66+
ratio = self._get_aspect_ratio()
67+
if not ratio:
68+
return
69+
# Set default width to 50mm and calculate height maintaining aspect ratio
70+
self.width = 50.0
71+
self.height = round(50.0 / ratio, 2)
72+
73+
@api.onchange("width", "respect_image_ratio")
74+
def _onchange_width(self):
75+
if self._context.get("from_height_onchange"):
76+
return
77+
if not (self.respect_image_ratio and self.width):
78+
return
79+
ratio = self._get_aspect_ratio()
80+
if ratio and self.width > 0:
81+
# Set context flag to prevent circular onchange
82+
self.with_context(from_width_onchange=True).height = round(
83+
self.width / ratio, 2
84+
)
85+
86+
@api.onchange("height")
87+
def _onchange_height(self):
88+
if self._context.get("from_width_onchange"):
89+
return
90+
if not (self.respect_image_ratio and self.height):
91+
return
92+
ratio = self._get_aspect_ratio()
93+
if ratio and self.height > 0:
94+
# Set context flag to prevent circular onchange
95+
self.with_context(from_height_onchange=True).width = round(
96+
self.height * ratio, 2
97+
)
98+
99+
@api.onchange("company_id")
100+
def _onchange_company_id(self):
101+
"""Prevent assigning to a different company when created from company form."""
102+
default_company_id = self.env.context.get("default_company_id")
103+
if not default_company_id:
104+
return
105+
if self.company_id and self.company_id.id != default_company_id:
106+
self.company_id = default_company_id
107+
return {
108+
"warning": {
109+
"title": _("Company Assignment"),
110+
"message": _(
111+
"You cannot assign this image to a different company. "
112+
"Please use the dedicated wizard to assign images to other "
113+
"companies."
114+
),
115+
}
116+
}

0 commit comments

Comments
 (0)