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.tsxis 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 scrollableBox(height 460,overflow: auto, canvaseswidth: 100%). - Render scale = container CSS width ×
min(devicePixelRatio, 2)— crisp on Retina/iPad without exploding canvas memory. - States:
<CircularProgress>while building/rasterizing; an errorTypography(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
_headerschange: CSP already hasworker-src 'self' blob:(satisfies the same-origin worker) andscript-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_headersbecomes unused. Leave it in place (harmless; avoids an unrelated CSP edit in this change).
Dependency¶
- Add
pdfjs-disttopackages/web/frontend/package.json. Lazy-imported insideExportPreview(itselfReact.lazy'd inExportDialog), so it loads only when the export dialog opens.
Testing¶
- ExportDialog tests unaffected:
ExportDialog.test.tsxalready mocks./ExportPreviewentirely (line 18), so the rewrite does not touch them. - Add
ExportPreview.test.tsx: mock@react-pdf/rendererandpdfjs-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 bothcardsandworksheetpreviews 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/?urlpattern; confirm the production build emits thepdf.workerasset and that it loads same-origin under CSP.
Files touched¶
packages/web/frontend/package.json— addpdfjs-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 theisMobilepreview 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.