As a user I want a proper PDF viewer so documents render reliably and annotations can be added in a later step #39

Closed
opened 2026-03-20 19:25:59 +01:00 by marcel · 0 comments
Owner

Background

The current document preview uses a plain <iframe> pointing at the file URL. This works for basic viewing but has two hard limits: browser PDF plugins differ across devices and can fail silently, and there is no way to overlay annotation layers on top of the rendered page — a requirement for the annotation feature (#40).

This issue replaces the iframe with PDF.js (Mozilla, Apache 2.0 license) as the rendering foundation. No annotation UI is added here — that is #40.

Desired behaviour

  • PDF documents render consistently across all browsers (Chrome, Firefox, Safari, mobile)
  • The viewer supports page navigation (previous / next, jump to page n)
  • Zoom in / out controls
  • The rendered output visually matches the current iframe experience — this is a drop-in improvement, not a redesign
  • Non-PDF files (images) continue to use the existing <img> tag path and are unaffected

Implementation notes

Library: pdfjs-dist (the prebuilt npm distribution of PDF.js).

npm install pdfjs-dist

PDF.js requires its worker script to be served as a separate file. With Vite this is handled via:

import { GlobalWorkerOptions } from 'pdfjs-dist';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
GlobalWorkerOptions.workerSrc = workerUrl;

Component: Replace the iframe in src/routes/documents/[id]/+page.svelte with a new PdfViewer.svelte component:

  • Accepts a url: string prop
  • Renders each page onto a <canvas> element at the correct device pixel ratio (devicePixelRatio scaling to avoid blurry text on retina screens)
  • Page navigation state is local to the component

Critical — lazy page rendering. Rendering all pages at once for a multi-page document will freeze the browser and consume hundreds of megabytes of canvas memory at 2× DPR. Only the visible page (±1 for pre-loading) must be rendered. Canvas content for pages that scroll out of view must be cleared and re-rendered on scroll. This is not an optimisation — it is a correctness requirement. An implementation that renders all pages eagerly is not acceptable.

Text layer. PDF.js can render a transparent text layer on top of each canvas, enabling text selection and copy-paste. This layer is also the foundation for the annotation drag-select in #40 (distinguishing "user is selecting text" from "user is drawing an annotation rectangle"). The text layer must be included in this implementation so that #40 does not require modifying the rendering pipeline.

Per-page container structure (required by #40):

<div class="pdf-page" data-page-number="1">
  <canvas />          <!-- PDF.js renders here -->
  <div class="textLayer" />   <!-- PDF.js text layer -->
  <!-- #40 will inject annotation overlays here -->
</div>

Each page wrapper must have a stable data-page-number attribute and position: relative so annotation overlays can be positioned absolutely within it.

No backend changes required — the file URL path (/api/documents/{id}/file) is unchanged.

Testing

  • Component test: PdfViewer renders a canvas element when given a valid PDF URL (mock pdfjs-dist)
  • E2E Playwright spec: open a document with a PDF, verify the canvas is present and the page navigation controls work

Dependencies

  • None — this can be implemented independently of the profile and history issues
  • #40 (annotations) depends on this issue being merged first

User Journey

User opens a document that has a PDF attached. Instead of the browser's built-in PDF plugin (which may look different or fail entirely on some devices), the document renders inside the app's own viewer — the same fonts, layout, and quality on every browser. The user can page through the document with previous/next buttons and jump to a specific page number. They can zoom in on fine print or zoom out to see the full page. When they open a document with an image attachment instead of a PDF, the image displays exactly as before — the viewer only activates for PDFs.

E2E Scenarios

Scenario: PDF renders in the custom viewer
  Given I am on a document detail page with a PDF file attached
  Then I see a canvas element rendering the PDF (not an iframe)
  And previous/next page navigation controls are visible

Scenario: Page navigation advances the page
  Given I am viewing a PDF with more than one page
  When I click the "next page" button
  Then the page indicator shows page 2 of N

Scenario: Non-PDF attachment still displays correctly
  Given I am on a document detail page with an image file attached
  Then I see an img element (not a canvas)
  And the image is visible
## Background The current document preview uses a plain `<iframe>` pointing at the file URL. This works for basic viewing but has two hard limits: browser PDF plugins differ across devices and can fail silently, and there is no way to overlay annotation layers on top of the rendered page — a requirement for the annotation feature (#40). This issue replaces the iframe with **PDF.js** (Mozilla, Apache 2.0 license) as the rendering foundation. No annotation UI is added here — that is #40. ## Desired behaviour - PDF documents render consistently across all browsers (Chrome, Firefox, Safari, mobile) - The viewer supports page navigation (previous / next, jump to page n) - Zoom in / out controls - The rendered output visually matches the current iframe experience — this is a drop-in improvement, not a redesign - Non-PDF files (images) continue to use the existing `<img>` tag path and are unaffected ## Implementation notes **Library:** `pdfjs-dist` (the prebuilt npm distribution of PDF.js). ```bash npm install pdfjs-dist ``` PDF.js requires its worker script to be served as a separate file. With Vite this is handled via: ```typescript import { GlobalWorkerOptions } from 'pdfjs-dist'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; GlobalWorkerOptions.workerSrc = workerUrl; ``` **Component:** Replace the iframe in `src/routes/documents/[id]/+page.svelte` with a new `PdfViewer.svelte` component: - Accepts a `url: string` prop - Renders each page onto a `<canvas>` element at the correct device pixel ratio (`devicePixelRatio` scaling to avoid blurry text on retina screens) - Page navigation state is local to the component **Critical — lazy page rendering.** Rendering all pages at once for a multi-page document will freeze the browser and consume hundreds of megabytes of canvas memory at 2× DPR. Only the visible page (±1 for pre-loading) must be rendered. Canvas content for pages that scroll out of view must be cleared and re-rendered on scroll. This is not an optimisation — it is a correctness requirement. An implementation that renders all pages eagerly is not acceptable. **Text layer.** PDF.js can render a transparent text layer on top of each canvas, enabling text selection and copy-paste. This layer is also the foundation for the annotation drag-select in #40 (distinguishing "user is selecting text" from "user is drawing an annotation rectangle"). The text layer **must be included** in this implementation so that #40 does not require modifying the rendering pipeline. **Per-page container structure** (required by #40): ```html <div class="pdf-page" data-page-number="1"> <canvas /> <!-- PDF.js renders here --> <div class="textLayer" /> <!-- PDF.js text layer --> <!-- #40 will inject annotation overlays here --> </div> ``` Each page wrapper must have a stable `data-page-number` attribute and `position: relative` so annotation overlays can be positioned absolutely within it. **No backend changes required** — the file URL path (`/api/documents/{id}/file`) is unchanged. ## Testing - Component test: `PdfViewer` renders a canvas element when given a valid PDF URL (mock `pdfjs-dist`) - E2E Playwright spec: open a document with a PDF, verify the canvas is present and the page navigation controls work ## Dependencies - None — this can be implemented independently of the profile and history issues - **#40** (annotations) depends on this issue being merged first --- ## User Journey User opens a document that has a PDF attached. Instead of the browser's built-in PDF plugin (which may look different or fail entirely on some devices), the document renders inside the app's own viewer — the same fonts, layout, and quality on every browser. The user can page through the document with previous/next buttons and jump to a specific page number. They can zoom in on fine print or zoom out to see the full page. When they open a document with an image attachment instead of a PDF, the image displays exactly as before — the viewer only activates for PDFs. ## E2E Scenarios ``` Scenario: PDF renders in the custom viewer Given I am on a document detail page with a PDF file attached Then I see a canvas element rendering the PDF (not an iframe) And previous/next page navigation controls are visible Scenario: Page navigation advances the page Given I am viewing a PDF with more than one page When I click the "next page" button Then the page indicator shows page 2 of N Scenario: Non-PDF attachment still displays correctly Given I am on a document detail page with an image file attached Then I see an img element (not a canvas) And the image is visible ```
marcel added the featurecollaboration labels 2026-03-20 19:26:51 +01:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#39