We use @@display-file rather than @@download because we want files (particularly PDFs) to open directly in the browser. We don't want users to have to download a file just to view it, and we don't want to be at the mercy of individual browser settings that might force a download when using @@download.
The issue is that when a user views a PDF in the browser via @@display-file and then saves it (e.g. from the browser's PDF viewer), it saves as document.pdf instead of the actual filename.
This happens because the DisplayFile view in plone.namedfile intentionally omits the Content-Disposition header entirely — so the browser has no filename hint when saving.
For comparison, @@download sets Content-Disposition: attachment; filename*=UTF-8''actual-name.pdf, which gives the browser the correct filename but forces a download.
The fix
We override @@display-file to add Content-Disposition: inline; filename*=UTF-8''actual-name.pdf. The key is the inline directive — it preserves the browser's default display behaviour (opening PDFs in the viewer, displaying images, etc.), while the filename* parameter provides the correct name when saving.
The result:
- Before: No
Content-Dispositionheader → browser saves asdocument.pdf - After:
Content-Disposition: inline; filename*=UTF-8''actual-name.pdf→ browser saves with the correct name
Implementation
Two files in your backend add-on's browser/ directory.
browser/display_file.py
from plone.namedfile.browser import DisplayFile as BaseDisplayFile
from plone.namedfile.utils import set_headers
from urllib.parse import quote
class DisplayFile(BaseDisplayFile):
"""Override @@display-file to include the filename in the
Content-Disposition header while keeping inline disposition.
"""
def set_headers(self, file):
if hasattr(file, "contentType"):
from plone.namedfile.utils import extract_media_type
mimetype = extract_media_type(file.contentType)
if self.use_denylist:
if mimetype in self.disallowed_inline_mimetypes:
return super().set_headers(file)
else:
if mimetype not in self.allowed_inline_mimetypes:
return super().set_headers(file)
canonical = self.get_canonical(file)
set_headers(file, self.request.response, canonical=canonical)
filename = getattr(file, "filename", None)
if filename:
if not isinstance(filename, str):
filename = str(filename, "utf-8", errors="ignore")
quoted = quote(filename.encode("utf-8"))
self.request.response.setHeader(
"Content-Disposition",
f"inline; filename*=UTF-8''{quoted}",
)
ZCML registration in browser/configure.zcml
<browser:page
name="display-file"
for="*"
class=".display_file.DisplayFile"
permission="zope2.View"
layer="your.addon.interfaces.IYourAddonLayer"
/>
Replace your.addon.interfaces.IYourAddonLayer with your add-on's actual browser layer interface.
How it works
The override keeps the parent class's mimetype security logic intact — mimetypes not on the allowlist (SVG, HTML, etc.) still get forced to Content-Disposition: attachment via the Download view, preserving XSS protection.
For allowed inline mimetypes, it:
- Calls the standard
set_headersutility without a filename, which setsContent-Type,Content-Length, andAccept-Rangesas normal. - Adds
Content-Disposition: inline; filename*=UTF-8''<name>— using the RFC 5987 encoding thatplone.namedfilealready uses elsewhere for filename headers.
Tested with plone.namedfile 7.3.0 on Plone 6.