Skip to content

Commit 375b593

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 fb9d8c3 commit 375b593

9 files changed

Lines changed: 301 additions & 44 deletions

File tree

attachment_preview/README.rst

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
.. image:: https://odoo-community.org/readme-banner-image
2-
:target: https://odoo-community.org/get-involved?utm_source=readme
3-
:alt: Odoo Community Association
4-
51
===================
62
Preview attachments
73
===================
@@ -17,7 +13,7 @@ Preview attachments
1713
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
1814
:target: https://odoo-community.org/page/development-status
1915
:alt: Beta
20-
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
16+
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
2117
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
2218
:alt: License: AGPL-3
2319
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github

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/description/index.html

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
55
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
6-
<title>README.rst</title>
6+
<title>Preview attachments</title>
77
<style type="text/css">
88

99
/*
@@ -360,21 +360,16 @@
360360
</style>
361361
</head>
362362
<body>
363-
<div class="document">
363+
<div class="document" id="preview-attachments">
364+
<h1 class="title">Preview attachments</h1>
364365

365-
366-
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
367-
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
368-
</a>
369-
<div class="section" id="preview-attachments">
370-
<h1>Preview attachments</h1>
371366
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
372367
!! This file is generated by oca-gen-addon-readme !!
373368
!! changes will be overwritten. !!
374369
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
375370
!! source digest: sha256:badc7aad1a4bee3f65173d08f56b91d13166d0c26644edf8475aa249016995e1
376371
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
377-
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/knowledge/tree/18.0/attachment_preview"><img alt="OCA/knowledge" src="https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/knowledge-18-0/knowledge-18-0-attachment_preview"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/knowledge&amp;target_branch=18.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
372+
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/knowledge/tree/18.0/attachment_preview"><img alt="OCA/knowledge" src="https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/knowledge-18-0/knowledge-18-0-attachment_preview"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/knowledge&amp;target_branch=18.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
378373
<p>This addon allows to preview attachments supported by
379374
<a class="reference external" href="http://viewerjs.org">http://viewerjs.org</a>.</p>
380375
<p>Currently, that’s most Libreoffice files and PDFs.</p>
@@ -394,13 +389,13 @@ <h1>Preview attachments</h1>
394389
</ul>
395390
</div>
396391
<div class="section" id="installation">
397-
<h2><a class="toc-backref" href="#toc-entry-1">Installation</a></h2>
392+
<h1><a class="toc-backref" href="#toc-entry-1">Installation</a></h1>
398393
<p>For filetype recognition, you’ll get the best results by installing
399394
<tt class="docutils literal"><span class="pre">python-magic</span></tt>:</p>
400395
<p>sudo apt-get install python-magic</p>
401396
</div>
402397
<div class="section" id="usage">
403-
<h2><a class="toc-backref" href="#toc-entry-2">Usage</a></h2>
398+
<h1><a class="toc-backref" href="#toc-entry-2">Usage</a></h1>
404399
<p>The module adds a little print preview icon right of download links for
405400
attachments or binary fields. When a preview is opened from the
406401
attachments menu it’s shown next to the form view. From this screen you
@@ -410,31 +405,31 @@ <h2><a class="toc-backref" href="#toc-entry-2">Usage</a></h2>
410405
<p><img alt="Screenshot navigator" src="https://raw.githubusercontent.com/attachment_preview/static/description/screenshot-paginator.png" /></p>
411406
</div>
412407
<div class="section" id="bug-tracker">
413-
<h2><a class="toc-backref" href="#toc-entry-3">Bug Tracker</a></h2>
408+
<h1><a class="toc-backref" href="#toc-entry-3">Bug Tracker</a></h1>
414409
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/knowledge/issues">GitHub Issues</a>.
415410
In case of trouble, please check there if your issue has already been reported.
416411
If you spotted it first, help us to smash it by providing a detailed and welcomed
417412
<a class="reference external" href="https://github.com/OCA/knowledge/issues/new?body=module:%20attachment_preview%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
418413
<p>Do not contact contributors directly about support or help with technical issues.</p>
419414
</div>
420415
<div class="section" id="credits">
421-
<h2><a class="toc-backref" href="#toc-entry-4">Credits</a></h2>
416+
<h1><a class="toc-backref" href="#toc-entry-4">Credits</a></h1>
422417
<div class="section" id="authors">
423-
<h3><a class="toc-backref" href="#toc-entry-5">Authors</a></h3>
418+
<h2><a class="toc-backref" href="#toc-entry-5">Authors</a></h2>
424419
<ul class="simple">
425420
<li>Therp BV</li>
426421
<li>Onestein</li>
427422
</ul>
428423
</div>
429424
<div class="section" id="contributors">
430-
<h3><a class="toc-backref" href="#toc-entry-6">Contributors</a></h3>
425+
<h2><a class="toc-backref" href="#toc-entry-6">Contributors</a></h2>
431426
<ul class="simple">
432427
<li>Holger Brunn &lt;<a class="reference external" href="mailto:mail&#64;hunki-enterprises.com">mail&#64;hunki-enterprises.com</a>&gt;</li>
433428
<li>Dennis Sluijk &lt;<a class="reference external" href="mailto:d.sluijk&#64;onestein.nl">d.sluijk&#64;onestein.nl</a>&gt;</li>
434429
</ul>
435430
</div>
436431
<div class="section" id="maintainers">
437-
<h3><a class="toc-backref" href="#toc-entry-7">Maintainers</a></h3>
432+
<h2><a class="toc-backref" href="#toc-entry-7">Maintainers</a></h2>
438433
<p>This module is maintained by the OCA.</p>
439434
<a class="reference external image-reference" href="https://odoo-community.org">
440435
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
@@ -447,6 +442,5 @@ <h3><a class="toc-backref" href="#toc-entry-7">Maintainers</a></h3>
447442
</div>
448443
</div>
449444
</div>
450-
</div>
451445
</body>
452446
</html>

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

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +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+
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+
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-
url = (window.location.origin || "") + attachment_url;
102+
url = origin + attachment_url;
29103
} else {
30104
url =
31-
(window.location.origin || "") +
105+
origin +
32106
"/attachment_preview/static/lib/ViewerJS/index.html" +
33107
"?type=" +
34108
encodeURIComponent(attachment_extension) +
35109
"&title=" +
36110
encodeURIComponent(attachment_title) +
37111
"&zoom=automatic" +
38112
"#" +
39-
attachment_url.replace(window.location.origin, "");
113+
attachment_url.replace(origin, "");
40114
}
41115
return url;
42116
}
43117
url =
44-
(window.location.origin || "") +
118+
origin +
45119
"/attachment_preview/static/lib/ViewerJS/index.html" +
46120
"?type=" +
47121
encodeURIComponent(attachment_extension) +
@@ -62,7 +136,8 @@ export function showPreview(
62136
attachment_extension,
63137
attachment_title,
64138
split_screen,
65-
attachment_info_list
139+
attachment_info_list,
140+
attachment_filename
66141
) {
67142
if (split_screen && attachment_info_list) {
68143
Component.env.bus.trigger("open_attachment_preview", {
@@ -75,7 +150,8 @@ export function showPreview(
75150
attachment_id,
76151
attachment_url,
77152
attachment_extension,
78-
attachment_title
153+
attachment_title,
154+
attachment_filename
79155
)
80156
);
81157
}

0 commit comments

Comments
 (0)