Load images and fonts
Implement assetResolver, the only path external resources can take into a paginated document.
Add an <img> to your HTML and it will not load. This is not a bug — it is the boundary Imposia is built around, and assetResolver is how you open it deliberately.
Resource policy and element preparation (0.7.0)
The default blockedResourcePolicy: "warn" allows a document to commit with blocked-resource diagnostics. Choose "error" when missing resources must reject preparation and retain the previous commit. elements can supply text, sanitized HTML, or image bytes before pagination, and fontLoading can conservatively filter font requests by character range. See the Core API for return types, cancellation, limits, and HTML export.
Why nothing loads by default
The iframe holding your pages is sandboxed with a strict Content Security Policy. connect-src is 'none', and images, fonts, and media may only come from blob: URLs that Core itself created. Authored URLs never become network requests.
What this buys you
Untrusted markup cannot make the frame reach the network. Every byte that reaches a page passed through your application first, so you decide what is allowed, where it comes from, and what credentials — if any — are attached.
Imposia discovers resources in your HTML and CSS, asks your resolver for each one, turns approved bytes into Core-owned blob: URLs, and revokes them when the document is replaced, fails, or is destroyed.
Implement the resolver
A resolver is one async function. It receives a request and answers with bytes or a refusal.
import type { AssetResolver } from "@imposia/core";
const ALLOWED = new Set(["https://cdn.example.com"]);
export const assetResolver: AssetResolver = async ({ url, kind, baseUrl, signal }) => {
const target = new URL(url, baseUrl);
if (!ALLOWED.has(target.origin)) {
return { status: "blocked", reason: `Origin not allowed: ${target.origin}` };
}
const response = await fetch(target, { signal });
if (!response.ok) {
return { status: "blocked", reason: `HTTP ${response.status}` };
}
return {
status: "resolved",
bytes: new Uint8Array(await response.arrayBuffer()),
mimeType: response.headers.get("content-type") ?? "application/octet-stream",
};
};Pass it once, when you mount the document:
<ImposiaPageViewer
source={{ html, baseUrl: "https://cdn.example.com/docs/" }}
documentOptions={{ assetResolver }}
/>The request
| Field | Meaning |
|---|---|
url | The URL exactly as authored, which may be relative |
kind | image, font, media, or stylesheet |
baseUrl | The document's base, when one was supplied on the source |
signal | Aborts when the generation is superseded, fails, or is destroyed |
Always resolve url against baseUrl yourself, and always forward signal to fetch. A resolver that ignores the signal keeps working for a generation nobody will ever see.
The answer
Return { status: "resolved", bytes, mimeType } to admit the resource, or { status: "blocked", reason } to refuse it. With the default blockedResourcePolicy: "warn", a refusal emits RESOURCE_BLOCKED and the document can commit. With "error", the same refusal rejects preparation before commit and retains the previous successful document.
What gets rejected after you return it
Approving bytes is not the last word. Core validates them, and a mismatch is treated as a refusal.
| Check | Rule |
|---|---|
| MIME allowlist | Images must be PNG, JPEG, GIF, WebP, or AVIF; fonts WOFF, WOFF2, TTF, or OTF; stylesheets text/css |
| Magic bytes | Font containers are verified against their real signature (wOFF, wOF2, OTTO, …), not the declared type |
| Limits | Reference count, total bytes, and nesting depth all have ceilings |
This is why a resolver that returns application/octet-stream for a real PNG will see the image blocked: the declared type has to be right.
Duplicate references still count toward the byte limit
Within one generation, several references to the same URL share a single resolver call and one blob: URL. Byte accounting is deliberately not deduplicated — every occurrence is charged against maxAssetBytes, so the limit means the same thing it did before that optimization existed.
Stylesheets pull in more work
When you resolve a stylesheet, Imposia parses it and discovers the resources it references — @font-face sources, url() values, and nested @import rules. Each one comes back to your resolver as its own request, with baseUrl set to that stylesheet's location. Depth is bounded, so an @import chain cannot recurse forever.