Skip to content

Commit 8749993

Browse files
[ADD] report_positioned_image
1 parent 35a9cd5 commit 8749993

18 files changed

Lines changed: 1171 additions & 0 deletions

report_positioned_image/README.rst

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

0 commit comments

Comments
 (0)