Skip to content

Commit 0b84ccf

Browse files
[ADD] report_positioned_image
1 parent 35a9cd5 commit 0b84ccf

18 files changed

Lines changed: 1228 additions & 0 deletions

report_positioned_image/README.rst

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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+
**Table of contents**
45+
46+
.. contents::
47+
:local:
48+
49+
Configuration
50+
=============
51+
52+
To configure company-level images:
53+
54+
1. Go to *Settings / Companies*
55+
2. Open your company record
56+
3. Navigate to the *Report Images* tab
57+
4. Add images with position settings:
58+
59+
- Upload an image - width defaults to 50mm and height is
60+
automatically calculated to maintain the original aspect ratio
61+
- *Top (mm)*: Distance from the top of the page
62+
- *Left (mm)*: Distance from the left edge of the page
63+
- *Width (mm)*: Width of the image (changing this auto-adjusts
64+
height)
65+
- *Height (mm)*: Height of the image (changing this auto-adjusts
66+
width)
67+
- *Respect Image Ratio*: When enabled (default), changing width or
68+
height automatically adjusts the other dimension to maintain
69+
aspect ratio. Uncheck for manual control of both dimensions.
70+
- *First Page Only*: Check to show only on the first page
71+
72+
To configure report-specific images:
73+
74+
1. Go to *Settings / Technical / Actions / Reports*
75+
2. Open the report you want to customize
76+
3. Navigate to the *Report Images* tab
77+
4. Check *Include Company Images* if you want to show company-level
78+
images in addition to report-specific images
79+
5. Add report-specific images in the list with the same position
80+
settings as above
81+
82+
**Note**: By default, images maintain their aspect ratio. When you
83+
upload an image, it's automatically sized to 50mm width with
84+
proportional height. You can then adjust either dimension and the other
85+
will update automatically to prevent distortion.
86+
87+
Bug Tracker
88+
===========
89+
90+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/reporting-engine/issues>`_.
91+
In case of trouble, please check there if your issue has already been reported.
92+
If you spotted it first, help us to smash it by providing a detailed and welcomed
93+
`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**>`_.
94+
95+
Do not contact contributors directly about support or help with technical issues.
96+
97+
Credits
98+
=======
99+
100+
Authors
101+
-------
102+
103+
* Quartile
104+
105+
Contributors
106+
------------
107+
108+
- Quartile <https://www.quartile.co>
109+
110+
- Tatsuki Kanda
111+
- Aung Ko Ko Lin
112+
113+
Maintainers
114+
-----------
115+
116+
This module is maintained by the OCA.
117+
118+
.. image:: https://odoo-community.org/logo.png
119+
:alt: Odoo Community Association
120+
:target: https://odoo-community.org
121+
122+
OCA, or the Odoo Community Association, is a nonprofit organization whose
123+
mission is to support the collaborative development of Odoo features and
124+
promote its widespread use.
125+
126+
This module is part of the `OCA/reporting-engine <https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image>`_ project on GitHub.
127+
128+
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
"views/report_positioned_image_views.xml",
15+
"views/res_company_views.xml",
16+
"views/ir_actions_report_views.xml",
17+
],
18+
"installable": True,
19+
}
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: 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+
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="Custom Images",
23+
)
24+
25+
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
26+
"""Set company context so _get_positioned_image_configs uses the
27+
correct company.
28+
"""
29+
company = self._get_report_company(res_ids)
30+
return super(IrActionsReport, self.with_company(company))._render_qweb_pdf(
31+
report_ref, res_ids, data
32+
)
33+
34+
def _prepare_html(self, html, report_model=False):
35+
image_configs = self._get_positioned_image_configs()
36+
if not image_configs:
37+
return super()._prepare_html(html, report_model=report_model)
38+
result = super()._prepare_html(html, report_model=report_model)
39+
if not isinstance(result, tuple):
40+
return result
41+
bodies, res_ids, header, footer, specific_paperformat_args = result
42+
if image_configs:
43+
header = self._inject_images_into_header(header, image_configs)
44+
return bodies, res_ids, header, footer, specific_paperformat_args
45+
46+
def _inject_images_into_header(self, header, image_configs):
47+
image_html = self._build_image_html(image_configs)
48+
return self._insert_html_into_header(header, image_html)
49+
50+
def _insert_html_into_header(self, header, html_to_inject):
51+
if Markup("</body>") in header:
52+
return header.replace(
53+
Markup("</body>"), html_to_inject + Markup("</body>"), 1
54+
)
55+
if Markup("<body>") in header:
56+
return header.replace(
57+
Markup("<body>"), Markup("<body>") + html_to_inject, 1
58+
)
59+
return header + html_to_inject
60+
61+
@staticmethod
62+
def _build_image_html(images):
63+
parts = []
64+
for image in images:
65+
image_content = image.get("image")
66+
if not image_content:
67+
continue
68+
style_parts = [
69+
"position: fixed",
70+
f"top: {image.get('pos_top', 5)}mm",
71+
f"left: {image.get('pos_left', 5)}mm",
72+
f"width: {image.get('width', 20)}mm",
73+
f"height: {image.get('height', 20)}mm",
74+
]
75+
style = "; ".join(style_parts) + ";"
76+
data_uri = image_data_uri(image_content)
77+
# Use 'first-page' class from report_qweb_element_page_visibility
78+
# for images that should only appear on the first page
79+
css_class = "first-page" if image.get("first_page_only") else ""
80+
class_attr = f' class="{css_class}"' if css_class else ""
81+
parts.append(
82+
f'<div{class_attr} style="{style}">'
83+
f'<img src="{data_uri}" style="width: 100%; height: 100%;"/>'
84+
"</div>"
85+
)
86+
return Markup("".join(parts))
87+
88+
def _get_report_company(self, res_ids):
89+
if not res_ids or not self.model:
90+
return self.env.company
91+
model = self.env[self.model]
92+
if "company_id" not in model._fields:
93+
return self.env.company
94+
records = model.browse(res_ids).exists()
95+
companies = records.mapped("company_id")
96+
return companies[0] if len(companies) == 1 else self.env.company
97+
98+
def _get_positioned_image_configs(self):
99+
company = self.env.company
100+
images = self.report_positioned_image_ids.filtered(
101+
lambda img: img.company_id == company
102+
)
103+
if self.include_company_images:
104+
images |= company.report_positioned_image_ids
105+
return [
106+
{
107+
"image": img.image,
108+
"pos_top": img.pos_top,
109+
"pos_left": img.pos_left,
110+
"width": img.width,
111+
"height": img.height,
112+
"first_page_only": img.first_page_only,
113+
}
114+
for img in images
115+
if img.image
116+
]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
required=True,
32+
default=lambda self: self._default_company_id(),
33+
)
34+
35+
def _default_company_id(self):
36+
return self.env.context.get("default_company_id") or self.env.company
37+
38+
def _get_aspect_ratio(self):
39+
"""Get image aspect ratio (width/height)."""
40+
if not self.image:
41+
return None
42+
try:
43+
img = Image.open(BytesIO(base64.b64decode(self.image)))
44+
return img.width / img.height
45+
except Exception:
46+
return None
47+
48+
@api.onchange("image")
49+
def _onchange_image(self):
50+
if not self.image:
51+
return
52+
ratio = self._get_aspect_ratio()
53+
if not ratio:
54+
return
55+
# Set default width to 50mm and calculate height maintaining aspect ratio
56+
self.width = 50.0
57+
self.height = round(50.0 / ratio, 2)
58+
59+
@api.onchange("width", "respect_image_ratio")
60+
def _onchange_width(self):
61+
if self._context.get("from_height_onchange"):
62+
return
63+
if not (self.respect_image_ratio and self.width):
64+
return
65+
ratio = self._get_aspect_ratio()
66+
if ratio and self.width > 0:
67+
# Set context flag to prevent circular onchange
68+
self.with_context(from_width_onchange=True).height = round(
69+
self.width / ratio, 2
70+
)
71+
72+
@api.onchange("height")
73+
def _onchange_height(self):
74+
if self._context.get("from_width_onchange"):
75+
return
76+
if not (self.respect_image_ratio and self.height):
77+
return
78+
ratio = self._get_aspect_ratio()
79+
if ratio and self.height > 0:
80+
# Set context flag to prevent circular onchange
81+
self.with_context(from_height_onchange=True).width = round(
82+
self.height * ratio, 2
83+
)
84+
85+
@api.constrains("pos_top", "pos_left", "width", "height")
86+
def _check_positive_values(self):
87+
"""Ensure position and dimension fields have positive values."""
88+
for record in self:
89+
if record.pos_top < 0:
90+
raise ValidationError(_("Top position must be a positive value."))
91+
if record.pos_left < 0:
92+
raise ValidationError(_("Left position must be a positive value."))
93+
if record.width <= 0:
94+
raise ValidationError(_("Width must be greater than zero."))
95+
if record.height <= 0:
96+
raise ValidationError(_("Height must be greater than zero."))
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2026 Quartile (https://www.quartile.co)
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from odoo import fields, models
5+
6+
7+
class ResCompany(models.Model):
8+
_inherit = "res.company"
9+
10+
report_positioned_image_ids = fields.Many2many(
11+
comodel_name="report.positioned.image",
12+
relation="res_company_positioned_image_rel",
13+
column1="company_id",
14+
column2="image_id",
15+
string="Company Images",
16+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"

0 commit comments

Comments
 (0)