Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion attachment_preview/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2014 Therp BV (<http://therp.nl>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import models
from . import controllers, models
4 changes: 4 additions & 0 deletions attachment_preview/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copyright 2026 Ledoweb
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from . import main
109 changes: 109 additions & 0 deletions attachment_preview/controllers/main.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions attachment_preview/readme/newsfragments/603.feature
Original file line number Diff line number Diff line change
@@ -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.
120 changes: 96 additions & 24 deletions attachment_preview/static/src/js/utils.esm.js
Original file line number Diff line number Diff line change
@@ -1,51 +1,121 @@
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) +
"&title=" +
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) +
Expand All @@ -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", {
Expand All @@ -80,7 +151,8 @@ export function showPreview(
attachment_id,
attachment_url,
attachment_extension,
attachment_title
attachment_title,
attachment_filename
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
74 changes: 74 additions & 0 deletions attachment_preview/tests/test_attachment_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Loading