Skip to content

Commit a1d9344

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

18 files changed

Lines changed: 1154 additions & 0 deletions

report_positioned_image/README.rst

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 generated by Odoo. Images can be
33+
precisely positioned using millimeter coordinates (top, left) and you
34+
can control whether 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+
Usage
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 *Company Images* tab
57+
4. Add images with position settings:
58+
59+
- *Top (mm)*: Distance from the top of the page
60+
- *Left (mm)*: Distance from the left edge of the page
61+
- *Width (mm)*: Width of the image
62+
- *Height (mm)*: Height of the image
63+
- *First Page Only*: Check to show only on the first page
64+
65+
To configure report-specific images:
66+
67+
1. Go to *Settings / Technical / Actions / Reports*
68+
2. Open the report you want to customize
69+
3. Navigate to the *Report Images* tab
70+
4. Check *Include Company Images* if you want to show company-level
71+
images in addition to report-specific images
72+
5. Add report-specific images in the list with the same position
73+
settings as above
74+
75+
Bug Tracker
76+
===========
77+
78+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/reporting-engine/issues>`_.
79+
In case of trouble, please check there if your issue has already been reported.
80+
If you spotted it first, help us to smash it by providing a detailed and welcomed
81+
`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**>`_.
82+
83+
Do not contact contributors directly about support or help with technical issues.
84+
85+
Credits
86+
=======
87+
88+
Authors
89+
-------
90+
91+
* Quartile
92+
93+
Contributors
94+
------------
95+
96+
- Quartile <https://www.quartile.co>
97+
98+
- Tatsuki Kanda
99+
- Aung Ko Ko Lin
100+
101+
Maintainers
102+
-----------
103+
104+
This module is maintained by the OCA.
105+
106+
.. image:: https://odoo-community.org/logo.png
107+
:alt: Odoo Community Association
108+
:target: https://odoo-community.org
109+
110+
OCA, or the Odoo Community Association, is a nonprofit organization whose
111+
mission is to support the collaborative development of Odoo features and
112+
promote its widespread use.
113+
114+
This module is part of the `OCA/reporting-engine <https://github.com/OCA/reporting-engine/tree/18.0/report_positioned_image>`_ project on GitHub.
115+
116+
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: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
include_company_report_image = fields.Boolean(
30+
string="Include Company Images",
31+
help="If checked, company-level images will be shown in addition to "
32+
"report-specific images.",
33+
)
34+
report_positioned_image_ids = fields.Many2many(
35+
comodel_name="report.positioned.image",
36+
relation="ir_actions_report_positioned_image_rel",
37+
column1="report_id",
38+
column2="image_id",
39+
string="Custom Images",
40+
)
41+
42+
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
43+
"""Set company context so _get_positioned_image_configs uses the
44+
correct company.
45+
"""
46+
company = self._get_report_company(res_ids)
47+
return super(IrActionsReport, self.with_company(company))._render_qweb_pdf(
48+
report_ref, res_ids, data
49+
)
50+
51+
def _prepare_html(self, html, report_model=False):
52+
image_configs = self._get_positioned_image_configs()
53+
if not image_configs:
54+
return super()._prepare_html(html, report_model=report_model)
55+
first_page_images, all_page_images = self._split_images_by_page(image_configs)
56+
result = super()._prepare_html(html, report_model=report_model)
57+
if not isinstance(result, tuple):
58+
return result
59+
bodies, res_ids, header, footer, specific_paperformat_args = result
60+
if first_page_images or all_page_images:
61+
header = self._inject_images_into_header(
62+
header, all_page_images, first_page_images
63+
)
64+
return bodies, res_ids, header, footer, specific_paperformat_args
65+
66+
def _split_images_by_page(self, image_configs):
67+
first_page_images = []
68+
all_page_images = []
69+
for config in image_configs:
70+
if config.get("first_page_only"):
71+
first_page_images.append(config)
72+
else:
73+
all_page_images.append(config)
74+
return first_page_images, all_page_images
75+
76+
def _inject_images_into_header(self, header, all_page_images, first_page_images):
77+
header_parts = []
78+
if all_page_images:
79+
header_parts.append(self._build_image_html(all_page_images))
80+
if first_page_images:
81+
header_parts.append(
82+
self._build_image_html(first_page_images, FIRST_PAGE_ONLY_CLASS)
83+
)
84+
header_parts.append(
85+
Markup(FIRST_PAGE_HIDE_STYLE.format(css_class=FIRST_PAGE_ONLY_CLASS))
86+
)
87+
combined_html = Markup("").join(header_parts)
88+
return self._insert_html_into_header(header, combined_html)
89+
90+
def _insert_html_into_header(self, header, html_to_inject):
91+
if Markup("</body>") in header:
92+
return header.replace(
93+
Markup("</body>"), html_to_inject + Markup("</body>"), 1
94+
)
95+
if Markup("<body>") in header:
96+
return header.replace(
97+
Markup("<body>"), Markup("<body>") + html_to_inject, 1
98+
)
99+
return header + html_to_inject
100+
101+
@staticmethod
102+
def _build_image_html(images, css_class=""):
103+
parts = []
104+
for image in images:
105+
image_content = image.get("image")
106+
if not image_content:
107+
continue
108+
style_parts = [
109+
"position: fixed",
110+
f"top: {image.get('pos_top', 5)}mm",
111+
f"left: {image.get('pos_left', 5)}mm",
112+
f"width: {image.get('width', 20)}mm",
113+
f"height: {image.get('height', 20)}mm",
114+
]
115+
style = "; ".join(style_parts) + ";"
116+
data_uri = image_data_uri(image_content)
117+
class_attr = f' class="{css_class}"' if css_class else ""
118+
parts.append(
119+
f'<div{class_attr} style="{style}">'
120+
f'<img src="{data_uri}" style="width: 100%; height: 100%;"/>'
121+
"</div>"
122+
)
123+
return Markup("".join(parts))
124+
125+
def _get_report_company(self, res_ids):
126+
"""Resolve the company used for report images.
127+
128+
Prefer the company from the report records when available.
129+
Fallback to the current environment company.
130+
"""
131+
if not res_ids or not self.model:
132+
return self.env.company
133+
model = self.env[self.model]
134+
if "company_id" not in model._fields:
135+
return self.env.company
136+
records = model.browse(res_ids).exists()
137+
companies = records.mapped("company_id")
138+
return companies[0] if len(companies) == 1 else self.env.company
139+
140+
def _get_positioned_image_configs(self):
141+
company = self.env.company
142+
images = self.report_positioned_image_ids.filtered(
143+
lambda img: img.company_id == company
144+
)
145+
if self.include_company_report_image:
146+
images |= company.report_positioned_image_ids
147+
return [
148+
{
149+
"image": img.image,
150+
"pos_top": img.pos_top,
151+
"pos_left": img.pos_left,
152+
"width": img.width,
153+
"height": img.height,
154+
"first_page_only": img.first_page_only,
155+
}
156+
for img in images
157+
if img.image
158+
]
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
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, left) and you can control whether they
4+
appear on all pages or only the first page.
5+
6+
The module supports two types of images:
7+
8+
- *Company-level Images*: Define images at the company level that can be
9+
included in reports by enabling the *Include Company Images* option
10+
- *Report-specific Images*: Configure specific images for individual reports,
11+
filtered by company context and always shown when configured

0 commit comments

Comments
 (0)