Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3a70853
PDF fitted first-page poster via a declared poster slot
lukemelia Sep 5, 2026
57b35d9
Merge branch 'cs-12492-image-thumbnails-rendition-slots-view-model-re…
lukemelia Sep 5, 2026
c98e52f
Merge branch 'cs-12492-image-thumbnails-rendition-slots-view-model-re…
lukemelia Sep 5, 2026
035dcd3
Merge branch 'cs-12492-image-thumbnails-rendition-slots-view-model-re…
lukemelia Sep 5, 2026
97acc19
Merge branch 'cs-12492-image-thumbnails-rendition-slots-view-model-re…
lukemelia Sep 5, 2026
939049a
Merge branch 'cs-12492-image-thumbnails-rendition-slots-view-model-re…
lukemelia Sep 6, 2026
d7693bf
Merge remote-tracking branch 'origin/cs-12492-image-thumbnails-rendit…
lukemelia Sep 8, 2026
6f432f2
Load pdf.js natively, fail the poster slot on decode errors
lukemelia Sep 8, 2026
4a0f065
Vendor pdf.js behind a lazy host shim instead of a CDN fetch
lukemelia Sep 8, 2026
19dd90b
TEMP: dump pdf file-row diagnostics in CI (will be reverted)
lukemelia Sep 9, 2026
013545e
Reach pdf.js through a statically-imported loader shim
lukemelia Sep 9, 2026
df55a4a
Ship pdfjs-loader as a real host module; TEMP: surface capture consol…
lukemelia Sep 9, 2026
f891847
Clear the capture readiness signal through the DOM, not a re-render
lukemelia Sep 9, 2026
dfd9851
TEMP: instrument finish() DOM state (will be reverted)
lukemelia Sep 9, 2026
8e638d1
Wait for capture readiness via mutation polling
lukemelia Sep 9, 2026
c117a76
Remove capture debug instrumentation; pin the corrupt-PDF contract
lukemelia Sep 9, 2026
b70545b
Pin the corrupt-PDF contract on the ledger, not per-pass diagnostics
lukemelia Sep 9, 2026
44ab2b4
Serve the live PDF viewer through an authed blob URL
lukemelia Sep 9, 2026
5ab5191
Address review: lib doorway spelling, readiness docs, bounded viewer …
lukemelia Sep 9, 2026
bad1caf
Stringify the paint-failure cause so it survives the console pipe
lukemelia Sep 9, 2026
355cc56
Add data-screenshot-failed: a definitive-failure signal for capture r…
lukemelia Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
170 changes: 170 additions & 0 deletions packages/base/file-formats/pdf-captures.gts
Original file line number Diff line number Diff line change
@@ -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 `<object>` 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
// `<object>` 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<CaptureSignature> {
// 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;
};
});

<template>
<div class='pdf-poster-capture' data-screenshot-pending='true'>
<canvas {{this.paintFirstPage}} />
</div>
<style scoped>
/* Fill the capture box; the page canvas centers at its own aspect on
the white page ground the slot's background provides. */
.pdf-poster-capture {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
</style>
</template>
}

// 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<string, ScreenshotSpec> = {
poster: {
render: PdfPosterCapture,
width: 170,
height: 250,
keyBy: 'file-content',
useAsThumbnail: true,
},
};
Loading
Loading