Skip to content

Commit 008cb16

Browse files
kanda999AungKoKoLin1997
authored andcommitted
[18.0][ADD] report_positioned_image
1 parent 35a9cd5 commit 008cb16

18 files changed

Lines changed: 1148 additions & 0 deletions

report_positioned_image/README.rst

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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:95c34a70f400e6b0cf4dffe8ff3683704930c7080a435724ec9b495387d48309
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 generated by Odoo. Images can be
33+
precisely positioned using millimeter coordinates (top, right) and you
34+
can control whether they appear on all pages or only the first page.
35+
36+
The module supports two configuration modes:
37+
38+
- **Company-level Images**: Define images at the company level that are
39+
automatically available for all reports
40+
- **Report-specific Images**: Configure specific images for individual
41+
reports, filtered by company context
42+
43+
**Table of contents**
44+
45+
.. contents::
46+
:local:
47+
48+
Usage
49+
=====
50+
51+
To configure company-level images:
52+
53+
1. Go to **Settings > Companies**
54+
2. Open your company record
55+
3. Navigate to the **Company Images** tab
56+
4. Add images with position settings:
57+
58+
- **Top (mm)**: Distance from the top of the page
59+
- **Right (mm)**: Distance from the right edge of the page
60+
- **Width (mm)**: Width of the image
61+
- **First Page Only**: Check to show only on the first page
62+
63+
To configure report-specific images:
64+
65+
1. Go to **Settings > Technical > Actions > Reports**
66+
2. Open the report you want to customize
67+
3. Navigate to the **Report Images** tab
68+
4. Select **Report Image** mode:
69+
70+
- **Company-level Images**: Use images from the company
71+
configuration
72+
- **Report-specific Images**: Configure specific images for this
73+
report
74+
75+
5. If you selected **Report-specific Images**, add images in the list
76+
below
77+
78+
Images are positioned using CSS absolute or fixed positioning and are
79+
injected into the PDF during report generation.
80+
81+
Bug Tracker
82+
===========
83+
84+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/reporting-engine/issues>`_.
85+
In case of trouble, please check there if your issue has already been reported.
86+
If you spotted it first, help us to smash it by providing a detailed and welcomed
87+
`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**>`_.
88+
89+
Do not contact contributors directly about support or help with technical issues.
90+
91+
Credits
92+
=======
93+
94+
Authors
95+
-------
96+
97+
* Quartile
98+
99+
Contributors
100+
------------
101+
102+
- Quartile <<<<https://www.quartile.co>>>>
103+
104+
- Tatsuki Kanda
105+
- Aung Ko Ko Lin
106+
107+
Maintainers
108+
-----------
109+
110+
This module is maintained by the OCA.
111+
112+
.. image:: https://odoo-community.org/logo.png
113+
:alt: Odoo Community Association
114+
:target: https://odoo-community.org
115+
116+
OCA, or the Odoo Community Association, is a nonprofit organization whose
117+
mission is to support the collaborative development of Odoo features and
118+
promote its widespread use.
119+
120+
This module is part of the `OCA/reporting-engine <https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image>`_ project on GitHub.
121+
122+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Copyright 2026 Quartile
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
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 generated by Odoo.",
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"],
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright 2026 Quartile
2+
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3+
4+
from . import ir_actions_report
5+
from . import report_positioned_image
6+
from . import res_company
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
FIRST_PAGE_ONLY_CLASS = "first-page-only"
10+
FIRST_PAGE_HIDE_SCRIPT = """
11+
<script>
12+
var page = window.location.search.match(/[?&]page=(\\d+)/);
13+
if (page && parseInt(page[1]) > 1) {{
14+
var elements = document.getElementsByClassName('{css_class}');
15+
for (var i = 0; i < elements.length; i++) {{
16+
elements[i].style.display = 'none';
17+
}}
18+
}}
19+
</script>
20+
"""
21+
22+
23+
class IrActionsReport(models.Model):
24+
_inherit = "ir.actions.report"
25+
26+
image_mode = fields.Selection(
27+
selection=[
28+
("company", "Company-level Images"),
29+
("custom", "Report-specific Images"),
30+
],
31+
string="Report Image",
32+
)
33+
report_positioned_image_ids = fields.Many2many(
34+
comodel_name="report.positioned.image",
35+
relation="ir_actions_report_positioned_image_rel",
36+
column1="report_id",
37+
column2="image_id",
38+
string="Custom Images",
39+
)
40+
41+
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
42+
"""Set company context so _get_positioned_image_configs uses the
43+
correct company.
44+
"""
45+
company = self._get_report_company(res_ids)
46+
return super(IrActionsReport, self.with_company(company))._render_qweb_pdf(
47+
report_ref, res_ids, data
48+
)
49+
50+
def _prepare_html(self, html, report_model=False):
51+
image_configs = self._get_positioned_image_configs()
52+
if not image_configs:
53+
return super()._prepare_html(html, report_model=report_model)
54+
first_page_images, all_page_images = self._split_images_by_page(image_configs)
55+
result = super()._prepare_html(html, report_model=report_model)
56+
if not isinstance(result, tuple):
57+
return result
58+
bodies, res_ids, header, footer, specific_paperformat_args = result
59+
if first_page_images or all_page_images:
60+
header = self._inject_images_into_header(
61+
header, all_page_images, first_page_images
62+
)
63+
return bodies, res_ids, header, footer, specific_paperformat_args
64+
65+
def _split_images_by_page(self, image_configs):
66+
first_page_images = []
67+
all_page_images = []
68+
for config in image_configs:
69+
if config.get("first_page_only"):
70+
first_page_images.append(config)
71+
else:
72+
all_page_images.append(config)
73+
return first_page_images, all_page_images
74+
75+
def _inject_images_into_header(self, header, all_page_images, first_page_images):
76+
header_parts = []
77+
if all_page_images:
78+
header_parts.append(self._build_image_html(all_page_images))
79+
if first_page_images:
80+
header_parts.append(
81+
self._build_image_html(first_page_images, FIRST_PAGE_ONLY_CLASS)
82+
)
83+
header_parts.append(
84+
Markup(FIRST_PAGE_HIDE_SCRIPT.format(css_class=FIRST_PAGE_ONLY_CLASS))
85+
)
86+
combined_html = Markup("").join(header_parts)
87+
return self._insert_html_into_header(header, combined_html)
88+
89+
def _insert_html_into_header(self, header, html_to_inject):
90+
if Markup("</body>") in header:
91+
return header.replace(
92+
Markup("</body>"), html_to_inject + Markup("</body>"), 1
93+
)
94+
if Markup("<body>") in header:
95+
return header.replace(
96+
Markup("<body>"), Markup("<body>") + html_to_inject, 1
97+
)
98+
return header + html_to_inject
99+
100+
@staticmethod
101+
def _build_image_html(images, css_class=""):
102+
parts = []
103+
for image in images:
104+
image_content = image.get("image")
105+
if not image_content:
106+
continue
107+
style_parts = [
108+
"position: fixed",
109+
f"top: {image.get('pos_top', 5)}mm",
110+
f"right: {image.get('pos_right', 5)}mm",
111+
f"width: {image.get('width', 20)}mm",
112+
f"height: {image.get('height', 20)}mm",
113+
]
114+
style = "; ".join(style_parts) + ";"
115+
data_uri = image_data_uri(image_content)
116+
class_attr = f' class="{css_class}"' if css_class else ""
117+
parts.append(
118+
f'<div{class_attr} style="{style}">'
119+
f'<img src="{data_uri}" style="width: 100%; height: 100%;"/>'
120+
"</div>"
121+
)
122+
return Markup("".join(parts))
123+
124+
def _get_report_company(self, res_ids):
125+
"""Resolve the company used for report images.
126+
127+
Prefer the company from the report records when available.
128+
Fallback to the current environment company.
129+
"""
130+
if not res_ids or not self.model:
131+
return self.env.company
132+
model = self.env[self.model]
133+
if "company_id" not in model._fields:
134+
return self.env.company
135+
records = model.browse(res_ids).exists()
136+
companies = records.mapped("company_id")
137+
return companies[0] if len(companies) == 1 else self.env.company
138+
139+
def _get_positioned_image_configs(self):
140+
if not self.image_mode:
141+
return []
142+
company = self.env.company
143+
images = (
144+
company.report_positioned_image_ids
145+
if self.image_mode == "company"
146+
else self.report_positioned_image_ids.filtered(
147+
lambda img: img.company_id == company
148+
)
149+
)
150+
return [
151+
{
152+
"image": img.image,
153+
"pos_top": img.pos_top,
154+
"pos_right": img.pos_right,
155+
"width": img.width,
156+
"height": img.height,
157+
"first_page_only": img.first_page_only,
158+
}
159+
for img in images
160+
if img.image
161+
]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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 _, api, fields, models
5+
from odoo.exceptions import ValidationError
6+
7+
8+
class ReportPositionedImage(models.Model):
9+
_name = "report.positioned.image"
10+
_description = "Report Positioned Image"
11+
12+
name = fields.Char(required=True)
13+
image = fields.Binary(attachment=True, required=True)
14+
pos_top = fields.Float(string="Top (mm)", default=5.0)
15+
pos_right = fields.Float(string="Right (mm)", default=5.0)
16+
width = fields.Float(string="Width (mm)", default=20.0)
17+
height = fields.Float(string="Height (mm)", default=20.0)
18+
first_page_only = fields.Boolean()
19+
company_id = fields.Many2one(
20+
comodel_name="res.company",
21+
string="Company",
22+
required=True,
23+
default=lambda self: self._default_company_id(),
24+
)
25+
26+
def _default_company_id(self):
27+
"""Get default company from context or current company."""
28+
return self.env.context.get("default_company_id") or self.env.company
29+
30+
@api.constrains("pos_top", "pos_right", "width", "height")
31+
def _check_positive_values(self):
32+
"""Ensure position and dimension fields have positive values."""
33+
for record in self:
34+
if record.pos_top < 0:
35+
raise ValidationError(_("Top position must be a positive value."))
36+
if record.pos_right < 0:
37+
raise ValidationError(_("Right position must be a positive value."))
38+
if record.width <= 0:
39+
raise ValidationError(_("Width must be greater than zero."))
40+
if record.height <= 0:
41+
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"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Quartile \<\<\<\<<https://www.quartile.co>\>\>\>\>
2+
- Tatsuki Kanda
3+
- Aung Ko Ko Lin
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
This module allows you to add positioned images (such as watermarks, logos,
2+
or stamps) to PDF reports generated by Odoo. Images can be precisely positioned
3+
using millimeter coordinates (top, right) and you can control whether they
4+
appear on all pages or only the first page.
5+
6+
The module supports two configuration modes:
7+
8+
- **Company-level Images**: Define images at the company level that are
9+
automatically available for all reports
10+
- **Report-specific Images**: Configure specific images for individual reports,
11+
filtered by company context

0 commit comments

Comments
 (0)