diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index dc817fe03d0..c067c8d4c4b 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2917,6 +2917,28 @@ export type BaseDefComponent = ComponentLike<{ // element fails that slot's capture rather than persisting an unready frame. // Components with no async work omit the attribute and capture immediately. // +// A component that learns its content can never become ready — a corrupt or +// password-protected document, an undecodable video — should not leave the +// pending attribute standing until the engine's timeout: swap in a +// `data-screenshot-failed` attribute instead (remove the pending attribute, +// set the failed one), which fails the slot immediately. Set the attribute's +// value to a short human-readable cause; the engine carries it into the +// slot's failure diagnostics, so an unreadable file is distinguishable from +// a hung component. Failing the slot is the correct outcome for unreadable +// content — no manifest entry lands and consumers fall back — where +// resolving readiness over an unpainted box would persist a blank frame as +// if it were real content. +// +// Clear the attribute with `el.removeAttribute('data-screenshot-pending')` +// from the async continuation (and set the failure signal with +// `el.setAttribute('data-screenshot-failed', cause)`) — never by +// re-rendering it off a tracked property +// (`data-screenshot-pending={{if this.pending 'true'}}`). Capture +// pages run in backgrounded tabs, where the browser throttles the timers a +// tracked update's render flush rides, so the flip can sit unflushed past +// the engine's whole wait; the engine watches for the DOM mutation itself, +// which a direct attribute mutation produces immediately. +// // `format` reuses one of the card's display formats instead. A format-based // screenshot referenced by that same format's own markup (say, a fitted // template that embeds its own `format: 'fitted'` capture) is circular — diff --git a/packages/base/file-formats/pdf-captures.gts b/packages/base/file-formats/pdf-captures.gts new file mode 100644 index 00000000000..f1b9ea5fe62 --- /dev/null +++ b/packages/base/file-formats/pdf-captures.gts @@ -0,0 +1,170 @@ +// The PDF family's declared-screenshot capture: a capture-only component +// that paints page 1 with pdf.js so the fitted cell (and the thumbnail +// fallback chain) get a real first page instead of the typed placeholder. +// Capture-only means: referenced only from the `static screenshots` +// declaration and rendered only by the screenshot render route during the +// prerender pass — never part of the format API, so the live viewer stays a +// native `` with no pdf.js in the app's dependency graph. +// +// pdf.js is the host's vendored copy (not a CDN fetch inside the render: +// this capture runs on prerender infrastructure, where public-network +// reachability would otherwise be a standing availability dependency of +// every realm that holds a PDF), reached through `loadPdfjs` below. The +// engine's chunk loads only when that function is called at capture time, +// so consumers of the family that never capture — the live viewer's native +// `` path included — never pay for it. +import GlimmerComponent from '@glimmer/component'; +import { modifier } from 'ember-modifier'; + +import { fileResourceURL } from './file-image'; +// The host's vendored pdf.js, behind a statically-imported sync shim whose +// function performs the host-side lazy chunk load — a runtime `import()` of +// a shimmed bare specifier is not a load path card code can rely on (the +// loader resolves shims for static imports; the dynamic form stalls), while +// a static import of this zero-cost function keeps the engine's chunk load +// at the call. The wrapper behind it wires a same-origin worker asset, so +// rasterization runs on a real worker rather than pdf.js's main-thread +// fallback. `@cardstack/boxel-host/lib/*` is the card-facing doorway for +// host library modules, the same spelling family as the +// `@cardstack/boxel-host/tools/*` shims. +import { loadPdfjs } from '@cardstack/boxel-host/lib/pdfjs-loader'; + +import type { ScreenshotSpec } from '../card-api'; + +interface CaptureSignature { + Args: { + model: any; + }; + Element: HTMLElement; +} + +export class PdfPosterCapture extends GlimmerComponent { + // The capture engine waits (bounded) for no `data-screenshot-pending` + // attribute before shooting: an async decode's paint isn't visible to the + // engine's image-paint wait, so the component owns the readiness signal. + // + // Both signals are written by mutating the attribute directly, not by a + // tracked re-render: the capture page is settled when the engine starts + // waiting, and a tracked update from this modifier's async continuation + // demonstrably never flushed there (the paint completed in under a + // second; the attribute still read pending at the engine's full timeout). + // The engine polls raw DOM, so raw DOM is the reliable channel. + private paintFirstPage = modifier((canvas: HTMLCanvasElement) => { + let cancelled = false; + let container = canvas.parentElement!; + let finish = () => { + if (!cancelled) { + container.removeAttribute('data-screenshot-pending'); + } + }; + // A document that cannot decode (corrupt, encrypted, password-protected + // — an ordinary case, not a corner) will never become ready: swap in the + // definitive-failure signal so the engine fails this slot immediately + // instead of holding the prerender lane for the full pending budget on + // every retry. Failing the slot is the point — no manifest entry lands + // (the injected durable URL stays an uncaptured 404 the fitted cell's + // image fallback absorbs). Resolving readiness instead would persist a + // blank white poster (the slot's default background) that the thumbnail + // seam would serve as if it were the real page. The attribute value + // carries the cause into the slot's failure diagnostics, so an + // unreadable document is distinguishable from a hung component. + let fail = (cause: unknown) => { + if (!cancelled) { + container.removeAttribute('data-screenshot-pending'); + container.setAttribute('data-screenshot-failed', String(cause)); + } + }; + (async () => { + // Hoisted so the finally can release it: capture renders are route + // transitions on a pooled warm tab — one long-lived JS heap across + // many captures — so an undestroyed document accumulates until the + // tab recycles. + let doc: any; + try { + let url = fileResourceURL(this.args.model); + if (!url) { + fail('no file resource url on the model'); + return; + } + let pdfjs: any = await loadPdfjs(); + let response = await fetch(url); + if (!response.ok) { + fail(`fetching the document returned ${response.status}`); + return; + } + let data = new Uint8Array(await response.arrayBuffer()); + doc = await pdfjs.getDocument({ data, isEvalSupported: false }).promise; + let page = await doc.getPage(1); + if (cancelled) { + return; + } + // Contain page 1 in the declared box at the capture's device scale, + // so the rasterized text stays sharp at the physical pixel size. + let box = canvas.parentElement!.getBoundingClientRect(); + let scale = window.devicePixelRatio || 1; + let base = page.getViewport({ scale: 1 }); + let fit = Math.min( + (box.width * scale) / base.width, + (box.height * scale) / base.height, + ); + let viewport = page.getViewport({ scale: fit }); + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + canvas.style.width = `${Math.round(viewport.width / scale)}px`; + canvas.style.height = `${Math.round(viewport.height / scale)}px`; + await page.render({ + canvasContext: canvas.getContext('2d'), + viewport, + }).promise; + // Readiness resolves only on a painted page; every failure path + // resolves the definitive-failure signal instead — see the comment + // above `fail()`. + finish(); + } catch (error) { + fail(error); + } finally { + try { + await doc?.destroy?.(); + } catch { + // Releasing a torn-down document must never mask the capture + // outcome. + } + } + })(); + return () => { + cancelled = true; + }; + }); + + +} + +// The PDF family's declared roster: one `poster` at the recommended +// thumbnail box (the CardsGrid tile, 170×250 at the default +// deviceScaleFactor of 2), keyed on file content so a metadata-only edit +// never re-rasterizes, feeding the thumbnail fallback chain and — through +// the view model's thumbnail seam — the fitted cell. +export const PDF_FAMILY_SCREENSHOTS: Record = { + poster: { + render: PdfPosterCapture, + width: 170, + height: 250, + keyBy: 'file-content', + useAsThumbnail: true, + }, +}; diff --git a/packages/base/file-formats/pdf-viewer.gts b/packages/base/file-formats/pdf-viewer.gts index fadcdebf895..10fc3212fe3 100644 --- a/packages/base/file-formats/pdf-viewer.gts +++ b/packages/base/file-formats/pdf-viewer.gts @@ -4,28 +4,141 @@ // for free), while the budgeted fitted cell shows a lightweight page placeholder // rather than a live PDF engine per tile. // -// The fitted first-page poster is deliberately not drawn here — it needs the -// derived-artifact contract (CS-12231) to rasterize and store a page image. Once -// that lands and populates `thumbnailUrl`, the preview stage prefers the real -// poster over this placeholder automatically, with no change to this component. +// The fitted first-page poster is deliberately not drawn here: the family's +// declared `poster` capture (see `pdf-captures`) rasterizes page 1 during the +// prerender pass, and the preview stage prefers that rendition over this +// placeholder through the view model's `thumbnailUrl` — the placeholder is +// the graceful fallback for an uncaptured or capture-errored document. import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import { LoadingIndicator } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { FileObject } from './file-resources'; import type { FilePreviewSignature } from './file-preview-stage'; +// Base cards read this global to tell a server-side prerender from a live +// client render (same signal `query-field-support` and the 3D family use). +function isLiveRender(): boolean { + return !(globalThis as { __boxelRenderContext?: unknown }) + .__boxelRenderContext; +} + +// One live fetch's outcome, remembered with the URL it belongs to so a +// model swap can never serve a stale document: the getters below ignore any +// entry whose `forUrl` no longer matches, which also lets the modifier's +// cleanup skip tracked writes entirely (a stale blob is simply never read). +interface LoadedDocument { + forUrl: string; + blobUrl: string | undefined; +} + +// Upper bound on the live document fetch. Generous, because a large +// document on a slow link is the ordinary case a reader still wants to win; +// bounded, because a stalled fetch must eventually yield to the plain-URL +// fallback rather than holding the loading state forever. +const DOCUMENT_FETCH_TIMEOUT_MS = 30_000; + export class PdfViewer extends GlimmerComponent { - // The served document URL, loaded by the native `` as a plain - // browser fetch. ``/`` loads bypass service workers (per - // the ServiceWorker spec), so no Authorization header can be attached: - // the document renders only when the realm is publicly readable. The + // The served document URL. ``/`` loads bypass service + // workers (per the ServiceWorker spec), so no Authorization header can be + // attached to the object's own fetch — a plain URL renders only when the + // realm is publicly readable. In a live render the document is therefore + // fetched here in the component (the host auth service worker injects the + // realm Authorization header on this GET, the same path that lets + // `` load realm images) and handed to the `` as a + // same-document blob URL; the plain URL remains the fallback when that + // fetch fails (an anonymous visitor on a public realm has no session to + // inject, and the plain URL works there). Prerender keeps the plain URL + // with no fetch: the snapshot needs the markup, not the bytes. The // realm's content negotiation keys off the request's Sec-Fetch-Dest to - // serve the file's bytes here rather than the host app shell. + // serve the file's bytes to an `` rather than the host app shell. get resourceUrl(): string { return this.args.model?.resourceUrl ?? this.args.model?.url ?? ''; } + @tracked private loaded: LoadedDocument | undefined; + + // Loading = a live fetch for the current URL has not settled yet. The + // `` is withheld until then so a private realm never flashes the + // plugin's error page for the unauthenticated plain-URL load it would + // otherwise start immediately; a spinner holds the space so a slow fetch + // reads as loading rather than broken, and the fetch's own timeout bounds + // how long this state can last. + private get isLoading(): boolean { + return ( + isLiveRender() && + !!this.resourceUrl && + this.loaded?.forUrl !== this.resourceUrl + ); + } + + private get objectUrl(): string { + let { loaded } = this; + return loaded?.forUrl === this.resourceUrl && loaded.blobUrl + ? loaded.blobUrl + : this.resourceUrl; + } + + // Lives on the wrapper that survives the loading→loaded swap, so state + // flips never re-run it; it re-runs only when the document URL changes. + private loadDocument = modifier((_element: HTMLElement, [url]: [string]) => { + if (!url || !isLiveRender()) { + return; + } + let cancelled = false; + let controller = new AbortController(); + // A stalled fetch aborts here with `cancelled` still false, so the + // settle below runs with no blob and the plain URL takes over — the + // same fallback a failed fetch gets. + let timeout = setTimeout( + () => controller.abort(), + DOCUMENT_FETCH_TIMEOUT_MS, + ); + let createdBlobUrl: string | undefined; + void (async () => { + let blobUrl: string | undefined; + try { + // No `credentials: 'include'` — that makes a credentialed CORS + // request, illegal against the realm's wildcard + // `Access-Control-Allow-Origin`. The host auth service worker + // injects the realm `Authorization` header on this GET. + let response = await fetch(url, { signal: controller.signal }); + if (response.ok) { + let bytes = await response.arrayBuffer(); + if (cancelled) { + return; + } + createdBlobUrl = URL.createObjectURL( + new Blob([bytes], { type: 'application/pdf' }), + ); + blobUrl = createdBlobUrl; + } + } catch { + // Fall through: `blobUrl` stays undefined and the plain URL serves + // as the fallback (the public-realm anonymous case, or a timed-out + // fetch). + } finally { + clearTimeout(timeout); + } + if (!cancelled) { + this.loaded = { forUrl: url, blobUrl }; + } + })(); + return () => { + cancelled = true; + clearTimeout(timeout); + controller.abort(); + if (createdBlobUrl) { + // The object element for this URL is going away with us (teardown or + // URL change re-rendering it), so its backing blob can be released. + URL.revokeObjectURL(createdBlobUrl); + } + }; + }); + get pageCount(): number | undefined { return this.args.model?.documentInfo?.pageCount; } @@ -51,18 +164,33 @@ export class PdfViewer extends GlimmerComponent { {{else}} - +
+ {{#if this.isLoading}} +
+ +
+ {{else}} + + {{/if}} +
{{/if}}