fix(ui): render PDFs with pdf.js instead of an iframe
Nightly Build / build (push) Successful in 7m59s

On iOS the file viewer showed only the first page of a PDF, with no way
to scroll to the rest — the document had to be downloaded and opened in
another app. The cause was not ours: WebKit refuses to mount its PDF
viewer inside an <iframe>/<object>/<embed> and paints a static first-page
thumbnail instead. That hits Safari on iOS and every WKWebView, so the
native shell too. The full viewer only exists for a top-level navigation.

The desktop browsers do mount a viewer, but each mounts its own — Chrome's
toolbar, Safari's page-index sidebar — so the same document also looked
different on every machine.

Both are answered by drawing the pages ourselves. New <pdf-view>
(web/components/shared/pdf-view.js) renders a continuous scroll of canvas
pages on the vendored pdf.js, with a zoom control and a page counter, and
replaces the iframe for both native .pdf files and server-compiled LaTeX.

Three properties are load-bearing:

- pdf.js is imported lazily (~450 KB + a 1.2 MB worker), so a session that
  never opens a PDF never pays for it.
- Canvases are created and destroyed as pages scroll. iOS caps the total
  canvas backing store a page may hold and silently blanks canvases past
  it, so an eager render would come out empty on exactly the platform this
  was written for. Off-screen pages keep only a correctly-sized box, which
  is also what keeps the scrollbar honest.
- The text layer (selection, in-page find) is best-effort: it is
  transparent DOM over the pixels, so its failures are swallowed rather
  than surfaced.

Vendored from pdfjs-dist 6.2.108: pdf.min.mjs, pdf.worker.min.mjs, the
standard-font data (needed by PDFs that reference Helvetica/Times without
embedding them) and the .textLayer block of pdf_viewer.css. CJK cmaps are
deliberately left out. pdf.js 6 needs Safari/iOS 17.4+.

Verified in headless Chromium against both a synthetic 12-page PDF using
non-embedded Helvetica and a real 14-page paper with embedded fonts and
figures: all pages present, last page renders after scrolling, page 1
released off-screen, text layer populated, zoom re-renders at the new
scale, no JS errors.
This commit is contained in:
Daniele
2026-08-08 17:16:15 +01:00
parent 3744884070
commit 71e1a26b08
29 changed files with 906 additions and 10 deletions
+13 -9
View File
@@ -4,6 +4,7 @@ import { keyed } from 'lit/directives/keyed.js';
import { LightElement, renderMarkdown } from '../../lib/base.js';
import { fileWatcher } from '../../lib/file-watcher.js';
import { t } from '../../lib/i18n.js';
import './pdf-view.js'; // registers <pdf-view>; pdf.js itself is imported lazily
/**
* Shared file-viewer engine. Holds all of the fetch / kind-detection /
@@ -554,17 +555,20 @@ export class FileViewerBase extends LightElement {
return html`<div class="fv-image-wrap"><img src=${this._blobUrl} alt=${this._path} class="fv-image" /></div>`;
}
if (this._kind === 'pdf' && this._blobUrl) {
// `keyed` re-creates the iframe element on every new blob URL: the first
// navigation of a fresh iframe replaces its history slot instead of
// pushing one — whereas re-assigning `src` on an existing iframe pushes
// a joint session-history entry each time (during the watch-reload loop
// that buried the back button under hundreds of blob: entries).
return keyed(this._blobUrl, html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`);
// Drawn by <pdf-view> (pdf.js on canvas), never by the browser's built-in
// viewer in an <iframe>: WebKit renders a framed PDF as a static first-page
// thumbnail, so on iOS — Safari and every WKWebView, the native shell
// included — the document had one page and no scroll. It also removes the
// per-browser viewer chrome (Chrome's toolbar, Safari's page sidebar), so
// a PDF now looks the same everywhere. `keyed` is gone with the iframe:
// updating a property pushes no session-history entry, which is what the
// watch-reload loop used to bury the back button under blob: entries.
return html`<pdf-view class="fv-pdf" .src=${this._blobUrl}></pdf-view>`;
}
if (this._kind === 'latex' && this._blobUrl) {
// Successfully compiled server-side — render the resulting PDF the same
// way a native .pdf would be rendered (see the keyed() note above).
return keyed(this._blobUrl, html`<iframe class="fv-pdf" src=${this._blobUrl} title=${this._path}></iframe>`);
// Successfully compiled server-side — render the resulting PDF exactly as
// a native .pdf is rendered (see the note above).
return html`<pdf-view class="fv-pdf" .src=${this._blobUrl}></pdf-view>`;
}
if (this._kind === 'svg' && this._blobUrl) {
// `allow-same-origin` (and nothing else) is required so the iframe can load