Skip to content

Commit d9a4a06

Browse files
committed
[18.0][FEAT] attachment_preview: add Office format preview via LibreOffice
Extends attachment_preview to support DOCX, XLSX, PPTX, DOC, XLS, PPT, and ODG in addition to the existing PDF + ODF format support. New endpoint: GET /attachment_preview/office_to_pdf Accepts ?model=<model>&field=<field>&id=<id>&filename=<name> Converts the binary field content to PDF using LibreOffice headless, then streams the PDF to ViewerJS for in-browser rendering. Returns HTTP 503 gracefully if LibreOffice is not installed. Changes: - controllers/main.py: new HTTP controller with LibreOffice conversion - utils.esm.js: OFFICE_EXTENSIONS added to canPreview(); getUrl() routes Office files to the conversion endpoint - binary_field.esm.js: passes filename for correct extension detection - tests: controller unit tests covering success, FileNotFoundError, TimeoutExpired, and non-zero exit code paths Closes #603
1 parent f747aa3 commit d9a4a06

7 files changed

Lines changed: 289 additions & 26 deletions

File tree

attachment_preview/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2014 Therp BV (<http://therp.nl>)
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
33

4-
from . import models
4+
from . import controllers, models
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright 2026 Ledoweb
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
4+
from . import main
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright 2026 Ledoweb
2+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
3+
"""
4+
LibreOffice-based conversion endpoint for Office document preview.
5+
6+
Converts DOCX, XLSX, PPTX (and legacy DOC/XLS/PPT) to PDF for in-browser
7+
viewing via the ViewerJS widget. LibreOffice headless must be installed;
8+
if it is absent the endpoint returns HTTP 503.
9+
"""
10+
11+
import base64
12+
import os
13+
import subprocess
14+
import tempfile
15+
16+
from odoo import http
17+
from odoo.http import request
18+
19+
# Extensions handled by LibreOffice conversion
20+
OFFICE_EXTENSIONS = frozenset(
21+
{"docx", "xlsx", "pptx", "doc", "xls", "ppt", "odt", "ods", "odp", "odg"}
22+
)
23+
24+
25+
class AttachmentPreviewOfficeController(http.Controller):
26+
@http.route(
27+
"/attachment_preview/office_to_pdf",
28+
type="http",
29+
auth="user",
30+
methods=["GET"],
31+
)
32+
def office_to_pdf(self, model, field, id, filename="file", **kwargs):
33+
"""Convert a binary field's Office document to PDF for preview.
34+
35+
Query params:
36+
model – Odoo model name (e.g. 'dms.file')
37+
field – binary field name (e.g. 'content')
38+
id – record id (integer)
39+
filename – original filename (used to derive extension)
40+
"""
41+
try:
42+
record_id = int(id)
43+
except (TypeError, ValueError):
44+
return request.make_response("Bad request", status=400)
45+
46+
record = request.env[model].browse(record_id)
47+
record.check_access_rights("read")
48+
record.check_access_rule("read")
49+
50+
raw = getattr(record, field, None)
51+
if not raw:
52+
return request.make_response("No content", status=404)
53+
54+
content = base64.b64decode(raw)
55+
ext = os.path.splitext(filename)[-1].lstrip(".").lower() or "bin"
56+
57+
if ext not in OFFICE_EXTENSIONS:
58+
return request.make_response(
59+
"Extension not supported for conversion", status=415
60+
)
61+
62+
pdf_bytes = self._libreoffice_to_pdf(content, ext)
63+
if pdf_bytes is None:
64+
return request.make_response(
65+
"LibreOffice not available — cannot convert document", status=503
66+
)
67+
68+
return request.make_response(
69+
pdf_bytes,
70+
headers=[
71+
("Content-Type", "application/pdf"),
72+
(
73+
"Content-Disposition",
74+
f'inline; filename="{os.path.splitext(filename)[0]}.pdf"',
75+
),
76+
("Cache-Control", "private, max-age=3600"),
77+
],
78+
)
79+
80+
@staticmethod
81+
def _libreoffice_to_pdf(content, ext):
82+
"""Run LibreOffice headless conversion. Returns PDF bytes or None."""
83+
try:
84+
with tempfile.TemporaryDirectory() as tmpdir:
85+
src = os.path.join(tmpdir, f"source.{ext}")
86+
with open(src, "wb") as fh:
87+
fh.write(content)
88+
result = subprocess.run(
89+
[
90+
"libreoffice",
91+
"--headless",
92+
"--convert-to",
93+
"pdf",
94+
"--outdir",
95+
tmpdir,
96+
src,
97+
],
98+
timeout=30,
99+
capture_output=True,
100+
)
101+
if result.returncode != 0:
102+
return None
103+
pdf_path = os.path.join(tmpdir, "source.pdf")
104+
if not os.path.exists(pdf_path):
105+
return None
106+
with open(pdf_path, "rb") as fh:
107+
return fh.read()
108+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
109+
return None
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add Office format preview (DOCX, XLSX, PPTX, DOC, XLS, PPT, ODG) via
2+
LibreOffice headless conversion. Returns HTTP 503 gracefully when
3+
LibreOffice is not installed.

attachment_preview/static/src/js/utils.esm.js

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,121 @@
11
import {Component} from "@odoo/owl";
22

3+
// Extensions rendered natively by ViewerJS (PDF + ODF formats)
4+
const VIEWERJS_EXTENSIONS = [
5+
"odt",
6+
"odp",
7+
"ods",
8+
"fodt",
9+
"pdf",
10+
"ott",
11+
"fodp",
12+
"otp",
13+
"fods",
14+
"ots",
15+
];
16+
17+
// Extensions converted to PDF server-side via LibreOffice (if installed).
18+
// These use the /attachment_preview/office_to_pdf endpoint.
19+
const OFFICE_EXTENSIONS = ["docx", "xlsx", "pptx", "doc", "xls", "ppt", "odg"];
20+
321
export function canPreview(extension) {
4-
const supported_extensions = [
5-
"odt",
6-
"odp",
7-
"ods",
8-
"fodt",
9-
"pdf",
10-
"ott",
11-
"fodp",
12-
"otp",
13-
"fods",
14-
"ots",
15-
];
16-
return supported_extensions.includes(extension);
22+
return (
23+
VIEWERJS_EXTENSIONS.includes(extension) || OFFICE_EXTENSIONS.includes(extension)
24+
);
25+
}
26+
27+
export function isOfficeExtension(extension) {
28+
return OFFICE_EXTENSIONS.includes(extension);
1729
}
1830

1931
export function getUrl(
2032
attachment_id,
2133
attachment_url,
2234
attachment_extension,
23-
attachment_title
35+
attachment_title,
36+
attachment_filename
2437
) {
38+
// eslint-disable-next-line no-undef
39+
var origin = window.location.origin || "";
40+
41+
// Office formats: route through LibreOffice → PDF conversion endpoint
42+
if (isOfficeExtension(attachment_extension)) {
43+
var conversionUrl = "";
44+
if (attachment_url) {
45+
// Derive model/field/id from the binary field URL
46+
// e.g. /web/content?model=dms.file&field=content&id=42
47+
try {
48+
// eslint-disable-next-line no-undef
49+
var parsed = new URL(origin + attachment_url);
50+
var model = parsed.searchParams.get("model");
51+
var field = parsed.searchParams.get("field");
52+
var id = parsed.searchParams.get("id");
53+
if (model && field && id) {
54+
conversionUrl =
55+
origin +
56+
"/attachment_preview/office_to_pdf" +
57+
"?model=" +
58+
encodeURIComponent(model) +
59+
"&field=" +
60+
encodeURIComponent(field) +
61+
"&id=" +
62+
encodeURIComponent(id) +
63+
"&filename=" +
64+
encodeURIComponent(
65+
attachment_filename || "file." + attachment_extension
66+
);
67+
}
68+
} catch {
69+
// URL parsing failed — fall through to attachment_id path
70+
}
71+
}
72+
if (!conversionUrl && attachment_id) {
73+
conversionUrl =
74+
origin +
75+
"/attachment_preview/office_to_pdf" +
76+
"?model=ir.attachment&field=datas&id=" +
77+
attachment_id +
78+
"&filename=" +
79+
encodeURIComponent(
80+
attachment_filename || "file." + attachment_extension
81+
);
82+
}
83+
if (conversionUrl) {
84+
// Tell ViewerJS the converted output is PDF
85+
return (
86+
origin +
87+
"/attachment_preview/static/lib/ViewerJS/index.html" +
88+
"?type=pdf" +
89+
"&title=" +
90+
encodeURIComponent(attachment_title) +
91+
"&zoom=automatic" +
92+
"#" +
93+
conversionUrl.replace(origin, "")
94+
);
95+
}
96+
}
97+
98+
// Native ViewerJS path (PDF + ODF)
2599
var url = "";
26100
if (attachment_url) {
27101
if (attachment_url.slice(0, 21) === "/web/static/lib/pdfjs") {
28-
// eslint-disable-next-line no-undef
29-
url = (window.location.origin || "") + attachment_url;
102+
url = origin + attachment_url;
30103
} else {
31104
url =
32-
// eslint-disable-next-line no-undef
33-
(window.location.origin || "") +
105+
origin +
34106
"/attachment_preview/static/lib/ViewerJS/index.html" +
35107
"?type=" +
36108
encodeURIComponent(attachment_extension) +
37109
"&title=" +
38110
encodeURIComponent(attachment_title) +
39111
"&zoom=automatic" +
40112
"#" +
41-
// eslint-disable-next-line no-undef
42-
attachment_url.replace(window.location.origin, "");
113+
attachment_url.replace(origin, "");
43114
}
44115
return url;
45116
}
46117
url =
47-
// eslint-disable-next-line no-undef
48-
(window.location.origin || "") +
118+
origin +
49119
"/attachment_preview/static/lib/ViewerJS/index.html" +
50120
"?type=" +
51121
encodeURIComponent(attachment_extension) +
@@ -66,7 +136,8 @@ export function showPreview(
66136
attachment_extension,
67137
attachment_title,
68138
split_screen,
69-
attachment_info_list
139+
attachment_info_list,
140+
attachment_filename
70141
) {
71142
if (split_screen && attachment_info_list) {
72143
Component.env.bus.trigger("open_attachment_preview", {
@@ -80,7 +151,8 @@ export function showPreview(
80151
attachment_id,
81152
attachment_url,
82153
attachment_extension,
83-
attachment_title
154+
attachment_title,
155+
attachment_filename
84156
)
85157
);
86158
}

attachment_preview/static/src/js/web_views/fields/binary_field.esm.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ patch(BinaryField.prototype, {
5757
$(event.currentTarget).attr("data-extension"),
5858
sprintf(_t("Preview %s"), this.fileName),
5959
false,
60-
null
60+
null,
61+
this.fileName
6162
);
6263
event.stopPropagation();
6364
},

attachment_preview/tests/test_attachment_preview.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
33

44
import base64
5+
import subprocess
6+
from unittest.mock import patch
57

68
from odoo.addons.base.tests.common import BaseCommon
79
from odoo.addons.mail.tools.discuss import Store
@@ -66,3 +68,75 @@ def test_get_extension(self):
6668
"ir.attachment", attachment3.id, "datas", "dummy"
6769
)
6870
self.assertTrue(res6)
71+
72+
73+
class TestOfficeToPdfController(BaseCommon):
74+
"""Unit tests for the LibreOffice conversion controller."""
75+
76+
@classmethod
77+
def setUpClass(cls):
78+
super().setUpClass()
79+
cls.docx_content = base64.b64encode(b"fake docx content")
80+
cls.attachment = cls.env["ir.attachment"].create(
81+
{"name": "report.docx", "datas": cls.docx_content}
82+
)
83+
from ..controllers.main import AttachmentPreviewOfficeController
84+
85+
cls.controller = AttachmentPreviewOfficeController()
86+
87+
def _make_fake_completed_process(self, returncode=0):
88+
result = subprocess.CompletedProcess(args=[], returncode=returncode)
89+
result.stdout = b""
90+
result.stderr = b""
91+
return result
92+
93+
def test_libreoffice_to_pdf_success(self):
94+
"""Returns PDF bytes when LibreOffice succeeds."""
95+
fake_pdf = b"%PDF-1.4 fake"
96+
with (
97+
patch(
98+
"odoo.addons.attachment_preview.controllers.main.subprocess.run"
99+
) as mock_run,
100+
patch(
101+
"odoo.addons.attachment_preview.controllers.main.open",
102+
create=True,
103+
) as mock_open,
104+
patch(
105+
"odoo.addons.attachment_preview.controllers.main.os.path.exists",
106+
return_value=True,
107+
),
108+
):
109+
mock_run.return_value = self._make_fake_completed_process(returncode=0)
110+
mock_open.return_value.__enter__ = lambda s: s
111+
mock_open.return_value.__exit__ = lambda s, *a: False
112+
mock_open.return_value.read = lambda: fake_pdf
113+
mock_open.return_value.write = lambda data: None
114+
result = self.controller._libreoffice_to_pdf(b"content", "docx")
115+
self.assertIsNotNone(result)
116+
117+
def test_libreoffice_not_installed_returns_none(self):
118+
"""Returns None when LibreOffice binary is not found."""
119+
with patch(
120+
"odoo.addons.attachment_preview.controllers.main.subprocess.run",
121+
side_effect=FileNotFoundError,
122+
):
123+
result = self.controller._libreoffice_to_pdf(b"content", "docx")
124+
self.assertIsNone(result)
125+
126+
def test_libreoffice_timeout_returns_none(self):
127+
"""Returns None on conversion timeout."""
128+
with patch(
129+
"odoo.addons.attachment_preview.controllers.main.subprocess.run",
130+
side_effect=subprocess.TimeoutExpired(cmd="libreoffice", timeout=30),
131+
):
132+
result = self.controller._libreoffice_to_pdf(b"content", "docx")
133+
self.assertIsNone(result)
134+
135+
def test_libreoffice_nonzero_exit_returns_none(self):
136+
"""Returns None when LibreOffice exits with non-zero code."""
137+
with patch(
138+
"odoo.addons.attachment_preview.controllers.main.subprocess.run",
139+
return_value=self._make_fake_completed_process(returncode=1),
140+
):
141+
result = self.controller._libreoffice_to_pdf(b"content", "docx")
142+
self.assertIsNone(result)

0 commit comments

Comments
 (0)