diff --git a/report_latex/README.rst b/report_latex/README.rst new file mode 100644 index 0000000000..170df10e9c --- /dev/null +++ b/report_latex/README.rst @@ -0,0 +1,83 @@ +============= +LaTeX reports +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:41aa656fe61335b5c8ac1652f1dbefd76b06c7e233b9178346bf7eec9cd42570 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Freporting--engine-lightgray.png?logo=github + :target: https://github.com/OCA/reporting-engine/tree/18.0/report_latex + :alt: OCA/reporting-engine +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/reporting-engine-18-0/reporting-engine-18-0-report_latex + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/reporting-engine&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Provides Latex reports for Odoo. + +Features: + +- Syntax highlighting for Latex in the Ace code editor. +- Support for \\input{...} syntax; split long documents into multiple + files. +- Direct editing of the generated Latex source. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Lambdao + +Contributors +------------ + +- Len + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/reporting-engine `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/report_latex/__init__.py b/report_latex/__init__.py new file mode 100644 index 0000000000..a6f61d52ae --- /dev/null +++ b/report_latex/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models +from . import controllers diff --git a/report_latex/__manifest__.py b/report_latex/__manifest__.py new file mode 100644 index 0000000000..e438a6c9f3 --- /dev/null +++ b/report_latex/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "LaTeX reports", + "category": "Reporting", + "version": "18.0.1.0.0", + "author": "Lambdao, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/reporting-engine", + "license": "AGPL-3", + "summary": """Create LaTeX reports.""", + "depends": ["web", "mail"], + "external_dependencies": { + "python": ["jinja2", "pypandoc"], + "deb": ["texlive", "texlive-extra-utils", "pandoc"], # pdflatex, latexpand + }, + "installable": True, + "auto_install": False, + "data": [ + "security/ir.model.access.csv", + "views/ir_actions_report.xml", + "views/latex_template.xml", + "views/latex_source.xml", + "views/menus.xml", + ], + "assets": { + "web.assets_backend": [ + "report_latex/static/src/js/latexactionservice.esm.js", + ], + "web._assets_core": [ + "report_latex/static/src/js/code_editor.esm.js", + ], + "web.ace_lib": [ + "report_latex/static/lib/ace/mode-latex.js", + ], + }, + "demo": [ + "demo/report_attachments.xml", + "demo/report_latex.xml", + ], +} diff --git a/report_latex/controllers/__init__.py b/report_latex/controllers/__init__.py new file mode 100644 index 0000000000..250d58f39f --- /dev/null +++ b/report_latex/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import main diff --git a/report_latex/controllers/main.py b/report_latex/controllers/main.py new file mode 100644 index 0000000000..96f0b656de --- /dev/null +++ b/report_latex/controllers/main.py @@ -0,0 +1,92 @@ +# Copyright 2017 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import json +import mimetypes +from urllib.parse import parse_qs + +from werkzeug import exceptions +from werkzeug.datastructures import MultiDict + +from odoo.http import content_disposition, request, route, serialize_exception +from odoo.tools import html_escape + +from odoo.addons.web.controllers.report import ReportController as ReportControllerBase + + +class ReportController(ReportControllerBase): + @route() + def report_routes(self, reportname, docids=None, converter=None, **data): + if converter != "latex": + return super().report_routes( + reportname=reportname, docids=docids, converter=converter, **data + ) + context = dict(request.env.context) + + if docids: + docids = [int(i) for i in docids.split(",")] + if data.get("options"): + data.update(json.loads(data.pop("options"))) + if data.get("context"): + # Ignore 'lang' here, because the context in data is the + # one from the webclient *but* if the user explicitely wants to + # change the lang, this mechanism overwrites it. + data["context"] = json.loads(data["context"]) + if data["context"].get("lang"): + del data["context"]["lang"] + context.update(data["context"]) + + ir_action = request.env["ir.actions.report"] + # TODO: is it useful to override to make sure the type is latex? + action_latex_report = ir_action._get_report_from_name(reportname) + if not action_latex_report: + raise exceptions.HTTPException( + description="Latex action report not found for report_name " + f"{reportname}" + ) + res, filetype = ir_action._render(reportname, docids, data) + filename = action_latex_report.gen_report_download_filename(docids, data) + if not filename.endswith(filetype): + filename = f"{filename}.{filetype}" + content_type = mimetypes.guess_type("x." + filetype)[0] + http_headers = [ + ("Content-Type", content_type), + ("Content-Length", len(res)), + ("Content-Disposition", content_disposition(filename)), + ] + return request.make_response(res, headers=http_headers) + + @route() + def report_download(self, data, context=None, token=None, readonly=True): + """This function is used by 'qwebactionmanager.js' in order to trigger + the download of a latex/controller report. + + :param data: a javascript array JSON.stringified containg report + internal url ([0]) and type [1] + :returns: Response with a filetoken cookie and an attachment header + """ + requestcontent = json.loads(data) + url, report_type = requestcontent[0], requestcontent[1] + if report_type != "latex": + return super().report_download( + data, context, token=token, readonly=readonly + ) + try: + reportname = url.split("/report/latex/")[1].split("?")[0] + docids = None + if "/" in reportname: + reportname, docids = reportname.split("/") + if docids: + response = self.report_routes( + reportname, docids=docids, converter="latex" + ) + else: # TODO: ??? + data = list(MultiDict(parse_qs(url.split("?")[1])).items()) + response = self.report_routes( + reportname, converter="latex", **dict(data) + ) + response.set_cookie("fileToken", context) + return response + except Exception as e: + se = serialize_exception(e) + error = {"code": 200, "message": "Odoo Server Error", "data": se} + return request.make_response(html_escape(json.dumps(error))) diff --git a/report_latex/demo/report_attachments.xml b/report_latex/demo/report_attachments.xml new file mode 100644 index 0000000000..e9e3a9e463 --- /dev/null +++ b/report_latex/demo/report_attachments.xml @@ -0,0 +1,27 @@ + + + + + + + lambdao_line.png + + res.users + + + + diff --git a/report_latex/demo/report_latex.xml b/report_latex/demo/report_latex.xml new file mode 100644 index 0000000000..32555605a6 --- /dev/null +++ b/report_latex/demo/report_latex.xml @@ -0,0 +1,136 @@ + + + + + user_section_math + + +\section{Mathematics} + +$$Pr[\mathcal{G}(n,1/2) \models \neg \textup{Ext}_{r,s}] \leq {n \choose r}{n-r \choose s}(1-2^{-r-s})^{n-r-s} \rightarrow 0$$ + + + + user_section_info + + +\section{User Information} + +\begin{itemize} + \item Name: \VAR{object.name} + \item Login: \VAR{object.login} + \item Email: \VAR{object.email} + \item Company: \VAR{object.company_id.name} +%{ if object.partner_id.phone }% + \item Phone: \VAR{object.partner_id.phone} +%{ endif }% +\end{itemize} + + + + user_section_groups + + +%{ if object.groups_id }% +\section{User Groups} +\begin{itemize} +%{ for group in object.groups_id }% + \item \VAR{group.name} +%{ endfor }% +\end{itemize} +%{ endif }% + + + + Latex Demo Template + + + + +\documentclass{article} +\usepackage{lipsum} +\usepackage[utf8]{inputenc} + +\usepackage{setspace} +\usepackage{eso-pic,graphicx} + +\usepackage[english]{babel} +\setlength{\parskip}{1em} + +\usepackage{avant} +\renewcommand*\familydefault{\sfdefault} +\usepackage[T1]{fontenc} + +\usepackage{xparse} +\usepackage{blindtext} +\usepackage{enumitem} + +\usepackage{hyperref} +\hypersetup{ + colorlinks, + citecolor=black, + filecolor=black, + linkcolor=black, + urlcolor=black +} + +\usepackage{amsmath} +\usepackage{amssymb} + +\setlength\parindent{0pt} + + +\onehalfspacing + +\AddToShipoutPicture{ + \AtPageUpperLeft{\raisebox{-3\baselineskip}{\makebox[\paperwidth]{\begin{minipage}{21cm}\flushright + \includegraphics[width=4cm]{lambdao_logo}\hspace{2cm} + \end{minipage}}}}\AtPageLowerLeft{\raisebox{7\baselineskip}{\makebox[\paperwidth]{ + \includegraphics[width=\textwidth]{lambdao_line} + }}} +} + +\usepackage{xcolor} +\definecolor{orange}{RGB}{255,165,0} +\newcommand{\VAR}[1]{\textcolor{orange}{\texttt{\detokenize{#1}}}} + +\begin{document} + +\title{User Report for \VAR{object.name}} +\author{Generated by Odoo} +\date{\today} +\maketitle + +\input{user_section_math} + +\input{user_section_info} + +\input{user_section_groups} + +\section{Report Details} +This report was generated on \VAR{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} by user \VAR{user.name}. + +\end{document} + + + + LaTeX Demo Report + ir.actions.report + res.users + latex_user_info + latex + + object.name.replace(' ', '_') + '-demo.pdf' + + report + + diff --git a/report_latex/models/__init__.py b/report_latex/models/__init__.py new file mode 100644 index 0000000000..7de53682b6 --- /dev/null +++ b/report_latex/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import latex_mixins +from . import latex_template +from . import latex_source +from . import ir_actions_report diff --git a/report_latex/models/ir_actions_report.py b/report_latex/models/ir_actions_report.py new file mode 100644 index 0000000000..a840c6648d --- /dev/null +++ b/report_latex/models/ir_actions_report.py @@ -0,0 +1,169 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging +import subprocess +import tempfile +from pathlib import Path + +import pypandoc +from jinja2 import Environment, FileSystemLoader + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval, time + +logger = logging.getLogger(__name__) + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + report_type = fields.Selection( + selection_add=[("latex", "latex")], + ondelete={"latex": "cascade"}, + ) + latex_template_id = fields.Many2one( + comodel_name="latex.template", + string="Latex template", + domain="[('is_root', '=', True)]", + ) + + @api.constrains("latex_template_id", "report_type") + def _check_latex_template_id(self): + for record in self: + if record.report_type == "latex" and not record.latex_template_id: + raise UserError(_("No LaTeX template configured for this report")) + if ( + record.report_type == "latex" + and record.latex_template_id + and not record.latex_template_id.is_root + ): + raise UserError(_("The LaTeX template must be a root template")) + + def _render_latex(self, reportname, res_ids, data=None): + """Generate PDF from LaTeX template.""" + report = self._get_report_from_name(reportname) + report.ensure_one() + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + combined_tex_file = report._gather_render_combine( + temp_path, res_ids, render=True + ) + if res_ids: + res_id = res_ids[0] + combined_content = combined_tex_file.read_text(encoding="utf-8") + source = report._store_latex_source(combined_content, res_id, temp_path) + source.compile_to_pdf(temp_path) + return source.pdf_attachment_id.raw, "pdf" + + def _gather_render_combine(self, temp_path, res_ids, render=True): + template_files = self._gather_latex_files(temp_path) + main_tex_file = self.latex_template_id.file_name # relative path + if res_ids and render: + for file_path in template_files: + if file_path.suffix == ".tex": + content = file_path.read_text(encoding="utf-8") + rendered_content = self._render_latex_template(content, res_ids) + file_path.write_text(rendered_content, encoding="utf-8") + combined_tex_file = temp_path / "combined.tex" # absolute path + output = subprocess.check_output( # noqa: S603 + ["/usr/bin/latexpand", main_tex_file], + stderr=subprocess.PIPE, + cwd=temp_path, # latexpand must be run in the temp directory + text=True, + ) + combined_tex_file.write_text(output, encoding="utf-8") + return combined_tex_file + + def _gather_latex_files(self, temp_path): + """Recursively gather all templates and subtemplates into temp directory.""" + self.ensure_one() + files = [] + all_templates = self.latex_template_id + templates_to_process = self.latex_template_id.subtemplate_ids + + while templates_to_process: + all_templates += templates_to_process + children = templates_to_process.mapped("subtemplate_ids") + templates_to_process = children - all_templates + + # Write all template files + for template in all_templates: + template_file = temp_path / template.file_name + template_file.write_text(template.content, encoding="utf-8") + files.append(template_file) + + # Copy all attachments + all_attachments = all_templates.mapped("attachment_ids") + for attachment in all_attachments: + if attachment.datas: + attachment_file = temp_path / attachment.name + attachment_file.write_bytes(attachment.raw) + + return files + + def _render_latex_template(self, content, res_ids): + """Render LaTeX template using Jinja2.""" + latex_escape = lambda s: pypandoc.convert_text( # noqa: E731 + s or "", to="latex", format="html" + ) + env = Environment( + loader=FileSystemLoader("."), + block_start_string="%{", + block_end_string="}%", + variable_start_string="\\VAR{", + variable_end_string="}", + comment_start_string="%#", + comment_end_string="#%", + line_statement_prefix="%%", + line_comment_prefix="%#", + trim_blocks=True, + autoescape=True, + ) + env.filters["latex_escape"] = latex_escape + env.filters["xx"] = latex_escape + objects = self.env[self.model].browse(res_ids) + main_object = objects[0] if objects else None + context = { + "object": main_object, + "objects": objects, + "user": self.env.user, + "company": self.env.company, + "time": __import__("time"), + "datetime": __import__("datetime"), + } + + jinja_template = env.from_string(content) + return jinja_template.render(**context) + + def _store_latex_source(self, combined_content, res_id, temp_path): + """Store the combined LaTeX source in latex.source record.""" + source = self.env["latex.source"].search( + [ + ("res_model", "=", self.model), + ("res_id", "=", res_id), + ("template_id", "=", self.latex_template_id.id), + ], + limit=1, + ) + if source: + source.write({"content": combined_content}) + else: + res_record = self.env[self.model].browse(res_id) + source_vals = { + "name": f"{self.name} - {res_record.display_name}", + "content": combined_content, + "template_id": self.latex_template_id.id, + "res_model": self.model, + "res_id": res_id, + } + source = self.env["latex.source"].create(source_vals) + source._create_assets_attachment(temp_path) + return source + + def gen_report_download_filename(self, res_ids, data): + if self.print_report_name and not len(res_ids) > 1: + obj = self.env[self.model].browse(res_ids) + return safe_eval(self.print_report_name, {"object": obj, "time": time}) + return f"{self.name}.pdf" diff --git a/report_latex/models/latex_mixins.py b/report_latex/models/latex_mixins.py new file mode 100644 index 0000000000..2e595a5175 --- /dev/null +++ b/report_latex/models/latex_mixins.py @@ -0,0 +1,49 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class LatexRecordMixin(models.AbstractModel): + _name = "latex.record.mixin" + _description = "Latex Record Mixin" + + res_model_id = fields.Many2one( + "ir.model", + "Document Model", + index=True, + ondelete="cascade", + required=True, + readonly=True, + ) + res_model = fields.Char( + "Related Document Model", + index=True, + related="res_model_id.model", + precompute=True, + store=True, + readonly=True, + ) + res_id = fields.Many2oneReference( + string="Related Document ID", index=True, model_field="res_model" + ) + reference = fields.Char(compute="_compute_reference") + + @api.depends("res_model", "res_id") + def _compute_reference(self): + for rec in self: + rec.reference = f"{rec.res_model},{rec.res_id}" + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if "res_model" in vals: + vals["res_model_id"] = self.env["ir.model"]._get(vals["res_model"]).id + return super().create(vals_list) + + @property + def record(self): + self.ensure_one() + if self.res_model not in self.env: + return self.env["_unknown"] + return self.env[self.res_model].browse(self.res_id) diff --git a/report_latex/models/latex_source.py b/report_latex/models/latex_source.py new file mode 100644 index 0000000000..8dcede651f --- /dev/null +++ b/report_latex/models/latex_source.py @@ -0,0 +1,192 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import base64 +import io +import logging +import os +import subprocess +import tempfile +import zipfile +from pathlib import Path + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +logger = logging.getLogger(__name__) + + +class LatexSource(models.Model): + _name = "latex.source" + _inherit = ["latex.record.mixin", "mail.thread"] + _description = "Latex source" + + name = fields.Char(required=True) + content = fields.Text(required=True) + template_id = fields.Many2one( + comodel_name="latex.template", required=True, readonly=True + ) + + version = fields.Integer(default=0, readonly=True) + previous_version = fields.Text(readonly=True) + modified_content = fields.Boolean(compute="_compute_modified_content") + version_history = fields.Json(readonly=True) + + pdf_attachment_id = fields.Many2one(comodel_name="ir.attachment", readonly=True) + assets_attachment_id = fields.Many2one(comodel_name="ir.attachment", readonly=True) + pdf_data = fields.Binary( + string="PDF", related="pdf_attachment_id.datas", readonly=True + ) + assets_data = fields.Binary( + string="Zipped Assets", related="assets_attachment_id.datas", readonly=True + ) + + @api.depends("content", "previous_version") + def _compute_modified_content(self): + for record in self: + record.modified_content = record.content != record.previous_version + + def _set_new_version(self): + self.ensure_one() + self.version += 1 + self.previous_version = self.content + history = self.version_history or {} + history[self.version] = self.content + self.version_history = history + return self.version + + def action_download_zip(self): + """Download the assets zip file.""" + self.ensure_one() + if not self.assets_attachment_id: + raise UserError(_("No assets available for download")) + + return { + "type": "ir.actions.act_url", + "url": f"/web/content/{self.assets_attachment_id.id}?download=true", + "target": "new", + } + + def action_compile_to_pdf(self): + self.ensure_one() + self.compile_to_pdf() + return { + "type": "ir.actions.act_url", + "url": f"/web/content/{self.pdf_attachment_id.id}?download=true", + "target": "new", + } + + def compile_to_pdf(self, temp_path=None): + self.ensure_one() + if self.modified_content or not self.pdf_attachment_id: + self._set_new_version() + if not temp_path: + self._compile_to_pdf_from_assets() + else: + self._compile_to_pdf(temp_path) + + def _compile_to_pdf(self, temp_path): + self.ensure_one() + combined_tex_file = temp_path / "combined.tex" + combined_tex_file.write_text(self.content, encoding="utf-8") + pdf_content = self._compile_latex_to_pdf(combined_tex_file, temp_path) + self._create_pdf_attachment(pdf_content) + + def _compile_to_pdf_from_assets(self): + self.ensure_one() + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + self._extract_assets_to_temp(temp_path) + self._compile_to_pdf(temp_path) + + def _extract_assets_to_temp(self, temp_path): + zip_data = base64.b64decode(self.assets_attachment_id.datas) + with zipfile.ZipFile(io.BytesIO(zip_data), "r") as zip_file: + zip_file.extractall(temp_path) + + def _gather_template_assets(self, temp_path): + all_templates = self.template_id + templates_to_process = self.template_id.subtemplate_ids + + while templates_to_process: + all_templates += templates_to_process + children = templates_to_process.mapped("subtemplate_ids") + templates_to_process = children - all_templates + + for template in all_templates: + template_file = temp_path / template.file_name + template_file.write_text(template.content, encoding="utf-8") + + all_attachments = all_templates.mapped("attachment_ids") + for attachment in all_attachments: + if attachment.datas: + attachment_file = temp_path / attachment.name + attachment_file.write_bytes(attachment.raw) + + def _create_assets_attachment(self, temp_path): + """Create attachment with all assets.""" + zip_content = self._compress_folder(temp_path) + + attachment = self.env["ir.attachment"].create( + { + "name": f"{self.name}_assets.zip", + "type": "binary", + "datas": base64.b64encode(zip_content), + "mimetype": "application/zip", + "res_model": self._name, + "res_id": self.id, + } + ) + self.assets_attachment_id = attachment.id + + def _create_pdf_attachment(self, pdf_content): + """Create attachment with PDF content.""" + attachment = self.env["ir.attachment"].create( + { + "name": f"{self.name}_v{self.version}.pdf", + "type": "binary", + "datas": base64.b64encode(pdf_content), + "mimetype": "application/pdf", + "res_model": self._name, + "res_id": self.id, + } + ) + self.pdf_attachment_id = attachment.id + + def _compress_folder(self, folder_path): + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for root, _, files in os.walk(folder_path): + for file in files: + full_path = os.path.join(root, file) + arcname = os.path.relpath(full_path, start=folder_path) + zip_file.write(full_path, arcname) + zip_buffer.seek(0) + return zip_buffer.getvalue() + + def _compile_latex_to_pdf(self, tex_file, temp_path): + """Compile LaTeX file to PDF using pdflatex.""" + try: + # Run pdflatex multiple times for proper references + # Bibtex requires 3 times. make it configurable? + # In any case, ToC requires 2 times, which is the base case + for _i in range(2): + subprocess.check_output( # noqa: S603 + [ + "/usr/bin/pdflatex", + "-interaction=nonstopmode", + "-output-directory", + str(temp_path), + str(tex_file), + ], + stderr=subprocess.PIPE, + text=True, + cwd=temp_path, + ) + pdf_file = temp_path / f"{tex_file.stem}.pdf" + return pdf_file.read_bytes() + + except Exception as e: + msg = _("LaTeX compilation error: %s") + logger.error("LaTeX compilation error: %s", str(e)) + raise UserError(msg % str(e)) from e diff --git a/report_latex/models/latex_template.py b/report_latex/models/latex_template.py new file mode 100644 index 0000000000..a097b6a6a9 --- /dev/null +++ b/report_latex/models/latex_template.py @@ -0,0 +1,40 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class LatexTemplate(models.Model): + _name = "latex.template" + _description = "Latex template" + _inherit = "mail.thread" + + name = fields.Char(required=True) + file_name = fields.Char(compute="_compute_file_name") + + is_root = fields.Boolean(default=False) + subtemplate_ids = fields.Many2many( + comodel_name="latex.template", + relation="latex_template_subtemplate_rel", + column1="template_id", + column2="subtemplate_id", + string="Input Templates", + domain="[('is_root', '=', False)]", + ) + attachment_ids = fields.Many2many( + comodel_name="ir.attachment", string="Attachments" + ) + content = fields.Text(required=True, translate=True) + + @api.depends("name") + def _compute_file_name(self): + for record in self: + if record.name: + # Clean filename and add .tex extension + safe_name = "".join( + c for c in record.name if c.isalnum() or c in (" ", "-", "_") + ).rstrip() + safe_name = safe_name.replace(" ", "_") + record.file_name = f"{safe_name}.tex" + else: + record.file_name = "template.tex" diff --git a/report_latex/pyproject.toml b/report_latex/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/report_latex/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/report_latex/readme/CONTRIBUTORS.md b/report_latex/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..cee1363728 --- /dev/null +++ b/report_latex/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Len \<\> diff --git a/report_latex/readme/DESCRIPTION.md b/report_latex/readme/DESCRIPTION.md new file mode 100644 index 0000000000..357fcc6b13 --- /dev/null +++ b/report_latex/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +Provides Latex reports for Odoo. + +Features: +- Syntax highlighting for Latex in the Ace code editor. +- Support for \input{...} syntax; split long documents into multiple files. +- Direct editing of the generated Latex source. + diff --git a/report_latex/security/ir.model.access.csv b/report_latex/security/ir.model.access.csv new file mode 100644 index 0000000000..990ac3ecb4 --- /dev/null +++ b/report_latex/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_latex_template_user,access_latex_template_user,model_latex_template,base.group_user,1,1,1,1 +access_latex_source_user,access_latex_source_user,model_latex_source,base.group_user,1,1,1,1 diff --git a/report_latex/static/demo/lambdao_line.png b/report_latex/static/demo/lambdao_line.png new file mode 100644 index 0000000000..da3d3d26b2 Binary files /dev/null and b/report_latex/static/demo/lambdao_line.png differ diff --git a/report_latex/static/demo/lambdao_logo.png b/report_latex/static/demo/lambdao_logo.png new file mode 100644 index 0000000000..3020042ff8 Binary files /dev/null and b/report_latex/static/demo/lambdao_logo.png differ diff --git a/report_latex/static/description/icon.png b/report_latex/static/description/icon.png new file mode 100644 index 0000000000..454836eb9c Binary files /dev/null and b/report_latex/static/description/icon.png differ diff --git a/report_latex/static/description/index.html b/report_latex/static/description/index.html new file mode 100644 index 0000000000..bf62da31c0 --- /dev/null +++ b/report_latex/static/description/index.html @@ -0,0 +1,430 @@ + + + + + +LaTeX reports + + + +
+

LaTeX reports

+ + +

Beta License: AGPL-3 OCA/reporting-engine Translate me on Weblate Try me on Runboat

+

Provides Latex reports for Odoo.

+

Features:

+
    +
  • Syntax highlighting for Latex in the Ace code editor.
  • +
  • Support for \input{…} syntax; split long documents into multiple +files.
  • +
  • Direct editing of the generated Latex source.
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Lambdao
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/reporting-engine project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/report_latex/static/lib/ace/mode-latex.js b/report_latex/static/lib/ace/mode-latex.js new file mode 100644 index 0000000000..57a4ab567a --- /dev/null +++ b/report_latex/static/lib/ace/mode-latex.js @@ -0,0 +1,311 @@ +/* eslint-disable */ + +define("ace/mode/latex_highlight_rules", [ + "require", + "exports", + "module", + "ace/lib/oop", + "ace/mode/text_highlight_rules", +], function (require, exports, module) { + "use strict"; + var oop = require("../lib/oop"); + var TextHighlightRules = require("./text_highlight_rules").TextHighlightRules; + var LatexHighlightRules = function () { + this.$rules = { + start: [ + { + token: "comment", + regex: "%.*$", + }, + { + token: [ + "keyword", + "lparen", + "variable.parameter", + "rparen", + "lparen", + "storage.type", + "rparen", + ], + regex: + "(\\\\(?:documentclass|usepackage|input))(?:(\\[)([^\\]]*)(\\]))?({)([^}]*)(})", + }, + { + token: ["keyword", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\(?:label|v?ref|cite(?:[^{]*)))(?:({)([^}]*)(}))?", + }, + { + token: ["storage.type", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\begin)({)(verbatim)(})", + next: "verbatim", + }, + { + token: ["storage.type", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\begin)({)(lstlisting)(})", + next: "lstlisting", + }, + { + token: ["storage.type", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\(?:begin|end))({)([\\w*]*)(})", + }, + { + token: "storage.type", + regex: /\\verb\b\*?/, + next: [ + { + token: ["keyword.operator", "string", "keyword.operator"], + regex: "(.)(.*?)(\\1|$)|", + next: "start", + }, + ], + }, + { + token: "storage.type", + regex: "\\\\[a-zA-Z]+", + }, + { + token: "lparen", + regex: "[[({]", + }, + { + token: "rparen", + regex: "[\\])}]", + }, + { + token: "constant.character.escape", + regex: "\\\\[^a-zA-Z]?", + }, + { + token: "string", + regex: "\\${1,2}", + next: "equation", + }, + ], + equation: [ + { + token: "comment", + regex: "%.*$", + }, + { + token: "string", + regex: "\\${1,2}", + next: "start", + }, + { + token: "constant.character.escape", + regex: "\\\\(?:[^a-zA-Z]|[a-zA-Z]+)", + }, + { + token: "error", + regex: "^\\s*$", + next: "start", + }, + { + defaultToken: "string", + }, + ], + verbatim: [ + { + token: ["storage.type", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\end)({)(verbatim)(})", + next: "start", + }, + { + defaultToken: "text", + }, + ], + lstlisting: [ + { + token: ["storage.type", "lparen", "variable.parameter", "rparen"], + regex: "(\\\\end)({)(lstlisting)(})", + next: "start", + }, + { + defaultToken: "text", + }, + ], + }; + this.normalizeRules(); + }; + oop.inherits(LatexHighlightRules, TextHighlightRules); + exports.LatexHighlightRules = LatexHighlightRules; +}); + +define("ace/mode/folding/latex", [ + "require", + "exports", + "module", + "ace/lib/oop", + "ace/mode/folding/fold_mode", + "ace/range", + "ace/token_iterator", +], function (require, exports, module) { + "use strict"; + var oop = require("../../lib/oop"); + var BaseFoldMode = require("./fold_mode").FoldMode; + var Range = require("../../range").Range; + var TokenIterator = require("../../token_iterator").TokenIterator; + var keywordLevels = { + "\\subparagraph": 1, + "\\paragraph": 2, + "\\subsubsubsection": 3, + "\\subsubsection": 4, + "\\subsection": 5, + "\\section": 6, + "\\chapter": 7, + "\\part": 8, + "\\begin": 9, + "\\end": 10, + }; + var FoldMode = (exports.FoldMode = function () {}); + oop.inherits(FoldMode, BaseFoldMode); + (function () { + this.foldingStartMarker = + /^\s*\\(begin)|\s*\\(part|chapter|(?:sub)*(?:section|paragraph))\b|{\s*$/; + this.foldingStopMarker = /^\s*\\(end)\b|^\s*}/; + this.getFoldWidgetRange = function (session, foldStyle, row) { + var line = session.doc.getLine(row); + var match = this.foldingStartMarker.exec(line); + if (match) { + if (match[1]) return this.latexBlock(session, row, match[0].length - 1); + if (match[2]) return this.latexSection(session, row, match[0].length - 1); + return this.openingBracketBlock(session, "{", row, match.index); + } + var match = this.foldingStopMarker.exec(line); + if (match) { + if (match[1]) return this.latexBlock(session, row, match[0].length - 1); + return this.closingBracketBlock( + session, + "}", + row, + match.index + match[0].length + ); + } + }; + this.latexBlock = function (session, row, column, returnRange) { + var keywords = { + "\\begin": 1, + "\\end": -1, + }; + var stream = new TokenIterator(session, row, column); + var token = stream.getCurrentToken(); + if ( + !token || + !(token.type == "storage.type" || token.type == "constant.character.escape") + ) + return; + var val = token.value; + var dir = keywords[val]; + var getType = function () { + var token = stream.stepForward(); + var type = token && token.type == "lparen" ? stream.stepForward().value : ""; + if (dir === -1) { + stream.stepBackward(); + if (type) stream.stepBackward(); + } + return type; + }; + var stack = [getType()]; + var startColumn = + dir === -1 ? stream.getCurrentTokenColumn() : session.getLine(row).length; + var startRow = row; + stream.step = dir === -1 ? stream.stepBackward : stream.stepForward; + while ((token = stream.step())) { + if ( + !token || + !(token.type == "storage.type" || token.type == "constant.character.escape") + ) + continue; + var level = keywords[token.value]; + if (!level) continue; + var type = getType(); + if (level === dir) stack.unshift(type); + else if (stack.shift() !== type || !stack.length) break; + } + if (stack.length) return; + if (dir == 1) { + stream.stepBackward(); + stream.stepBackward(); + } + if (returnRange) return stream.getCurrentTokenRange(); + var row = stream.getCurrentTokenRow(); + if (dir === -1) + return new Range(row, session.getLine(row).length, startRow, startColumn); + return new Range(startRow, startColumn, row, stream.getCurrentTokenColumn()); + }; + this.latexSection = function (session, row, column) { + var stream = new TokenIterator(session, row, column); + var token = stream.getCurrentToken(); + if (!token || token.type != "storage.type") return; + var startLevel = keywordLevels[token.value] || 0; + var stackDepth = 0; + var endRow = row; + while ((token = stream.stepForward())) { + if (token.type !== "storage.type") continue; + var level = keywordLevels[token.value] || 0; + if (level >= 9) { + if (!stackDepth) endRow = stream.getCurrentTokenRow() - 1; + stackDepth += level == 9 ? 1 : -1; + if (stackDepth < 0) break; + } else if (level >= startLevel) break; + } + if (!stackDepth) endRow = stream.getCurrentTokenRow() - 1; + while (endRow > row && !/\S/.test(session.getLine(endRow))) endRow--; + return new Range( + row, + session.getLine(row).length, + endRow, + session.getLine(endRow).length + ); + }; + }).call(FoldMode.prototype); +}); + +define("ace/mode/latex", [ + "require", + "exports", + "module", + "ace/lib/oop", + "ace/mode/text", + "ace/mode/latex_highlight_rules", + "ace/mode/behaviour/cstyle", + "ace/mode/folding/latex", +], function (require, exports, module) { + "use strict"; + var oop = require("../lib/oop"); + var TextMode = require("./text").Mode; + var LatexHighlightRules = require("./latex_highlight_rules").LatexHighlightRules; + var CstyleBehaviour = require("./behaviour/cstyle").CstyleBehaviour; + var LatexFoldMode = require("./folding/latex").FoldMode; + var Mode = function () { + this.HighlightRules = LatexHighlightRules; + this.foldingRules = new LatexFoldMode(); + this.$behaviour = new CstyleBehaviour({braces: true}); + }; + oop.inherits(Mode, TextMode); + (function () { + this.type = "text"; + this.lineCommentStart = "%"; + this.$id = "ace/mode/latex"; + this.getMatching = function (session, row, column) { + if (row == undefined) row = session.selection.lead; + if (typeof row === "object") { + column = row.column; + row = row.row; + } + var startToken = session.getTokenAt(row, column); + if (!startToken) return; + if (startToken.value == "\\begin" || startToken.value == "\\end") { + return this.foldingRules.latexBlock(session, row, column, true); + } + }; + }).call(Mode.prototype); + exports.Mode = Mode; +}); +(function () { + window.require(["ace/mode/latex"], function (m) { + if (typeof module === "object" && typeof exports === "object" && module) { + module.exports = m; + } + }); +})(); diff --git a/report_latex/static/src/js/code_editor.esm.js b/report_latex/static/src/js/code_editor.esm.js new file mode 100644 index 0000000000..5c3a25c5a9 --- /dev/null +++ b/report_latex/static/src/js/code_editor.esm.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line jsdoc/check-tag-names +/** @odoo-module **/ + +import {CodeEditor} from "@web/core/code_editor/code_editor"; + +// Odoo whitelists available modes +CodeEditor.MODES.push("latex"); diff --git a/report_latex/static/src/js/latexactionservice.esm.js b/report_latex/static/src/js/latexactionservice.esm.js new file mode 100644 index 0000000000..c056f4ce42 --- /dev/null +++ b/report_latex/static/src/js/latexactionservice.esm.js @@ -0,0 +1,56 @@ +// eslint-disable-next-line jsdoc/check-tag-names +/** @odoo-module **/ + +import {download} from "@web/core/network/download"; +import {registry} from "@web/core/registry"; + +registry + .category("ir.actions.report handlers") + .add("latex_handler", async function (action, options, env) { + if (action.report_type === "latex") { + let url = `/report/latex/${action.report_name}`; + const actionContext = action.context || {}; + if ( + action.data === undefined || + action.data === null || + (typeof action.data === "object" && + Object.keys(action.data).length === 0) + ) { + // Build a query string with `action.data` (it's the place where reports + // using a wizard to customize the output traditionally put their options) + if (actionContext.active_ids) { + var activeIDsPath = "/" + actionContext.active_ids.join(","); + url += activeIDsPath; + } + } else { + var serializedOptionsPath = + "?options=" + encodeURIComponent(JSON.stringify(action.data)); + serializedOptionsPath += + "&context=" + encodeURIComponent(JSON.stringify(actionContext)); + url += serializedOptionsPath; + } + env.services.ui.block(); + try { + await download({ + url: "/report/download", + data: { + data: JSON.stringify([url, action.report_type]), + context: JSON.stringify(action.context), + }, + }); + } finally { + env.services.ui.unblock(); + } + const onClose = options.onClose; + if (action.close_on_report_download) { + return env.services.action.doAction( + {type: "ir.actions.act_window_close"}, + {onClose} + ); + } else if (onClose) { + onClose(); + } + return Promise.resolve(true); + } + return Promise.resolve(false); + }); diff --git a/report_latex/tests/__init__.py b/report_latex/tests/__init__.py new file mode 100644 index 0000000000..88384862e0 --- /dev/null +++ b/report_latex/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_latex_report diff --git a/report_latex/tests/test_latex_report.py b/report_latex/tests/test_latex_report.py new file mode 100644 index 0000000000..81c536e1c9 --- /dev/null +++ b/report_latex/tests/test_latex_report.py @@ -0,0 +1,25 @@ +# Copyright 2025 Lambdao +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import os +import unittest + +from odoo.tests.common import TransactionCase + + +class TestLatexReport(TransactionCase): + def setUp(self): + super().setUp() + self.user = self.env.ref("base.user_demo") + self.template = self.env.ref("report_latex.demo_res_users_latex_template") + self.report = self.env.ref("report_latex.demo_res_users_latex_report") + + @unittest.skipIf(not os.getenv("LaTeX"), "Compilation needs LaTeX packages") + def test_latex_report(self): + """Test LaTeX report generation.""" + data = {"options": None} + report_ref = self.report.report_name + res_ids = [self.user.id] + result = self.report._render_latex(report_ref, res_ids, data=data) + self.assertTrue(result[0], "LaTeX report should generate content") + self.assertEqual(result[1], "pdf", "Result should be a PDF") diff --git a/report_latex/views/ir_actions_report.xml b/report_latex/views/ir_actions_report.xml new file mode 100644 index 0000000000..28288c7bf9 --- /dev/null +++ b/report_latex/views/ir_actions_report.xml @@ -0,0 +1,47 @@ + + + + + ir.actions.report.form.latex + ir.actions.report + + + + + + + + + ir.actions.report.search.latex + ir.actions.report + + + + + + + + + + LaTeX Reports + ir.actions.report + list,form + {'default_report_type': 'latex', 'search_default_report_type_latex': 1} + +

+ Create your first LaTeX Report. +

+
+
+
diff --git a/report_latex/views/latex_source.xml b/report_latex/views/latex_source.xml new file mode 100644 index 0000000000..cd3ffff7f1 --- /dev/null +++ b/report_latex/views/latex_source.xml @@ -0,0 +1,119 @@ + + + + + latex.source.list + latex.source + + + + + + + + + + + + latex.source.form + latex.source + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + latex.source.search + latex.source + + + + + + + + + + + + + + + + LaTeX Sources + latex.source + list,form + {} + +

+ Create your first LaTeX source! +

+

+ LaTeX sources are generated content based on templates and linked to specific records. + They contain the compiled LaTeX code ready for PDF generation. +

+
+
+
diff --git a/report_latex/views/latex_template.xml b/report_latex/views/latex_template.xml new file mode 100644 index 0000000000..780907bfcc --- /dev/null +++ b/report_latex/views/latex_template.xml @@ -0,0 +1,103 @@ + + + + + latex.template.list + latex.template + + + + + + + + + + + + + latex.template.form + latex.template + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + latex.template.search + latex.template + + + + + + + + + + + + + + LaTeX Templates + latex.template + list,form + {'search_default_is_root': 1} + +

+ Create your first LaTeX template! +

+

+ LaTeX templates are used to generate PDF reports using LaTeX syntax. + You can create reusable templates and sub-templates with Jinja2 variables. +

+
+
+
diff --git a/report_latex/views/menus.xml b/report_latex/views/menus.xml new file mode 100644 index 0000000000..f7a269c8e1 --- /dev/null +++ b/report_latex/views/menus.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 65153ae3b9..819b2a431f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ # generated from manifests external_dependencies +jinja2 lxml mock openpyxl py3o.formats py3o.template +pypandoc xlrd xlsxwriter