Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
128 changes: 128 additions & 0 deletions packages/base/file-formats/pdf-captures.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// 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 loads from a pinned CDN build at capture time, the same delivery
// the 3D family uses for three.js: the decoder is needed only inside the
// capture render, and vendoring a PDF engine into the base realm would tax
// every consumer for a poster only the prerender pass draws.
import GlimmerComponent from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { modifier } from 'ember-modifier';

import { fileResourceURL } from './file-image';

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.
@tracked pending = true;

private paintFirstPage = modifier((canvas: HTMLCanvasElement) => {
let cancelled = false;
let finish = () => {
if (!cancelled) {
this.pending = false;
}
};
(async () => {
try {
let url = fileResourceURL(this.args.model);
if (!url) {
return;
}
let pdfjs: any =
// @ts-expect-error Pinned browser ESM import; the Boxel loader resolves https:// at runtime
await import('https://esm.sh/pdfjs-dist@4.10.38/legacy/build/pdf.mjs');
pdfjs.GlobalWorkerOptions.workerSrc =
'https://esm.sh/pdfjs-dist@4.10.38/legacy/build/pdf.worker.mjs';
let response = await fetch(url);
if (!response.ok) {
return;
}
let data = new Uint8Array(await response.arrayBuffer());
let 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;
} catch {
// A corrupt or unreadable document is an absent poster, not a broken
// capture render: readiness still resolves, the engine shoots the
// empty page, and byte-hash dedupe keeps the blank from churning.
} finally {
finish();
}
})();
return () => {
cancelled = true;
};
});

<template>
<div
class='pdf-poster-capture'
data-screenshot-pending={{if this.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,
},
};
9 changes: 5 additions & 4 deletions packages/base/file-formats/pdf-viewer.gts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
// 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 { eq } from '@cardstack/boxel-ui/helpers';
Expand Down
6 changes: 6 additions & 0 deletions packages/base/pdf-file-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import FileTypePdfIcon from '@cardstack/boxel-icons/file-type-pdf';
import { FileDef, contains, field } from './card-api';
import type { ByteStream, SerializedFile } from './file-api';
import { DocumentInfoField } from './file-formats/metadata-fields';
import { PDF_FAMILY_SCREENSHOTS } from './file-formats/pdf-captures';
import { PdfViewer } from './file-formats/pdf-viewer';
import { extractPdfMetadata, type DocumentInfo } from './pdf-meta-extractor';
import type { FilePreviewComponent } from './file-formats/file-preview-stage';
Expand Down Expand Up @@ -46,6 +47,11 @@ export class PdfDef extends FileDef {
// the renderer that draws the pages — a native `<object>` viewer.
static previewComponent: FilePreviewComponent = PdfViewer;

// The fitted first-page poster: a capture-only render of page 1, keyed on
// the file's bytes and flagged useAsThumbnail, so the fitted cell and the
// thumbnail chain show the real page while the viewer stays engine-free.
static screenshots = PDF_FAMILY_SCREENSHOTS;

static async extractAttributes(
url: string,
getStream: () => Promise<ByteStream>,
Expand Down
47 changes: 47 additions & 0 deletions packages/realm-server/tests/declared-screenshots-file-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import QUnit from 'qunit';
const { module, test } = QUnit;
import { readFileSync } from 'fs';
import { basename } from 'path';
import { fileURLToPath } from 'url';

import {
declaredCaptureSpecHash,
Expand Down Expand Up @@ -304,6 +306,51 @@ module(basename(import.meta.filename), function (hooks) {
);
});

test('the PDF family captures a first-page poster onto the file row', async function (assert) {
let pdfBytes = new Uint8Array(
readFileSync(
fileURLToPath(
new URL(
'../../experiments-realm/filedef-fixtures/samples/pdf-simple.pdf',
import.meta.url,
),
),
),
);
let baseline = await maxPrerenderHtmlJobId(testDbAdapter, realm.url);
await realm.write('doc.pdf', pdfBytes);
await settlePrerenderHtmlJobs(testDbAdapter, realm.url, {
afterJobId: baseline,
timeout: 60000,
});

let fileRow = await prerenderedHtmlRowFor(
testDbAdapter,
`${testRealm}doc.pdf`,
'file',
);
assert.ok(fileRow, 'the file row exists');
let manifest = fileRow!.screenshots as ScreenshotManifest | null;
assert.ok(manifest?.poster, 'the first-page poster landed on the file row');
assert.true(
manifest!.poster.useAsThumbnail,
'the poster feeds the thumbnail chain',
);
assert.strictEqual(manifest!.poster.contentType, 'image/png');
assert.ok(
startsWith(objectBytes(manifest!.poster.objectKey), PNG_MAGIC),
'the capture is a PNG',
);

// The fitted shell prefers the captured poster over the typed page
// placeholder, via the view model's thumbnail seam.
let fitted = JSON.stringify(fileRow!.fitted_html ?? {});
assert.ok(
fitted.includes(`_screenshot/doc.pdf?name=poster`),
`the fitted rendering carries the poster URL (got: ${fitted.slice(0, 500)})`,
);
});

test('an unchanged file carries its capture forward; a content change recaptures', async function (assert) {
await writeAndSettle('sample.mismatch', 'carry me');
let firstRow = await prerenderedHtmlRowFor(
Expand Down
Loading