Documents: one previewer for every file — PDFs drawn with a vendored pdf.js, thumbnails, pictures and text inline, as paperless-ngx does #942

Open
opened 2026-10-16 15:46:42 +00:00 by tiagoagueda · 2 comments
Owner

Postulo keeps many kinds of document: CVs and letters it writes, files people upload, PDFs it froze when something was sent, and the renderings a capture kept. Each is shown in its own way, and most are not shown at all: they are downloaded. This issue asks for one previewer used everywhere a document or file appears. It is modelled on what paperless-ngx does, kept within the security rules Postulo already has.

Read against main at 56fc9d531 (2026-10-16).

What exists today

Document Where How it is shown
CV, letter (written in Postulo) documents/views.py:394 CVPreviewView, :606 CoverLetterPreviewView; framed by templates/documents/cv_detail.html:210 and letter_detail.html:41 HTML in a same-origin <iframe class="document-frame">. The theme stylesheet is inlined under a CSP nonce (#232). The frame is allowed by FRAMEABLE_BY_OURSELVES (views.py:389, #293: frame-ancestors 'self' + X-Frame-Options: SAMEORIGIN on these two views only).
Where the CV/letter pages break DraftMixin, views.py:150 (the note at :153: "The preview is HTML, and HTML cannot show where a page ends") Not shown. Download draft PDF (#236) renders through WeasyPrint (or the Chromium extra) and returns it as an attachment via handed_over() (views.py:67). To see a page break you have to download a file and open it elsewhere.
Uploaded files ("the files you keep": a designed CV, diplomas, certificates, reference letters, portfolios) UploadedDocument, documents/models.py:662; list templates/documents/upload_list.html; UploadDownloadView, views.py:809 Download only (as_attachment=True). There is no thumbnail and no inline view. Allowed types are integrity.EXTENSIONS = pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg (documents/integrity.py:30). For Word and ODT files the text is extracted for search (UploadedDocument.text, #877).
Sent documents (frozen PDFs) RenderedDocument, models.py:977; rendered_list.html, rendered_compare.html, cotton/sent_versions.html, application_detail.html:186; RenderedDownloadView, views.py:830 A link to the file, served inline. Since #771 the type comes from the extension only. The file carries FILE_POLICY = default-src 'none'; sandbox, so what you see depends on the browser's own PDF viewer under a sandboxing CSP, and some engines refuse to run their viewer there (to be checked per browser). Comparing two versions (rendered_compare.html) compares only the text; for the layout you download both.
Captured page renderings CapturedPage / RenderingKind (PNG, JPEG, WebP, PDF), jobs/models.py:1880–1960; jobs/page_views.py:121 CapturedRenderingView; templates/jobs/capture_page.html:83 A picture is drawn inline in an <img> inside a scrolling box. A PDF is always an attachment, because it is a stranger's document. The kept source is never served as a page: it is text in a <pre> or a text/plain download (serve_private_text, core/files.py:211).
Pictures (avatar, CV photo, company logo) serve_stored_picture, core/files.py:189 (stored in the database, #662) <img>. Sanitised on the way in by core/pictures.py (Pillow; see #777).
API api/routers/documents.py:335 download_document /documents/{upload,rendered}/{pk}/download returns an attachment. There is no preview or thumbnail endpoint.
Attachments of mail (IMAP plugin) plugin repo Not shown in core.

Constraints a previewer must keep. All of these are already in place:

  • Files are served only through a view that has checked ownership. OwnedObjectMixin makes another account's file a 404. serve_private_file (core/files.py:121) checks the within owner folder (#663, #773), uses the type from the extension and never from a title (#771), and sends nosniff, Cache-Control: private, no-store and FILE_POLICY on every file. Only the inert types in HAND_OVER_TYPES (files.py:45) are handed to nginx or Apache (#813).
  • A strict CSP on every page (config/settings/base.py:818): script-src 'self' with no inline scripts or style= attributes, img-src 'self' data:, connect-src 'self', worker-src 'self', frame-src 'self', frame-ancestors 'none', object-src 'none'. The browser suite runs under this policy and fails on a violation (#232).
  • No CDN. Third-party JavaScript is vendored in src/postulo/static/js/vendor/, which holds htmx 2.0.10, zxcvbn-ts, Leaflet 1.9.4 and Leaflet.markercluster 1.5.3. Each is pinned in package.json, copied by npm run sync:vendor (scripts/sync-vendor.mjs) with its LICENSE.txt beside it, and listed in THIRD-PARTY.md. tests/test_trademarks.py holds the table to the tree. All other JavaScript is static/js/app.js.
  • No library in the dependencies can draw a PDF page. WeasyPrint writes PDFs but cannot rasterise them; its PNG output was removed long ago. Pillow comes in through WeasyPrint and is imported only by core/pictures.py. The Europass reader deliberately reads PDFs without a PDF library (plugins/europass/pdf.py).

What paperless-ngx does

paperless-ngx is GPL-3.0. Its ideas are free to use. Its code could legally be combined into AGPL-3.0 Postulo (GPLv3 §13), but copying it would bring its notices and an Angular-shaped design, so take the ideas rather than the code.

  • Original and archive version. The uploaded original is always kept. Beside it, consumption may write an archive version: a PDF/A-2b made by OCRmyPDF for PDFs and images (PAPERLESS_OCR_OUTPUT_TYPE, PAPERLESS_ARCHIVE_FILE_GENERATION=auto|always|never). The viewer shows the archive version when one exists. See configuration.md and parsers/tesseract.py.
  • Formats that are not PDFs are converted by optional services. With PAPERLESS_TIKA_ENABLED, Word, Excel, PowerPoint, OpenDocument and RTF files go through Tika for their text and through Gotenberg's LibreOffice route to a PDF/A (parsers/tika.py). Mail is rendered through Gotenberg's Chromium route (parsers/mail.py). Without those services, these formats are not accepted at all. Plain text (.txt, .csv) gets no archive; it is shown as text (parsers/text.py).
  • Thumbnails are made once, at consumption. The first page of the archive PDF (or of the original) is drawn by ImageMagick with a Ghostscript fallback, as WebP, at most 500 px wide. If both fail, a generic document.webp is used (documents/parsers.py make_thumbnail_from_pdf). A text file's thumbnail is its first characters drawn with Pillow on a 500×700 image.
  • The API has three addresses per document: /api/documents/<id>/preview/ (inline, the archive version unless ?original=true), /thumb/ (image/webp), and /download/ (attachment, same ?original switch). Preview and thumbnail carry ETag and Last-Modified with no-cache, so the browser revalidates and does not download again (documents/views.py).
  • The frontend draws PDFs itself with pdfjs-dist (6.3.289 in src-ui/package.json today), using pdf.js's own PDFViewer/PDFSinglePageViewer, EventBus, PDFLinkService and PDFFindController. The text layer is on so text can be selected, and the worker is served from its own assets (GlobalWorkerOptions.workerSrc = 'assets/js/pdf.worker.min.mjs', with cMaps, ICC and wasm beside it). See pdf-viewer.component.ts.
  • The document page has the preview beside the metadata (document-detail.component.html):
    • PDFs get page number "n of N", − and + buttons, a zoom select (page fit, page width, percentages), and search hits highlighted.
    • Images are shown in an <img>.
    • Text is shown in a scrolling pre-wrap box.
    • Anything else falls back to an <object>.
    • "Download" gives the archive version, with "Download original" beside it.
    • A user setting switches to the browser's own PDF viewer.
    • On phones the preview moves into a tab of its own.
  • In lists, cards show the WebP thumbnail. Hovering one opens a preview popup with the whole document in the same pdf.js viewer (text for text files).

About pdf.js. It is Apache-2.0, which is compatible with AGPL-3.0. Its notice goes beside the files, as for Leaflet. The current release is v6.4.299 (2026-10-03).

In v6, src/display/api.js has no isEvalSupported option any more. CVE-2024-4367 (eval in font rendering) was fixed in 4.2.67 by removing eval entirely, so no 'unsafe-eval' is needed.

Under Postulo's CSP it should fit as follows:

  • Worker. A worker served from 'self' is created directly as a module worker. pdf.js wraps a worker in a blob: URL only when workerSrc is on another origin, so worker-src 'self' is enough.
  • WebAssembly decoders (openjpeg, qcms, jbig2) need 'wasm-unsafe-eval' in script-src. With useWasm: false, pdf.js uses its JavaScript fallbacks (openjpeg_nowasm_fallback.js), so the policy can stay unchanged.
  • Fetching and styles. Fonts, cMaps and standard font data are fetched from 'self'. Styles are set through CSSOM, which style-src does not block.
  • The document itself is fetched by fetch()/XHR under connect-src 'self', with credentials.

All of this is to be confirmed by the browser suite running under the policy, as #232 does for every page.

Proposed design

1. One component, <c-document-viewer>

A single Cotton component, used everywhere a document or file is shown:

  • the CV and letter pages (replacing the bare frame),
  • an uploaded file (a detail or preview page, which does not exist yet),
  • sent documents, and the comparison page (two viewers side by side),
  • the application's documents,
  • a captured page's rendering,
  • later, a plugin's attachments, through the plugin surface.

It takes the file's preview address, its kind (pdf | image | text | html | none), a title, and the download address. It chooses the renderer. With no script, it falls back to a plain link: "Open" or "Download", plus the thumbnail where there is one.

2. PDF through a vendored pdf.js

  • Add pdfjs-dist at an exact version to package.json ("pdfjs-dist": "6.4.299"). sync:vendor copies build/pdf.min.mjs, build/pdf.worker.min.mjs, web/pdf_viewer.mjs + pdf_viewer.css, cmaps/, standard_fonts/ (and wasm/ only if decision D4 says so) into static/js/vendor/pdfjs/ with LICENSE.txt. Add a row to THIRD-PARTY.md. Load it only on pages that show a viewer, as Leaflet is (#898).
  • Configure it as GlobalWorkerOptions.workerSrc = the static URL; getDocument({url, withCredentials: true, useWasm: false, enableXfa: false, isOffscreenCanvasSupported: true, cMapUrl, standardFontDataUrl}). Do not build a PDFScriptingManager: no PDF JavaScript runs. Set annotationMode to draw annotations but not forms. External links in the PDF open with rel="noopener noreferrer" in a new tab (LinkTarget.BLANK), and only http/https/mailto links are allowed.
  • Controls use the Basecoat vocabulary (btn + data-variant="ghost", data-size="icon-sm", Lucide icons with an sr-only name):
    • previous and next page, a page number box "n of N",
    • zoom out and zoom in, a select with Fit width, Fit page, 50–200 %,
    • Download, and Open in a new tab (the inline preview address).
    • Keyboard: PageUp/PageDown or ←/→ change the page, +/- zoom, Home/End. These keys work only while the viewer has focus, never globally.
  • The text layer stays on, so screen readers and Find in page reach the words, and text can be selected and copied. Search inside the viewer (PDFFindController) is optional, a nice-to-have.
  • CV and letter pages: show the real PDF, so page breaks, margins and the chosen paper (#344) are what you see. This needs a draft PDF address that answers inline and is cached by renderers.draft_pdf (it already caches per address). A render takes seconds where HTML takes milliseconds, so the viewer shows the last PDF, with Redraw or an automatic redraw after a save, debounced. See decision D1.

3. Preview and thumbnail addresses

Every viewable record gets …/preview/ and …/thumb/ next to …/download/. These are web views; API equivalents GET /documents/{source}/{pk}/preview and /thumb would mirror paperless.

  • Same permission check. Each one goes through OwnedObjectMixin / owned_or_404 and serve_private_file(..., within=…), with no new route around ownership.
  • Content-Disposition: inline only for a fixed allow-list keyed by what the record is (#771): application/pdf, image/png, image/jpeg, image/webp, and text/plain; charset=utf-8. Everything else stays an attachment. nosniff, no-store and FILE_POLICY stay. pdf.js reads the bytes with fetch, so the sandboxing policy on the file response does not get in its way, unlike the browser's built-in viewer.
  • Revalidation. ETag (the stored SHA-256 we already keep: checksum, rendering_checksum) and Last-Modified, as paperless does, so moving between pages does not download the file again. Cache-Control stays private, no-store unless D5 decides otherwise.
  • A captured PDF rendering (a stranger's PDF) may be previewed only through pdf.js, never inline in the browser's own viewer. Its Open in a new tab opens the viewer page, not the raw file.

4. Thumbnails, made on the server

  • What and when. The first page as WebP, at most 400–500 px wide, written once when a file arrives or a snapshot is frozen. The work is an errand, so an upload does not wait for it. The thumbnail is kept beside the file under the owner's folder, counted in the account's quota, deleted with the row, included in the export and archive (or regenerated on import), and swept like other media (#775).

  • How, for PDFs. No current dependency can rasterise a PDF. Candidates:

    • pypdfium2 (PDFium, Apache-2.0/BSD-3-Clause, wheels with no system library). It renders to a PIL image that Pillow saves as WebP. This is the lightest choice.
    • pdftoppm (Poppler, GPL, a system package): another binary in the image.
    • ImageMagick + Ghostscript, as paperless uses: heavy, and Ghostscript is AGPL with a history of CVEs.
    • Draw thumbnails in the browser with pdf.js: no server dependency, but nothing to show in a list until the page script runs.

    Proposed: pypdfium2, as an optional extra ([preview]) included in the container image. Rasterising a PDF someone else wrote is a parser on hostile input, so it runs in the errand worker under a time and memory limit, never in a request. Captured renderings sent by a client are thumbnailed only if D3 says so. See D2.

  • Pictures: Pillow (already present), through the same restricted decode path as core/pictures.py (#777).

  • Text: no thumbnail. Show a kind icon, rather than paperless's drawn text.

  • Word, ODT and RTF: a kind icon, unless a converter exists (see section 5).

  • Where shown: the Files list and Sent documents (a grid or a leading thumbnail per row), application documents, and the proof attached to a career entry (#669). Each <img> gets loading="lazy", a width and height to avoid layout shift, and alt="" because the title beside it carries the name.

5. Other kinds

  • Images: an <img> with fit or actual size, a zoom toggle, and the same Download and Open buttons.
  • Text: fetched and shown escaped in a <pre> with white-space: pre-wrap, in the file's declared language/dir (#283). Never served as HTML.
  • Word, ODT and RTF: download only by default. Conversion to PDF is offered only if an optional converter is installed, for example a postulo-gotenberg or LibreOffice plugin, following paperless's split. The converted PDF would be kept as a derivative of the original, like paperless's archive version, never replacing it. See D3. The extracted search text (#877) could be shown as a Text only fallback.
  • HTML (CV/letter preview): if it is kept (D1), it stays in the same-origin frame with its own response policy. Add the sandbox attribute (without allow-scripts) to the <iframe> as a second barrier: the preview has no script, and a theme from a plugin (#343) should not gain one.
  • Kept page sources: remain text only, as today. Nothing here changes tests/security/test_captured_pages.py.

6. Accessibility and phones

  • Focus. The viewer is a region labelled by the document title. Its controls are reachable by Tab, and the page box announces "Page n of N" through a polite live region.
  • Text access. The text layer is what a screen reader reads. A Download and an Open in a new tab link are always present as the alternative. A PDF that has no text (a scan) says so: "This file has no text a screen reader can read."
  • Reduced motion. Zoom animations respect prefers-reduced-motion.
  • Phones. Below md the viewer opens full screen, as a drawer or dialog, from the thumbnail or a Preview button. Paperless uses a tab of its own. The page layout is held at 320 px by the reflow browser test in every language.
  • Contrast. The page canvas keeps a white page in dark mode, with only the surrounding area following the theme.

7. Text, translation and tests

  • New strings through gettext; translated in Weblate only (#706). Tooltips and sr-only names on every icon button.
  • Unit tests:
    • thumbnail generation (PDF, PNG, JPEG; a broken or encrypted PDF gives the kind icon and no traceback; the time limit holds),
    • preview headers per kind (inline allow-list, attachment for everything else, a data:-like title cannot change the type),
    • ownership (another account's preview or thumbnail is a 404),
    • the within guard,
    • ETag/304.
  • Security tests in tests/security/: a captured PDF is never answered inline; an HTML or SVG upload can never reach a preview.
  • Browser test (in the browser suite, under the real CSP):
    • the viewer loads a two-page PDF, shows "1 of 2", Next moves to page 2, zoom changes the scale, keyboard works, no CSP violation is reported,
    • the axe check passes,
    • with script off, the viewer falls back to the links.
  • test_trademarks.py must see the pdf.js notice. The vendored file sizes are worth stating in the PR (pdf.js plus worker is about 1.5 MB minified, loaded only where used).

Decisions for the maintainer

  • D1: CV and letter preview. (a) PDF only: what you see is what is sent, at a few seconds per redraw. (b) HTML live preview with a PDF toggle in the same viewer: fast while editing, accurate on demand. (c) Keep HTML and only open the draft PDF in the viewer. My recommendation is (b), defaulting to PDF once a draft exists.
  • D2: thumbnail engine. pypdfium2 as an optional extra (recommended), pdftoppm, or thumbnails drawn in the browser only.
  • D3: an optional converter plugin (Gotenberg or LibreOffice) to preview DOCX, ODT and RTF as PDF, as paperless does with Tika and Gotenberg. Out of core or not at all? Also: may a stranger's captured PDF be rasterised on the server for a thumbnail, or previewed only in the browser?
  • D4: WebAssembly in pdf.js. Keep useWasm: false with the JavaScript fallbacks (no CSP change; recommended), or add 'wasm-unsafe-eval' to script-src on viewer pages for faster JPEG 2000 and JBIG2 decoding.
  • D5: caching. Keep no-store on previews and thumbnails (safe on shared browsers; consistent with #787) or allow private, max-age for thumbnails, with an address that changes when the file changes.
  • D6: the browser's own viewer. Offer a per-person setting to use the browser's PDF viewer instead of pdf.js, as paperless does. Its interaction with FILE_POLICY would need checking per browser.

Related: #232, #236, #293 (the framed preview), #343 (theme files in the preview), #344 (paper size), #662, #663, #669, #771, #773, #777, #787, #813, #877, #878.

Postulo keeps many kinds of document: CVs and letters it writes, files people upload, PDFs it froze when something was sent, and the renderings a capture kept. Each is shown in its own way, and most are not shown at all: they are downloaded. This issue asks for **one previewer** used everywhere a document or file appears. It is modelled on what paperless-ngx does, kept within the security rules Postulo already has. Read against `main` at `56fc9d531` (2026-10-16). ## What exists today | Document | Where | How it is shown | | --- | --- | --- | | CV, letter (written in Postulo) | `documents/views.py:394` `CVPreviewView`, `:606` `CoverLetterPreviewView`; framed by `templates/documents/cv_detail.html:210` and `letter_detail.html:41` | **HTML** in a same-origin `<iframe class="document-frame">`. The theme stylesheet is inlined under a CSP nonce (#232). The frame is allowed by `FRAMEABLE_BY_OURSELVES` (`views.py:389`, #293: `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN` on these two views only). | | Where the CV/letter pages break | `DraftMixin`, `views.py:150` (the note at `:153`: *"The preview is HTML, and HTML cannot show where a page ends"*) | Not shown. **Download draft PDF** (#236) renders through WeasyPrint (or the Chromium extra) and returns it as an **attachment** via `handed_over()` (`views.py:67`). To see a page break you have to download a file and open it elsewhere. | | Uploaded files ("the files you keep": a designed CV, diplomas, certificates, reference letters, portfolios) | `UploadedDocument`, `documents/models.py:662`; list `templates/documents/upload_list.html`; `UploadDownloadView`, `views.py:809` | **Download only** (`as_attachment=True`). There is no thumbnail and no inline view. Allowed types are `integrity.EXTENSIONS` = pdf, doc, docx, odt, rtf, txt, png, jpg, jpeg (`documents/integrity.py:30`). For Word and ODT files the text is extracted for search (`UploadedDocument.text`, #877). | | Sent documents (frozen PDFs) | `RenderedDocument`, `models.py:977`; `rendered_list.html`, `rendered_compare.html`, `cotton/sent_versions.html`, `application_detail.html:186`; `RenderedDownloadView`, `views.py:830` | A link to the file, served **inline**. Since #771 the type comes from the extension only. The file carries `FILE_POLICY` = `default-src 'none'; sandbox`, so what you see depends on the browser's own PDF viewer under a sandboxing CSP, and some engines refuse to run their viewer there (to be checked per browser). Comparing two versions (`rendered_compare.html`) compares only the text; for the layout you download both. | | Captured page renderings | `CapturedPage` / `RenderingKind` (PNG, JPEG, WebP, PDF), `jobs/models.py:1880–1960`; `jobs/page_views.py:121` `CapturedRenderingView`; `templates/jobs/capture_page.html:83` | A picture is drawn inline in an `<img>` inside a scrolling box. A PDF is **always an attachment**, because it is a stranger's document. The kept source is never served as a page: it is text in a `<pre>` or a `text/plain` download (`serve_private_text`, `core/files.py:211`). | | Pictures (avatar, CV photo, company logo) | `serve_stored_picture`, `core/files.py:189` (stored in the database, #662) | `<img>`. Sanitised on the way in by `core/pictures.py` (Pillow; see #777). | | API | `api/routers/documents.py:335` `download_document` | `/documents/{upload,rendered}/{pk}/download` returns an attachment. There is no preview or thumbnail endpoint. | | Attachments of mail (IMAP plugin) | plugin repo | Not shown in core. | **Constraints a previewer must keep.** All of these are already in place: - **Files are served only through a view that has checked ownership.** `OwnedObjectMixin` makes another account's file a 404. `serve_private_file` (`core/files.py:121`) checks the `within` owner folder (#663, #773), uses the type from the extension and never from a title (#771), and sends `nosniff`, `Cache-Control: private, no-store` and `FILE_POLICY` on every file. Only the inert types in `HAND_OVER_TYPES` (`files.py:45`) are handed to nginx or Apache (#813). - **A strict CSP on every page** (`config/settings/base.py:818`): `script-src 'self'` with no inline scripts or `style=` attributes, `img-src 'self' data:`, `connect-src 'self'`, `worker-src 'self'`, `frame-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`. The browser suite runs under this policy and fails on a violation (#232). - **No CDN.** Third-party JavaScript is vendored in `src/postulo/static/js/vendor/`, which holds htmx 2.0.10, zxcvbn-ts, Leaflet 1.9.4 and Leaflet.markercluster 1.5.3. Each is pinned in `package.json`, copied by `npm run sync:vendor` (`scripts/sync-vendor.mjs`) with its `LICENSE.txt` beside it, and listed in `THIRD-PARTY.md`. `tests/test_trademarks.py` holds the table to the tree. All other JavaScript is `static/js/app.js`. - **No library in the dependencies can draw a PDF page.** WeasyPrint writes PDFs but cannot rasterise them; its PNG output was removed long ago. Pillow comes in through WeasyPrint and is imported only by `core/pictures.py`. The Europass reader deliberately reads PDFs without a PDF library (`plugins/europass/pdf.py`). ## What paperless-ngx does paperless-ngx is GPL-3.0. Its ideas are free to use. Its code could legally be combined into AGPL-3.0 Postulo (GPLv3 §13), but copying it would bring its notices and an Angular-shaped design, so take the ideas rather than the code. - **Original and archive version.** The uploaded original is always kept. Beside it, consumption may write an **archive version**: a PDF/A-2b made by OCRmyPDF for PDFs and images (`PAPERLESS_OCR_OUTPUT_TYPE`, `PAPERLESS_ARCHIVE_FILE_GENERATION=auto|always|never`). The viewer shows the archive version when one exists. See [configuration.md](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/configuration.md) and [parsers/tesseract.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/paperless/parsers/tesseract.py). - **Formats that are not PDFs are converted by optional services.** With `PAPERLESS_TIKA_ENABLED`, Word, Excel, PowerPoint, OpenDocument and RTF files go through Tika for their text and through **Gotenberg's LibreOffice route** to a PDF/A ([parsers/tika.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/paperless/parsers/tika.py)). Mail is rendered through Gotenberg's Chromium route ([parsers/mail.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/paperless/parsers/mail.py)). Without those services, these formats are not accepted at all. Plain text (`.txt`, `.csv`) gets no archive; it is shown as text ([parsers/text.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/paperless/parsers/text.py)). - **Thumbnails are made once, at consumption.** The first page of the archive PDF (or of the original) is drawn by ImageMagick with a Ghostscript fallback, as **WebP, at most 500 px wide**. If both fail, a generic `document.webp` is used ([documents/parsers.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/documents/parsers.py) `make_thumbnail_from_pdf`). A text file's thumbnail is its first characters drawn with Pillow on a 500×700 image. - **The API has three addresses** per document: `/api/documents/<id>/preview/` (inline, the archive version unless `?original=true`), `/thumb/` (`image/webp`), and `/download/` (attachment, same `?original` switch). Preview and thumbnail carry ETag and Last-Modified with `no-cache`, so the browser revalidates and does not download again ([documents/views.py](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/documents/views.py)). - **The frontend draws PDFs itself** with `pdfjs-dist` (6.3.289 in `src-ui/package.json` today), using pdf.js's own `PDFViewer`/`PDFSinglePageViewer`, `EventBus`, `PDFLinkService` and `PDFFindController`. The text layer is on so text can be selected, and the worker is served from its own assets (`GlobalWorkerOptions.workerSrc = 'assets/js/pdf.worker.min.mjs'`, with cMaps, ICC and wasm beside it). See [pdf-viewer.component.ts](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src-ui/src/app/components/common/pdf-viewer/pdf-viewer.component.ts). - **The document page** has the preview beside the metadata ([document-detail.component.html](https://github.com/paperless-ngx/paperless-ngx/blob/dev/src-ui/src/app/components/document-detail/document-detail.component.html)): - PDFs get page number "n of N", − and + buttons, a zoom select (page fit, page width, percentages), and search hits highlighted. - Images are shown in an `<img>`. - Text is shown in a scrolling `pre-wrap` box. - Anything else falls back to an `<object>`. - "Download" gives the archive version, with "Download original" beside it. - A user setting switches to the browser's own PDF viewer. - On phones the preview moves into a tab of its own. - **In lists**, cards show the WebP thumbnail. Hovering one opens a [preview popup](https://github.com/paperless-ngx/paperless-ngx/tree/dev/src-ui/src/app/components/common/preview-popup) with the whole document in the same pdf.js viewer (text for text files). **About pdf.js.** It is [Apache-2.0](https://github.com/mozilla/pdf.js/blob/master/LICENSE), which is compatible with AGPL-3.0. Its notice goes beside the files, as for Leaflet. The current release is [v6.4.299](https://github.com/mozilla/pdf.js/releases) (2026-10-03). In v6, `src/display/api.js` has no `isEvalSupported` option any more. CVE-2024-4367 (eval in font rendering) was fixed in 4.2.67 by removing `eval` entirely, so no `'unsafe-eval'` is needed. Under Postulo's CSP it should fit as follows: - **Worker.** A worker served from `'self'` is created directly as a module worker. pdf.js wraps a worker in a `blob:` URL only when `workerSrc` is on another origin, so `worker-src 'self'` is enough. - **WebAssembly decoders** (openjpeg, qcms, jbig2) need `'wasm-unsafe-eval'` in `script-src`. With `useWasm: false`, pdf.js uses its JavaScript fallbacks (`openjpeg_nowasm_fallback.js`), so the policy can stay unchanged. - **Fetching and styles.** Fonts, cMaps and standard font data are fetched from `'self'`. Styles are set through CSSOM, which `style-src` does not block. - **The document itself** is fetched by `fetch()`/XHR under `connect-src 'self'`, with credentials. All of this is to be confirmed by the browser suite running under the policy, as #232 does for every page. ## Proposed design ### 1. One component, `<c-document-viewer>` A single Cotton component, used everywhere a document or file is shown: - the CV and letter pages (replacing the bare frame), - an uploaded file (a detail or preview page, which does not exist yet), - sent documents, and the comparison page (two viewers side by side), - the application's documents, - a captured page's rendering, - later, a plugin's attachments, through the plugin surface. It takes the file's **preview address**, its **kind** (`pdf | image | text | html | none`), a title, and the download address. It chooses the renderer. With no script, it falls back to a plain link: "Open" or "Download", plus the thumbnail where there is one. ### 2. PDF through a vendored pdf.js - Add `pdfjs-dist` at an exact version to `package.json` (`"pdfjs-dist": "6.4.299"`). `sync:vendor` copies `build/pdf.min.mjs`, `build/pdf.worker.min.mjs`, `web/pdf_viewer.mjs` + `pdf_viewer.css`, `cmaps/`, `standard_fonts/` (and `wasm/` only if decision D4 says so) into `static/js/vendor/pdfjs/` with `LICENSE.txt`. Add a row to `THIRD-PARTY.md`. Load it only on pages that show a viewer, as Leaflet is (#898). - Configure it as `GlobalWorkerOptions.workerSrc` = the static URL; `getDocument({url, withCredentials: true, useWasm: false, enableXfa: false, isOffscreenCanvasSupported: true, cMapUrl, standardFontDataUrl})`. Do not build a `PDFScriptingManager`: no PDF JavaScript runs. Set `annotationMode` to draw annotations but not forms. External links in the PDF open with `rel="noopener noreferrer"` in a new tab (`LinkTarget.BLANK`), and only `http`/`https`/`mailto` links are allowed. - **Controls** use the Basecoat vocabulary (`btn` + `data-variant="ghost"`, `data-size="icon-sm"`, Lucide icons with an `sr-only` name): - previous and next page, a page number box "n of N", - zoom out and zoom in, a select with *Fit width*, *Fit page*, 50–200 %, - Download, and *Open in a new tab* (the inline preview address). - Keyboard: PageUp/PageDown or ←/→ change the page, `+`/`-` zoom, `Home`/`End`. These keys work only while the viewer has focus, never globally. - **The text layer stays on**, so screen readers and Find in page reach the words, and text can be selected and copied. Search inside the viewer (`PDFFindController`) is optional, a nice-to-have. - **CV and letter pages: show the real PDF**, so page breaks, margins and the chosen paper (#344) are what you see. This needs a *draft PDF* address that answers **inline** and is cached by `renderers.draft_pdf` (it already caches per address). A render takes seconds where HTML takes milliseconds, so the viewer shows the last PDF, with *Redraw* or an automatic redraw after a save, debounced. See decision **D1**. ### 3. Preview and thumbnail addresses Every viewable record gets `…/preview/` and `…/thumb/` next to `…/download/`. These are web views; API equivalents `GET /documents/{source}/{pk}/preview` and `/thumb` would mirror paperless. - **Same permission check.** Each one goes through `OwnedObjectMixin` / `owned_or_404` and `serve_private_file(..., within=…)`, with no new route around ownership. - **`Content-Disposition: inline` only for a fixed allow-list** keyed by what the record *is* (#771): `application/pdf`, `image/png`, `image/jpeg`, `image/webp`, and `text/plain; charset=utf-8`. Everything else stays an attachment. `nosniff`, `no-store` and `FILE_POLICY` stay. pdf.js reads the bytes with `fetch`, so the sandboxing policy on the file response does not get in its way, unlike the browser's built-in viewer. - **Revalidation.** ETag (the stored SHA-256 we already keep: `checksum`, `rendering_checksum`) and Last-Modified, as paperless does, so moving between pages does not download the file again. `Cache-Control` stays `private, no-store` unless D5 decides otherwise. - **A captured PDF rendering** (a stranger's PDF) may be previewed **only through pdf.js**, never inline in the browser's own viewer. Its *Open in a new tab* opens the viewer page, not the raw file. ### 4. Thumbnails, made on the server - **What and when.** The first page as WebP, at most 400–500 px wide, written **once** when a file arrives or a snapshot is frozen. The work is an errand, so an upload does not wait for it. The thumbnail is kept beside the file under the owner's folder, counted in the account's quota, deleted with the row, included in the export and archive (or regenerated on import), and swept like other media (#775). - **How, for PDFs.** No current dependency can rasterise a PDF. Candidates: - **pypdfium2** (PDFium, Apache-2.0/BSD-3-Clause, wheels with no system library). It renders to a PIL image that Pillow saves as WebP. This is the lightest choice. - **pdftoppm** (Poppler, GPL, a system package): another binary in the image. - **ImageMagick + Ghostscript**, as paperless uses: heavy, and Ghostscript is AGPL with a history of CVEs. - **Draw thumbnails in the browser** with pdf.js: no server dependency, but nothing to show in a list until the page script runs. Proposed: **pypdfium2, as an optional extra (`[preview]`) included in the container image**. Rasterising a PDF someone else wrote is a parser on hostile input, so it runs in the errand worker under a time and memory limit, never in a request. Captured renderings sent by a client are thumbnailed only if D3 says so. See **D2**. - **Pictures:** Pillow (already present), through the same restricted decode path as `core/pictures.py` (#777). - **Text:** no thumbnail. Show a kind icon, rather than paperless's drawn text. - **Word, ODT and RTF:** a kind icon, unless a converter exists (see section 5). - **Where shown:** the Files list and Sent documents (a grid or a leading thumbnail per row), application documents, and the proof attached to a career entry (#669). Each `<img>` gets `loading="lazy"`, a width and height to avoid layout shift, and `alt=""` because the title beside it carries the name. ### 5. Other kinds - **Images:** an `<img>` with fit or actual size, a zoom toggle, and the same Download and Open buttons. - **Text:** fetched and shown escaped in a `<pre>` with `white-space: pre-wrap`, in the file's declared `language`/`dir` (#283). Never served as HTML. - **Word, ODT and RTF:** **download only** by default. Conversion to PDF is offered only if an *optional converter* is installed, for example a `postulo-gotenberg` or LibreOffice plugin, following paperless's split. The converted PDF would be kept as a derivative of the original, like paperless's archive version, never replacing it. See **D3**. The extracted search text (#877) could be shown as a *Text only* fallback. - **HTML (CV/letter preview):** if it is kept (D1), it stays in the same-origin frame with its own response policy. Add the `sandbox` attribute (without `allow-scripts`) to the `<iframe>` as a second barrier: the preview has no script, and a theme from a plugin (#343) should not gain one. - **Kept page sources:** remain text only, as today. Nothing here changes `tests/security/test_captured_pages.py`. ### 6. Accessibility and phones - **Focus.** The viewer is a region labelled by the document title. Its controls are reachable by Tab, and the page box announces "Page n of N" through a polite live region. - **Text access.** The text layer is what a screen reader reads. A *Download* and an *Open in a new tab* link are always present as the alternative. A PDF that has no text (a scan) says so: "This file has no text a screen reader can read." - **Reduced motion.** Zoom animations respect `prefers-reduced-motion`. - **Phones.** Below `md` the viewer opens full screen, as a drawer or dialog, from the thumbnail or a *Preview* button. Paperless uses a tab of its own. The page layout is held at 320 px by the reflow browser test in every language. - **Contrast.** The page canvas keeps a white page in dark mode, with only the surrounding area following the theme. ### 7. Text, translation and tests - New strings through `gettext`; translated in Weblate only (#706). Tooltips and `sr-only` names on every icon button. - **Unit tests:** - thumbnail generation (PDF, PNG, JPEG; a broken or encrypted PDF gives the kind icon and no traceback; the time limit holds), - preview headers per kind (inline allow-list, attachment for everything else, a `data:`-like title cannot change the type), - ownership (another account's preview or thumbnail is a 404), - the `within` guard, - ETag/304. - **Security tests** in `tests/security/`: a captured PDF is never answered inline; an HTML or SVG upload can never reach a preview. - **Browser test** (in the browser suite, under the real CSP): - the viewer loads a two-page PDF, shows "1 of 2", Next moves to page 2, zoom changes the scale, keyboard works, no CSP violation is reported, - the axe check passes, - with script off, the viewer falls back to the links. - **`test_trademarks.py`** must see the pdf.js notice. The vendored file sizes are worth stating in the PR (pdf.js plus worker is about 1.5 MB minified, loaded only where used). ## Decisions for the maintainer - **D1: CV and letter preview.** (a) PDF only: what you see is what is sent, at a few seconds per redraw. (b) HTML live preview with a *PDF* toggle in the same viewer: fast while editing, accurate on demand. (c) Keep HTML and only open the draft PDF in the viewer. My recommendation is **(b)**, defaulting to PDF once a draft exists. - **D2: thumbnail engine.** pypdfium2 as an optional extra (recommended), pdftoppm, or thumbnails drawn in the browser only. - **D3: an optional converter plugin** (Gotenberg or LibreOffice) to preview DOCX, ODT and RTF as PDF, as paperless does with Tika and Gotenberg. Out of core or not at all? Also: may a stranger's captured PDF be rasterised on the server for a thumbnail, or previewed only in the browser? - **D4: WebAssembly in pdf.js.** Keep `useWasm: false` with the JavaScript fallbacks (no CSP change; recommended), or add `'wasm-unsafe-eval'` to `script-src` on viewer pages for faster JPEG 2000 and JBIG2 decoding. - **D5: caching.** Keep `no-store` on previews and thumbnails (safe on shared browsers; consistent with #787) or allow `private, max-age` for thumbnails, with an address that changes when the file changes. - **D6: the browser's own viewer.** Offer a per-person setting to use the browser's PDF viewer instead of pdf.js, as paperless does. Its interaction with `FILE_POLICY` would need checking per browser. Related: #232, #236, #293 (the framed preview), #343 (theme files in the preview), #344 (paper size), #662, #663, #669, #771, #773, #777, #787, #813, #877, #878.
tiagoagueda added this to the 0.6.0 milestone 2026-10-25 17:05:08 +00:00
Author
Owner

From the status review of 2026-10-30: of the gaps the core records about itself, this is the one a person meets most often. Every uploaded file and every sent PDF is a download today. Suggest taking it first among the 0.6.0 interface work.

From the status review of 2026-10-30: of the gaps the core records about itself, this is the one a person meets most often. Every uploaded file and every sent PDF is a download today. Suggest taking it first among the 0.6.0 interface work.
Author
Owner

Decided by the maintainer (2026-11-12)

  • D1: a CV and a letter are previewed as their PDF: what you see is what is sent.
  • D2: as recommended (pypdfium2 as an optional extra).
  • D3: the converter that previews DOCX, ODT and the like is an optional plugin, not part of the core.

🤖 Generated with Claude Code

## Decided by the maintainer (2026-11-12) - **D1:** a CV and a letter are previewed as their PDF: what you see is what is sent. - **D2:** as recommended (pypdfium2 as an optional extra). - **D3:** the converter that previews DOCX, ODT and the like is an **optional plugin**, not part of the core. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#942
No description provided.