Skip to content

Mobile PDF Preview via pdf.js Canvas (7.0.3)

Date: 2026-07-21 Status: Approved design Scope: Therapy Pack export preview — packages/web/frontend/src/components/pack/export/

Problem

The export dialog's inline PDF preview (ExportPreview.tsx) uses @react-pdf/renderer's <PDFViewer>, which renders an <iframe src="blob:…">. iOS Safari and iPadOS Safari do not render PDFs inside iframes — a WebKit limitation, not a CSP or blob problem (frame-src blob: is already set; downloads work fine). The preview pane is therefore blank on iPhone and iPad, and inconsistent on Android Chrome. iPadOS Safari additionally masquerades as a desktop Mac UA with a wide viewport, so width-based or UA-based mobile detection cannot reliably identify the affected devices.

Decision

Replace <PDFViewer> with a pdf.js (pdfjs-dist) canvas renderer for all users (option A1). A <canvas> is engine-agnostic pixels — no iframe, no native PDF viewer, nothing to detect. One code path renders identically on desktop, iPhone, and iPad.

Rejected alternatives: - A2 (canvas on mobile only, keep <PDFViewer> on desktop): requires the fragile iPad-as-Mac detection and maintains two preview paths that can drift. Rejected. - B (open-in-new-tab button on mobile): not an inline preview; tap-to-view only. - C (HTML/DOM preview): a second renderer to keep in sync with the real PDF; can drift from what downloads.

Design

Component: ExportPreview.tsx (rewritten)

  • Props unchanged: { format: 'cards' | 'worksheet'; cardModel; worksheetModel }. ExportDialog.tsx is untouched — same lazy import, same 460px slot, same props.
  • Render flow, on mount and whenever format/model changes:
  • Lazily import('@react-pdf/renderer'), build the doc (PictureCardsDoc / WorksheetDoc), await pdf(doc).toBlob()arrayBuffer().
  • Lazily import('pdfjs-dist'), getDocument({ data }), render every page to its own <canvas>, stacked in a scrollable Box (height 460, overflow: auto, canvases width: 100%).
  • Render scale = container CSS width × min(devicePixelRatio, 2) — crisp on Retina/iPad without exploding canvas memory.
  • States: <CircularProgress> while building/rasterizing; an error Typography (matching the dialog's existing error tone) if the build or rasterize throws.
  • Cancellation: an incrementing render-token ref; renders that complete after props have changed are discarded, so fast tab-switching (cards ⇄ worksheet) cannot paint a stale document.

Worker & CSP

  • pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString() — Vite bundles the worker as a same-origin asset.
  • No _headers change: CSP already has worker-src 'self' blob: (satisfies the same-origin worker) and script-src 'self' 'wasm-unsafe-eval' (satisfies pdf.js's optional WASM codecs).
  • Our export PDFs embed DejaVu fonts (exportFonts.ts), so pdf.js needs no external cmap/standard-font fetches.
  • frame-src 'self' blob: in _headers becomes unused. Leave it in place (harmless; avoids an unrelated CSP edit in this change).

Dependency

  • Add pdfjs-dist to packages/web/frontend/package.json. Lazy-imported inside ExportPreview (itself React.lazy'd in ExportDialog), so it loads only when the export dialog opens.

Testing

  • ExportDialog tests unaffected: ExportDialog.test.tsx already mocks ./ExportPreview entirely (line 18), so the rewrite does not touch them.
  • Add ExportPreview.test.tsx: mock @react-pdf/renderer and pdfjs-dist; assert a <canvas> appears on success and an error message on failure. (Canvas + worker cannot truly execute in jsdom; the test covers the state machine, not pixel output.)
  • Device verification is the real gate: confirm on staging (develop.phonolex.pages.dev) from an actual iPhone and iPad that both cards and worksheet previews render. This class of bug is invisible to build/type-check/unit tests.

Risks

  • Bundle weight of pdfjs-dist — mitigated by lazy-loading only on dialog open.
  • Vite worker resolution — standard import.meta.url / ?url pattern; confirm the production build emits the pdf.worker asset and that it loads same-origin under CSP.

Files touched

  • packages/web/frontend/package.json — add pdfjs-dist.
  • packages/web/frontend/src/components/pack/export/ExportPreview.tsx — rewrite.
  • packages/web/frontend/src/components/pack/export/ExportPreview.test.tsx — new, light.
  • packages/web/frontend/src/components/pack/export/ExportDialog.tsx — remove the isMobile preview gate (see Addendum).
  • packages/web/frontend/src/components/pack/export/ExportDialog.test.tsx — flip the mobile test.

Addendum (2026-07-21, post-Task-1 review)

The design above assumed ExportDialog.tsx was a clean mount of <ExportPreview> — that was true on the fix/build-guard-api-url checkout the design was drafted against, but not on develop. develop (via the merged 7-0-1 mobile-responsiveness work) gates the preview behind isMobile = useMediaQuery(theme.breakpoints.down('sm')): on phone-width viewports it renders a "Preview isn't available on mobile" message and never mounts <ExportPreview>. That gate was a stopgap for the very iframe bug this change fixes. It only lets the canvas preview reach iPad (>sm), not iPhone. Correction: this change also removes that gate (and its now-obsolete comment and the useMediaQuery/useTheme imports) so the canvas preview mounts on all viewports, and flips the existing "skips the preview on mobile" test to assert the preview now mounts at phone width. The "ExportDialog.tsx is untouched" statement in the Design section is superseded by this addendum.