PDF.js Deep Dive from Demo 2: getDocument
Introduction
The demo in the previous article called getDocument with only { url: '...' }. This article follows that single call through the full execution chain of getDocument.
The getDocument call
The source parameter is named src. The demo passes only one key:
| Key in src | Value |
| ------------ | -------------------------------------------------- |
| url | 'https://assets-blog.streampdf.com/PDF32000_2008.pdf' |
Based on the definition of getDocument in display/api.js, the execution flow is as follows.
Lines 235-236: Initialize the loading task
Instantiate the PDFDocumentLoadingTask class: view source, and assign the return value to the task variable.
Extract docId from task; at this point docId is d0.
Lines 238-335: Validate and process parameters
This block of code expands the user-provided src object into 35+ local variables: provided values are validated and type-converted, while missing ones fall back to defaults. The demo only passes url, so the vast majority of parameters take the default branch.
The only provided url is converted into a URL object by getUrlProp:
const url = URL.parse('https://assets-blog.streampdf.com/PDF32000_2008.pdf', window.location);
// const url = src.url ? getUrlProp(src.url) : null;
All other parameters are absent. In source order, the computed results are:
| Variable | Result | Logic |
| ---------------------------- | ------------------------ | ------------------------------------------------------------------------ |
| data | null | If provided, normalized to Uint8Array by getDataProp |
| httpHeaders | null | Passed through as-is; null when absent |
| withCredentials | false | Only accepts === true |
| password | null | ?? null |
| rangeTransport | null | Only accepts a PDFDataRangeTransport instance |
| rangeChunkSize | 2 ** 16 (64KB) | Only accepts the provided value if it is a positive integer |
| worker | null | Only accepts a PDFWorker instance |
| verbosity | undefined | Taken verbatim from src.verbosity |
| docBaseUrl | null | Only accepts a string that is not a data: URL |
| cMapUrl | null | getFactoryUrlProp: non-string becomes null, and it must end with / |
| cMapPacked | true | !== false |
| iccUrl | null | Same as cMapUrl |
| standardFontDataUrl | null | Same as cMapUrl |
| wasmUrl | null | Same as cMapUrl |
| ignoreErrors | true | src.stopAtErrors !== true, see below |
| maxImageSize | -1 (unlimited) | Only accepted if an integer and > -1 |
| isOffscreenCanvasSupported | true | Defaults to !isNodeJS when absent |
| isImageDecoderSupported | true | Defaults to !isNodeJS when absent |
| canvasMaxAreaInBytes | -1 (unlimited) | Only accepted if an integer |
| disableFontFace | false | Defaults to isNodeJS when absent |
| fontExtraProperties | false | Only accepts === true |
| enableXfa | false | Only accepts === true |
| ownerDocument | globalThis.document | Defaults to the global document when absent |
| disableRange | false | Only accepts === true |
| disableStream | false | Only accepts === true |
| disableAutoFetch | false | Only accepts === true |
| pdfBug | false | Only accepts === true |
| CanvasFactory | DOMCanvasFactory | Environment branch, see below |
| FilterFactory | DOMFilterFactory | Same as above |
| BinaryDataFactory | DOMBinaryDataFactory | Same as above |
| enableHWA | false | Only accepts === true |
| enableWebGPU | false | Only accepts === true |
| gpuPromise | Promise.resolve(false) | WebGPU is off, so GPU initialization does not start |
| useWasm | true | !== false |
| pagesMapper | new PagesMapper() | Created fresh when absent |
| useSystemFonts | true | Cascading default, see below |
| useWorkerFetch | false | Compound check, see the Q&A below |
| styleElement | null | Only preserved in TESTING builds; always null in production |
These are all the local variables produced by this block of code. Most of the checks are trivial "fall back to default when absent" cases, but a few deserve a closer look.
ignoreErrors: a semantically inverted internal variable
The user-facing parameter is called stopAtErrors, yet the internal variable is called ignoreErrors, computed as src.stopAtErrors !== true. In other words, the default behavior is to ignore errors: when parsing fails, try to recover rather than rejecting promises such as getOperatorList / getTextContent / RenderTask. When reading the source, it is very easy to copy the name and value the wrong way around here.
rangeChunkSize: the 64KB setup for later
rangeChunkSize defaults to 64KB. Since disableRange = false, when the transport is created later, range requests will be issued against the PDF, and each fetched chunk will be exactly this size. Keep it in mind for now.
docBaseUrl: why data: URLs are excluded
The source comment explains the reason: a data: URL cannot be used to resolve absolute paths, and it contains the entire PDF document with no upper bound on length, so it is not sent to the worker thread.
gpuPromise: designed to run in parallel with worker startup
const gpuPromise = Promise.resolve(false); // enableWebGPU ? initGPU() : Promise.resolve(false);
Even when WebGPU is disabled, this immediately returns a resolved promise instead of deferring the check until it is used. The comment explains the intent: let GPU initialization run in parallel with worker startup, with the resolved boolean forwarded to the worker afterwards.
The three Factories: environment branches
const CanvasFactory = DOMCanvasFactory;
// src.CanvasFactory || (GENERIC build && isNodeJS ? NodeCanvasFactory : DOMCanvasFactory);
The same applies to FilterFactory and BinaryDataFactory. The check has three tiers: user-provided custom > Node environment implementation > DOM implementation. The demo runs in a browser without customization, so all three factories are the DOM versions. Their responsibilities:
- CanvasFactory: creates canvases for rendering
- FilterFactory: creates image filters (such as SVG filters)
- BinaryDataFactory: downloads built-in static assets; works together with useWorkerFetch below
useSystemFonts: the default depends on other parameters
const useSystemFonts = true;
// typeof src.useSystemFonts === "boolean" ? src.useSystemFonts : !isNodeJS && !disableFontFace;
Its default depends on disableFontFace, which was just parsed above. Why? Fonts not embedded in the PDF are loaded as FontFaces; if disableFontFace is true, that loading path is disabled, and falling back to system fonts at that point would produce completely garbled glyphs. So in that scenario the default is simply false as well, avoiding rendering utterly broken fonts.
useWorkerFetch: a compound check
const useWorkerFetch = false;
// typeof src.useWorkerFetch === "boolean"
// ? src.useWorkerFetch
// : MOZCENTRAL ||
// !!(
// BinaryDataFactory === DOMBinaryDataFactory &&
// cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl &&
// isValidFetchUrl(cMapUrl, document.baseURI) &&
// isValidFetchUrl(standardFontDataUrl, document.baseURI) &&
// isValidFetchUrl(wasmUrl, document.baseURI)
// );
> Q: What is this check doing?
> A: It decides who downloads the built-in static assets (CMap files, standard font files, wasm files): the worker thread downloads them itself via the Fetch API (true), or the main thread downloads them and forwards them to the worker (false).
>
> If the user explicitly passes a boolean, the user's value is used directly. Otherwise the default is computed: MOZCENTRAL (the Firefox built-in viewer) is always true; for a regular build, all three of the following conditions must hold at the same time for it to be true:
>
> 1. No custom BinaryDataFactory — because when useWorkerFetch is true, that parameter is simply ignored (inside transportFactory, binaryDataFactory is set to null). A user-provided factory must be respected, so the default cannot be true;
> 2. cMapUrl / standardFontDataUrl / wasmUrl are all provided, and cMapPacked is true — without URLs there is nothing to fetch;
> 3. These three URLs resolve to http/https relative to document.baseURI — the Fetch API only supports http/https, not file/ftp.
>
> The demo in this article only passes url, so cMapUrl is null, condition 2 is not met, and therefore useWorkerFetch = false: the main-thread path is taken, with DOMBinaryDataFactory downloading on the main thread, and the worker requesting data from the main thread via FetchBinaryData messages when needed.
Lines 338: Set the main-thread log level
setVerbosityLevel(verbosity); // verbosity is undefined
verbosity is undefined, so the main-thread log level stays at its default. Note that this only affects the main thread; the worker thread's log level is passed separately when the worker is created.
Lines 342-350: Instantiate the three factories
const transportFactory = {
canvasFactory: new DOMCanvasFactory({ ownerDocument: document, enableHWA: false }),
filterFactory: new DOMFilterFactory({ docId: 'd0', ownerDocument: document }),
binaryDataFactory: new DOMBinaryDataFactory({
cMapUrl: null,
standardFontDataUrl: null,
wasmUrl: null,
}),
};
// binaryDataFactory: null when MOZCENTRAL or useWorkerFetch is true
The three Factory classes selected earlier are instantiated and packaged as transportFactory, which is then handed to the transport. Since useWorkerFetch = false, binaryDataFactory is not null — echoing the Q&A in the previous section: built-in static assets are downloaded on the main thread.
Instantiate DOMCanvasFactory: view source
Instantiate DOMFilterFactory: view source
Instantiate DOMBinaryDataFactory: view source
Lines 352-360: Create the worker
worker = PDFWorker.create({ verbosity: undefined, port: undefined });
// port comes from GlobalWorkerOptions.workerPort, which the demo does not set
task._worker = worker;
The demo does not pass a worker, so PDF.js creates and owns one itself. PDFWorker.create synchronously returns a PDFWorker instance; its constructor reads GlobalWorkerOptions.workerSrc (the worker file URL configured in the first article), creates a Worker, and completes the startup handshake through ready / test messages — covered in detail in Article 8. For now, just remember: worker.promise is a Promise<void> that resolves (to undefined) once the test reply succeeds, and rejects if startup fails or the worker is destroyed.
Lines 362-402: Assemble the two parameter packages
The first is docParams, which will later be sent across threads to the worker:
const docParams = {
docId: 'd0',
apiVersion: '6.3.289', // build version number
data: null,
password: null,
disableAutoFetch: false,
rangeChunkSize: 65536,
docBaseUrl: null,
enableXfa: false,
evaluatorOptions: {
maxImageSize: -1,
disableFontFace: false,
ignoreErrors: true,
isOffscreenCanvasSupported: true,
isImageDecoderSupported: true,
canvasMaxAreaInBytes: -1,
fontExtraProperties: false,
useSystemFonts: true,
useWasm: true,
useWorkerFetch: false,
cMapUrl: null,
cMapPacked: true,
iccUrl: null,
standardFontDataUrl: null,
wasmUrl: null,
hasGPU: false, // placeholder, backfilled later
},
};
The second is transportParams, which stays on the main thread:
const transportParams = {
ownerDocument: document,
pdfBug: false,
styleElement: null,
enableHWA: false,
loadingParams: { disableAutoFetch: false, enableXfa: false },
};
One sentence to distinguish them: docParams is the "work order" sent to the worker, while transportParams is the "local configuration" kept on the main thread.
Lines 404-473: The handshake after the worker is ready
Promise.all([worker.promise, gpuPromise]).then(...)
Once both worker readiness and GPU probing (resolving to false) complete, the following happens in sequence:
1. hasGPU = false is backfilled into evaluatorOptions;
2. A GetDocRequest message is sent to the worker with docParams as the payload. Since data is null, there is no buffer whose ownership needs to be transferred;
3. Choose the network stream: with data being null and rangeTransport being null, the url branch is taken and a network stream is instantiated (the fetch implementation is preferred, with a fallback to XHR when unsupported; see Article 9 for how range requests are issued internally):
networkStream = new NetworkStream({
url,
httpHeaders: null,
withCredentials: false,
rangeChunkSize: 65536,
disableRange: false,
disableStream: false,
});
NetworkStream here is not a fixed imported class; it is picked at runtime inside this branch:
const NetworkStream = getNetworkStream(url); // api.js#L431
getNetworkStream makes a three-way choice:
function getNetworkStream(url) {
// eslint-disable-next-line no-nested-ternary
return isValidFetchUrl(url)
? PDFFetchStream
: typeof PDFJSDev !== 'undefined' && PDFJSDev.test('GENERIC') && isNodeJS
? PDFNodeStream
: PDFNetworkStream;
}
| Condition | Stream implementation |
| --- | --- |
| isValidFetchUrl(url): protocol is http / https | PDFFetchStream |
| Otherwise, GENERIC build running in Node | PDFNodeStream |
| Everything else | PDFNetworkStream (XHR) |
The demo's url is https://..., so it actually instantiates PDFFetchStream. The other inputs have their own streams too: rangeTransport uses PDFDataTransportStream, while data means the whole PDF is already in memory and no stream is created at all.
In summary: const NetworkStream = PDFFetchStream.
Execution continues:
1. Receive the worker's acknowledgment of GetDocRequest (workerId): click to view the event. The value returned here is d0_worker, assigned to the workerId variable
2. Create a MessageHandler with { docId: 'd0', workerId: 'd0_worker', port: Worker }: click to view the call, assigned to the messageHandler variable
3. Create a WorkerTransport with { messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper }: click to view the call, attached to task._transport
4. Send a Ready message to the worker, declaring the main-thread side ready: click to view the event
At the end of the chain, task._setupCapability.resolve is called to settle the task's asynchronous setup.
Lines 474-475: Return value
All of the above .then chain proceeds asynchronously in the background, while getDocument itself returns task synchronously. If any step in the chain fails, the capability inside task is rejected — the very promise that the demo waits on with await loadingTask.promise.
At this point, getDocument has finished executing.
Summary
In this section we followed the demo through the complete getDocument call:
1. Create PDFDocumentLoadingTask and extract docId = 'd0';
2. Expand src into 35+ local variables; the demo only passes url, and everything else falls back to defaults;
3. Instantiate the three DOM factories and package them as transportFactory;
4. PDFWorker.create creates the worker and starts the handshake;
5. Assemble docParams (sent to the worker) and transportParams (kept on the main thread);
6. Once the worker is ready, send GetDocRequest, set up the network stream, create MessageHandler and WorkerTransport, and finally send Ready;
7. Return task synchronously; the demo waits for the document to be ready via await loadingTask.promise.
Back to the demo in the first article: lines 4-7 call exactly the getDocument dissected here.
Following the caller chain upward, the next call site is: getPage
---
About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: streampdf.com
Opera’s WebAI: websites that use AI on your own laptop instead of the cloud
---
title: Opera’s WebAI: websites that use AI on your own laptop instead of the cloud
published: true
date: 2026-10-29 12:25:36 UTC
tags: GenerativeAI,AIPrivacy,BrowserAI,LargeLanguageModels
canonical_url: bluetickconsultants.com
---
Published: 29 October 2026 | Updated: 29 October 2026
Quick summary (TL;DR): Opera WebAI is an opt-in preview in Opera One that lets websites and extensions run AI on your own computer instead of the cloud. It costs nothing per request, but it only reaches Opera users on supported PCs and Macs, so most sites still need a cloud fallback.
The problem
Every time a website summarises an article for you, translates a review, or suggests a reply, your text usually travels to a big company’s computers, gets processed there, and comes back. You are trusting that company with whatever you typed.
The website owner pays for every one of those trips. So small sites either skip the feature, or they charge you a subscription to cover it. And if the AI company raises its prices or changes its privacy rules, everyone downstream has to live with it.
Meanwhile, the laptop you are reading this on is probably powerful enough to do a lot of that work by itself. Until now, websites simply had no standard way to ask it to.
What we’re solving
On October 27, 2026, Opera released an early preview of WebAI in its Opera One browser. It lets websites and browser extensions use an AI model that runs locally, meaning on your own computer, not on a server somewhere else.
- No subscription for you, no per-request bill for developers. Opera says sites can add AI features without running AI servers or paying for each request.
- Your text stays on your device while the local model processes it.
- More than one model. At launch it uses Google’s Gemma, an open-weight model (free to download, with published internals). On Apple silicon Macs it can also use Apple’s own on-device model.
- Eight building blocks for developers. Opera lists the Prompt, Decisions, Semantic Embedder, Summarizer, Writer, Rewriter, Translator, and Proofreader APIs (an API is a menu of commands a website can call).
- You stay in control. It is off by default. You switch it on in Settings, and each website must ask your permission before using the model.
Is it really a brand-new standard? Partly. Opera calls WebAI a proposed web standard, but several of its APIs, including Prompt, Summarizer, and Translator, already exist in Chrome and follow drafts from W3C and WICG groups (the bodies where browser makers agree on web features). What Opera adds is the idea of doing it without tying the web to one company’s model : Chrome uses Google’s Gemini Nano and requires developers to accept Google’s usage policy, while Opera plugs in open models and has no such policy.
What it does not solve:
- It only works in Opera One for now , on 64-bit Windows PCs with Intel or AMD chips and on Apple silicon Macs. Intel Macs, Linux, and Windows on Arm are not supported yet. Opera’s own announcements differ on when Opera GX and Opera Neon get it, so treat Opera One as the confirmed starting point.
- Everyone else needs a backup plan. Safari and Firefox users get nothing, so sites still need a cloud fallback or no AI feature at all.
- Local does not mean private by default. A website can still send the model’s answer to its own servers.
- Small models are less capable. They make more mistakes than big cloud models on long or complicated documents.
A real example
WebAI is one day old, so it has no named adopters yet. Opera has published six experimental extensions of its own, such as Project Lens, which colours an article’s paragraphs by how relevant they are to your project. The best documented real-world case uses the same family of built-in browser APIs in Chrome.
Policybazaar, India (built-in AI in Chrome, 2025)
- Who: Policybazaar, an Indian insurance marketplace with over 97 million registered customers.
- The problem: customers speak many Indian languages, and the support team is not online in the evenings.
- What they built: Finova AI, an insurance chatbot. The browser’s built-in Language Detector API spots which language you typed in, and the Translator API converts it to English and back on your device, while the answers themselves come from Policybazaar’s own server-side model.
- Why on-device: Google’s write-up says they picked it for lower delay and lower cost than doing the same work on a server.
- The numbers: 73% of people who opened the chatbot went on to have multi-question conversations lasting several minutes, and the new call-to-action button got twice the clicks of the old one. Policybazaar’s tech lead said a 1,000-character article translated into Hindi in about two seconds.
This is Chrome, not Opera. But the Translator API is one of the eight Opera now supports, so it shows the kind of feature WebAI is meant to make cheap for any website.
How Opera WebAI works (with code)
Think of it like a calculator built into your phone. Apps do not need to ship their own calculator; they just ask the phone’s one. WebAI does the same for AI inside the browser.
- You turn it on under Settings, Opera AI, “Enable Web AI API”. Opera then downloads a local model, which can take a few minutes.
- A website asks permission to use it. You say yes or no, per site.
- The website sends text to the browser , not to a server. The browser runs the model using your graphics chip where it can (through WebGPU) and falls back to the main processor otherwise.
- The answer comes back to the page. Speed depends on how powerful your computer is.
The example below flags clickbait headlines, one of the use cases Opera itself highlights. It uses the Prompt API’s LanguageModel commands, following the current syntax documented on MDN, so the same code targets both Opera’s WebAI and Chrome. There is no browser in our test environment, so we ran it in Node.js with a small stand-in that behaves like the browser’s model. The logic and output below are real; a real model would produce its own verdicts.
// Flag clickbait headlines with the browser's built-in, on-device model.
// Uses the Prompt API (LanguageModel), which Opera's WebAI and Chrome share.
async function checkHeadline(headline) {
if (!("LanguageModel" in globalThis)) return "no local AI: use a fallback";
const status = await LanguageModel.availability();
if (status === "unavailable") return "model not supported on this device";
const session = await LanguageModel.create({
initialPrompts: [{
role: "system",
content: "Reply with one word: CLICKBAIT or OK.",
}],
});
const verdict = await session.prompt(headline); // runs on this computer
session.destroy(); // free memory when done
return verdict.trim();
}
const headlines = [
"You won't BELIEVE what this laptop can do",
"Opera adds local AI preview to Opera One",
];
for (const h of headlines) checkHeadline(h).then((v) => console.log(v, "-", h));
Output with a local model available:
CLICKBAIT - You won't BELIEVE what this laptop can do
OK - Opera adds local AI preview to Opera One
Output in a browser without local AI (for example Safari or Firefox):
no local AI: use a fallback - You won't BELIEVE what this laptop can do
no local AI: use a fallback - Opera adds local AI preview to Opera One
What the code is doing, in plain words:
- First it checks whether the browser has a local model at all. If not, it says so instead of crashing. That is your cue to use a cloud service or hide the feature.
- availability() asks whether this device can run the model. It can answer available, downloadable, downloading, or unavailable.
- create() starts a session and gives the model one standing instruction: answer with a single word.
- prompt() sends the headline to the model running on your machine and waits for the answer.
- destroy() closes the session so the model stops using memory.
- In a real browser , create() must follow a user action such as a click, and the user must have granted the site permission.
What it costs
All prices are in US dollars, checked on October 28, 2026. Cloud API rates are Google’s paid-tier Gemini prices as listed by MorphLLM’s tracker, which says it verified them against Google’s pricing page on October 25, 2026.
| Option | Upfront cost | Ongoing cost | Hidden costs | Best for |
| --- | --- | --- | --- | --- |
| Do nothing (no AI features) | $0 | $0 | Visitors may go elsewhere for summaries or translation | Sites where AI adds little |
| Cloud AI API (e.g. Gemini 2.5 Flash-Lite) | Developer time to build a backend | $0.10 per million input tokens, $0.40 per million output | Servers, API keys, user text leaves the device, prices can change | Any site that must work in every browser |
| Bigger cloud model (Gemini 3.8 Flash) | Same as above | $0.75 in, $3.75 out per million tokens; doubles on Jan 1, 2027 | As above, plus a price rise already scheduled | Harder tasks where quality matters most |
| Opera WebAI (local) | $0 for users and developers | $0 per request | Opera One preview only; user must opt in and download a model; uses the visitor’s battery and hardware | Extensions and sites with many Opera users |
| Chrome built-in AI (Gemini Nano) | $0 | $0 per request | 4.27 GB model download; developers must accept Google’s usage policy; Chrome only | Sites whose visitors mostly use Chrome |
A worked example
Say your site summarises articles for 10,000 visitors a day. Assume each article is about 2,000 tokens in and the summary is about 200 tokens out (a token is roughly three-quarters of an English word; these sizes are our estimate).
- Gemini 2.5 Flash-Lite in the cloud: about $2.80 a day, or about $84 a month.
- Gemini 3.8 Flash in the cloud: about $22.50 a day, or about $675 a month. After the January 2027 price rise, about $1,350 a month.
- WebAI on the visitor’s laptop: $0 per summary. But only visitors on Opera One who switched it on get it, so you still pay the cloud bill for everyone else.
Who should care:
- Solo users: it is free. If you already use Opera One on a supported computer, try the preview; your laptop pays in battery, not money.
- Small teams and extension makers: the most direct win. A browser extension aimed at Opera users can ship AI features with no server bill at all.
- Large sites: treat it as a discount on part of your traffic, not a replacement for your cloud setup. Use local AI when it is there, and fall back to the cloud when it is not.
Pros and cons
Pros
- Free per request. No servers to run and no bill that grows with your traffic.
- Data stays local while the model works on it.
- Not locked to one AI company. Opera uses open models and Apple’s model, and has no vendor usage policy for developers.
- Same APIs as Chrome for Prompt, Summarizer, and Translator, so code written once can run in both.
- Opt-in and per-site permission. Nothing downloads until you switch it on, and you can switch it off any time.
- Good fit for small everyday jobs. Short jobs like sorting tabs, flagging clickbait, or spotting relevant job posts suit a small local model.
Cons
- Tiny reach for now. One browser, a preview, an opt-in switch, and only some computers.
- The standards fight is real. Mozilla said the Prompt API has severe negative consequences for the web’s interoperability, updatability, and neutrality. The W3C’s Technical Architecture Group also reviewed it and raised issues, while Apple’s WebKit team has given no public position. Opera’s model-neutral approach answers some of that, but not all.
- Same site, different answers. Different browsers run different models, so one website can behave differently depending on where you open it.
- Your hardware pays. Local models use memory and battery, and older laptops will be slower.
- Weaker on hard jobs. Small local models make more mistakes on long or complex documents.
- No IT admin controls yet. Opera picks the model, and companies cannot manage it centrally.
- Ownership questions. Tech Times notes Opera’s majority owner, Kunlun Tech, is based in Beijing and that no independent security audit of Opera or WebAI was found. Local processing limits what WebAI itself sends anywhere, but it does not change Opera’s wider data obligations.
Key takeaways
- Opera’s WebAI lets websites and extensions run AI on your own computer, with no subscription for you and no per-request bill for developers.
- It is an early, opt-in preview in Opera One on 64-bit Windows and Apple silicon Macs, using Gemma or Apple’s on-device model.
- Much of it builds on APIs Chrome already ships; Opera’s twist is open, swappable models and no single company’s usage rules.
- The strongest proof of the idea so far is Policybazaar’s multilingual chatbot built on Chrome’s built-in translation APIs.
- Local AI is free per request, but it only reaches some visitors, so most sites will still need a cloud fallback.
- Mozilla opposes the Prompt API and the W3C’s architecture group raised issues in its review, so whether this becomes a true web standard is still open.
Frequently Asked Questions
What is Opera WebAI?
Opera WebAI is a feature in Opera’s browsers that lets websites and extensions use an AI model running on your own computer instead of a cloud server. Opera released it as an opt-in early preview in Opera One on October 27, 2026, and calls it a proposed web standard.
Is Opera WebAI free?
Yes. There is no subscription for users and no per-request bill for developers, because the model runs on the visitor’s computer. The cost moves to your hardware instead: local AI uses memory and battery.
How do I turn on WebAI in Opera One?
Update Opera One, open Settings, go to Opera AI and switch on Enable Web AI API. Opera then downloads a local model, which can take a few minutes. Each website must still ask your permission before it can use the model.
Which computers can run Opera WebAI?
The preview runs on 64-bit Windows PCs with Intel or AMD processors and on Macs with Apple silicon. Intel-based Macs, Linux and Windows on Arm are not supported yet.
Does Opera WebAI work in Chrome, Safari or Firefox?
WebAI itself is Opera’s. Chrome has its own built-in AI using Google’s Gemini Nano, and both support the Prompt API, so the same code can run in either. Safari and Firefox offer no equivalent, so sites need a cloud fallback for those visitors.
Is Opera WebAI private?
Your text stays on your device while the local model processes it, and you choose which sites can use it. But local does not mean private by default: a website can still send the model’s answer to its own servers.
Should I build features on Opera WebAI today?
If you make browser extensions for Opera users, yes: you can ship AI features with no server bill. For a public website, use WebAI when it is available and fall back to a cloud AI service when it is not, because most visitors will not have it yet.
The post Opera’s WebAI: websites that use AI on your own laptop instead of the cloud appeared first on Bluetick Consultants Inc..
PDF.js Deep Dive 3: getViewport & render
Introduction
This is the third article in the PDF.js Deep Dive series. In the previous article, we explored the Display layer's getDocument and getPage methods.
In this article, we dive into the getViewport and render methods exposed by the Display layer.
All code snippets in this article are based on PDF.js v6.3.289.
- PDF.js Deep Dive 1: The Architecture of PDF.js
- PDF.js Deep Dive 2: getDocument & getPage
getViewport
For page.getViewport, the call lands on the PDFPageProxy.getViewport method.
/**
* @param {GetViewportParameters} params - Viewport parameters.
* @returns {PageViewport} Contains 'width' and 'height' properties
* along with transforms required for rendering.
*/
getViewport({
scale,
rotation = this.rotate,
offsetX = 0,
offsetY = 0,
dontFlip = false,
} = {}) {
return new PageViewport({
viewBox: this.view,
userUnit: this.userUnit,
scale,
rotation,
offsetX,
offsetY,
dontFlip,
});
}
which instantiates the PageViewport class.
Entering PageViewport
// Source: src/display/page_viewport.js
/**
* @param {PageViewportParameters}
*/
constructor({
viewBox,
userUnit,
scale,
rotation,
offsetX = 0,
offsetY = 0,
dontFlip = false,
}) {
this.viewBox = viewBox;
this.userUnit = userUnit;
this.scale = scale;
this.rotation = rotation;
this.offsetX = offsetX;
this.offsetY = offsetY;
scale *= userUnit; // Take the userUnit into account.
// creating transform to convert pdf coordinate system to the normal
// canvas like coordinates taking in account scale and rotation
const centerX = (viewBox[2] + viewBox[0]) / 2;
const centerY = (viewBox[3] + viewBox[1]) / 2;
let rotateA, rotateB, rotateC, rotateD;
// Normalize the rotation, by clamping it to the [0, 360) range.
rotation %= 360;
if (rotation < 0) {
rotation += 360;
}
switch (rotation) {
case 180:
rotateA = -1;
rotateB = 0;
rotateC = 0;
rotateD = 1;
break;
case 90:
rotateA = 0;
rotateB = 1;
rotateC = 1;
rotateD = 0;
break;
case 270:
rotateA = 0;
rotateB = -1;
rotateC = -1;
rotateD = 0;
break;
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
break;
default:
throw new Error(
"PageViewport: Invalid rotation, must be a multiple of 90 degrees."
);
}
if (dontFlip) {
rotateC = -rotateC;
rotateD = -rotateD;
}
let offsetCanvasX, offsetCanvasY;
let width, height;
if (rotateA === 0) {
offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
width = (viewBox[3] - viewBox[1]) * scale;
height = (viewBox[2] - viewBox[0]) * scale;
} else {
offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
width = (viewBox[2] - viewBox[0]) * scale;
height = (viewBox[3] - viewBox[1]) * scale;
}
// creating transform for the following operations:
// translate(-centerX, -centerY), rotate and flip vertically,
// scale, and translate(offsetCanvasX, offsetCanvasY)
this.transform = [
rotateA * scale,
rotateB * scale,
rotateC * scale,
rotateD * scale,
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
];
this.width = width;
this.height = height;
}
It computes the rotation, the scale, the offsets, and the viewport dimensions after flipping — the page's width and height.
Let's zoom in on the PDF point and the CSS px units. In PDF, 72 points equal one inch; in CSS, 96 px equal one inch. That means rendering at 100% size on the web requires scaling PDF points up by 96/72.
PDF's coordinate system has its origin at the bottom-left corner, with the X axis pointing right and the Y axis pointing up. CSS's coordinate system has its origin at the top-left corner, with the X axis pointing right and the Y axis pointing down. So for an unrotated PDF, we need to flip the PDF's Y axis to match the web canvas coordinate system. That's why you see
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
the rotateD of -1 in there — it exists precisely to flip the Y axis.
Flip demo: get-viewport-demo
MDN canvas transform
render
For page.render, the call lands on the PDFPageProxy.render method.
/**
* Begins the process of rendering a page to the desired context.
*
* @param {RenderParameters} params - Page render parameters.
* @returns {RenderTask} An object that contains a promise that is
* resolved when the page finishes rendering.
*/
render({
canvasContext,
canvas = canvasContext.canvas,
viewport,
intent = "display",
annotationMode = AnnotationMode.ENABLE,
transform = null,
background = null,
optionalContentConfigPromise = null,
annotationCanvasMap = null,
pageColors = null,
printAnnotationStorage = null,
isEditing = false,
recordImages = false,
recordOperations = false,
operationsFilter = null,
}) {
this._stats?.time("Overall");
const intentArgs = this._transport.getRenderingIntent(
intent,
annotationMode,
printAnnotationStorage,
isEditing
);
const { renderingIntent, cacheKey } = intentArgs;
// If there was a pending destroy, cancel it so no cleanup happens during
// this call to render.
this.#pendingCleanup = false;
optionalContentConfigPromise ||=
this._transport.getOptionalContentConfig(renderingIntent);
const intentState = this._intentStates.getOrInsertComputed(
cacheKey,
makeObj
);
// Ensure that a pending `streamReader` cancel timeout is always aborted.
if (intentState.streamReaderCancelTimeout) {
clearTimeout(intentState.streamReaderCancelTimeout);
intentState.streamReaderCancelTimeout = null;
}
const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT);
// If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise.
if (!intentState.displayReadyCapability) {
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = {
fnArray: [],
argsArray: [],
lastChunk: false,
separateAnnots: null,
};
this._stats?.time("Page Request");
this._pumpOperatorList(intentArgs);
}
const recordForDebugger = !!(
this._pdfBug && globalThis.StepperManager?.enabled
);
const shouldRecordOperations =
!!canvas &&
!this.recordedBBoxes &&
(recordOperations || recordForDebugger);
const shouldRecordImages =
!!canvas && !this.imageCoordinates && recordImages;
const complete = error => {
intentState.renderTasks.delete(internalRenderTask);
if (shouldRecordOperations) {
const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take();
if (recordedBBoxes) {
internalRenderTask.stepper?.setOperatorBBoxes(
recordedBBoxes,
internalRenderTask.gfx.dependencyTracker.takeDebugMetadata()
);
if (recordOperations) {
this.recordedBBoxes = recordedBBoxes;
}
}
}
if (shouldRecordImages && !error) {
this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take();
}
// Attempt to reduce memory usage during *printing*, by always running
// cleanup immediately once rendering has finished.
if (intentPrint) {
this.#pendingCleanup = true;
}
this.#tryCleanup();
if (error) {
internalRenderTask.capability.reject(error);
this._abortOperatorList({
intentState,
reason: error instanceof Error ? error : new Error(error),
});
} else {
internalRenderTask.capability.resolve();
}
if (this._stats) {
this._stats.timeEnd("Rendering");
this._stats.timeEnd("Overall");
if (globalThis.Stats?.enabled) {
globalThis.Stats.add(this.pageNumber, this._stats);
}
}
};
let dependencyTracker = null;
let bboxTracker = null;
if (shouldRecordOperations || shouldRecordImages) {
bboxTracker = new CanvasBBoxTracker(
canvas,
intentState.operatorList.length
);
}
if (shouldRecordOperations) {
dependencyTracker = new CanvasDependencyTracker(
bboxTracker,
recordForDebugger
);
}
const internalRenderTask = new InternalRenderTask({
callback: complete,
// Only include the required properties, and *not* the entire object.
params: {
canvas,
canvasContext,
dependencyTracker: dependencyTracker ?? bboxTracker,
imagesTracker: shouldRecordImages
? new CanvasImagesTracker(canvas)
: null,
viewport,
transform,
background,
},
objs: this.objs,
commonObjs: this.commonObjs,
annotationCanvasMap,
operatorList: intentState.operatorList,
pageIndex: this._pageIndex,
canvasFactory: this._transport.canvasFactory,
filterFactory: this._transport.filterFactory,
useRequestAnimationFrame: !intentPrint,
pdfBug: this._pdfBug,
pageColors,
enableHWA: this._transport.enableHWA,
operationsFilter,
});
(intentState.renderTasks ||= new Set()).add(internalRenderTask);
const renderTask = internalRenderTask.task;
Promise.all([
intentState.displayReadyCapability.promise,
optionalContentConfigPromise,
])
.then(([transparency, optionalContentConfig]) => {
if (this.destroyed) {
complete();
return;
}
this._stats?.time("Rendering");
if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
throw new Error(
"Must use the same `intent`-argument when calling the `PDFPageProxy.render` " +
"and `PDFDocumentProxy.getOptionalContentConfig` methods."
);
}
internalRenderTask.initializeGraphics({
transparency,
optionalContentConfig,
});
internalRenderTask.operatorListChanged();
})
.catch(complete);
return renderTask;
}
This is a very complex method — let's break it down into chunks.
Step 1: call getRenderingIntent to compute the rendering intent. There are three intents: any, display, and print.
Step 2: call this._intentStates.getOrInsertComputed to get or insert the computed intent state, keyed by the rendering intent from step 1.
Step 3: fetch the page's operator list.
// If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise.
if (!intentState.displayReadyCapability) {
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = {
fnArray: [],
argsArray: [],
lastChunk: false,
separateAnnots: null,
};
this._stats?.time('Page Request');
this._pumpOperatorList(intentArgs);
}
There is a cache layer here: it checks whether intentState.displayReadyCapability exists. If it doesn't, a promise is created and the page's operator list is requested.
> Q: What is intentState.displayReadyCapability? And why must we wait for this promise to resolve before we can actually render anything to the canvas?
>
> A: It is the { promise, resolve, reject } returned by Promise.withResolvers(). Its promise resolves with the boolean transparency (whether the page contains blend modes, which decides whether rendering needs an alpha canvas).
>
> We must wait for it to resolve for two core reasons. First, transparency is a required parameter of initializeGraphics — CanvasGraphics needs to know whether to enable the alpha channel to correctly handle blend modes, and this value can only be determined after the worker has parsed the page's content stream and resources. Second, it is essentially the "page ready" startup signal from the worker: the worker sends the StartRenderPage message before running getOperatorList, and the Display layer resolves it in _startRenderPage. Until this point the operatorList is still empty; chunks are only streamed in afterwards via _renderPageChunk, so the render pipeline must wait for this signal before it can start consuming them.
>
> Note that it means "page resources are ready, ready to start receiving operator chunks", not "the operator list has been fully delivered" — whether everything has arrived is marked by intentState.operatorList.lastChunk.
>
> Additionally, its existence doubles as a cache marker: as long as it already exists, the operator list for this rendering intent (cacheKey) has been requested before, so _pumpOperatorList doesn't need to be called again.
Step 4: instantiate the InternalRenderTask class and add it to the render task queue.
Step 5: wait for intentState.displayReadyCapability to resolve. Then initialize the graphics context with initializeGraphics, and notify that the operator list has changed with operatorListChanged — kicking off the actual rendering logic.
With the overall flow in place, let's analyze the execution logic of each step in detail.
Step 1: getRenderingIntent, computing the rendering intent
Entering PDFPageProxy.render → WorkerTransport.getRenderingIntent
// Source: src/display/api.js
getRenderingIntent(
intent,
annotationMode = AnnotationMode.ENABLE,
printAnnotationStorage = null,
isEditing = false,
isOpList = false
) {
let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value.
let annotationStorageSerializable = SerializableEmpty;
switch (intent) {
case "any":
renderingIntent = RenderingIntentFlag.ANY;
break;
case "display":
break;
case "print":
renderingIntent = RenderingIntentFlag.PRINT;
break;
default:
warn(`getRenderingIntent - invalid intent: ${intent}`);
}
const annotationStorage =
renderingIntent & RenderingIntentFlag.PRINT &&
printAnnotationStorage instanceof PrintAnnotationStorage
? printAnnotationStorage
: this.annotationStorage;
switch (annotationMode) {
case AnnotationMode.DISABLE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE;
break;
case AnnotationMode.ENABLE:
break;
case AnnotationMode.ENABLE_FORMS:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS;
break;
case AnnotationMode.ENABLE_STORAGE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
annotationStorageSerializable = annotationStorage.serializable;
break;
default:
warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`);
}
if (isEditing) {
renderingIntent += RenderingIntentFlag.IS_EDITING;
}
if (isOpList) {
renderingIntent += RenderingIntentFlag.OPLIST;
}
const { ids: modifiedIds, hash: modifiedIdsHash } =
annotationStorage.modifiedIds;
const cacheKeyBuf = [
renderingIntent,
annotationStorageSerializable.hash,
modifiedIdsHash,
];
return {
renderingIntent,
cacheKey: cacheKeyBuf.join("_"),
annotationStorageSerializable,
modifiedIds,
};
}
There are three modes: any, display, and print.
- any is for getOperatorList / getTextContent and other "any" scenarios.
- display renders the page.
- print prints the page.
Plus the annotationMode option, which controls whether annotations are rendered. And the isEditing editing mode.
Step 2: this._intentStates.getOrInsertComputed
this._intentStates is a Map that caches data keyed by the rendering intent.
makeObj is a utility function: const makeObj = () => Object.create(null);
getOrInsertComputed is a fairly new function in the JS spec: if the key exists, it returns the corresponding value; if it doesn't, it creates a value via the second-argument callback and returns it. See MDN.
Step 3: fetching the page's operator list
Here we check whether intentState.displayReadyCapability exists. If it does, the data was requested before and there's no need to call _pumpOperatorList again.
Otherwise we call _pumpOperatorList to request the page's operator list.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList
// Source: src/display/api.js
_pumpOperatorList({
renderingIntent,
cacheKey,
annotationStorageSerializable,
modifiedIds,
}) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(
Number.isInteger(renderingIntent) && renderingIntent > 0,
'_pumpOperatorList: Expected valid "renderingIntent" argument.'
);
}
const { map, transfer } = annotationStorageSerializable;
const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList",
{
pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
pageIndex: this._pageIndex,
intent: renderingIntent,
cacheKey,
annotationStorage: map,
modifiedIds,
},
/* queueingStrategy = */ undefined,
transfer
);
const reader = readableStream.getReader();
const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader;
const pump = () => {
reader.read().then(
({ value, done }) => {
if (done) {
intentState.streamReader = null;
return;
}
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
this._renderPageChunk(value, intentState);
pump();
},
reason => {
intentState.streamReader = null;
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
if (intentState.operatorList) {
// Mark operator list as complete.
intentState.operatorList.lastChunk = true;
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
this.#tryCleanup();
}
if (intentState.displayReadyCapability) {
intentState.displayReadyCapability.reject(reason);
} else if (intentState.opListReadCapability) {
intentState.opListReadCapability.reject(reason);
} else {
throw reason;
}
}
);
};
pump();
}
Here, through the transport, we send an important streaming event to the worker: GetOperatorList.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList
handler.on(
'GetOperatorList',
function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) {
pdfManager.getPage(pageId).then(function (page) {
const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`);
startWorkerTask(task);
// NOTE: Keep this condition in sync with the `info` helper function.
const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0;
// Pre compile the pdf page and fetch the fonts/images.
page
.getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
annotationStorage,
modifiedIds,
pageIndex,
})
.then(
opListInfo => {
if (start) {
info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
}
sink.close();
},
reason => {
if (task.terminated) {
return; // ignoring errors from the terminated thread
}
sink.error(reason);
},
)
.finally(() => {
finishWorkerTask(task);
});
});
},
);
It calls pdfManager.getPage to get the Page instance. This call points to NetworkPDFManager.getPage, which in turn points to BasePdfManager.getPage.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage (parent BasePdfManager.getPage)
getPage(pageIndex) {
return this.pdfDocument.getPage(pageIndex);
}
which calls this.pdfDocument.getPage.
Let's first see where this.pdfDocument comes from.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor
this.pdfDocument = new PDFDocument(this, this.streamManager.getStream());
instantiating PDFDocument.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor → PDFDocument.constructor
constructor(pdfManager, stream) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(
stream instanceof BaseStream,
'PDFDocument: Invalid "stream" argument.'
);
}
if (stream.length <= 0) {
throw new InvalidPDFException(
"The PDF file is empty, i.e. its size is zero bytes."
);
}
this.pdfManager = pdfManager;
this.stream = stream;
this.xref = new XRef(stream, pdfManager);
const idCounters = {
font: 0,
};
this._globalIdFactory = class {
static getDocId() {
return `g_${pdfManager.docId}`;
}
static createFontId() {
return `f${++idCounters.font}`;
}
static createObjId() {
unreachable("Abstract method `createObjId` called.");
}
static getPageObjId() {
unreachable("Abstract method `getPageObjId` called.");
}
};
}
Here, an XRef instance is created to parse the PDF file's XRef table.
> Q: What is the XRef table?
>
> A: The XRef (Cross-Reference) table is the PDF file's "object directory". Every entity inside a PDF — pages, fonts, images, content streams, etc. — is a numbered object (e.g. 3 0 obj). The XRef table records the byte offset of each object within the file.
>
> For example:
>
> > ```
> > xref
> > 0 5
> > 0000000000 65535 f
> > 0000000010 00000 n
> > 0000000053 00000 n
> > 0000000102 00000 n
> > 0000000173 00000 n
> > ```
>
> The first line, 0 5, means the next 5 entries list the objects starting from object 0. Each entry is exactly 20 bytes: the first 10 digits are the byte offset, the middle 5 digits are the generation number, and the last one is n (in-use) or f (free/deleted).
>
> When the parser gets an object reference (say 2 0 R, meaning "reference object 2"), it looks up the XRef table and computes the object's location in the file directly, instead of scanning the file from the start like XML/JSON. This is also the key to PDF's incremental updates (append-only): after each append-only modification, you only need to append a new XRef section and point the startxref pointer at the end of the file to the latest one.
>
> In pdf.js, the XRef class parses this table, maintains the {object number → offset} mapping, and resolves the actual content of objects (when it hits an indirect reference like 2 0 R, it recursively parses until it gets the real value).
> This is one of the optimization points in StreamPDF: the XRef table is pre-parsed on the server side. We won't expand on it here — a dedicated article will introduce it in detail later.
We won't go deeper for now — back to rendering. A later article will explore PDF.js's lexical and syntactic parsing of PDF documents.
Now that PDFDocument is initialized, let's return to the getPage method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage → PDFDocument.getPage
getPage(pageIndex) {
const cachedPromise = this.#pagePromises.get(pageIndex);
if (cachedPromise) {
return cachedPromise;
}
const { catalog, linearization, xfaFactory } = this;
let promise;
if (xfaFactory) {
promise = Promise.resolve([Dict.empty, null]);
} else if (linearization?.pageFirst === pageIndex) {
promise = this.#getLinearizationPage(pageIndex);
} else {
promise = catalog.getPageDict(pageIndex);
}
promise = promise.then(
([pageDict, ref]) =>
new Page({
pdfManager: this.pdfManager,
xref: this.xref,
pageIndex,
pageDict,
ref,
globalIdFactory: this._globalIdFactory,
fontCache: catalog.fontCache,
builtInCMapCache: catalog.builtInCMapCache,
standardFontDataCache: catalog.standardFontDataCache,
globalColorSpaceCache: catalog.globalColorSpaceCache,
globalImageCache: catalog.globalImageCache,
systemFontCache: catalog.systemFontCache,
nonBlendModesSet: catalog.nonBlendModesSet,
xfaFactory,
})
);
this.#pagePromises.set(pageIndex, promise);
return promise;
}
It reads the catalog, checks linearization, and creates the page.
> Q: What is the catalog?
> A: The Catalog is the PDF file's "root dictionary", sitting at the top of the object graph. The /Root entry in the trailer at the end of the file points to it — it's the entry point of the entire PDF. It holds references to all other top-level structures: most importantly /Pages (the root of the page tree), plus /Names (named objects), /Outlines (bookmarks), /AcroForm (forms), /Metadata (XMP metadata), and more.
>
> In pdf.js, the Catalog class wraps this root dictionary and provides caches shared across pages — the font cache (fontCache), the global color space cache (globalColorSpaceCache), the image cache (globalImageCache), the built-in CMap cache (builtInCMapCache), etc. These caches are reused across pages to avoid re-parsing the same font definitions or color spaces — which is why the Page constructor above takes that long list of catalog.fontCache, catalog.globalColorSpaceCache, and so on.
> Q: What is linearization?
> A: Linearization is a "fast web view" optimization provided by the PDF specification.
>
> A normal PDF is parsed like this: jump to the end of the file and read startxref → read the trailer → find the main XRef section from the trailer → only then can objects be located. That means you need the tail of the file before you can start working.
>
> A linearized PDF additionally places a Linearized dictionary at the beginning of the file (at object 1), which records:
>
> - N — total page count
> - P — the index of the first page (default 0)
> - O — the object number of the first page
> - T — the offset of the main XRef section in the file
> - H — the hint arrays, telling the reader which pages live where in the file
>
> With just a few hundred bytes from the start of the file, the parser can use this dictionary to skip the main XRef and jump straight to the first page's data to start rendering, without waiting for the whole file to download. The effect is dramatic on large files (tens-of-MB scanned documents).
>
> In pdf.js, the PDFDocument.linearization getter tries to parse the beginning of the file; if it finds this Linearized dictionary, it returns an object with fields like numPages, pageFirst, and objectNumberFirst. The getPage logic above is: if the PDF is linearized and the requested page is the first page, take the #getLinearizationPage fast path and use linearization.objectNumberFirst to get the first page's dictionary directly; otherwise fall back to the standard catalog lookup.
> But linearization depends heavily on the tool that produced the file — most PDFs have never been linearized.
We've now touched some real PDF format specifications.
This returns the Page instance — let's stay restrained and not dig deeper.
Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList
Continuing:
// Pre compile the pdf page and fetch the fonts/images.
page
.getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
annotationStorage,
modifiedIds,
pageIndex,
})
.then(
opListInfo => {
if (start) {
info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
}
sink.close();
},
reason => {
if (task.terminated) {
return; // ignoring errors from the terminated thread
}
sink.error(reason);
},
)
.finally(() => {
finishWorkerTask(task);
});
It calls the Page.getOperatorList method to get the page's rendering operators.
Let's take a quick look at the implementation of Page.getOperatorList.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList
// Source: src/core/document.js
async getOperatorList({
handler,
sink,
task,
intent,
cacheKey,
pageIndex = this.pageIndex,
annotationStorage = null,
modifiedIds = null,
}) {
const contentStreamPromise = this.getContentStream();
const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
// ...
const pageListPromise = Promise.all([
contentStreamPromise,
resourcesPromise,
]).then(async ([contentStream]) => {
const resources = await this.#getMergedResources(
contentStream.dict,
RESOURCES_KEYS_OPERATOR_LIST
);
const opList = new OperatorList(intent, sink);
handler.send("StartRenderPage", {
transparency: partialEvaluator.hasBlendModes(
resources,
this.nonBlendModesSet
),
pageIndex,
cacheKey,
});
await partialEvaluator.getOperatorList({
stream: contentStream,
task,
resources,
operatorList: opList,
});
return opList;
});
// ...
A lot of logic is omitted — but did you spot the key handler.send("StartRenderPage")? This event corresponds to the intentState.displayReadyCapability we saw earlier.
The main thread's StartRenderPage event listener:
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList → Main: on StartRenderPage
messageHandler.on('StartRenderPage', data => {
if (this.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
const page = this.#pageCache.get(data.pageIndex);
page._startRenderPage(data.transparency, data.cacheKey);
});
which calls page._startRenderPage.
/**
* @private
*/
_startRenderPage(transparency, cacheKey) {
const intentState = this._intentStates.get(cacheKey);
if (!intentState) {
return; // Rendering was cancelled.
}
this._stats?.timeEnd("Page Request");
// TODO Refactor RenderPageRequest to separate rendering
// and operator list logic
intentState.displayReadyCapability?.resolve(transparency);
}
This method resolves the intentState.displayReadyCapability promise.
OK, let's return to the _pumpOperatorList method.
Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList
Continuing with the streaming data: through the sink, data keeps flowing back to the reader on the main thread.
intentState.streamReader = reader;
const pump = () => {
reader.read().then(
({ value, done }) => {
if (done) {
intentState.streamReader = null;
return;
}
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
this._renderPageChunk(value, intentState);
pump();
},
reason => {
intentState.streamReader = null;
if (this._transport.destroyed) {
return; // Ignore any pending requests if the worker was terminated.
}
if (intentState.operatorList) {
// Mark operator list as complete.
intentState.operatorList.lastChunk = true;
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
this.#tryCleanup();
}
if (intentState.displayReadyCapability) {
intentState.displayReadyCapability.reject(reason);
} else if (intentState.opListReadCapability) {
intentState.opListReadCapability.reject(reason);
} else {
throw reason;
}
},
);
};
pump();
Every time the worker pushes a chunk into the stream, it's consumed here, which calls this._renderPageChunk(value, intentState);
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk
_renderPageChunk(operatorListChunk, intentState) {
// Add the new chunk to the current operator list.
for (let i = 0, ii = operatorListChunk.length; i < ii; i++) {
intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
}
intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
intentState.operatorList.separateAnnots = operatorListChunk.separateAnnots;
// Notify all the rendering tasks there are more operators to be consumed.
for (const internalRenderTask of intentState.renderTasks) {
internalRenderTask.operatorListChanged();
}
if (operatorListChunk.lastChunk) {
this.#tryCleanup();
}
}
After pre-processing the data, it calls internalRenderTask.operatorListChanged() to notify the render tasks that new operators have arrived.
Step 4: the render tasks have new operators
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged
operatorListChanged() {
if (!this.graphicsReady) {
this.graphicsReadyCallback ||= this._continueBound;
return;
}
this.gfx.dependencyTracker?.growOperationsCount(
this.operatorList.fnArray.length
);
this.stepper?.updateOperatorList(this.operatorList);
if (this.running) {
return;
}
this._continue();
}
which goes to _continue.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue
_continue() {
this.running = true;
if (this.cancelled) {
return;
}
if (this.task.onContinue) {
this.task.onContinue(this._scheduleNextBound);
} else {
this._scheduleNext();
}
}
which goes to _scheduleNext.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext
_scheduleNext() {
if (this._useRequestAnimationFrame) {
this.#rAF = window.requestAnimationFrame(() => {
this.#rAF = null;
this._nextBound().catch(this._cancelBound);
});
} else {
Promise.resolve().then(this._nextBound).catch(this._cancelBound);
}
}
Via window.requestAnimationFrame, it schedules the _nextBound method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound
In the constructor, this._nextBound = this._next.bind(this);
which goes to the _next method.
Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound → internalRenderTask._next
async _next() {
if (this.cancelled) {
return;
}
this.operatorListIdx = this.gfx.executeOperatorList(
this.operatorList,
this.operatorListIdx,
this._continueBound,
this.stepper,
this._operationsFilter
);
if (this.operatorListIdx === this.operatorList.argsArray.length) {
this.running = false;
if (this.operatorList.lastChunk) {
this.gfx.endDrawing();
InternalRenderTask.#canvasInUse.delete(this._canvas);
this.callback();
}
}
}
It calls this.gfx.executeOperatorList — the operator list is now actually executed.
Step 5: initializeGraphics & operatorListChanged
Once intentState.displayReadyCapability.promise is ready, the graphics context is initialized.
Entering initializeGraphics
// Source: src/display/api.js (InternalRenderTask)
initializeGraphics({ transparency = false, optionalContentConfig }) {
if (this.cancelled) {
return;
}
if (this._canvas) {
if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
throw new Error(
"Cannot use the same canvas during multiple render() operations. " +
"Use different canvas or ensure previous operations were " +
"cancelled or completed."
);
}
InternalRenderTask.#canvasInUse.add(this._canvas);
}
if (this._pdfBug && globalThis.StepperManager?.enabled) {
this.stepper = globalThis.StepperManager.create(this._pageIndex);
this.stepper.init(this.operatorList);
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
}
const {
viewport,
transform,
background,
dependencyTracker,
imagesTracker,
} = this.params;
// When printing in Firefox, we get a specific context in mozPrintCallback
// which cannot be created from the canvas itself.
const canvasContext =
this._canvasContext ||
this._canvas.getContext("2d", {
alpha: false,
willReadFrequently: !this._enableHWA,
});
this.gfx = new CanvasGraphics(
canvasContext,
this.commonObjs,
this.objs,
this.canvasFactory,
this.filterFactory,
{ optionalContentConfig },
this.annotationCanvasMap,
this.pageColors,
dependencyTracker,
imagesTracker
);
this.gfx.beginDrawing({
transform,
viewport,
transparency,
background,
});
this.operatorListIdx = 0;
this.graphicsReady = true;
this.graphicsReadyCallback?.();
}
By setting this.graphicsReady = true, the internalRenderTask.operatorListChanged method is allowed to proceed.
It then immediately triggers operatorListChanged once more, starting execution of the operator list.
operatorListChanged() {
if (!this.graphicsReady) {
this.graphicsReadyCallback ||= this._continueBound;
return;
}
this.gfx.dependencyTracker?.growOperationsCount(
this.operatorList.fnArray.length
);
this.stepper?.updateOperatorList(this.operatorList);
if (this.running) {
return;
}
this._continue();
}
Summary
In this article, we explored the Display layer's getViewport and render methods.
The core of getViewport is building an affine transform matrix: converting PDF's coordinate system (origin at the bottom-left, unit = point) into canvas's coordinate system (origin at the top-left, unit = px), while handling rotation, scale, and dontFlip.
render is a rendering pipeline spanning the main thread and the worker:
!Image description
Text version:
Main thread (PDFPageProxy.render)
|-- getRenderingIntent compute the intent, build the cache key
|-- _intentStates.getOrInsert... get or create the intent state by cache key
|-- _pumpOperatorList send the streaming GetOperatorList request to the worker
| `-- reader.read() loop consumes chunks, hands each to _renderPageChunk
|-- new InternalRenderTask create the render task, add it to renderTasks
`-- await displayReadyCapability
`-- initializeGraphics create CanvasGraphics, beginDrawing
`-- operatorListChanged -> _next -> gfx.executeOperatorList
Worker thread (on:GetOperatorList)
|-- pdfManager.getPage locate the Page
`-- Page.getOperatorList
|-- handler.send("StartRenderPage") resolves displayReadyCapability
`-- partialEvaluator.getOperatorList parses operators, streams them back via sink
Understanding this pipeline means understanding the skeleton of PDF.js rendering: the main thread only schedules and consumes operators; the real parsing work all happens in the worker.
At this point, after these three articles, the rendering architecture of PDF.js is clear. Starting from the next article, we'll dive into some of the details to round out the depth.
Happy reading!
Next up: MessageHandler — a deep dive into the core mechanism of message communication.
Mentions
PDF.js Deep Dive from Demo 2: getDocument Introduction The demo in the previous article called getDocument with only { url: '...' }. This article follows that single call through the full execution chain of getDocument. The getDocument call The source parameter is named src. The demo passes only one key: | Key in src | Value | | ------------ | -------------------------------------------------- | | url | 'https://assets-blog.streampdf.com/PDF32000_2008.pdf' | Based on the definition of getDocument in display/api.js, the execution flow is as follows. Lines 235-236: Initialize the loading task Instantiate the PDFDocumentLoadingTask class: view source, and assign the return value to the task variable. Extract docId from task; at this point docId is d0. Lines 238-335: Validate and process parameters This block of code expands the user-provided src object into 35+ local variables: provided values are validated and type-converted, while missing ones fall back to defaults. The demo only passes url, so the vast majority of parameters take the default branch. The only provided url is converted into a URL object by getUrlProp: const url = URL.parse('https://assets-blog.streampdf.com/PDF32000_2008.pdf', window.location); // const url = src.url ? getUrlProp(src.url) : null; All other parameters are absent. In source order, the computed results are: | Variable | Result | Logic | | ---------------------------- | ------------------------ | ------------------------------------------------------------------------ | | data | null | If provided, normalized to Uint8Array by getDataProp | | httpHeaders | null | Passed through as-is; null when absent | | withCredentials | false | Only accepts === true | | password | null | ?? null | | rangeTransport | null | Only accepts a PDFDataRangeTransport instance | | rangeChunkSize | 2 ** 16 (64KB) | Only accepts the provided value if it is a positive integer | | worker | null | Only accepts a PDFWorker instance | | verbosity | undefined | Taken verbatim from src.verbosity | | docBaseUrl | null | Only accepts a string that is not a data: URL | | cMapUrl | null | getFactoryUrlProp: non-string becomes null, and it must end with / | | cMapPacked | true | !== false | | iccUrl | null | Same as cMapUrl | | standardFontDataUrl | null | Same as cMapUrl | | wasmUrl | null | Same as cMapUrl | | ignoreErrors | true | src.stopAtErrors !== true, see below | | maxImageSize | -1 (unlimited) | Only accepted if an integer and > -1 | | isOffscreenCanvasSupported | true | Defaults to !isNodeJS when absent | | isImageDecoderSupported | true | Defaults to !isNodeJS when absent | | canvasMaxAreaInBytes | -1 (unlimited) | Only accepted if an integer | | disableFontFace | false | Defaults to isNodeJS when absent | | fontExtraProperties | false | Only accepts === true | | enableXfa | false | Only accepts === true | | ownerDocument | globalThis.document | Defaults to the global document when absent | | disableRange | false | Only accepts === true | | disableStream | false | Only accepts === true | | disableAutoFetch | false | Only accepts === true | | pdfBug | false | Only accepts === true | | CanvasFactory | DOMCanvasFactory | Environment branch, see below | | FilterFactory | DOMFilterFactory | Same as above | | BinaryDataFactory | DOMBinaryDataFactory | Same as above | | enableHWA | false | Only accepts === true | | enableWebGPU | false | Only accepts === true | | gpuPromise | Promise.resolve(false) | WebGPU is off, so GPU initialization does not start | | useWasm | true | !== false | | pagesMapper | new PagesMapper() | Created fresh when absent | | useSystemFonts | true | Cascading default, see below | | useWorkerFetch | false | Compound check, see the Q&A below | | styleElement | null | Only preserved in TESTING builds; always null in production | These are all the local variables produced by this block of code. Most of the checks are trivial "fall back to default when absent" cases, but a few deserve a closer look. ignoreErrors: a semantically inverted internal variable The user-facing parameter is called stopAtErrors, yet the internal variable is called ignoreErrors, computed as src.stopAtErrors !== true. In other words, the default behavior is to ignore errors: when parsing fails, try to recover rather than rejecting promises such as getOperatorList / getTextContent / RenderTask. When reading the source, it is very easy to copy the name and value the wrong way around here. rangeChunkSize: the 64KB setup for later rangeChunkSize defaults to 64KB. Since disableRange = false, when the transport is created later, range requests will be issued against the PDF, and each fetched chunk will be exactly this size. Keep it in mind for now. docBaseUrl: why data: URLs are excluded The source comment explains the reason: a data: URL cannot be used to resolve absolute paths, and it contains the entire PDF document with no upper bound on length, so it is not sent to the worker thread. gpuPromise: designed to run in parallel with worker startup const gpuPromise = Promise.resolve(false); // enableWebGPU ? initGPU() : Promise.resolve(false); Even when WebGPU is disabled, this immediately returns a resolved promise instead of deferring the check until it is used. The comment explains the intent: let GPU initialization run in parallel with worker startup, with the resolved boolean forwarded to the worker afterwards. The three Factories: environment branches const CanvasFactory = DOMCanvasFactory; // src.CanvasFactory || (GENERIC build && isNodeJS ? NodeCanvasFactory : DOMCanvasFactory); The same applies to FilterFactory and BinaryDataFactory. The check has three tiers: user-provided custom > Node environment implementation > DOM implementation. The demo runs in a browser without customization, so all three factories are the DOM versions. Their responsibilities: - CanvasFactory: creates canvases for rendering - FilterFactory: creates image filters (such as SVG filters) - BinaryDataFactory: downloads built-in static assets; works together with useWorkerFetch below useSystemFonts: the default depends on other parameters const useSystemFonts = true; // typeof src.useSystemFonts === "boolean" ? src.useSystemFonts : !isNodeJS && !disableFontFace; Its default depends on disableFontFace, which was just parsed above. Why? Fonts not embedded in the PDF are loaded as FontFaces; if disableFontFace is true, that loading path is disabled, and falling back to system fonts at that point would produce completely garbled glyphs. So in that scenario the default is simply false as well, avoiding rendering utterly broken fonts. useWorkerFetch: a compound check const useWorkerFetch = false; // typeof src.useWorkerFetch === "boolean" // ? src.useWorkerFetch // : MOZCENTRAL || // !!( // BinaryDataFactory === DOMBinaryDataFactory && // cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl && // isValidFetchUrl(cMapUrl, document.baseURI) && // isValidFetchUrl(standardFontDataUrl, document.baseURI) && // isValidFetchUrl(wasmUrl, document.baseURI) // ); > Q: What is this check doing? > A: It decides who downloads the built-in static assets (CMap files, standard font files, wasm files): the worker thread downloads them itself via the Fetch API (true), or the main thread downloads them and forwards them to the worker (false). > > If the user explicitly passes a boolean, the user's value is used directly. Otherwise the default is computed: MOZCENTRAL (the Firefox built-in viewer) is always true; for a regular build, all three of the following conditions must hold at the same time for it to be true: > > 1. No custom BinaryDataFactory — because when useWorkerFetch is true, that parameter is simply ignored (inside transportFactory, binaryDataFactory is set to null). A user-provided factory must be respected, so the default cannot be true; > 2. cMapUrl / standardFontDataUrl / wasmUrl are all provided, and cMapPacked is true — without URLs there is nothing to fetch; > 3. These three URLs resolve to http/https relative to document.baseURI — the Fetch API only supports http/https, not file/ftp. > > The demo in this article only passes url, so cMapUrl is null, condition 2 is not met, and therefore useWorkerFetch = false: the main-thread path is taken, with DOMBinaryDataFactory downloading on the main thread, and the worker requesting data from the main thread via FetchBinaryData messages when needed. Lines 338: Set the main-thread log level setVerbosityLevel(verbosity); // verbosity is undefined verbosity is undefined, so the main-thread log level stays at its default. Note that this only affects the main thread; the worker thread's log level is passed separately when the worker is created. Lines 342-350: Instantiate the three factories const transportFactory = { canvasFactory: new DOMCanvasFactory({ ownerDocument: document, enableHWA: false }), filterFactory: new DOMFilterFactory({ docId: 'd0', ownerDocument: document }), binaryDataFactory: new DOMBinaryDataFactory({ cMapUrl: null, standardFontDataUrl: null, wasmUrl: null, }), }; // binaryDataFactory: null when MOZCENTRAL or useWorkerFetch is true The three Factory classes selected earlier are instantiated and packaged as transportFactory, which is then handed to the transport. Since useWorkerFetch = false, binaryDataFactory is not null — echoing the Q&A in the previous section: built-in static assets are downloaded on the main thread. Instantiate DOMCanvasFactory: view source Instantiate DOMFilterFactory: view source Instantiate DOMBinaryDataFactory: view source Lines 352-360: Create the worker worker = PDFWorker.create({ verbosity: undefined, port: undefined }); // port comes from GlobalWorkerOptions.workerPort, which the demo does not set task._worker = worker; The demo does not pass a worker, so PDF.js creates and owns one itself. PDFWorker.create synchronously returns a PDFWorker instance; its constructor reads GlobalWorkerOptions.workerSrc (the worker file URL configured in the first article), creates a Worker, and completes the startup handshake through ready / test messages — covered in detail in Article 8. For now, just remember: worker.promise is a Promise<void> that resolves (to undefined) once the test reply succeeds, and rejects if startup fails or the worker is destroyed. Lines 362-402: Assemble the two parameter packages The first is docParams, which will later be sent across threads to the worker: const docParams = { docId: 'd0', apiVersion: '6.3.289', // build version number data: null, password: null, disableAutoFetch: false, rangeChunkSize: 65536, docBaseUrl: null, enableXfa: false, evaluatorOptions: { maxImageSize: -1, disableFontFace: false, ignoreErrors: true, isOffscreenCanvasSupported: true, isImageDecoderSupported: true, canvasMaxAreaInBytes: -1, fontExtraProperties: false, useSystemFonts: true, useWasm: true, useWorkerFetch: false, cMapUrl: null, cMapPacked: true, iccUrl: null, standardFontDataUrl: null, wasmUrl: null, hasGPU: false, // placeholder, backfilled later }, }; The second is transportParams, which stays on the main thread: const transportParams = { ownerDocument: document, pdfBug: false, styleElement: null, enableHWA: false, loadingParams: { disableAutoFetch: false, enableXfa: false }, }; One sentence to distinguish them: docParams is the "work order" sent to the worker, while transportParams is the "local configuration" kept on the main thread. Lines 404-473: The handshake after the worker is ready Promise.all([worker.promise, gpuPromise]).then(...) Once both worker readiness and GPU probing (resolving to false) complete, the following happens in sequence: 1. hasGPU = false is backfilled into evaluatorOptions; 2. A GetDocRequest message is sent to the worker with docParams as the payload. Since data is null, there is no buffer whose ownership needs to be transferred; 3. Choose the network stream: with data being null and rangeTransport being null, the url branch is taken and a network stream is instantiated (the fetch implementation is preferred, with a fallback to XHR when unsupported; see Article 9 for how range requests are issued internally): networkStream = new NetworkStream({ url, httpHeaders: null, withCredentials: false, rangeChunkSize: 65536, disableRange: false, disableStream: false, }); NetworkStream here is not a fixed imported class; it is picked at runtime inside this branch: const NetworkStream = getNetworkStream(url); // api.js#L431 getNetworkStream makes a three-way choice: function getNetworkStream(url) { // eslint-disable-next-line no-nested-ternary return isValidFetchUrl(url) ? PDFFetchStream : typeof PDFJSDev !== 'undefined' && PDFJSDev.test('GENERIC') && isNodeJS ? PDFNodeStream : PDFNetworkStream; } | Condition | Stream implementation | | --- | --- | | isValidFetchUrl(url): protocol is http / https | PDFFetchStream | | Otherwise, GENERIC build running in Node | PDFNodeStream | | Everything else | PDFNetworkStream (XHR) | The demo's url is https://..., so it actually instantiates PDFFetchStream. The other inputs have their own streams too: rangeTransport uses PDFDataTransportStream, while data means the whole PDF is already in memory and no stream is created at all. In summary: const NetworkStream = PDFFetchStream. Execution continues: 1. Receive the worker's acknowledgment of GetDocRequest (workerId): click to view the event. The value returned here is d0_worker, assigned to the workerId variable 2. Create a MessageHandler with { docId: 'd0', workerId: 'd0_worker', port: Worker }: click to view the call, assigned to the messageHandler variable 3. Create a WorkerTransport with { messageHandler, task, networkStream, transportParams, transportFactory, pagesMapper }: click to view the call, attached to task._transport 4. Send a Ready message to the worker, declaring the main-thread side ready: click to view the event At the end of the chain, task._setupCapability.resolve is called to settle the task's asynchronous setup. Lines 474-475: Return value All of the above .then chain proceeds asynchronously in the background, while getDocument itself returns task synchronously. If any step in the chain fails, the capability inside task is rejected — the very promise that the demo waits on with await loadingTask.promise. At this point, getDocument has finished executing. Summary In this section we followed the demo through the complete getDocument call: 1. Create PDFDocumentLoadingTask and extract docId = 'd0'; 2. Expand src into 35+ local variables; the demo only passes url, and everything else falls back to defaults; 3. Instantiate the three DOM factories and package them as transportFactory; 4. PDFWorker.create creates the worker and starts the handshake; 5. Assemble docParams (sent to the worker) and transportParams (kept on the main thread); 6. Once the worker is ready, send GetDocRequest, set up the network stream, create MessageHandler and WorkerTransport, and finally send Ready; 7. Return task synchronously; the demo waits for the document to be ready via await loadingTask.promise. Back to the demo in the first article: lines 4-7 call exactly the getDocument dissected here. Following the caller chain upward, the next call site is: getPage --- About me: Creator of StreamPDF, focused on research and development in AI and PDF. Original article: streampdf.com
I'd suppose most of those junk websites have no value in them, really. The most valuable information in the web is in good ol' (x)html, maybe some css. But something like readability.js [1] can help in filtering the junk around the main info. [1]: mozilla/readability There's also a C rewrite: eafer/rdrview
Cómo detectar deepfakes de voz y protegerte del fraude 📈 Google Trends revela que los deepfakes de voz están en auge: cómo detectarlos y protegerse --- Introducción En los últimos meses, las búsquedas de “deepfake voice” y “voice phishing” han crecido de forma constante en Google Trends, señalando que los ataques con voces sintéticas ya no son una amenaza de nicho. Con herramientas como ElevenLabs, Azure Speech o Google WaveNet al alcance de cualquier usuario, los estafadores pueden imitar la voz de un familiar, un ejecutivo o un agente de soporte con una calidad que engaña incluso a los oyentes más experimentados. En este artículo encontrarás: 1. Cómo reconocer una llamada falsificada en segundos. 2. Herramientas gratuitas y ejemplos de código para detectar deepfakes en tiempo real. 3. Pasos prácticos para actuar y reducir el riesgo en tu organización o en tu día a día. --- 1. ¿Cómo distinguir una llamada deepfake de una conversación real? | Señal a observar | Por qué ocurre | Qué hacer | |------------------|----------------|-----------| | Entonación “plana” o cambios bruscos de pitch | Los modelos de TTS todavía luchan con la variación natural de la voz humana. | Presta atención a subidas y bajadas de tono que no siguen el patrón habitual del interlocutor. | | Pausas artificiales o “latencia” entre palabras | La generación en tiempo real introduce pequeños retrasos mientras el modelo procesa el texto. | Si escuchas silencios de 200‑400 ms donde no debería haberlos, sospecha. | | Artefactos de sonido (zumbidos, “silbidos” en consonantes) | Los algoritmos de síntesis pueden producir pequeñas distorsiones en fonemas fricativos. | Usa auriculares de buena calidad y busca esos ruidos sutiles. | | Falta de “relleno” natural (muletillas, respiraciones) | Las voces sintéticas suelen omitir “uh”, “eh” o suspiros. | Pregunta algo inesperado que provoque una respuesta espontánea; si la respuesta suena demasiado “ensayada”, alerta. | Los algoritmos de detección automática analizan espectrogramas y métricas de prosodia (pitch, jitter, shimmer) para identificar patrones que el oído humano no percibe. --- 2. Herramientas gratuitas y ejemplos de código A continuación, tres opciones open‑source que puedes integrar en minutos. Todas funcionan en Linux/macOS/Windows y requieren solo Python 3.9+. 2.1 Resemblyzer – Comparación de embeddings de voz # Instalación pip install git+https://github.com/resemble-ai/Resemblyzer.git from resemblyzer import VoiceEncoder, preprocess_wav import numpy as np import soundfile as sf # Carga la muestra sospechosa y la referencia (grabación real del usuario) wav_sospechosa, _ = preprocess_wav("call.wav") wav_ref, _ = preprocess_wav("voice_reference.wav") enc = VoiceEncoder() emb_sospechosa = enc.embed_utterance(wav_sospechosa) emb_ref = enc.embed_utterance(wav_ref) # Cosine similarity: >0.85 → muy similar (posible deepfake) similarity = np.inner(emb_sospechosa, emb_ref) print(f"Similitud: {similarity:.3f}") > Tip práctico: guarda una muestra corta (5 s) de la voz de cada empleado en un directorio seguro y ejecuta el script cada vez que recibas una llamada sospechosa. 2.2 DeepSpeech (Mozilla) – Transcripción y detección de anomalías # Instalación (requiere CUDA opcional para velocidad) pip install deepspeech wget https://github.com/mozilla/DeepSpeech/releases/download/v0.9.3/deepspeech-0.9.3-models.pbmm import deepspeech import wave import numpy as np model = deepspeech.Model('deepspeech-0.9.3-models.pbmm') def transcribe(path): with wave.open(path, 'rb') as wf: frames = wf.getnframes() buffer = wf.readframes(frames) data = np.frombuffer(buffer, dtype=np.int16) return model.stt(data) texto = transcribe('call.wav') print("Transcripción:", texto) # Busca palabras fuera de contexto o frases incoherentes → posible deepfake 2.3 Kaldi + VoiceID – Creación de perfiles de hablantes # Instalación rápida vía Docker (evita complicaciones de compilación) docker run -it --rm kaldi/kaldi:latest /bin/bash Dentro del contenedor, sigue la guía oficial de VoiceID para entrenar un modelo con tus grabaciones de referencia y luego ejecutar: Supongamos que ya tienes un modelo entrenado llamado speaker_model online2-wav-nnet3-latgen-faster \ --online=false \ --do-endpointing=false \ --frame-subsampling-factor=3 \ --acoustic-scale=1.0 \ speaker_model/graph/HCLG.fst \ speaker_model/graph/words.txt \ speaker_model/graph/phones.txt \ speaker_model/graph/phones/word_boundary.int \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/graph/phones/word_boundary.txt \ speaker_model/ --- Herramienta mencionada: Groq Cloud
PDF Reader in JavaScript
Opera’s WebAI: websites that use AI on your own laptop instead of the cloud --- title: Opera’s WebAI: websites that use AI on your own laptop instead of the cloud published: true date: 2026-10-29 12:25:36 UTC tags: GenerativeAI,AIPrivacy,BrowserAI,LargeLanguageModels canonical_url: bluetickconsultants.com --- Published: 29 October 2026 | Updated: 29 October 2026 Quick summary (TL;DR): Opera WebAI is an opt-in preview in Opera One that lets websites and extensions run AI on your own computer instead of the cloud. It costs nothing per request, but it only reaches Opera users on supported PCs and Macs, so most sites still need a cloud fallback. The problem Every time a website summarises an article for you, translates a review, or suggests a reply, your text usually travels to a big company’s computers, gets processed there, and comes back. You are trusting that company with whatever you typed. The website owner pays for every one of those trips. So small sites either skip the feature, or they charge you a subscription to cover it. And if the AI company raises its prices or changes its privacy rules, everyone downstream has to live with it. Meanwhile, the laptop you are reading this on is probably powerful enough to do a lot of that work by itself. Until now, websites simply had no standard way to ask it to. What we’re solving On October 27, 2026, Opera released an early preview of WebAI in its Opera One browser. It lets websites and browser extensions use an AI model that runs locally, meaning on your own computer, not on a server somewhere else. - No subscription for you, no per-request bill for developers. Opera says sites can add AI features without running AI servers or paying for each request. - Your text stays on your device while the local model processes it. - More than one model. At launch it uses Google’s Gemma, an open-weight model (free to download, with published internals). On Apple silicon Macs it can also use Apple’s own on-device model. - Eight building blocks for developers. Opera lists the Prompt, Decisions, Semantic Embedder, Summarizer, Writer, Rewriter, Translator, and Proofreader APIs (an API is a menu of commands a website can call). - You stay in control. It is off by default. You switch it on in Settings, and each website must ask your permission before using the model. Is it really a brand-new standard? Partly. Opera calls WebAI a proposed web standard, but several of its APIs, including Prompt, Summarizer, and Translator, already exist in Chrome and follow drafts from W3C and WICG groups (the bodies where browser makers agree on web features). What Opera adds is the idea of doing it without tying the web to one company’s model : Chrome uses Google’s Gemini Nano and requires developers to accept Google’s usage policy, while Opera plugs in open models and has no such policy. What it does not solve: - It only works in Opera One for now , on 64-bit Windows PCs with Intel or AMD chips and on Apple silicon Macs. Intel Macs, Linux, and Windows on Arm are not supported yet. Opera’s own announcements differ on when Opera GX and Opera Neon get it, so treat Opera One as the confirmed starting point. - Everyone else needs a backup plan. Safari and Firefox users get nothing, so sites still need a cloud fallback or no AI feature at all. - Local does not mean private by default. A website can still send the model’s answer to its own servers. - Small models are less capable. They make more mistakes than big cloud models on long or complicated documents. A real example WebAI is one day old, so it has no named adopters yet. Opera has published six experimental extensions of its own, such as Project Lens, which colours an article’s paragraphs by how relevant they are to your project. The best documented real-world case uses the same family of built-in browser APIs in Chrome. Policybazaar, India (built-in AI in Chrome, 2025) - Who: Policybazaar, an Indian insurance marketplace with over 97 million registered customers. - The problem: customers speak many Indian languages, and the support team is not online in the evenings. - What they built: Finova AI, an insurance chatbot. The browser’s built-in Language Detector API spots which language you typed in, and the Translator API converts it to English and back on your device, while the answers themselves come from Policybazaar’s own server-side model. - Why on-device: Google’s write-up says they picked it for lower delay and lower cost than doing the same work on a server. - The numbers: 73% of people who opened the chatbot went on to have multi-question conversations lasting several minutes, and the new call-to-action button got twice the clicks of the old one. Policybazaar’s tech lead said a 1,000-character article translated into Hindi in about two seconds. This is Chrome, not Opera. But the Translator API is one of the eight Opera now supports, so it shows the kind of feature WebAI is meant to make cheap for any website. How Opera WebAI works (with code) Think of it like a calculator built into your phone. Apps do not need to ship their own calculator; they just ask the phone’s one. WebAI does the same for AI inside the browser. - You turn it on under Settings, Opera AI, “Enable Web AI API”. Opera then downloads a local model, which can take a few minutes. - A website asks permission to use it. You say yes or no, per site. - The website sends text to the browser , not to a server. The browser runs the model using your graphics chip where it can (through WebGPU) and falls back to the main processor otherwise. - The answer comes back to the page. Speed depends on how powerful your computer is. The example below flags clickbait headlines, one of the use cases Opera itself highlights. It uses the Prompt API’s LanguageModel commands, following the current syntax documented on MDN, so the same code targets both Opera’s WebAI and Chrome. There is no browser in our test environment, so we ran it in Node.js with a small stand-in that behaves like the browser’s model. The logic and output below are real; a real model would produce its own verdicts. // Flag clickbait headlines with the browser's built-in, on-device model. // Uses the Prompt API (LanguageModel), which Opera's WebAI and Chrome share. async function checkHeadline(headline) { if (!("LanguageModel" in globalThis)) return "no local AI: use a fallback"; const status = await LanguageModel.availability(); if (status === "unavailable") return "model not supported on this device"; const session = await LanguageModel.create({ initialPrompts: [{ role: "system", content: "Reply with one word: CLICKBAIT or OK.", }], }); const verdict = await session.prompt(headline); // runs on this computer session.destroy(); // free memory when done return verdict.trim(); } const headlines = [ "You won't BELIEVE what this laptop can do", "Opera adds local AI preview to Opera One", ]; for (const h of headlines) checkHeadline(h).then((v) => console.log(v, "-", h)); Output with a local model available: CLICKBAIT - You won't BELIEVE what this laptop can do OK - Opera adds local AI preview to Opera One Output in a browser without local AI (for example Safari or Firefox): no local AI: use a fallback - You won't BELIEVE what this laptop can do no local AI: use a fallback - Opera adds local AI preview to Opera One What the code is doing, in plain words: - First it checks whether the browser has a local model at all. If not, it says so instead of crashing. That is your cue to use a cloud service or hide the feature. - availability() asks whether this device can run the model. It can answer available, downloadable, downloading, or unavailable. - create() starts a session and gives the model one standing instruction: answer with a single word. - prompt() sends the headline to the model running on your machine and waits for the answer. - destroy() closes the session so the model stops using memory. - In a real browser , create() must follow a user action such as a click, and the user must have granted the site permission. What it costs All prices are in US dollars, checked on October 28, 2026. Cloud API rates are Google’s paid-tier Gemini prices as listed by MorphLLM’s tracker, which says it verified them against Google’s pricing page on October 25, 2026. | Option | Upfront cost | Ongoing cost | Hidden costs | Best for | | --- | --- | --- | --- | --- | | Do nothing (no AI features) | $0 | $0 | Visitors may go elsewhere for summaries or translation | Sites where AI adds little | | Cloud AI API (e.g. Gemini 2.5 Flash-Lite) | Developer time to build a backend | $0.10 per million input tokens, $0.40 per million output | Servers, API keys, user text leaves the device, prices can change | Any site that must work in every browser | | Bigger cloud model (Gemini 3.8 Flash) | Same as above | $0.75 in, $3.75 out per million tokens; doubles on Jan 1, 2027 | As above, plus a price rise already scheduled | Harder tasks where quality matters most | | Opera WebAI (local) | $0 for users and developers | $0 per request | Opera One preview only; user must opt in and download a model; uses the visitor’s battery and hardware | Extensions and sites with many Opera users | | Chrome built-in AI (Gemini Nano) | $0 | $0 per request | 4.27 GB model download; developers must accept Google’s usage policy; Chrome only | Sites whose visitors mostly use Chrome | A worked example Say your site summarises articles for 10,000 visitors a day. Assume each article is about 2,000 tokens in and the summary is about 200 tokens out (a token is roughly three-quarters of an English word; these sizes are our estimate). - Gemini 2.5 Flash-Lite in the cloud: about $2.80 a day, or about $84 a month. - Gemini 3.8 Flash in the cloud: about $22.50 a day, or about $675 a month. After the January 2027 price rise, about $1,350 a month. - WebAI on the visitor’s laptop: $0 per summary. But only visitors on Opera One who switched it on get it, so you still pay the cloud bill for everyone else. Who should care: - Solo users: it is free. If you already use Opera One on a supported computer, try the preview; your laptop pays in battery, not money. - Small teams and extension makers: the most direct win. A browser extension aimed at Opera users can ship AI features with no server bill at all. - Large sites: treat it as a discount on part of your traffic, not a replacement for your cloud setup. Use local AI when it is there, and fall back to the cloud when it is not. Pros and cons Pros - Free per request. No servers to run and no bill that grows with your traffic. - Data stays local while the model works on it. - Not locked to one AI company. Opera uses open models and Apple’s model, and has no vendor usage policy for developers. - Same APIs as Chrome for Prompt, Summarizer, and Translator, so code written once can run in both. - Opt-in and per-site permission. Nothing downloads until you switch it on, and you can switch it off any time. - Good fit for small everyday jobs. Short jobs like sorting tabs, flagging clickbait, or spotting relevant job posts suit a small local model. Cons - Tiny reach for now. One browser, a preview, an opt-in switch, and only some computers. - The standards fight is real. Mozilla said the Prompt API has severe negative consequences for the web’s interoperability, updatability, and neutrality. The W3C’s Technical Architecture Group also reviewed it and raised issues, while Apple’s WebKit team has given no public position. Opera’s model-neutral approach answers some of that, but not all. - Same site, different answers. Different browsers run different models, so one website can behave differently depending on where you open it. - Your hardware pays. Local models use memory and battery, and older laptops will be slower. - Weaker on hard jobs. Small local models make more mistakes on long or complex documents. - No IT admin controls yet. Opera picks the model, and companies cannot manage it centrally. - Ownership questions. Tech Times notes Opera’s majority owner, Kunlun Tech, is based in Beijing and that no independent security audit of Opera or WebAI was found. Local processing limits what WebAI itself sends anywhere, but it does not change Opera’s wider data obligations. Key takeaways - Opera’s WebAI lets websites and extensions run AI on your own computer, with no subscription for you and no per-request bill for developers. - It is an early, opt-in preview in Opera One on 64-bit Windows and Apple silicon Macs, using Gemma or Apple’s on-device model. - Much of it builds on APIs Chrome already ships; Opera’s twist is open, swappable models and no single company’s usage rules. - The strongest proof of the idea so far is Policybazaar’s multilingual chatbot built on Chrome’s built-in translation APIs. - Local AI is free per request, but it only reaches some visitors, so most sites will still need a cloud fallback. - Mozilla opposes the Prompt API and the W3C’s architecture group raised issues in its review, so whether this becomes a true web standard is still open. Frequently Asked Questions What is Opera WebAI? Opera WebAI is a feature in Opera’s browsers that lets websites and extensions use an AI model running on your own computer instead of a cloud server. Opera released it as an opt-in early preview in Opera One on October 27, 2026, and calls it a proposed web standard. Is Opera WebAI free? Yes. There is no subscription for users and no per-request bill for developers, because the model runs on the visitor’s computer. The cost moves to your hardware instead: local AI uses memory and battery. How do I turn on WebAI in Opera One? Update Opera One, open Settings, go to Opera AI and switch on Enable Web AI API. Opera then downloads a local model, which can take a few minutes. Each website must still ask your permission before it can use the model. Which computers can run Opera WebAI? The preview runs on 64-bit Windows PCs with Intel or AMD processors and on Macs with Apple silicon. Intel-based Macs, Linux and Windows on Arm are not supported yet. Does Opera WebAI work in Chrome, Safari or Firefox? WebAI itself is Opera’s. Chrome has its own built-in AI using Google’s Gemini Nano, and both support the Prompt API, so the same code can run in either. Safari and Firefox offer no equivalent, so sites need a cloud fallback for those visitors. Is Opera WebAI private? Your text stays on your device while the local model processes it, and you choose which sites can use it. But local does not mean private by default: a website can still send the model’s answer to its own servers. Should I build features on Opera WebAI today? If you make browser extensions for Opera users, yes: you can ship AI features with no server bill. For a public website, use WebAI when it is available and fall back to a cloud AI service when it is not, because most visitors will not have it yet. The post Opera’s WebAI: websites that use AI on your own laptop instead of the cloud appeared first on Bluetick Consultants Inc..
such a great tool mozilla/pdf.js
@firefox I'd be more inclined if you considered DBSC like your main competitor. standards-positions#912
PDF.js Deep Dive 3: getViewport & render Introduction This is the third article in the PDF.js Deep Dive series. In the previous article, we explored the Display layer's getDocument and getPage methods. In this article, we dive into the getViewport and render methods exposed by the Display layer. All code snippets in this article are based on PDF.js v6.3.289. - PDF.js Deep Dive 1: The Architecture of PDF.js - PDF.js Deep Dive 2: getDocument & getPage getViewport For page.getViewport, the call lands on the PDFPageProxy.getViewport method. /** * @param {GetViewportParameters} params - Viewport parameters. * @returns {PageViewport} Contains 'width' and 'height' properties * along with transforms required for rendering. */ getViewport({ scale, rotation = this.rotate, offsetX = 0, offsetY = 0, dontFlip = false, } = {}) { return new PageViewport({ viewBox: this.view, userUnit: this.userUnit, scale, rotation, offsetX, offsetY, dontFlip, }); } which instantiates the PageViewport class. Entering PageViewport // Source: src/display/page_viewport.js /** * @param {PageViewportParameters} */ constructor({ viewBox, userUnit, scale, rotation, offsetX = 0, offsetY = 0, dontFlip = false, }) { this.viewBox = viewBox; this.userUnit = userUnit; this.scale = scale; this.rotation = rotation; this.offsetX = offsetX; this.offsetY = offsetY; scale *= userUnit; // Take the userUnit into account. // creating transform to convert pdf coordinate system to the normal // canvas like coordinates taking in account scale and rotation const centerX = (viewBox[2] + viewBox[0]) / 2; const centerY = (viewBox[3] + viewBox[1]) / 2; let rotateA, rotateB, rotateC, rotateD; // Normalize the rotation, by clamping it to the [0, 360) range. rotation %= 360; if (rotation < 0) { rotation += 360; } switch (rotation) { case 180: rotateA = -1; rotateB = 0; rotateC = 0; rotateD = 1; break; case 90: rotateA = 0; rotateB = 1; rotateC = 1; rotateD = 0; break; case 270: rotateA = 0; rotateB = -1; rotateC = -1; rotateD = 0; break; case 0: rotateA = 1; rotateB = 0; rotateC = 0; rotateD = -1; break; default: throw new Error( "PageViewport: Invalid rotation, must be a multiple of 90 degrees." ); } if (dontFlip) { rotateC = -rotateC; rotateD = -rotateD; } let offsetCanvasX, offsetCanvasY; let width, height; if (rotateA === 0) { offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX; offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY; width = (viewBox[3] - viewBox[1]) * scale; height = (viewBox[2] - viewBox[0]) * scale; } else { offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX; offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY; width = (viewBox[2] - viewBox[0]) * scale; height = (viewBox[3] - viewBox[1]) * scale; } // creating transform for the following operations: // translate(-centerX, -centerY), rotate and flip vertically, // scale, and translate(offsetCanvasX, offsetCanvasY) this.transform = [ rotateA * scale, rotateB * scale, rotateC * scale, rotateD * scale, offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY, ]; this.width = width; this.height = height; } It computes the rotation, the scale, the offsets, and the viewport dimensions after flipping — the page's width and height. Let's zoom in on the PDF point and the CSS px units. In PDF, 72 points equal one inch; in CSS, 96 px equal one inch. That means rendering at 100% size on the web requires scaling PDF points up by 96/72. PDF's coordinate system has its origin at the bottom-left corner, with the X axis pointing right and the Y axis pointing up. CSS's coordinate system has its origin at the top-left corner, with the X axis pointing right and the Y axis pointing down. So for an unrotated PDF, we need to flip the PDF's Y axis to match the web canvas coordinate system. That's why you see case 0: rotateA = 1; rotateB = 0; rotateC = 0; rotateD = -1; the rotateD of -1 in there — it exists precisely to flip the Y axis. Flip demo: get-viewport-demo MDN canvas transform render For page.render, the call lands on the PDFPageProxy.render method. /** * Begins the process of rendering a page to the desired context. * * @param {RenderParameters} params - Page render parameters. * @returns {RenderTask} An object that contains a promise that is * resolved when the page finishes rendering. */ render({ canvasContext, canvas = canvasContext.canvas, viewport, intent = "display", annotationMode = AnnotationMode.ENABLE, transform = null, background = null, optionalContentConfigPromise = null, annotationCanvasMap = null, pageColors = null, printAnnotationStorage = null, isEditing = false, recordImages = false, recordOperations = false, operationsFilter = null, }) { this._stats?.time("Overall"); const intentArgs = this._transport.getRenderingIntent( intent, annotationMode, printAnnotationStorage, isEditing ); const { renderingIntent, cacheKey } = intentArgs; // If there was a pending destroy, cancel it so no cleanup happens during // this call to render. this.#pendingCleanup = false; optionalContentConfigPromise ||= this._transport.getOptionalContentConfig(renderingIntent); const intentState = this._intentStates.getOrInsertComputed( cacheKey, makeObj ); // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); intentState.streamReaderCancelTimeout = null; } const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT); // If there's no displayReadyCapability yet, then the operatorList // was never requested before. Make the request and create the promise. if (!intentState.displayReadyCapability) { intentState.displayReadyCapability = Promise.withResolvers(); intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null, }; this._stats?.time("Page Request"); this._pumpOperatorList(intentArgs); } const recordForDebugger = !!( this._pdfBug && globalThis.StepperManager?.enabled ); const shouldRecordOperations = !!canvas && !this.recordedBBoxes && (recordOperations || recordForDebugger); const shouldRecordImages = !!canvas && !this.imageCoordinates && recordImages; const complete = error => { intentState.renderTasks.delete(internalRenderTask); if (shouldRecordOperations) { const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take(); if (recordedBBoxes) { internalRenderTask.stepper?.setOperatorBBoxes( recordedBBoxes, internalRenderTask.gfx.dependencyTracker.takeDebugMetadata() ); if (recordOperations) { this.recordedBBoxes = recordedBBoxes; } } } if (shouldRecordImages && !error) { this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take(); } // Attempt to reduce memory usage during *printing*, by always running // cleanup immediately once rendering has finished. if (intentPrint) { this.#pendingCleanup = true; } this.#tryCleanup(); if (error) { internalRenderTask.capability.reject(error); this._abortOperatorList({ intentState, reason: error instanceof Error ? error : new Error(error), }); } else { internalRenderTask.capability.resolve(); } if (this._stats) { this._stats.timeEnd("Rendering"); this._stats.timeEnd("Overall"); if (globalThis.Stats?.enabled) { globalThis.Stats.add(this.pageNumber, this._stats); } } }; let dependencyTracker = null; let bboxTracker = null; if (shouldRecordOperations || shouldRecordImages) { bboxTracker = new CanvasBBoxTracker( canvas, intentState.operatorList.length ); } if (shouldRecordOperations) { dependencyTracker = new CanvasDependencyTracker( bboxTracker, recordForDebugger ); } const internalRenderTask = new InternalRenderTask({ callback: complete, // Only include the required properties, and *not* the entire object. params: { canvas, canvasContext, dependencyTracker: dependencyTracker ?? bboxTracker, imagesTracker: shouldRecordImages ? new CanvasImagesTracker(canvas) : null, viewport, transform, background, }, objs: this.objs, commonObjs: this.commonObjs, annotationCanvasMap, operatorList: intentState.operatorList, pageIndex: this._pageIndex, canvasFactory: this._transport.canvasFactory, filterFactory: this._transport.filterFactory, useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, operationsFilter, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); const renderTask = internalRenderTask.task; Promise.all([ intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) .then(([transparency, optionalContentConfig]) => { if (this.destroyed) { complete(); return; } this._stats?.time("Rendering"); if (!(optionalContentConfig.renderingIntent & renderingIntent)) { throw new Error( "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " + "and `PDFDocumentProxy.getOptionalContentConfig` methods." ); } internalRenderTask.initializeGraphics({ transparency, optionalContentConfig, }); internalRenderTask.operatorListChanged(); }) .catch(complete); return renderTask; } This is a very complex method — let's break it down into chunks. Step 1: call getRenderingIntent to compute the rendering intent. There are three intents: any, display, and print. Step 2: call this._intentStates.getOrInsertComputed to get or insert the computed intent state, keyed by the rendering intent from step 1. Step 3: fetch the page's operator list. // If there's no displayReadyCapability yet, then the operatorList // was never requested before. Make the request and create the promise. if (!intentState.displayReadyCapability) { intentState.displayReadyCapability = Promise.withResolvers(); intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null, }; this._stats?.time('Page Request'); this._pumpOperatorList(intentArgs); } There is a cache layer here: it checks whether intentState.displayReadyCapability exists. If it doesn't, a promise is created and the page's operator list is requested. > Q: What is intentState.displayReadyCapability? And why must we wait for this promise to resolve before we can actually render anything to the canvas? > > A: It is the { promise, resolve, reject } returned by Promise.withResolvers(). Its promise resolves with the boolean transparency (whether the page contains blend modes, which decides whether rendering needs an alpha canvas). > > We must wait for it to resolve for two core reasons. First, transparency is a required parameter of initializeGraphics — CanvasGraphics needs to know whether to enable the alpha channel to correctly handle blend modes, and this value can only be determined after the worker has parsed the page's content stream and resources. Second, it is essentially the "page ready" startup signal from the worker: the worker sends the StartRenderPage message before running getOperatorList, and the Display layer resolves it in _startRenderPage. Until this point the operatorList is still empty; chunks are only streamed in afterwards via _renderPageChunk, so the render pipeline must wait for this signal before it can start consuming them. > > Note that it means "page resources are ready, ready to start receiving operator chunks", not "the operator list has been fully delivered" — whether everything has arrived is marked by intentState.operatorList.lastChunk. > > Additionally, its existence doubles as a cache marker: as long as it already exists, the operator list for this rendering intent (cacheKey) has been requested before, so _pumpOperatorList doesn't need to be called again. Step 4: instantiate the InternalRenderTask class and add it to the render task queue. Step 5: wait for intentState.displayReadyCapability to resolve. Then initialize the graphics context with initializeGraphics, and notify that the operator list has changed with operatorListChanged — kicking off the actual rendering logic. With the overall flow in place, let's analyze the execution logic of each step in detail. Step 1: getRenderingIntent, computing the rendering intent Entering PDFPageProxy.render → WorkerTransport.getRenderingIntent // Source: src/display/api.js getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, printAnnotationStorage = null, isEditing = false, isOpList = false ) { let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value. let annotationStorageSerializable = SerializableEmpty; switch (intent) { case "any": renderingIntent = RenderingIntentFlag.ANY; break; case "display": break; case "print": renderingIntent = RenderingIntentFlag.PRINT; break; default: warn(`getRenderingIntent - invalid intent: ${intent}`); } const annotationStorage = renderingIntent & RenderingIntentFlag.PRINT && printAnnotationStorage instanceof PrintAnnotationStorage ? printAnnotationStorage : this.annotationStorage; switch (annotationMode) { case AnnotationMode.DISABLE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE; break; case AnnotationMode.ENABLE: break; case AnnotationMode.ENABLE_FORMS: renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS; break; case AnnotationMode.ENABLE_STORAGE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE; annotationStorageSerializable = annotationStorage.serializable; break; default: warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`); } if (isEditing) { renderingIntent += RenderingIntentFlag.IS_EDITING; } if (isOpList) { renderingIntent += RenderingIntentFlag.OPLIST; } const { ids: modifiedIds, hash: modifiedIdsHash } = annotationStorage.modifiedIds; const cacheKeyBuf = [ renderingIntent, annotationStorageSerializable.hash, modifiedIdsHash, ]; return { renderingIntent, cacheKey: cacheKeyBuf.join("_"), annotationStorageSerializable, modifiedIds, }; } There are three modes: any, display, and print. - any is for getOperatorList / getTextContent and other "any" scenarios. - display renders the page. - print prints the page. Plus the annotationMode option, which controls whether annotations are rendered. And the isEditing editing mode. Step 2: this._intentStates.getOrInsertComputed this._intentStates is a Map that caches data keyed by the rendering intent. makeObj is a utility function: const makeObj = () => Object.create(null); getOrInsertComputed is a fairly new function in the JS spec: if the key exists, it returns the corresponding value; if it doesn't, it creates a value via the second-argument callback and returns it. See MDN. Step 3: fetching the page's operator list Here we check whether intentState.displayReadyCapability exists. If it does, the data was requested before and there's no need to call _pumpOperatorList again. Otherwise we call _pumpOperatorList to request the page's operator list. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList // Source: src/display/api.js _pumpOperatorList({ renderingIntent, cacheKey, annotationStorageSerializable, modifiedIds, }) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { assert( Number.isInteger(renderingIntent) && renderingIntent > 0, '_pumpOperatorList: Expected valid "renderingIntent" argument.' ); } const { map, transfer } = annotationStorageSerializable; const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", { pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1, pageIndex: this._pageIndex, intent: renderingIntent, cacheKey, annotationStorage: map, modifiedIds, }, /* queueingStrategy = */ undefined, transfer ); const reader = readableStream.getReader(); const intentState = this._intentStates.get(cacheKey); intentState.streamReader = reader; const pump = () => { reader.read().then( ({ value, done }) => { if (done) { intentState.streamReader = null; return; } if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } this._renderPageChunk(value, intentState); pump(); }, reason => { intentState.streamReader = null; if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } if (intentState.operatorList) { // Mark operator list as complete. intentState.operatorList.lastChunk = true; for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } this.#tryCleanup(); } if (intentState.displayReadyCapability) { intentState.displayReadyCapability.reject(reason); } else if (intentState.opListReadCapability) { intentState.opListReadCapability.reject(reason); } else { throw reason; } } ); }; pump(); } Here, through the transport, we send an important streaming event to the worker: GetOperatorList. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList handler.on( 'GetOperatorList', function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) { pdfManager.getPage(pageId).then(function (page) { const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`); startWorkerTask(task); // NOTE: Keep this condition in sync with the `info` helper function. const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0; // Pre compile the pdf page and fetch the fonts/images. page .getOperatorList({ handler, sink, task, intent, cacheKey, annotationStorage, modifiedIds, pageIndex, }) .then( opListInfo => { if (start) { info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`); } sink.close(); }, reason => { if (task.terminated) { return; // ignoring errors from the terminated thread } sink.error(reason); }, ) .finally(() => { finishWorkerTask(task); }); }); }, ); It calls pdfManager.getPage to get the Page instance. This call points to NetworkPDFManager.getPage, which in turn points to BasePdfManager.getPage. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage (parent BasePdfManager.getPage) getPage(pageIndex) { return this.pdfDocument.getPage(pageIndex); } which calls this.pdfDocument.getPage. Let's first see where this.pdfDocument comes from. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor this.pdfDocument = new PDFDocument(this, this.streamManager.getStream()); instantiating PDFDocument. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.constructor → PDFDocument.constructor constructor(pdfManager, stream) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { assert( stream instanceof BaseStream, 'PDFDocument: Invalid "stream" argument.' ); } if (stream.length <= 0) { throw new InvalidPDFException( "The PDF file is empty, i.e. its size is zero bytes." ); } this.pdfManager = pdfManager; this.stream = stream; this.xref = new XRef(stream, pdfManager); const idCounters = { font: 0, }; this._globalIdFactory = class { static getDocId() { return `g_${pdfManager.docId}`; } static createFontId() { return `f${++idCounters.font}`; } static createObjId() { unreachable("Abstract method `createObjId` called."); } static getPageObjId() { unreachable("Abstract method `getPageObjId` called."); } }; } Here, an XRef instance is created to parse the PDF file's XRef table. > Q: What is the XRef table? > > A: The XRef (Cross-Reference) table is the PDF file's "object directory". Every entity inside a PDF — pages, fonts, images, content streams, etc. — is a numbered object (e.g. 3 0 obj). The XRef table records the byte offset of each object within the file. > > For example: > > > ``` > > xref > > 0 5 > > 0000000000 65535 f > > 0000000010 00000 n > > 0000000053 00000 n > > 0000000102 00000 n > > 0000000173 00000 n > > ``` > > The first line, 0 5, means the next 5 entries list the objects starting from object 0. Each entry is exactly 20 bytes: the first 10 digits are the byte offset, the middle 5 digits are the generation number, and the last one is n (in-use) or f (free/deleted). > > When the parser gets an object reference (say 2 0 R, meaning "reference object 2"), it looks up the XRef table and computes the object's location in the file directly, instead of scanning the file from the start like XML/JSON. This is also the key to PDF's incremental updates (append-only): after each append-only modification, you only need to append a new XRef section and point the startxref pointer at the end of the file to the latest one. > > In pdf.js, the XRef class parses this table, maintains the {object number → offset} mapping, and resolves the actual content of objects (when it hits an indirect reference like 2 0 R, it recursively parses until it gets the real value). > This is one of the optimization points in StreamPDF: the XRef table is pre-parsed on the server side. We won't expand on it here — a dedicated article will introduce it in detail later. We won't go deeper for now — back to rendering. A later article will explore PDF.js's lexical and syntactic parsing of PDF documents. Now that PDFDocument is initialized, let's return to the getPage method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → NetworkPDFManager.getPage → PDFDocument.getPage getPage(pageIndex) { const cachedPromise = this.#pagePromises.get(pageIndex); if (cachedPromise) { return cachedPromise; } const { catalog, linearization, xfaFactory } = this; let promise; if (xfaFactory) { promise = Promise.resolve([Dict.empty, null]); } else if (linearization?.pageFirst === pageIndex) { promise = this.#getLinearizationPage(pageIndex); } else { promise = catalog.getPageDict(pageIndex); } promise = promise.then( ([pageDict, ref]) => new Page({ pdfManager: this.pdfManager, xref: this.xref, pageIndex, pageDict, ref, globalIdFactory: this._globalIdFactory, fontCache: catalog.fontCache, builtInCMapCache: catalog.builtInCMapCache, standardFontDataCache: catalog.standardFontDataCache, globalColorSpaceCache: catalog.globalColorSpaceCache, globalImageCache: catalog.globalImageCache, systemFontCache: catalog.systemFontCache, nonBlendModesSet: catalog.nonBlendModesSet, xfaFactory, }) ); this.#pagePromises.set(pageIndex, promise); return promise; } It reads the catalog, checks linearization, and creates the page. > Q: What is the catalog? > A: The Catalog is the PDF file's "root dictionary", sitting at the top of the object graph. The /Root entry in the trailer at the end of the file points to it — it's the entry point of the entire PDF. It holds references to all other top-level structures: most importantly /Pages (the root of the page tree), plus /Names (named objects), /Outlines (bookmarks), /AcroForm (forms), /Metadata (XMP metadata), and more. > > In pdf.js, the Catalog class wraps this root dictionary and provides caches shared across pages — the font cache (fontCache), the global color space cache (globalColorSpaceCache), the image cache (globalImageCache), the built-in CMap cache (builtInCMapCache), etc. These caches are reused across pages to avoid re-parsing the same font definitions or color spaces — which is why the Page constructor above takes that long list of catalog.fontCache, catalog.globalColorSpaceCache, and so on. > Q: What is linearization? > A: Linearization is a "fast web view" optimization provided by the PDF specification. > > A normal PDF is parsed like this: jump to the end of the file and read startxref → read the trailer → find the main XRef section from the trailer → only then can objects be located. That means you need the tail of the file before you can start working. > > A linearized PDF additionally places a Linearized dictionary at the beginning of the file (at object 1), which records: > > - N — total page count > - P — the index of the first page (default 0) > - O — the object number of the first page > - T — the offset of the main XRef section in the file > - H — the hint arrays, telling the reader which pages live where in the file > > With just a few hundred bytes from the start of the file, the parser can use this dictionary to skip the main XRef and jump straight to the first page's data to start rendering, without waiting for the whole file to download. The effect is dramatic on large files (tens-of-MB scanned documents). > > In pdf.js, the PDFDocument.linearization getter tries to parse the beginning of the file; if it finds this Linearized dictionary, it returns an object with fields like numPages, pageFirst, and objectNumberFirst. The getPage logic above is: if the PDF is linearized and the requested page is the first page, take the #getLinearizationPage fast path and use linearization.objectNumberFirst to get the first page's dictionary directly; otherwise fall back to the standard catalog lookup. > But linearization depends heavily on the tool that produced the file — most PDFs have never been linearized. We've now touched some real PDF format specifications. This returns the Page instance — let's stay restrained and not dig deeper. Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList Continuing: // Pre compile the pdf page and fetch the fonts/images. page .getOperatorList({ handler, sink, task, intent, cacheKey, annotationStorage, modifiedIds, pageIndex, }) .then( opListInfo => { if (start) { info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`); } sink.close(); }, reason => { if (task.terminated) { return; // ignoring errors from the terminated thread } sink.error(reason); }, ) .finally(() => { finishWorkerTask(task); }); It calls the Page.getOperatorList method to get the page's rendering operators. Let's take a quick look at the implementation of Page.getOperatorList. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList // Source: src/core/document.js async getOperatorList({ handler, sink, task, intent, cacheKey, pageIndex = this.pageIndex, annotationStorage = null, modifiedIds = null, }) { const contentStreamPromise = this.getContentStream(); const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST); // ... const pageListPromise = Promise.all([ contentStreamPromise, resourcesPromise, ]).then(async ([contentStream]) => { const resources = await this.#getMergedResources( contentStream.dict, RESOURCES_KEYS_OPERATOR_LIST ); const opList = new OperatorList(intent, sink); handler.send("StartRenderPage", { transparency: partialEvaluator.hasBlendModes( resources, this.nonBlendModesSet ), pageIndex, cacheKey, }); await partialEvaluator.getOperatorList({ stream: contentStream, task, resources, operatorList: opList, }); return opList; }); // ... A lot of logic is omitted — but did you spot the key handler.send("StartRenderPage")? This event corresponds to the intentState.displayReadyCapability we saw earlier. The main thread's StartRenderPage event listener: Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → Worker: on GetOperatorList → Page.getOperatorList → Main: on StartRenderPage messageHandler.on('StartRenderPage', data => { if (this.destroyed) { return; // Ignore any pending requests if the worker was terminated. } const page = this.#pageCache.get(data.pageIndex); page._startRenderPage(data.transparency, data.cacheKey); }); which calls page._startRenderPage. /** * @private */ _startRenderPage(transparency, cacheKey) { const intentState = this._intentStates.get(cacheKey); if (!intentState) { return; // Rendering was cancelled. } this._stats?.timeEnd("Page Request"); // TODO Refactor RenderPageRequest to separate rendering // and operator list logic intentState.displayReadyCapability?.resolve(transparency); } This method resolves the intentState.displayReadyCapability promise. OK, let's return to the _pumpOperatorList method. Back to PDFPageProxy.render → PDFPageProxy._pumpOperatorList Continuing with the streaming data: through the sink, data keeps flowing back to the reader on the main thread. intentState.streamReader = reader; const pump = () => { reader.read().then( ({ value, done }) => { if (done) { intentState.streamReader = null; return; } if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } this._renderPageChunk(value, intentState); pump(); }, reason => { intentState.streamReader = null; if (this._transport.destroyed) { return; // Ignore any pending requests if the worker was terminated. } if (intentState.operatorList) { // Mark operator list as complete. intentState.operatorList.lastChunk = true; for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } this.#tryCleanup(); } if (intentState.displayReadyCapability) { intentState.displayReadyCapability.reject(reason); } else if (intentState.opListReadCapability) { intentState.opListReadCapability.reject(reason); } else { throw reason; } }, ); }; pump(); Every time the worker pushes a chunk into the stream, it's consumed here, which calls this._renderPageChunk(value, intentState); Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk _renderPageChunk(operatorListChunk, intentState) { // Add the new chunk to the current operator list. for (let i = 0, ii = operatorListChunk.length; i < ii; i++) { intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]); intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]); } intentState.operatorList.lastChunk = operatorListChunk.lastChunk; intentState.operatorList.separateAnnots = operatorListChunk.separateAnnots; // Notify all the rendering tasks there are more operators to be consumed. for (const internalRenderTask of intentState.renderTasks) { internalRenderTask.operatorListChanged(); } if (operatorListChunk.lastChunk) { this.#tryCleanup(); } } After pre-processing the data, it calls internalRenderTask.operatorListChanged() to notify the render tasks that new operators have arrived. Step 4: the render tasks have new operators Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged operatorListChanged() { if (!this.graphicsReady) { this.graphicsReadyCallback ||= this._continueBound; return; } this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length ); this.stepper?.updateOperatorList(this.operatorList); if (this.running) { return; } this._continue(); } which goes to _continue. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue _continue() { this.running = true; if (this.cancelled) { return; } if (this.task.onContinue) { this.task.onContinue(this._scheduleNextBound); } else { this._scheduleNext(); } } which goes to _scheduleNext. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext _scheduleNext() { if (this._useRequestAnimationFrame) { this.#rAF = window.requestAnimationFrame(() => { this.#rAF = null; this._nextBound().catch(this._cancelBound); }); } else { Promise.resolve().then(this._nextBound).catch(this._cancelBound); } } Via window.requestAnimationFrame, it schedules the _nextBound method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound In the constructor, this._nextBound = this._next.bind(this); which goes to the _next method. Entering PDFPageProxy.render → PDFPageProxy._pumpOperatorList → PDFPageProxy._renderPageChunk → internalRenderTask.operatorListChanged → internalRenderTask._continue → internalRenderTask._scheduleNext → internalRenderTask._nextBound → internalRenderTask._next async _next() { if (this.cancelled) { return; } this.operatorListIdx = this.gfx.executeOperatorList( this.operatorList, this.operatorListIdx, this._continueBound, this.stepper, this._operationsFilter ); if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; if (this.operatorList.lastChunk) { this.gfx.endDrawing(); InternalRenderTask.#canvasInUse.delete(this._canvas); this.callback(); } } } It calls this.gfx.executeOperatorList — the operator list is now actually executed. Step 5: initializeGraphics & operatorListChanged Once intentState.displayReadyCapability.promise is ready, the graphics context is initialized. Entering initializeGraphics // Source: src/display/api.js (InternalRenderTask) initializeGraphics({ transparency = false, optionalContentConfig }) { if (this.cancelled) { return; } if (this._canvas) { if (InternalRenderTask.#canvasInUse.has(this._canvas)) { throw new Error( "Cannot use the same canvas during multiple render() operations. " + "Use different canvas or ensure previous operations were " + "cancelled or completed." ); } InternalRenderTask.#canvasInUse.add(this._canvas); } if (this._pdfBug && globalThis.StepperManager?.enabled) { this.stepper = globalThis.StepperManager.create(this._pageIndex); this.stepper.init(this.operatorList); this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); } const { viewport, transform, background, dependencyTracker, imagesTracker, } = this.params; // When printing in Firefox, we get a specific context in mozPrintCallback // which cannot be created from the canvas itself. const canvasContext = this._canvasContext || this._canvas.getContext("2d", { alpha: false, willReadFrequently: !this._enableHWA, }); this.gfx = new CanvasGraphics( canvasContext, this.commonObjs, this.objs, this.canvasFactory, this.filterFactory, { optionalContentConfig }, this.annotationCanvasMap, this.pageColors, dependencyTracker, imagesTracker ); this.gfx.beginDrawing({ transform, viewport, transparency, background, }); this.operatorListIdx = 0; this.graphicsReady = true; this.graphicsReadyCallback?.(); } By setting this.graphicsReady = true, the internalRenderTask.operatorListChanged method is allowed to proceed. It then immediately triggers operatorListChanged once more, starting execution of the operator list. operatorListChanged() { if (!this.graphicsReady) { this.graphicsReadyCallback ||= this._continueBound; return; } this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length ); this.stepper?.updateOperatorList(this.operatorList); if (this.running) { return; } this._continue(); } Summary In this article, we explored the Display layer's getViewport and render methods. The core of getViewport is building an affine transform matrix: converting PDF's coordinate system (origin at the bottom-left, unit = point) into canvas's coordinate system (origin at the top-left, unit = px), while handling rotation, scale, and dontFlip. render is a rendering pipeline spanning the main thread and the worker: !Image description Text version: Main thread (PDFPageProxy.render) |-- getRenderingIntent compute the intent, build the cache key |-- _intentStates.getOrInsert... get or create the intent state by cache key |-- _pumpOperatorList send the streaming GetOperatorList request to the worker | `-- reader.read() loop consumes chunks, hands each to _renderPageChunk |-- new InternalRenderTask create the render task, add it to renderTasks `-- await displayReadyCapability `-- initializeGraphics create CanvasGraphics, beginDrawing `-- operatorListChanged -> _next -> gfx.executeOperatorList Worker thread (on:GetOperatorList) |-- pdfManager.getPage locate the Page `-- Page.getOperatorList |-- handler.send("StartRenderPage") resolves displayReadyCapability `-- partialEvaluator.getOperatorList parses operators, streams them back via sink Understanding this pipeline means understanding the skeleton of PDF.js rendering: the main thread only schedules and consumes operators; the real parsing work all happens in the worker. At this point, after these three articles, the rendering architecture of PDF.js is clear. Starting from the next article, we'll dive into some of the details to round out the depth. Happy reading! Next up: MessageHandler — a deep dive into the core mechanism of message communication.
Cool ad, but imma just leave this here github.com/mozilla/bedrock/commit/d459addab846d81… t.co
mozilla accounts button labels in chinese, japanese and korean were wrapping one character per line fixed it, merged fxa#20724