Skip to content

Commit 9be740b

Browse files
committed
[ADD] dms_libreoffice_preview: server-side LibreOffice→PDF preview for office files
Adds in-browser preview of .doc{x}, .odt, .xls{x}, .ods, .ppt{x}, .odp, .rtf in OCA DMS by converting them to PDF via headless LibreOffice subprocess on first request, caching the result as a child ir.attachment of the source dms.file, and registering a side-pane handler at score 10 that beats the OfficeFallbackPreview download card shipped in base dms. First downstream consumer of the dms.preview_handlers registry introduced in Phase 13 of OCA/dms 19.0. The registry composes cleanly: a future dms_onlyoffice (live editing via OnlyOffice Document Server) would register at score 20 and win over this module's read-only PDF preview whenever it's installed. Architecture - Lazy: nothing converts until first preview request. - Cached: ir.attachment with res_model='dms.file', res_id=file.id, description='libreoffice_preview:<checksum>' as cache key. Keyed on dms.file.checksum (SHA1 of content) so renaming a file doesn't trigger re-conversion, only actual content edits do. Cache auto-cascades on file deletion via the existing res_model/res_id wiring. - ACL: controller calls dms.file.check_access('read') before serving; the converted PDF inherits the parent's permissions. Raises request.not_found() (with `from e` chain) on AccessError so unauthorized callers can't probe existence. - Sync subprocess per call, 60s timeout, per-conversion temp dir to avoid .~lock collisions between concurrent workers. Daemon mode (unoserver) is a future optimization, not v1. JS handler reuses the existing o_dms_preview__iframe class so the browser's native PDF viewer takes over once the server emits the converted PDF. No new SCSS needed. External dependency - libreoffice + fonts-noto + fonts-liberation (declared in external_dependencies.deb; OCA CI's oca_install_apt_packages picks them up automatically on the test runner). Tests - Python: 7 tests covering the mimetype gate, conversion happy path, cache hit + invalidation (via checksum drift), missing-soffice UserError, and controller routing (404 unsupported, 200 with application/pdf for office files). HTTP tests authenticate as dms-user (the user in DocumentsBaseCase.access_group) rather than admin which isn't in the group. - Hoot: every supported office mimetype routes to LibreofficePreview at score 10 (beats base dms's score-0 OfficeFallbackPreview); src URL builder includes /dms/file/<id>/libreoffice_preview + v= cache bust. CI workaround - workflow_dispatch added to both test.yml and pre-commit.yml as the fork-only manual-trigger escape valve. GitHub silently throttles pull_request webhooks after burst pushes; this lets us trigger CI manually with `gh workflow run`. Strip before opening upstream against OCA/dms. Signed-off-by: Don Kendall <dkendall@ledoweb.com>
1 parent 9a2a20e commit 9be740b

19 files changed

Lines changed: 1243 additions & 0 deletions

File tree

.github/workflows/pre-commit.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
branches:
99
- "19.0"
1010
- "19.0-ocabot-*"
11+
workflow_dispatch:
1112

1213
jobs:
1314
pre-commit:

dms_libreoffice_preview/README.rst

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
5+
=========================
6+
DMS — LibreOffice Preview
7+
=========================
8+
9+
..
10+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
11+
!! This file is generated by oca-gen-addon-readme !!
12+
!! changes will be overwritten. !!
13+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
14+
!! source digest: sha256:81baba4c980b32e56a3e208ba841264571d907763fa987650db3d700e2592292
15+
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
16+
17+
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
18+
:target: https://odoo-community.org/page/development-status
19+
:alt: Beta
20+
.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png
21+
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
22+
:alt: License: LGPL-3
23+
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdms-lightgray.png?logo=github
24+
:target: https://github.com/OCA/dms/tree/19.0/dms_libreoffice_preview
25+
:alt: OCA/dms
26+
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
27+
:target: https://translation.odoo-community.org/projects/dms-19-0/dms-19-0-dms_libreoffice_preview
28+
:alt: Translate me on Weblate
29+
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
30+
:target: https://runboat.odoo-community.org/builds?repo=OCA/dms&target_branch=19.0
31+
:alt: Try me on Runboat
32+
33+
|badge1| |badge2| |badge3| |badge4| |badge5|
34+
35+
Adds in-browser preview of office file formats (Word, Excel, PowerPoint,
36+
OpenDocument, RTF) to OCA DMS by converting them to PDF on the server
37+
with a headless LibreOffice subprocess. The converted PDF is cached as a
38+
child ``ir.attachment`` of the source ``dms.file`` and served via the
39+
existing side-pane preview registry (``dms.preview_handlers``), so the
40+
browser's native PDF viewer renders the result with no new UI code.
41+
42+
Without this module, office files in DMS fall back to a download-only
43+
card. Install this module on any deployment where LibreOffice can be
44+
installed system-wide and you don't need full in-browser editing (use
45+
``dms_onlyoffice`` for that).
46+
47+
**Table of contents**
48+
49+
.. contents::
50+
:local:
51+
52+
Installation
53+
============
54+
55+
System dependencies
56+
-------------------
57+
58+
LibreOffice must be installed on the Odoo server. On Debian / Ubuntu:
59+
60+
::
61+
62+
apt-get install libreoffice fonts-noto fonts-liberation
63+
64+
The ``fonts-noto`` and ``fonts-liberation`` packages are recommended
65+
even if your container already has a font set — headless LibreOffice
66+
falls back to ugly substitutes when common fonts are missing, which
67+
produces unreadable PDFs for typical office documents.
68+
69+
Once LibreOffice is on the ``PATH``, install this module as usual. The
70+
preview is lazy: nothing converts until the first time a user opens an
71+
office file in the DMS side-pane. Subsequent opens of the same file
72+
(same ``write_date``) hit the ``ir.attachment`` cache.
73+
74+
Usage
75+
=====
76+
77+
No configuration required. With LibreOffice on the server and this
78+
module installed, opening any office file (``.doc{x}``, ``.odt``,
79+
``.xls{x}``, ``.ods``, ``.ppt{x}``, ``.odp``, ``.rtf``) from the DMS
80+
kanban or list view shows the rendered PDF in the side-pane preview.
81+
82+
The first open takes 1–5 seconds (LibreOffice conversion); subsequent
83+
opens hit the cached attachment and feel instant. If the source file
84+
changes (new content, new name, etc.), the cache invalidates and the
85+
next open triggers a fresh conversion.
86+
87+
If LibreOffice fails to convert a file (corrupt source, unusual format
88+
variation), the side-pane shows a brief error message. The original file
89+
is always downloadable via the side-pane toolbar's Download button
90+
regardless of preview success.
91+
92+
Bug Tracker
93+
===========
94+
95+
Bugs are tracked on `GitHub Issues <https://github.com/OCA/dms/issues>`_.
96+
In case of trouble, please check there if your issue has already been reported.
97+
If you spotted it first, help us to smash it by providing a detailed and welcomed
98+
`feedback <https://github.com/OCA/dms/issues/new?body=module:%20dms_libreoffice_preview%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
99+
100+
Do not contact contributors directly about support or help with technical issues.
101+
102+
Credits
103+
=======
104+
105+
Authors
106+
-------
107+
108+
* ledoent
109+
110+
Contributors
111+
------------
112+
113+
- Don Kendall <dkendall@ledoweb.com>
114+
115+
Maintainers
116+
-----------
117+
118+
This module is maintained by the OCA.
119+
120+
.. image:: https://odoo-community.org/logo.png
121+
:alt: Odoo Community Association
122+
:target: https://odoo-community.org
123+
124+
OCA, or the Odoo Community Association, is a nonprofit organization whose
125+
mission is to support the collaborative development of Odoo features and
126+
promote its widespread use.
127+
128+
.. |maintainer-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px
129+
:target: https://github.com/dnplkndll
130+
:alt: dnplkndll
131+
132+
Current `maintainer <https://odoo-community.org/page/maintainer-role>`__:
133+
134+
|maintainer-dnplkndll|
135+
136+
This module is part of the `OCA/dms <https://github.com/OCA/dms/tree/19.0/dms_libreoffice_preview>`_ project on GitHub.
137+
138+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import controllers
2+
from . import models
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright 2026 ledoent — Don Kendall
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
{
5+
"name": "DMS — LibreOffice Preview",
6+
"summary": (
7+
"In-browser preview of office files in DMS via "
8+
"server-side LibreOffice→PDF conversion"
9+
),
10+
"version": "19.0.1.0.0",
11+
"category": "Document Management",
12+
"license": "LGPL-3",
13+
"website": "https://github.com/OCA/dms",
14+
"author": "ledoent, Odoo Community Association (OCA)",
15+
"maintainers": ["dnplkndll"],
16+
"depends": ["dms"],
17+
"external_dependencies": {
18+
"deb": ["libreoffice", "fonts-noto", "fonts-liberation"],
19+
},
20+
"assets": {
21+
"web.assets_backend": [
22+
"dms_libreoffice_preview/static/src/js/libreoffice_preview.esm.js",
23+
"dms_libreoffice_preview/static/src/js/libreoffice_preview.xml",
24+
],
25+
"web.assets_unit_tests": [
26+
"dms_libreoffice_preview/static/tests/**/*.test.js",
27+
],
28+
},
29+
"installable": True,
30+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright 2026 ledoent — Don Kendall
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
4+
from odoo import http
5+
from odoo.exceptions import AccessError, UserError
6+
from odoo.http import request
7+
8+
9+
class DmsLibreofficePreviewController(http.Controller):
10+
@http.route(
11+
"/dms/file/<int:file_id>/libreoffice_preview",
12+
type="http",
13+
auth="user",
14+
methods=["GET"],
15+
# Not readonly: cache-miss path writes a new ir.attachment via
16+
# _ensure_libreoffice_preview(). Marking readonly=True triggers
17+
# Odoo 19's "retry with r/w cursor" warning which checklog-odoo
18+
# promotes to a build error.
19+
)
20+
def libreoffice_preview(self, file_id, **kwargs):
21+
"""Serve a cached or freshly-converted PDF preview inline.
22+
23+
The `?v=<write_date>` query parameter is not validated server-side
24+
— it exists purely as a cache-buster for the browser (the iframe
25+
URL changes when `dms.file.write_date` changes, forcing a refetch
26+
instead of pulling a stale render from the HTTP cache). Cache
27+
validity on the server is owned by `_libreoffice_preview_attachment`.
28+
"""
29+
dms_file = request.env["dms.file"].browse(file_id).exists()
30+
if not dms_file:
31+
raise request.not_found()
32+
try:
33+
dms_file.check_access("read")
34+
except AccessError as e:
35+
raise request.not_found() from e
36+
if not dms_file._libreoffice_preview_supported():
37+
raise request.not_found()
38+
try:
39+
attachment = dms_file.sudo()._ensure_libreoffice_preview()
40+
except UserError as e:
41+
# Conversion errors are surfaced as 502 with the user-facing
42+
# message — the side-pane handler can render this as an empty
43+
# state via the existing error path.
44+
return request.make_response(
45+
str(e), status=502, headers=[("Content-Type", "text/plain")]
46+
)
47+
return (
48+
request.env["ir.binary"]
49+
._get_stream_from(attachment, "raw")
50+
.get_response(as_attachment=False)
51+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import dms_file

0 commit comments

Comments
 (0)