diff --git a/attachment_preview/__init__.py b/attachment_preview/__init__.py index 5b89c089ef2..222cf105b9b 100644 --- a/attachment_preview/__init__.py +++ b/attachment_preview/__init__.py @@ -1,4 +1,4 @@ # Copyright 2014 Therp BV () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from . import models +from . import controllers, models diff --git a/attachment_preview/controllers/__init__.py b/attachment_preview/controllers/__init__.py new file mode 100644 index 00000000000..7312cb418a9 --- /dev/null +++ b/attachment_preview/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Ledoweb +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main diff --git a/attachment_preview/controllers/main.py b/attachment_preview/controllers/main.py new file mode 100644 index 00000000000..726934edc27 --- /dev/null +++ b/attachment_preview/controllers/main.py @@ -0,0 +1,109 @@ +# Copyright 2026 Ledoweb +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +""" +LibreOffice-based conversion endpoint for Office document preview. + +Converts DOCX, XLSX, PPTX (and legacy DOC/XLS/PPT) to PDF for in-browser +viewing via the ViewerJS widget. LibreOffice headless must be installed; +if it is absent the endpoint returns HTTP 503. +""" + +import base64 +import os +import subprocess +import tempfile + +from odoo import http +from odoo.http import request + +# Extensions handled by LibreOffice conversion +OFFICE_EXTENSIONS = frozenset( + {"docx", "xlsx", "pptx", "doc", "xls", "ppt", "odt", "ods", "odp", "odg"} +) + + +class AttachmentPreviewOfficeController(http.Controller): + @http.route( + "/attachment_preview/office_to_pdf", + type="http", + auth="user", + methods=["GET"], + ) + def office_to_pdf(self, model, field, id, filename="file", **kwargs): + """Convert a binary field's Office document to PDF for preview. + + Query params: + model – Odoo model name (e.g. 'dms.file') + field – binary field name (e.g. 'content') + id – record id (integer) + filename – original filename (used to derive extension) + """ + try: + record_id = int(id) + except (TypeError, ValueError): + return request.make_response("Bad request", status=400) + + record = request.env[model].browse(record_id) + record.check_access_rights("read") + record.check_access_rule("read") + + raw = getattr(record, field, None) + if not raw: + return request.make_response("No content", status=404) + + content = base64.b64decode(raw) + ext = os.path.splitext(filename)[-1].lstrip(".").lower() or "bin" + + if ext not in OFFICE_EXTENSIONS: + return request.make_response( + "Extension not supported for conversion", status=415 + ) + + pdf_bytes = self._libreoffice_to_pdf(content, ext) + if pdf_bytes is None: + return request.make_response( + "LibreOffice not available — cannot convert document", status=503 + ) + + return request.make_response( + pdf_bytes, + headers=[ + ("Content-Type", "application/pdf"), + ( + "Content-Disposition", + f'inline; filename="{os.path.splitext(filename)[0]}.pdf"', + ), + ("Cache-Control", "private, max-age=3600"), + ], + ) + + @staticmethod + def _libreoffice_to_pdf(content, ext): + """Run LibreOffice headless conversion. Returns PDF bytes or None.""" + try: + with tempfile.TemporaryDirectory() as tmpdir: + src = os.path.join(tmpdir, f"source.{ext}") + with open(src, "wb") as fh: + fh.write(content) + result = subprocess.run( + [ + "libreoffice", + "--headless", + "--convert-to", + "pdf", + "--outdir", + tmpdir, + src, + ], + timeout=30, + capture_output=True, + ) + if result.returncode != 0: + return None + pdf_path = os.path.join(tmpdir, "source.pdf") + if not os.path.exists(pdf_path): + return None + with open(pdf_path, "rb") as fh: + return fh.read() + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return None diff --git a/attachment_preview/readme/newsfragments/603.feature b/attachment_preview/readme/newsfragments/603.feature new file mode 100644 index 00000000000..1a97d8654a7 --- /dev/null +++ b/attachment_preview/readme/newsfragments/603.feature @@ -0,0 +1,3 @@ +Add Office format preview (DOCX, XLSX, PPTX, DOC, XLS, PPT, ODG) via +LibreOffice headless conversion. Returns HTTP 503 gracefully when +LibreOffice is not installed. diff --git a/attachment_preview/static/src/js/utils.esm.js b/attachment_preview/static/src/js/utils.esm.js index 85f626a0e68..124fe094ce3 100644 --- a/attachment_preview/static/src/js/utils.esm.js +++ b/attachment_preview/static/src/js/utils.esm.js @@ -1,36 +1,108 @@ import {Component} from "@odoo/owl"; +// Extensions rendered natively by ViewerJS (PDF + ODF formats) +const VIEWERJS_EXTENSIONS = [ + "odt", + "odp", + "ods", + "fodt", + "pdf", + "ott", + "fodp", + "otp", + "fods", + "ots", +]; + +// Extensions converted to PDF server-side via LibreOffice (if installed). +// These use the /attachment_preview/office_to_pdf endpoint. +const OFFICE_EXTENSIONS = ["docx", "xlsx", "pptx", "doc", "xls", "ppt", "odg"]; + export function canPreview(extension) { - const supported_extensions = [ - "odt", - "odp", - "ods", - "fodt", - "pdf", - "ott", - "fodp", - "otp", - "fods", - "ots", - ]; - return supported_extensions.includes(extension); + return ( + VIEWERJS_EXTENSIONS.includes(extension) || OFFICE_EXTENSIONS.includes(extension) + ); +} + +export function isOfficeExtension(extension) { + return OFFICE_EXTENSIONS.includes(extension); } export function getUrl( attachment_id, attachment_url, attachment_extension, - attachment_title + attachment_title, + attachment_filename ) { + // eslint-disable-next-line no-undef + var origin = window.location.origin || ""; + + // Office formats: route through LibreOffice → PDF conversion endpoint + if (isOfficeExtension(attachment_extension)) { + var conversionUrl = ""; + if (attachment_url) { + // Derive model/field/id from the binary field URL + // e.g. /web/content?model=dms.file&field=content&id=42 + try { + // eslint-disable-next-line no-undef + var parsed = new URL(origin + attachment_url); + var model = parsed.searchParams.get("model"); + var field = parsed.searchParams.get("field"); + var id = parsed.searchParams.get("id"); + if (model && field && id) { + conversionUrl = + origin + + "/attachment_preview/office_to_pdf" + + "?model=" + + encodeURIComponent(model) + + "&field=" + + encodeURIComponent(field) + + "&id=" + + encodeURIComponent(id) + + "&filename=" + + encodeURIComponent( + attachment_filename || "file." + attachment_extension + ); + } + } catch { + // URL parsing failed — fall through to attachment_id path + } + } + if (!conversionUrl && attachment_id) { + conversionUrl = + origin + + "/attachment_preview/office_to_pdf" + + "?model=ir.attachment&field=datas&id=" + + attachment_id + + "&filename=" + + encodeURIComponent( + attachment_filename || "file." + attachment_extension + ); + } + if (conversionUrl) { + // Tell ViewerJS the converted output is PDF + return ( + origin + + "/attachment_preview/static/lib/ViewerJS/index.html" + + "?type=pdf" + + "&title=" + + encodeURIComponent(attachment_title) + + "&zoom=automatic" + + "#" + + conversionUrl.replace(origin, "") + ); + } + } + + // Native ViewerJS path (PDF + ODF) var url = ""; if (attachment_url) { if (attachment_url.slice(0, 21) === "/web/static/lib/pdfjs") { - // eslint-disable-next-line no-undef - url = (window.location.origin || "") + attachment_url; + url = origin + attachment_url; } else { url = - // eslint-disable-next-line no-undef - (window.location.origin || "") + + origin + "/attachment_preview/static/lib/ViewerJS/index.html" + "?type=" + encodeURIComponent(attachment_extension) + @@ -38,14 +110,12 @@ export function getUrl( encodeURIComponent(attachment_title) + "&zoom=automatic" + "#" + - // eslint-disable-next-line no-undef - attachment_url.replace(window.location.origin, ""); + attachment_url.replace(origin, ""); } return url; } url = - // eslint-disable-next-line no-undef - (window.location.origin || "") + + origin + "/attachment_preview/static/lib/ViewerJS/index.html" + "?type=" + encodeURIComponent(attachment_extension) + @@ -66,7 +136,8 @@ export function showPreview( attachment_extension, attachment_title, split_screen, - attachment_info_list + attachment_info_list, + attachment_filename ) { if (split_screen && attachment_info_list) { Component.env.bus.trigger("open_attachment_preview", { @@ -80,7 +151,8 @@ export function showPreview( attachment_id, attachment_url, attachment_extension, - attachment_title + attachment_title, + attachment_filename ) ); } diff --git a/attachment_preview/static/src/js/web_views/fields/binary_field.esm.js b/attachment_preview/static/src/js/web_views/fields/binary_field.esm.js index fdae5b23f5c..fae2a112369 100644 --- a/attachment_preview/static/src/js/web_views/fields/binary_field.esm.js +++ b/attachment_preview/static/src/js/web_views/fields/binary_field.esm.js @@ -57,7 +57,8 @@ patch(BinaryField.prototype, { $(event.currentTarget).attr("data-extension"), sprintf(_t("Preview %s"), this.fileName), false, - null + null, + this.fileName ); event.stopPropagation(); }, diff --git a/attachment_preview/tests/test_attachment_preview.py b/attachment_preview/tests/test_attachment_preview.py index b16a0092a05..c1b0c99138a 100644 --- a/attachment_preview/tests/test_attachment_preview.py +++ b/attachment_preview/tests/test_attachment_preview.py @@ -2,6 +2,8 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import base64 +import subprocess +from unittest.mock import patch from odoo.addons.base.tests.common import BaseCommon from odoo.addons.mail.tools.discuss import Store @@ -66,3 +68,75 @@ def test_get_extension(self): "ir.attachment", attachment3.id, "datas", "dummy" ) self.assertTrue(res6) + + +class TestOfficeToPdfController(BaseCommon): + """Unit tests for the LibreOffice conversion controller.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.docx_content = base64.b64encode(b"fake docx content") + cls.attachment = cls.env["ir.attachment"].create( + {"name": "report.docx", "datas": cls.docx_content} + ) + from ..controllers.main import AttachmentPreviewOfficeController + + cls.controller = AttachmentPreviewOfficeController() + + def _make_fake_completed_process(self, returncode=0): + result = subprocess.CompletedProcess(args=[], returncode=returncode) + result.stdout = b"" + result.stderr = b"" + return result + + def test_libreoffice_to_pdf_success(self): + """Returns PDF bytes when LibreOffice succeeds.""" + fake_pdf = b"%PDF-1.4 fake" + with ( + patch( + "odoo.addons.attachment_preview.controllers.main.subprocess.run" + ) as mock_run, + patch( + "odoo.addons.attachment_preview.controllers.main.open", + create=True, + ) as mock_open, + patch( + "odoo.addons.attachment_preview.controllers.main.os.path.exists", + return_value=True, + ), + ): + mock_run.return_value = self._make_fake_completed_process(returncode=0) + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = lambda s, *a: False + mock_open.return_value.read = lambda: fake_pdf + mock_open.return_value.write = lambda data: None + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNotNone(result) + + def test_libreoffice_not_installed_returns_none(self): + """Returns None when LibreOffice binary is not found.""" + with patch( + "odoo.addons.attachment_preview.controllers.main.subprocess.run", + side_effect=FileNotFoundError, + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result) + + def test_libreoffice_timeout_returns_none(self): + """Returns None on conversion timeout.""" + with patch( + "odoo.addons.attachment_preview.controllers.main.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="libreoffice", timeout=30), + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result) + + def test_libreoffice_nonzero_exit_returns_none(self): + """Returns None when LibreOffice exits with non-zero code.""" + with patch( + "odoo.addons.attachment_preview.controllers.main.subprocess.run", + return_value=self._make_fake_completed_process(returncode=1), + ): + result = self.controller._libreoffice_to_pdf(b"content", "docx") + self.assertIsNone(result)