Skip to content
1 change: 1 addition & 0 deletions packages/boxel-cli/src/commands/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const EXTENSION_BY_CONTENT_TYPE: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'application/pdf': 'pdf',
};

export interface CaptureManifestEntry {
Expand Down
41 changes: 41 additions & 0 deletions packages/boxel-cli/tests/commands/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,47 @@ describe('boxel screenshot: single capture', () => {
});
});

it('names a paged capture from its content type, not the image default', async () => {
// The response's contentType picks the extension; an unmapped one falls
// back to png, which would file a paged document as an image.
let pdfBytes = Buffer.from('%PDF-1.4 fake-paged-bytes');
let pdfBase64 = pdfBytes.toString('base64');
let { authenticator } = makeFake({
postResponses: [
() =>
readyResponse({
status: 'ready',
base64: pdfBase64,
contentType: 'application/pdf',
captures: [
{
name: null,
url: null,
width: null,
height: null,
deviceScaleFactor: null,
pageCount: 3,
base64: pdfBase64,
},
],
}),
],
});
let out = tempDir();
let result = await screenshot(`${CARD_URL}.json`, {
authenticator,
out,
captureSpec: { type: 'pdf' },
});

expect(result.error).toBeUndefined();
expect(result.captures).toHaveLength(1);
let entry = result.captures[0];
expect(entry.status).toBe('ok');
expect(entry.file).toBe(join(out, 'Person-fadhlan.pdf'));
expect(readFileSync(entry.file!)).toEqual(pdfBytes);
});

it('passes the capture spec through verbatim (including target)', async () => {
let { authenticator, requests } = makeFake({
postResponses: [
Expand Down
51 changes: 33 additions & 18 deletions packages/realm-server/handlers/handle-screenshot-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ import type { RealmServerTokenClaim } from '../utils/jwt.ts';
// response. `deviceScaleFactor` is the effective scale: the engine-reported
// factor when the capture just ran, else the spec's declared override on a
// ledger serve (which has no engine report), else null at the default scale.
// `pageCount` describes a paged (pdf) capture, which has no pixel extent —
// its `width`/`height` are null — and is absent on a raster one.
interface CaptureResult {
name: string | null;
url: string | null;
width: number | null;
height: number | null;
deviceScaleFactor: number | null;
pageCount?: number;
base64?: string;
}

Expand Down Expand Up @@ -89,9 +92,9 @@ interface CaptureResult {
* response. `url` is the durable served URL when the capture persisted under
* its ledger identity — any singular geometry spec on a capture format,
* custom geometry included — and null when nothing persists (a batch, a
* `target` capture, a non-capture format such as fitted, a card the index
* doesn't know, a server without a MediaCache store, or a caller without
* realm read) — embed the `base64` in that case.
* `target` capture, a pdf capture, a non-capture format such as fitted, a
* card the index doesn't know, a server without a MediaCache store, or a
* caller without realm read) — embed the `base64` in that case.
*
* Request body (JSON:API):
* ```json
Expand Down Expand Up @@ -249,13 +252,18 @@ export default function handleScreenshotCard({
// capture persists and serves under its own durable URL exactly like a
// format-only one. A batch has no identity (the identity names one
// capture, not a set), fitted sits outside the canonical
// (ledger/GET-DSL) serving contract, and a `target` capture crops to
// one element while sitting outside the identity pick — hashing it
// would alias element-cropped bytes onto the geometry-only key, so the
// whole-viewport URL would serve the crop. All three leave the identity
// undefined and stay capture-only.
// (ledger/GET-DSL) serving contract, a `target` capture crops to one
// element while sitting outside the identity pick — hashing it would
// alias element-cropped bytes onto the geometry-only key, so the
// whole-viewport URL would serve the crop — and pdf output is
// capture-only until the serving surfaces persist and serve paged
// documents. All four leave the identity undefined and return their
// bytes without a served URL.
let spec: CaptureSpec | undefined =
isCaptureFormat(format) && !captureSpec?.captures && !captureSpec?.target
isCaptureFormat(format) &&
!captureSpec?.captures &&
!captureSpec?.target &&
captureSpec?.type !== 'pdf'
? { format, ...(captureSpec ?? {}) }
: undefined;

Expand Down Expand Up @@ -471,15 +479,22 @@ export default function handleScreenshotCard({
// byte-only entries have no durable served URL. Normalize them into the
// one captures[] shape callers build on — url: null marks "no durable
// reference, embed the base64" — so captures[i].url is never a
// silently-undefined read. Honors the base64 opt-out here too.
attributes.captures = result.captures.map((c) => ({
name: c.name,
url: null,
width: c.width ?? null,
height: c.height ?? null,
deviceScaleFactor: c.deviceScaleFactor ?? null,
...(withBase64 && c.base64 !== undefined ? { base64: c.base64 } : {}),
}));
// silently-undefined read. Honors the base64 opt-out here too. A paged
// capture carries its page count instead of the pixel extent it does
// not have — the same count the engine bounds the document against.
attributes.captures = result.captures.map(
(c): CaptureResult => ({
name: c.name,
url: null,
width: c.width ?? null,
height: c.height ?? null,
deviceScaleFactor: c.deviceScaleFactor ?? null,
...(c.pageCount !== undefined ? { pageCount: c.pageCount } : {}),
...(withBase64 && c.base64 !== undefined
? { base64: c.base64 }
: {}),
}),
);
}
if (entryKey && spec && result.status === 'ready') {
// A canonical capture persisted under its ledger identity: replace
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-server/prerender/render-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ export class RenderRunner {
base64: first.base64,
width: first.width,
height: first.height,
contentType: 'image/png',
contentType: shot.contentType,
// Step timings ride on meta.diagnostics so they survive the remote
// wire: `decorateRenderErrorsWithTimings` merges its own (disjoint)
// timing fields onto this block, and the remote prerenderer client
Expand Down
115 changes: 107 additions & 8 deletions packages/realm-server/prerender/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
SCREENSHOT_DEFAULT_IMAGE_TYPE,
SCREENSHOT_MAX_CAPTURES,
SCREENSHOT_MAX_PHYSICAL_EDGE_PX,
captureOutputContentType,
checkPdfCaptureBounds,
screenshotContentType,
type CaptureContentType,
type DeclaredScreenshotCaptureResult,
type DeclaredScreenshotError,
type DeclaredScreenshotFormat,
Expand Down Expand Up @@ -1235,6 +1238,10 @@ export interface ScreenshotCapture {
// One item per requested capture; a single "default" entry for a singular
// (non-batch) request. Always at least one item on success.
captures: ScreenshotCaptureItem[];
// The encoding of every capture in this render: pdf output is singular-only
// (the shared parse refuses it in batch entries), so a batch is always
// raster and the response-level contentType stays single-valued.
contentType: CaptureContentType;
// Per-step wall-clock across the shared render, for stage telemetry:
// navigation (route transition + path settle), the prerender settle wait
// (including any envelope-box wait), the image/font paint wait, and the
Expand Down Expand Up @@ -1361,8 +1368,16 @@ function normalizeCaptureEntries(
if (captureSpec?.captures && captureSpec.captures.length > 0) {
return captureSpec.captures;
}
let { viewport, deviceScaleFactor, fullPage, clip, target, envelope } =
captureSpec ?? {};
let {
viewport,
deviceScaleFactor,
fullPage,
clip,
target,
envelope,
type,
media,
} = captureSpec ?? {};
return [
{
name: 'default',
Expand All @@ -1372,6 +1387,8 @@ function normalizeCaptureEntries(
clip,
target,
envelope,
type,
media,
},
];
}
Expand Down Expand Up @@ -1552,6 +1569,66 @@ async function captureOneEntry(
deviceScaleFactor: number,
output?: DeclaredEntryOutput,
): Promise<ScreenshotCaptureItem | RenderError> {
// A pdf capture paginates the settled render onto paper instead of
// rasterizing the viewport: same settle sequence and emulated media as a
// raster capture, but `page.pdf()` lays the document out at the paper's
// content width (the card's own `@page { size }` rule, or Chrome's default
// paper), not at a capture viewport — which is why the shared parse refuses
// a viewport on a pdf spec. The bounds (page count, byte size) exist only
// once Chrome has paginated, so they are enforced here, the fullPage
// late-check pattern.
if (entry.type === 'pdf') {
// Defensive: every wire surface (the realm-server request bodies and the
// prerender server's /prerender-screenshot route) refuses these
// combinations through the shared parse; this guards the in-process
// callers that assemble capture entries directly.
if (entry.target || entry.clip || entry.fullPage || entry.viewport) {
return buildInvalidRenderResponseError(
page,
`capture "${entry.name}" cannot combine pdf output with target, clip, fullPage, or viewport`,
{ title: 'Invalid screenshot capture spec' },
);
}
// `page.pdf()` renders under `print` media by default, whatever media the
// page settled under. The `media` axis is the caller's control over that,
// so pin the emulated media to what the spec asks — `screen` today (the
// default, and the only value the shared parse admits), which keeps the
// paged document on the same styles the raster path captures, laid out at
// paper width. Cleared in `finally` so a reused pooled page carries no
// media override into the next capture.
let media = entry.media ?? 'screen';
await page.emulateMediaType(media);
try {
let bytes = await page.pdf({
printBackground: true,
// The author's own `@page { size: … }` rule wins; Chrome's default
// paper applies when the card declares none.
preferCSSPageSize: true,
});
let bounds = checkPdfCaptureBounds(entry.name, bytes);
if (bounds.error !== undefined) {
return buildInvalidRenderResponseError(page, bounds.error, {
title: 'PDF capture too large',
});
}
return {
name: entry.name,
base64: Buffer.from(bytes).toString('base64'),
// Pagination has no single pixel extent; the scale is the render's,
// reported for parity with raster captures.
deviceScaleFactor,
pageCount: bounds.pageCount,
};
} finally {
// Restore the default (screen) media so the next capture on this pooled
// page renders exactly as an un-emulated one would.
try {
await page.emulateMediaType();
} catch {
// Page may be closing/evicted; a best-effort restore is enough.
}
}
Comment thread
lukemelia marked this conversation as resolved.
}
// A `target` is an element-handle screenshot, a capture call distinct from
// the page-level one below: it crops to the first match's box and honors no
// clip/fullPage (rejected above). `page.$` runs the selector through
Expand Down Expand Up @@ -1716,9 +1793,11 @@ export async function captureScreenshot(

// Defensive: `fullPage`, `clip`, and `target` are mutually exclusive — a
// fullPage capture ignores a clip, and an element (`target`) screenshot
// honors neither. The shared capture-spec parse already 400s these on both
// request surfaces, but a direct prerender-server caller could still send one
// — fail cleanly rather than return a silently-wrong screenshot.
// honors neither. Every wire surface (the realm-server request bodies and
// the prerender server's /prerender-screenshot route) already 400s these
// through the shared capture-spec parse; this guards the in-process callers
// that assemble capture options directly — fail cleanly rather than return
// a silently-wrong screenshot.
for (let entry of entries) {
if (entry.fullPage && entry.clip) {
return buildInvalidRenderResponseError(
Expand All @@ -1741,6 +1820,16 @@ export async function captureScreenshot(
{ title: 'Invalid screenshot capture spec' },
);
}
// pdf output is singular-only (one contentType per response); every wire
// surface refuses a pdf batch through the shared parse, so this guards
// the in-process callers that assemble capture options directly.
if (entry.type === 'pdf' && entries.length > 1) {
return buildInvalidRenderResponseError(
page,
`capture "${entry.name}" requests pdf output, which is only valid on a singular capture`,
{ title: 'Invalid screenshot capture spec' },
);
}
}

// Pooled pages are reused by the indexing HTML-capture path; a viewport left
Expand Down Expand Up @@ -1915,11 +2004,19 @@ export async function captureScreenshot(
}
log.debug(
`captureScreenshot success format=${format} ancestorLevel=${ancestorLevel} captures=${captures.length} dims=${captures
.map((c) => `${c.name}:${c.width}x${c.height}@${c.deviceScaleFactor}`)
.map(
(c) =>
`${c.name}:${c.width ?? '-'}x${c.height ?? '-'}@${c.deviceScaleFactor}`,
)
.join(',')}`,
);
return {
captures,
// pdf is singular-only, so a batch is always raster; declared entries
// carry their own per-slot contentType and never read this field.
contentType: captureOutputContentType(
entries.length === 1 ? (entries[0].type ?? 'png') : 'png',
),
stepTimings: { navMs, settleMs, imagePaintMs, screenshotMs },
};
} finally {
Expand Down Expand Up @@ -2154,8 +2251,10 @@ export async function captureDeclaredScreenshots(
entries.push({
name: resolved.name,
specHash: resolved.specHash,
width: item.width,
height: item.height,
// Declared captures are always raster (their payload type is an image
// type), so the item always carries pixel dimensions.
width: item.width!,
height: item.height!,
deviceScaleFactor: item.deviceScaleFactor,
contentType: screenshotContentType(resolved.imageType),
imageType: resolved.imageType,
Expand Down
Loading
Loading