PDF.js Deep Dive from Demo 4: getViewport
Introduction
In the previous article, we obtained the page variable via await pdf.getPage(1). It is a PDFPageProxy instance whose _pageInfo holds the page metadata: rotate: 0, userUnit: 1, view: [0, 0, 595, 842].
Back to the demo from the first article, lines 10-11:
const viewport = page.getViewport({ scale: 0.5 });
An object parameter is passed in:
| Parameter | Value | Meaning |
| --------- | ----- | --------------- |
| scale | 0.5 | The zoom factor |
Unlike getPage, getViewport is a synchronous call: it does not touch the worker and performs no IO — it is purely a math computation. Starting from this call, this article walks through the complete execution of getViewport.
The getViewport call
Based on its definition in api.js, the execution flow is as follows.
Lines 1423-1439: Destructure the parameters
view source
getViewport({
scale, // 0.5, the only parameter passed in
rotation = this.rotate, // this._pageInfo.rotate = 0
offsetX = 0,
offsetY = 0,
dontFlip = false,
} = {}) {
return new PageViewport({
viewBox: this.view, // [0, 0, 595, 842]
userUnit: this.userUnit, // 1
scale,
rotation,
offsetX,
offsetY,
dontFlip,
});
}
Note that the default value of rotation comes from the page itself: the PDF page's own /Rotate attribute is applied automatically, so the caller does not need to care about it. this.view and this.userUnit come from the pageInfo returned by the GetPage message in the previous article.
Once the parameters are collected, they are all handed to the PageViewport constructor (display/page_viewport.js): view source. All subsequent computation takes place there.
> Q: What are offsetX / offsetY for?
>
> A: They translate the rendered result on the canvas — for example, to tile multiple pages onto one large canvas, or to leave padding around a page. The demo does not pass them, so they default to 0.
Line 70: scale absorbs userUnit (page_viewport.js)
scale *= userUnit; // 0.5 * 1 = 0.5
> Q: What is userUnit?
>
> A: A unit conversion factor introduced in PDF 1.6: 1 unit in PDF user space = userUnit / 72 inches, with a default value of 1. By folding it into scale, subsequent computations no longer need to distinguish between "user space units" and "actual size".
Lines 74-75: Compute the page center point (page_viewport.js)
const centerX = (viewBox[2] + viewBox[0]) / 2; // (595 + 0) / 2 = 297.5
const centerY = (viewBox[3] + viewBox[1]) / 2; // (842 + 0) / 2 = 421
Rotation happens around the page center, so the center point is computed first for later use.
Lines 78-116: Normalize the rotation and compute the rotation vector (page_viewport.js)
rotation %= 360; // 0
if (rotation < 0) {
rotation += 360;
}
switch (rotation) {
case 0:
rotateA = 1;
rotateB = 0;
rotateC = 0;
rotateD = -1;
break;
// case 90 / 180 / 270 omitted
default:
throw new Error('PageViewport: Invalid rotation, must be a multiple of 90 degrees.');
}
The rotation is normalized into the [0, 360) range, and only multiples of 90 are allowed; any other value throws an error directly — this is a restriction of the PDF specification.
The demo's rotation is 0, which hits case 0: rotateA = 1, rotateB = 0, rotateC = 0, rotateD = -1.
> Q: Why is rotateD -1 when rotation is 0?
> A: This is the y-axis flip. In the PDF coordinate system the y-axis points up (a mathematical coordinate system with the origin at the bottom-left), while in the canvas coordinate system the y-axis points down (a screen coordinate system with the origin at the top-left). Even without any rotation, a flip is needed to align the two, and rotateD = -1 is exactly that flip.
>
> Correspondingly, dontFlip = true negates rotateC and rotateD, canceling this flip — useful when the target coordinate system also has an upward y-axis.
Lines 120-130: Compute width, height, and canvas offsets (page_viewport.js)
if (rotateA === 0) {
// The branch for rotation 90/270: width and height are swapped
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; // 297.5 * 0.5 + 0 = 148.75
offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY; // 421 * 0.5 + 0 = 210.5
width = (viewBox[2] - viewBox[0]) * scale; // 595 * 0.5 = 297.5
height = (viewBox[3] - viewBox[1]) * scale; // 842 * 0.5 = 421
}
The rotateA === 0 branch corresponds to a 90/270 degree rotation: the page lies on its side, and the computations of width and height are swapped. The demo's rotation is 0, so it takes the else branch.
Lines 134-141: Assemble the transform matrix (page_viewport.js)
this.transform = [
rotateA * scale, // 0.5
rotateB * scale, // 0
rotateC * scale, // 0
rotateD * scale, // -0.5
offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, // 148.75 - 148.75 - 0 = 0
offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY, // 210.5 - 0 + 210.5 = 421
];
// transform = [0.5, 0, 0, -0.5, 0, 421]
The source code comment explains the composite transformation these 6 components correspond to:
> translate(-centerX, -centerY), rotate and flip vertically, scale, and translate(offsetCanvasX, offsetCanvasY)
That is: move the page center to the origin → rotate and flip vertically → scale → translate to the target position on the canvas. These are exactly the 6 arguments that canvas setTransform(a, b, c, d, e, f) needs, and they will be applied directly during rendering (covered in the next article).
Let's verify the correctness of the matrix: take the top-left corner of the page in the PDF coordinate system, (0, 842) (with the y-axis pointing up, "top" is the larger value), and apply the matrix:
- x' = 0.5 * 0 + 0 * 842 + 0 = 0
- y' = 0 * 0 + (-0.5) * 842 + 421 = 0
It maps exactly to the canvas coordinate (0, 0), i.e., the top-left corner of the canvas. ✓
The 6th component, 421, happens to equal height: after the y-axis flip, the whole page ends up above the canvas (negative y), so it needs to be translated down by one page height to land back inside the canvas.
Lines 143-144: Store width and height (page_viewport.js)
this.width = width; // 297.5
this.height = height; // 421
These two values are the source of canvas.width = Math.floor(viewport.width) in the first article's demo: Math.floor(297.5) is 297, so the actual canvas size is 297 x 421.
At this point, all of getViewport's computation is complete, and the returned PageViewport instance is assigned to the demo's viewport variable.
Summary
This article followed the demo through the complete computation of getViewport({ scale: 0.5 }):
1. Parameter destructuring: scale = 0.5, rotation defaults to the page's /Rotate value 0, and the rest are 0/false;
2. scale absorbs userUnit, remaining 0.5;
3. Compute the page center point (297.5, 421);
4. After rotation normalization, take the rotation vector (1, 0, 0, -1), where rotateD = -1 performs the y-axis flip;
5. Compute the width and height 297.5 x 421 and the canvas offsets (148.75, 210.5);
6. Assemble transform = [0.5, 0, 0, -0.5, 0, 421], completing the "PDF coordinates → canvas coordinates" mapping.
viewport also carries methods like convertToViewportPoint / convertToPdfPoint (bidirectional coordinate conversion, used for text selection and annotation positioning: view source) and clone (deriving a new viewport: view source), which the demo does not use, so we will not expand on them here.
With the viewport in hand, the demo packages it together with canvasContext and passes them to page.render(renderContext) — the transform matrix will be applied to the canvas context there. That will be covered in the next article.
---
About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: PDF.js Deep Dive from Demo 4: getViewport
Native edges: principles for native apps with a shared core
For a long time, building a client for every platform you cared about was expensive. You wrote every screen twice, fixed every bug twice, and either staffed a team per platform or decided which platforms your users would have to live without. So the industry did what it usually does and found ways to make that cost bearable.
Electron, React Native, and more recently Tauri came out of that. They gave teams a dynamic runtime, hot reloading, and the whole ecosystem of web libraries, so one team could ship to several platforms at once. And it worked: Slack, Linear, Discord, Notion, and Visual Studio Code run on a web stack, and many of us have them open all day. Raycast went the same way to get to Windows. Raycast 2.0 shares a React and TypeScript frontend between macOS and Windows and loads it in each platform's web view, and the team was open about why: hiring, the UI components the web already has, consistent extensions, and iteration speed. Those are good reasons.
Coding agents changed part of that math. Writing a SwiftUI view and then its Jetpack Compose twin used to be the expensive part, and today an agent writes most of it while you review. What's still expensive is compiling it, and if you've ever sat waiting on Xcode or Gradle, you know how much of a day goes into that. At Tuist we've spent years chipping away at that problem, with caching, selective testing, runners to run the builds on, and now Once, with remote execution coming next.
Once native builds are fast too, iteration speed becomes a much weaker argument for a web runtime. The other reasons, like the ecosystem or hiring, may still apply to you, and I don't expect anyone to rewrite a large codebase because the economics moved. But it got us thinking about how we'd build a multi-platform app if we started one today, so we wrote down the principles we'd follow and built a small sample app to see whether they hold up.
None of this is entirely new to us. Almost ten years ago we were exploring modular architectures for iOS apps and came up with microfeatures, which many teams adopted and which we later renamed The Modular Architecture. Tuist itself grew out of that work. This post takes the same ideas about boundaries and build graphs and stretches them across platforms.
We call this set of principles native edges. An app has two layers, and both ship inside it. The headless core holds the business logic and has no UI at all, and the native edges are what users see and touch, one per platform, each connecting the core to its platform. We stayed away from frontend and backend on purpose, because most people hear those words and think UI and server.
The principles are:
1. Keep business logic in a headless core.
2. Choose a core language that runs everywhere you ship.
3. Connect the edges to the core through a small, typed API.
4. Make every screen reachable by an address.
5. Model the whole app in one build graph.
You don't have to take all of them. Some projects will have good reasons to keep a piece of logic in Swift, to share less, or to use different tools than we do, and that's fine.
The sample app
To see whether the principles hold up end to end, we built Native Edges, a client-side agent. You sign in with your ChatGPT account and talk to an agent whose loop, tools, and conversation history live on your device, while the model runs in the cloud. There's an iOS app in SwiftUI, an Android app in Kotlin and Jetpack Compose, and a command-line tool in Rust, and all three sit on one headless core, also written in Rust. The core handles the device-code sign-in and token refresh, runs the agent loop and its requests to the model, provides the tools the agent uses to read earlier conversations, and stores the history in SQLite through Diesel.
Here's the iOS app, built and launched on a simulator with once run. It signs in with a device code, lists the stored conversations, and asks the agent something that makes it go back to earlier ones. Then the CLI lists the app's routes and builds the URL for one conversation, and the simulator opens it:
▶️ Watch the demo on the original post
We'll keep coming back to the sample throughout the post. Here are its three edges, plus two more that the same core could support but the sample doesn't include:
- Native edges: iOS (SwiftUI + Swift), Android (Jetpack Compose + Kotlin), CLI (terminal), plus Linux and Windows (GPUI) and Web (HTML + JavaScript), which aren't in the sample.
- Headless core in Rust: route catalog and deep links, sign-in and sessions, agent loop and model API, conversation history.
- Bridges: iOS calls the core through UniFFI Swift bindings, Android through UniFFI Kotlin bindings + JNA, the CLI and desktop through plain Rust calls, and the web through WebAssembly.
(See the diagram on the original post)
1. Keep business logic in a headless core
The rule we follow is simple to say: put in the edge whatever only the platform can do, and put everything else in the core.
The edge is the layer users touch. For apps, that means a UI written in a framework that compiles for the platform and follows its conventions, which today is SwiftUI on Apple platforms and Jetpack Compose on Android. Anything you can only reach through the platform's frameworks belongs there too: the Keychain or the Android Keystore, presenting a browser for an OAuth flow, registering for push notifications, Live Activities, share extensions, or on-device speech recognition. These are the details users notice when an app gets them wrong, so we'd rather each platform own them.
A command-line tool is an edge too, one whose platform happens to be the terminal, and so is a web app, with the core compiled to WebAssembly and the browser as its platform.
The core is the logic that's the same everywhere: which fields of a record can be edited, how a list is paginated, how an OAuth token is refreshed, how history is reconciled after a reconnect, or making sure a late response from a cancelled request doesn't overwrite newer results. Because the core has no UI and doesn't depend on any platform framework, you can cover most of the app's behavior with unit tests that run in seconds, without a simulator, an emulator, or UI automation, and those tests cover every platform at once. Each edge still needs some tests for rendering and platform integration, but far fewer than before.
Ghostty, the terminal emulator by Mitchell Hashimoto, is one of my favorite examples of this split. Its core, libghostty, is written in Zig, exposes a C API, and handles terminal emulation, fonts, and rendering. On top of it sit a macOS app written in Swift with AppKit and SwiftUI and a Linux app that uses GTK. Ghostty's core does more than ours, since it includes rendering, but the idea is the same: one shared engine, and on each platform an app that feels like it belongs there.
2. Choose a core language that runs everywhere you ship
Look for a language with good toolchains and a good standard library on every platform you support, so you don't find out halfway through a feature that the API you need doesn't exist on one of them. Rust does well here. It compiles to iOS, Android, macOS, Linux, Windows, and WebAssembly, and its crates cover almost everything a client needs. The browser is a bit of a special case: the core runs there as WebAssembly, but on wasm32-unknown-unknown parts of the standard library, like the file system and threads, don't work, so a web edge needs crates that go through the browser's APIs instead, like fetch for networking or IndexedDB for storage. Zig is another good option, with explicit allocators and cross-compilation built into its toolchain, and Go can work too, although it brings a garbage collector with it.
Memory is one of the main reasons we lean towards Rust and Zig. Neither has a garbage collector, so you decide when memory is allocated and freed, which matters in places with strict limits, like watches, embedded devices, or the app extensions and widgets the system kills when they use too much. It also means your app doesn't run a second garbage collector next to Swift's reference counting or the Android runtime's own collector. You still have to respect ownership rules at the boundary and measure memory on constrained targets, but you're the one in control.
A question we get a lot is why we don't use Kotlin Multiplatform on Apple platforms, or Swift on Android. Both work, and teams ship real apps with them. Our concern is that a language built for one platform brings its runtime and memory model along to the others, and its standard library and tooling tend to be most complete on the platform where it started. Rust and Zig don't have a home platform in that sense, and for a shared core that's what we want.
I also think the choice of language will matter less and less. As models write more of the code, the questions that remain are whether the language fits the job and whether agents are good at writing and reasoning about it. Rust does well on both: its compiler is strict and its error messages are detailed, so when an agent gets something wrong, it finds out right away and knows why.
If we picked a stack today, it would look like this:
| Platform | Native edge: UI | Native edge: platform services | Headless core |
| --- | --- | --- | --- |
| iOS, iPadOS, macOS | SwiftUI | Swift | Rust |
| Android | Jetpack Compose | Kotlin | Rust |
| Linux, Windows | GPUI | Rust | Rust |
| Command line | Terminal | Rust | Rust |
| Web | HTML and JavaScript | Browser APIs | Rust compiled to WebAssembly |
For desktop outside macOS, we'd go with GPUI, the UI framework the Zed team built for their editor. GPUI draws its own UI on the GPU instead of using the operating system's toolkit, compiles to a native binary, and integrates with each platform, and there are component libraries for it, like GPUI Component, for building desktop apps in the spirit of Zed. Since GPUI is Rust too, on Linux and Windows the edge and the core share a language and need no bridge at all.
The command line is in the table because the terminal is a platform too, and developers can tell right away when a CLI starts fast, follows the terminal's conventions, and plays well with pipes and scripts. With a headless core, a CLI is a thin edge over logic you already have, so building one in Rust is cheap. Agents use a lot of software through CLIs, and I expect this edge to matter more and more as they do.
3. Connect the edges to the core through a small, typed API
The sample uses UniFFI, a tool from Mozilla that generates Swift and Kotlin bindings from annotations in the Rust code. The core marks what it exports, and the edges call typed functions and get typed values back, with no C headers or JSON in between:
#[derive(uniffi::Record)]
pub struct Destination {
pub route: String,
pub params: Params,
pub url: String,
}
#[derive(Debug, uniffi::Error)]
#[uniffi(flat_error)]
pub enum CoreError {
/// No session, or it expired: show the sign-in screen.
Unauthorized { message: String },
Failed { kind: String, message: String },
}
/// Resolves a deep link into its destination and parents.
#[uniffi::export]
pub fn open(url: String) -> Result<Vec<Destination>, CoreError> {
// ...
}
/// Sends a message and waits for the agent's answer.
#[uniffi::export]
pub fn send(
id: Option<String>,
text: String,
) -> Result<Conversation, CoreError> {
// ...
}
In Swift, these are ordinary functions, and the Rust error becomes a Swift error you can catch. send blocks while the agent works, so the sample runs it off the main actor with a small background helper of its own:
let stack = try EdgesCore.open(url: link)
let reply = try await background {
try EdgesCore.send(id: id, text: text)
}
Kotlin gets the same API, with CoreException.Unauthorized in place of the Swift error case, and the CLI calls the Rust functions directly. Adding a capability means exporting a function in Rust, and the apps get it with compiler-checked types. The core returns plain values, like a conversation and the URLs it links to, and leaves it to each edge to decide how to present them: a pushed view on iOS, a back-stack entry on Android, or printed output in the terminal.
Sometimes the core needs something only the platform provides, so the API has to work in the other direction too. A token refresh rotates the session, and the new session has to land in the Keychain on iOS and in Keystore-backed encrypted storage on Android. For that we use the delegate pattern: the core declares what it needs as an interface, and each edge provides an implementation. In UniFFI, that's a Rust trait marked with_foreign, which Swift and Kotlin can implement:
/// Provided by the edge: the Keychain on iOS, the Keystore
/// on Android, and a private file in the terminal.
#[uniffi::export(with_foreign)]
pub trait SecureStorage: Send + Sync {
fn read(&self, key: String) -> Option<String>;
fn write(&self, key: String, value: String);
fn delete(&self, key: String);
}
On iOS, the implementation is a small class over the Keychain, and the app hands it to the core at launch, along with a directory where the core keeps its SQLite database:
final class Keychain: SecureStorage {
func read(key: String) -> String? { /* SecItemCopyMatching */ }
func write(key: String, value: String) { /* SecItemAdd */ }
func delete(key: String) { /* SecItemDelete */ }
}
let directory = URL.applicationSupportDirectory.path
_ = try launch(dataDir: directory, storage: Keychain())
Android passes a Keystore class that encrypts the value with an Android Keystore key, and the CLI passes one that writes files only you can read. The split of responsibilities is what we like here: the core decides what to store and when, such as a renewed session after a refresh, and the edge decides how the operating system protects it. The core's tests pass an in-memory implementation, so they don't need a Keychain or a simulator. We kept the trait simple for the sample, but in a production app write and delete should report failures so the core can handle them.
4. Make every screen reachable by an address
Core tests cover the shared rules, but sometimes you want to test how an edge and the core work together, like whether a screen renders the data the core returns or whether a tap triggers the right call. Whether you or an agent runs that test, you want to get to the screen in one step, not ten.
A CLI makes this easy, because every operation is a named command with options and arguments you can find in the code and run. Web apps are close, since every page has a URL that an agent can read from the route definitions and open. Mobile and desktop apps usually have neither, so getting to a screen means launching the app and tapping through every step in between, which is slow and breaks every time the UI changes. We think native apps deserve a declarative navigation system that does two things:
1. Makes routes discoverable. The app declares destinations as data, like an enum or a list of types, so someone reading the code, or an agent, can find every place the app can go.
2. Lets you open any route directly. A single entry point takes a route, usually as a URL, and builds the navigation state for it, so you can jump straight to a specific screen or record.
Navigation itself belongs to the platform. Each one has strong conventions for stacks, tabs, sheets, transitions, and Back behavior, and users expect them. What the core can own is the route catalog and URL parsing, so every edge agrees on which destinations exist and what a link means. In the sample, each route has an identifier, a URL pattern, and a parent, so a deep link opens on top of the screens you'd normally pass through, and Back works as if you had tapped your way there:
// Simplified from core/src/routes.rs.
pub static ROUTES: &[Route] = &[
Route {
id: "conversations",
path: "conversations",
parent: None,
},
Route {
id: "new",
path: "conversations/new",
parent: Some("conversations"),
},
Route {
id: "conversation",
path: "conversations/{id}",
parent: Some("conversations"),
},
];
Every edge asks the core what a URL means, and tapping a row opens that row's URL, so taps and deep links go through the same code. That's what makes this useful for agents: one can list the routes with the CLI and then open a specific conversation on a simulator or an emulator with a single command:
edges routes
xcrun simctl openurl booted edges://conversations/011d032b115d6baa
adb shell am start -a android.intent.action.VIEW \
-d edges://conversations/011d032b115d6baa
In the sample, each edge keeps its own database, so a link only opens a conversation that exists in that app. The same URLs can also arrive from notifications and shared links, so treat them as untrusted input: opening a route picks a screen, and the core and the server still check what the user can see.
Each platform already has tools for the edge side:
- iOS and macOS: model destinations as a Codable enum and drive a NavigationStack from a NavigationPath, then turn incoming URLs into that path in onOpenURL. Point-Free's swift-navigation models every destination, including sheets and alerts, as state, so opening a screen means constructing a value. On macOS, open <url> does what simctl openurl does on a simulator.
- Android: Navigation Compose supports type-safe routes declared as @Serializable classes, with deep links mapped to them. In Navigation 3, the back stack is a list of keys that you own, and deep link matchers turn a URL into the initial back stack.
- Desktop with GPUI: GPUI declares actions with names and dispatches them by name, which is how Zed's command palette lists everything the editor can do. Routes can follow the same pattern, with a command-line flag like --open <route> as the entry point.
5. Model the whole app in one build graph
You can build all of this with the tools you already have, but we'd recommend a build system that can model the entire graph: the Rust core compiled for each platform, the Swift and Kotlin bindings generated from it, the apps that link them, and the tests for each of them.
Xcode and Gradle work well inside their own ecosystems, and they treat everything outside as foreign. Rust usually gets in as a script phase or a custom task that runs Cargo, or as a prebuilt binary checked into the repository, and the outer build system sees one opaque step. Gradle can cache a custom task if you declare every input and output, but neither tool can schedule or cache the individual crates inside it, so the Rust side ends up rebuilding more than it should.
Bazel and Buck2 can model all of this, which is why many teams use them. They also ask you to learn their rules and configure toolchains, and a lot of teams find the developer experience hard to live with. For the sample we used Once, the build system we're building at Tuist, which models the same kind of typed graph with remote caching and keeps the configuration in a single TOML file. It's early and still in canary, so expect rough edges. Here's an excerpt of the sample's once.toml:
# The headless core, built once per edge.
[[target]]
name = "CoreIOS"
kind = "rust_library"
srcs = ["core/src/**/*.rs"]
deps = ["./CoreIOSDependencies"]
[target.attrs]
crate_name = "edges_core"
crate_root = "core/src/lib.rs"
target = "aarch64-apple-ios-sim"
crate_type = "staticlib"
# The terminal edge.
[[target]]
name = "CLI"
kind = "rust_binary"
srcs = ["cli/src/**/*.rs"]
deps = ["./Core", "./CoreDependencies"]
# The SwiftUI edge, and the Swift bindings it calls.
[[target]]
name = "SwiftBindings"
kind = "uniffi_bindings"
deps = ["bindgen/Bindgen", "./CoreIOS"]
[target.attrs]
language = "swift"
[[target]]
name = "IOS"
kind = "apple_application"
srcs = ["ios/Sources/**/*.swift"]
deps = ["./CoreIOS", "./SwiftBindings", "./SimulatorSDK"]
[target.attrs]
platform = "ios"
bundle_id = "dev.tuist.edges"
sdk_frameworks = ["Security"]
# The Jetpack Compose edge.
[[target]]
name = "Android"
kind = "android_binary"
srcs = ["android/src/**/*.kt"]
deps = ["./CoreAndroid", "./KotlinBindings", "./Compose"]
[target.attrs]
application_id = "dev.tuist.edges"
min_sdk_version = 26
Rust libraries, Apple applications, Android binaries, the bindings between them, and their tests all live in the same graph. Once resolves third-party crates from Cargo.lock into per-platform dependency targets, so each crate compiles separately with its own cache key, instead of one cargo build compiling everything at once.
The graph generates the bindings too, so nobody has to check them into the repository. Once doesn't have a built-in rule for UniFFI yet, so the sample adds one in a small Starlark module that builds uniffi-bindgen from source, runs it against the compiled core, and compiles the generated code into a Swift module for iOS and a Kotlin library for Android. Once caches that step like any other, keyed on the generator, its configuration, and the compiled core.
This graph is also what caching relies on. Each step gets a key computed from its inputs, so a change to a SwiftUI view doesn't recompile the Rust core, and you can share the results between your machine, your teammates', CI, and agents working in other worktrees. When the work a build does grows with the size of your change and not the size of your app, native iteration starts to feel close to the hot reloading that made web runtimes so attractive. Linking, installing, and launching still take time, but you stop paying for a full rebuild on top.
Keep the graph fast
The shape of the graph matters as much as the build system. A build can only run in parallel what the graph lets it run in parallel, and Apple's WWDC talk on build parallelization calls the longest chain of dependencies the critical path: no extra cores or remote workers make the build shorter than that. So we keep the graph shallow and wide. We only add a dependency when we need it, put interfaces in their own modules so implementations compile side by side (Bruno Rocha explains the technique in Reducing iOS Build Times by using Interface Modules), and keep shared foundations small, because everything downstream rebuilds when they change. Spotify's account of moving its iOS app to Bazel shows what that buys you: with remote caching and the module isolation they did during the migration, their CI feedback loop went from 80 minutes to 20.
The same goes for deciding what each edge includes. A widget doesn't need the agent, and a web build pays for every kilobyte it ships, but if each edge builds the core with its own Cargo features, the cache sees a different core per edge, and you end up building and keeping warm one graph per edge. Bazel's documentation on configurations warns about the same growth. We'd rather split the core into crates, like storage, networking, and the agent, and let each edge depend on the ones it needs, the way Mozilla's megazords link only the components an app uses. A crate's hash doesn't depend on who uses it, so adding or removing an edge doesn't invalidate anything shared. For dropping unused code from a binary, the linker's dead-code stripping does the job after the core is compiled and cached.
Try both approaches below. Each edge links the crates it picks through a small umbrella crate, drawn dashed. Turn edges on and off or edit a file in storage, and watch which nodes get a new hash:
🧪 Try the interactive graph on the original post
The sample's graph is small, but it has this shape: each edge depends only on its build of the core and its own platform libraries, and no edge depends on another.
Adopting native edges
Starting with one platform
We like to start with one platform and move logic into the core as soon as it looks reusable, without trying to design the core up front. A few signs that something belongs there: it doesn't import any platform framework, another platform would need the same behavior, or you'd want to test it without launching the app. Some typical candidates:
- The API client: routes, request and response types, validation, and error mapping.
- Authentication: building the sign-in URL, exchanging codes for tokens, and refreshing them.
- Pagination, search, and filtering rules for lists.
- Form validation, like which fields are required and what a valid date range looks like.
- The route catalog and deep-link parsing.
- Local caching and reconciling data after a reconnect.
When the second platform comes along, most of the work is building a new edge on top of a core that's already built and tested. And keep doing it as features change: when a Swift or Kotlin model starts making business decisions, that's your cue to move the logic into the core.
If you already have clients
Most teams won't start from scratch, and that's the more interesting case. If you already have clients on several platforms, each in its own language and built with its own tools, I'd start by modeling them in one build graph connected to a remote cache. That makes builds faster before any code moves, and it makes adding a Rust core affordable later. Then pick one piece that's duplicated across your clients, like the API client, authentication, or validation rules, and move it into the core, and while you're at it, think about what your app would look like as a CLI or a set of commands an agent can run. You keep shipping your clients the whole time, and over time you'll notice the edges getting thinner and thinner until they're mostly UI and calls to platform APIs.
What we're doing at Tuist
We're doing this ourselves. The native clients of Atlas, our internal operations platform, follow these principles: an iOS and macOS app in SwiftUI, an Android app in Kotlin, and a CLI, all on one Rust core. We've also started moving Tuist's own clients to the same model. When we looked across them, we found a lot of duplicated logic, most of it in Swift and some of it rewritten in Kotlin and other languages, and we're now moving that layer into a shared Rust core with the CLI and the apps as its edges. We'll write about it as the work progresses.
It would be a dream for us to see more apps built natively again, apps that use less memory, feel at home on their platform, and run faster. This is early work, and we kept the sample small on purpose. The sample app and Once are on GitHub if you want to try the principles, and if you build something with them, we'd love to hear about it.
PDF.js Deep Dive from Demo 3: getPage
Introduction
In the demo of the first article, we obtained the pdf variable via await loadingTask.promise — that promise is resolved by the main thread's GetDoc handler after the worker sends back the GetDoc message; see article 13 for details — and it is an instance of PDFDocumentProxy.
Back to the demo in the first article, lines 8-9:
const page = await pdf.getPage(1);
The getPage call
This call passes a single numeric argument:
| Parameter | Value | Meaning |
| ------------ | ----- | ------------------------------------------ |
| pageNumber | 1 | Page number; the first page is 1 (1-based) |
getPage returns a Promise that resolves to a PDFPageProxy object. This article follows that single call through the full execution chain of getPage.
According to the definition in api.js, the execution flow is as follows.
Lines 773-775: PDFDocumentProxy.getPage: a one-line delegation
getPage(pageNumber) {
return this._transport.getPage(pageNumber);
}
The pdf object itself is just a facade; the real logic lives entirely on this._transport — the WorkerTransport instance created in article 2 (task._transport).
Everything below happens inside WorkerTransport.getPage.
Lines 3031-3037: Validate the arguments
if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) {
return Promise.reject(new Error('Invalid page request.'));
}
Plugging the demo values into each condition:
| Condition | With pageNumber = 1 | Result |
| ------------------------------------------- | ----------------------------------------------------------- | ------- |
| !Number.isInteger(pageNumber) | Number.isInteger(1) is true, so the negation is false | Not hit |
| pageNumber <= 0 | 1 <= 0 is false | Not hit |
| pageNumber > this.pagesMapper.pagesNumber | pagesNumber is 756, 1 > 756 is false | Not hit |
pagesNumber is 756, written from pdfInfo.numPages when the document becomes ready: view source. The demo PDF in this article has 756 pages, so validation passes. If you pass 0, a negative number, a decimal, or a number beyond the page count, this returns a rejected promise immediately.
> Q: Why use Promise.reject instead of throw?
> A: To keep the asynchronous semantics consistent. Callers of getPage always await a promise, so argument errors go through the same error channel. Callers don't have to handle both a synchronous throw and an asynchronous rejection.
Lines 3038-3039: Convert the page number
const pageIndex = pageNumber - 1; // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0
These two lines produce three page numbering schemes, each serving a different consumer:
| Variable | Perspective | Base | Value here | Purpose |
| -------------- | -------------------------- | ------- | ---------- | ------------------------------------- |
| pageNumber | User | 1-based | 1 | The public getPage argument |
| pageIndex | Main thread | 0-based | 0 | Key of #pageCache / #pagePromises |
| newPageIndex | Worker (original document) | 0-based | 0 | Payload of the GetPage message |
pagesMapper.getPageId(1) returns 1. The demo never moves, deletes, or copies pages, so the mapping is an identity mapping (#pageNumberToId is null), and therefore newPageIndex === pageIndex === 0. The three only diverge once the document has been edited.
PagesMapper: translating between user page numbers and original page order
PagesMapper was created with new PagesMapper() during argument parsing in article 2: view source. It exposes just two things:
- pagesNumber: the current total page count. Once assigned, the internal #pageNumberToId / #prevPageNumbers are reset to null, falling back to the identity mapping;
- getPageId(pageNumber) / getPageNumber(pageId): two-way translation (L462-L464, L443-L454). Under the identity mapping they return their input unchanged; only an actual page reorder builds a real table.
> Q: Why go through PagesMapper at all?
> A: To support page reordering. In an editor scenario, users can move, delete, and copy pages, after which "page 3" may no longer be the original document's page 3. The worker side only understands the original document's page order, so what gets sent to the worker is newPageIndex, while the two main-thread caches are keyed by the user-facing pageIndex. Under an identity mapping the two are identical and can be ignored. Note: PagesMapper has nothing to do with the PDF file itself — the PDF contains no such data; it exists purely to hold state while editing.
One sentence to distinguish them: pageIndex is the cache key on the main thread, while newPageIndex is the key sent to the worker.
Lines 3041-3044: Check the cache
const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0
if (cachedPromise) {
return cachedPromise;
}
#pagePromises is a Map<pageIndex, Promise<PDFPageProxy>>. On the first call there is no cache hit, so we continue.
> Q: What does the cache actually do?
> A: In practice, calling await pdf.getPage(1) twice in a row returns the same PDFPageProxy object (page === page2 is true). The second call hits the cached promise directly and no message is sent to the worker.
Three layers of cache: separate stores, separate jobs
WorkerTransport declares all three Maps as class fields in one place: view source
#pageCache = new Map();
#pagePromises = new Map();
#pageRefCache = new Map();
| Field | Key → Value | Responsibility | Typical use |
| --------------- | ----------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| #pageCache | pageIndex → PDFPageProxy | Index of created page objects | StartRenderPage looks up a page by index (api.js#L2805); startCleanup iterates it (api.js#L3227) |
| #pagePromises | pageIndex → Promise<PDFPageProxy> | Deduplicates in-flight requests and repeat calls | Read at the top of getPage (line 3041 here), written at the end (line 3067) |
| #pageRefCache | refStr → pageIndex | Reverse lookup from object reference to page index | Written by getPage (line 3054), read by cachedPageNumber (api.js#L3245-L3255) |
All three share the same lifetime and are cleared when the transport is destroyed: destroy() clears them in turn (api.js#L2611-L2616).
> Q: Why keep #pagePromises when #pageCache already holds the page object?
> A: They solve different problems. #pagePromises is written before the message even leaves, so that "several getPage calls arriving at the same time only send one GetPage message". #pageCache is only written after the reply arrives, so that transport-internal events can look up objects by page index. Caching the promise rather than the page is precisely what covers the window before the reply arrives.
Lines 3045-3048: Send the GetPage message to the worker
const promise = this.messageHandler
.sendWithPromise('GetPage', {
pageIndex: newPageIndex, // 0
})
.then(pageInfo => {
// The reply handling is covered in the next section
});
messageHandler is the MessageHandler created in article 2. The communication mechanism of sendWithPromise is covered in article 6. All you need to know here is that it sends the message to the worker thread and returns a promise that waits for the worker's reply.
When the worker receives GetPage: view source
handler.on('GetPage', async function ({ pageIndex }) {
const page = await pdfManager.getPage(pageIndex);
const [rotate, ref, userUnit, view] = await Promise.all([
pdfManager.ensure(page, 'rotate'),
pdfManager.ensure(page, 'ref'),
pdfManager.ensure(page, 'userUnit'),
pdfManager.ensure(page, 'view'),
]);
return {
rotate,
ref,
refStr: ref?.toString() ?? null,
userUnit,
view,
};
});
How the worker parses the page object internally is covered in article 14. The reply for the demo's first page is:
pageInfo = {
rotate: 0,
ref: { num: 4, gen: 0 }, // after structured cloning across threads, the Ref instance degrades to a plain object
refStr: '4R', // the result of ref.toString() on the worker side
userUnit: 1,
view: [0, 0, 595, 842],
};
| Field | Value here | Meaning | Matching PDFPageProxy getter |
| ---------- | -------------------- | ------------------------------------------------- | ------------------------------ |
| rotate | 0 | Clockwise rotation in degrees | rotate |
| ref | { num: 4, gen: 0 } | Object reference (the entry Ref of this page in the PDF file) | ref |
| refStr | "4R" | String form of the reference, used as a cache key | None (internal only) |
| userUnit | 1 | User unit conversion factor | userUnit |
| view | [0, 0, 595, 842] | Visible area (PDF user space) | view |
The 595 × 842 of view is exactly A4 paper size in 1/72-inch units, matching the demo's PDF specification.
> Q: What is ref: { num: 4, gen: 0 }? Why isn't it a Ref instance?
>
> A: It is PDF's Indirect Reference — every object in a PDF file has a unique (num, gen) number, and the page object is no exception. num = 4 means "object #4", gen = 0 is the generation number (almost always 0 in new PDFs; it only bumps up after incremental updates).
>
> On the worker side this was a real Ref instance: view source created via Ref.get(4, 0), with a cached private #str = '4R'. But it cannot cross threads: Web Worker postMessage uses Structured Clone, which only copies enumerable own properties. Ref's #str is a private field — it doesn't clone — and class instances degrade to plain objects, leaving only the two public fields num and gen. That's why the main thread receives { num: 4, gen: 0 }.
>
> The main-thread PDFPageProxy.ref getter (api.js#L1399) simply returns this plain object — it never reconstructs a Ref, because the main thread only ever needs to read num / gen and never sends them back as-is. Later messages like GetPageIndex or the editor's SaveDocument rebuild a Ref on the worker side from these two fields.
>
> Why pre-compute refStr on the worker side? Precisely because Ref#toString() is lost after crossing threads — the main thread holds only a plain object and can't call toString. So the worker pre-computes refStr: ref?.toString() ?? null in its return value. Two fields, two roles: ref for callers that need to split num / gen, refStr for cache lookups that need a string key.
> Q: Why is refStr "4R" rather than "4 0 R"?
>
> A: Ref#toString returns the compact string cached inside the object: numR when gen is zero, numRgen otherwise: view source. The reverse-lookup side, cachedPageNumber, builds its key as ${ref.num}R as well, so both sides use the same format.
> Q: Why only these 4 properties?
>
> A: They are all metadata of the page: rotation angle, object reference, unit conversion factor, and visible area. Reading them only requires parsing the page object dictionary, without touching the page content stream. The real content parsing (drawing commands) is deferred to the GetOperatorList message at render time. This is why getPage can return quickly.
>
> These 4 properties map one-to-one to getters on PDFPageProxy: rotate, ref, userUnit, and view. The next article's getViewport will use two of them.
Lines 3049-3068: Handle the reply and create the PDFPageProxy
Once the reply arrives, we enter the .then callback:
.then(pageInfo => {
if (this.destroyed) {
throw new Error("Transport destroyed");
}
if (pageInfo.refStr) {
this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0
}
const page = new PDFPageProxy(
pageIndex, // 0
pageInfo,
this,
this.pagesMapper,
this._params.pdfBug // false
);
this.#pageCache.set(pageIndex, page);
return page;
});
this.#pagePromises.set(pageIndex, promise); // line 3067
return promise; // line 3068
Four things happen in sequence:
1. destroyed check: while waiting for the reply, the document may already have been destroyed (loadingTask.destroy()); if so, throw an error;
2. #pageRefCache reverse-lookup cache: "4R" → 0. Used by cachedPageNumber(ref) for reverse lookups — for example, when an outline link jumps to a page, the PDF internally locates pages by object reference (4 0 R), which must be translated back to a page number;
3. Create the PDFPageProxy and store it in #pageCache;
4. Store the promise itself in #pagePromises.
> Q: Why does #pageRefCache store newPageIndex rather than pageIndex?
>
> A: Because it records "which page of the original document this object reference points to", which has nothing to do with the user's perspective. During reverse lookup, cachedPageNumber first obtains the original page index, then translates it back to the user page number via pagesMapper.getPageNumber(pageIndex + 1) (api.js#L3245-L3255). Under an identity mapping the two happen to be equal, but semantically they are not interchangeable.
The PDFPageProxy constructor: assignment only, no parsing
View source
constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
this._pageIndex = pageIndex; // 0
this._pageInfo = pageInfo; // the pageInfo received above
this._transport = transport;
this._stats = pdfBug ? new StatTimer() : null; // false, so performance stats are not enabled
this._pdfBug = pdfBug;
this.commonObjs = transport.commonObjs; // resource container shared across the whole document (fonts, etc.)
this.objs = new PDFObjects(); // resource container private to this page
this._intentStates = new Map(); // rendering intent state cache, used by render in article 5
this.destroyed = false;
this.recordedBBoxes = null; // recorded regions for the editor scenario
this.#pagesMapper = pagesMapper;
this.imageCoordinates = null; // image coordinate mapping, only populated after rendering
}
The whole constructor contains not a single await and does no parsing at all; it only moves references around and seeds placeholders:
- _pageIndex is exposed to the outside as pageNumber via a getter returning _pageIndex + 1;
- _pageInfo is the pageInfo above; all four getters read from it;
- commonObjs and objs are resource containers used later during rendering; PDFObjects will be covered in a separate article;
- recordedBBoxes / imageCoordinates stay null until the first render.
This is also why the main-thread cost of getPage is so small: the heavy lifting happens entirely on the worker thread, and the main thread only assembles the object.
At this point, the promise returned by getPage(1) resolves to page, and the demo's await receives it.
Summary
In this section we followed the demo through the complete getPage(1) call:
1. PDFDocumentProxy.getPage delegates in one line to WorkerTransport.getPage;
2. Argument validation: 1 is a valid page number (756 pages in total);
3. Page number conversion: pageNumber(1) → pageIndex(0) → newPageIndex(0); under the identity mapping all three agree;
4. The #pagePromises cache is checked and missed on the first call;
5. A GetPage message is sent to the worker, and rotate / ref / refStr / userUnit / view come back;
6. #pageRefCache is written, the PDFPageProxy is created, and #pageCache is populated;
7. The promise is stored in #pagePromises and returned; the demo's await yields page.
With page in hand, the demo's next step is page.getViewport({ scale: 0.5 }) — which will use the view and userUnit from this article's pageInfo. That will be covered in the next article.
---
About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: streampdf.com
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
PDF Reader in JavaScript
2 points
such a great tool
mozilla/pdf.js
2 likes · 37 views
@firefox I'd be more inclined if you considered DBSC like your main competitor. standards-positions#912
1 like · 153 views
Cool ad, but imma just leave this here github.com/mozilla/bedrock/commit/d459addab846d81… t.co
2 likes · 96 views
mozilla accounts
button labels in chinese, japanese and korean were wrapping one character per line
fixed it, merged
fxa#20724
1 like · 1 view
Mentions
PDF.js Deep Dive from Demo 4: getViewport Introduction In the previous article, we obtained the page variable via await pdf.getPage(1). It is a PDFPageProxy instance whose _pageInfo holds the page metadata: rotate: 0, userUnit: 1, view: [0, 0, 595, 842]. Back to the demo from the first article, lines 10-11: const viewport = page.getViewport({ scale: 0.5 }); An object parameter is passed in: | Parameter | Value | Meaning | | --------- | ----- | --------------- | | scale | 0.5 | The zoom factor | Unlike getPage, getViewport is a synchronous call: it does not touch the worker and performs no IO — it is purely a math computation. Starting from this call, this article walks through the complete execution of getViewport. The getViewport call Based on its definition in api.js, the execution flow is as follows. Lines 1423-1439: Destructure the parameters view source getViewport({ scale, // 0.5, the only parameter passed in rotation = this.rotate, // this._pageInfo.rotate = 0 offsetX = 0, offsetY = 0, dontFlip = false, } = {}) { return new PageViewport({ viewBox: this.view, // [0, 0, 595, 842] userUnit: this.userUnit, // 1 scale, rotation, offsetX, offsetY, dontFlip, }); } Note that the default value of rotation comes from the page itself: the PDF page's own /Rotate attribute is applied automatically, so the caller does not need to care about it. this.view and this.userUnit come from the pageInfo returned by the GetPage message in the previous article. Once the parameters are collected, they are all handed to the PageViewport constructor (display/page_viewport.js): view source. All subsequent computation takes place there. > Q: What are offsetX / offsetY for? > > A: They translate the rendered result on the canvas — for example, to tile multiple pages onto one large canvas, or to leave padding around a page. The demo does not pass them, so they default to 0. Line 70: scale absorbs userUnit (page_viewport.js) scale *= userUnit; // 0.5 * 1 = 0.5 > Q: What is userUnit? > > A: A unit conversion factor introduced in PDF 1.6: 1 unit in PDF user space = userUnit / 72 inches, with a default value of 1. By folding it into scale, subsequent computations no longer need to distinguish between "user space units" and "actual size". Lines 74-75: Compute the page center point (page_viewport.js) const centerX = (viewBox[2] + viewBox[0]) / 2; // (595 + 0) / 2 = 297.5 const centerY = (viewBox[3] + viewBox[1]) / 2; // (842 + 0) / 2 = 421 Rotation happens around the page center, so the center point is computed first for later use. Lines 78-116: Normalize the rotation and compute the rotation vector (page_viewport.js) rotation %= 360; // 0 if (rotation < 0) { rotation += 360; } switch (rotation) { case 0: rotateA = 1; rotateB = 0; rotateC = 0; rotateD = -1; break; // case 90 / 180 / 270 omitted default: throw new Error('PageViewport: Invalid rotation, must be a multiple of 90 degrees.'); } The rotation is normalized into the [0, 360) range, and only multiples of 90 are allowed; any other value throws an error directly — this is a restriction of the PDF specification. The demo's rotation is 0, which hits case 0: rotateA = 1, rotateB = 0, rotateC = 0, rotateD = -1. > Q: Why is rotateD -1 when rotation is 0? > A: This is the y-axis flip. In the PDF coordinate system the y-axis points up (a mathematical coordinate system with the origin at the bottom-left), while in the canvas coordinate system the y-axis points down (a screen coordinate system with the origin at the top-left). Even without any rotation, a flip is needed to align the two, and rotateD = -1 is exactly that flip. > > Correspondingly, dontFlip = true negates rotateC and rotateD, canceling this flip — useful when the target coordinate system also has an upward y-axis. Lines 120-130: Compute width, height, and canvas offsets (page_viewport.js) if (rotateA === 0) { // The branch for rotation 90/270: width and height are swapped 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; // 297.5 * 0.5 + 0 = 148.75 offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY; // 421 * 0.5 + 0 = 210.5 width = (viewBox[2] - viewBox[0]) * scale; // 595 * 0.5 = 297.5 height = (viewBox[3] - viewBox[1]) * scale; // 842 * 0.5 = 421 } The rotateA === 0 branch corresponds to a 90/270 degree rotation: the page lies on its side, and the computations of width and height are swapped. The demo's rotation is 0, so it takes the else branch. Lines 134-141: Assemble the transform matrix (page_viewport.js) this.transform = [ rotateA * scale, // 0.5 rotateB * scale, // 0 rotateC * scale, // 0 rotateD * scale, // -0.5 offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY, // 148.75 - 148.75 - 0 = 0 offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY, // 210.5 - 0 + 210.5 = 421 ]; // transform = [0.5, 0, 0, -0.5, 0, 421] The source code comment explains the composite transformation these 6 components correspond to: > translate(-centerX, -centerY), rotate and flip vertically, scale, and translate(offsetCanvasX, offsetCanvasY) That is: move the page center to the origin → rotate and flip vertically → scale → translate to the target position on the canvas. These are exactly the 6 arguments that canvas setTransform(a, b, c, d, e, f) needs, and they will be applied directly during rendering (covered in the next article). Let's verify the correctness of the matrix: take the top-left corner of the page in the PDF coordinate system, (0, 842) (with the y-axis pointing up, "top" is the larger value), and apply the matrix: - x' = 0.5 * 0 + 0 * 842 + 0 = 0 - y' = 0 * 0 + (-0.5) * 842 + 421 = 0 It maps exactly to the canvas coordinate (0, 0), i.e., the top-left corner of the canvas. ✓ The 6th component, 421, happens to equal height: after the y-axis flip, the whole page ends up above the canvas (negative y), so it needs to be translated down by one page height to land back inside the canvas. Lines 143-144: Store width and height (page_viewport.js) this.width = width; // 297.5 this.height = height; // 421 These two values are the source of canvas.width = Math.floor(viewport.width) in the first article's demo: Math.floor(297.5) is 297, so the actual canvas size is 297 x 421. At this point, all of getViewport's computation is complete, and the returned PageViewport instance is assigned to the demo's viewport variable. Summary This article followed the demo through the complete computation of getViewport({ scale: 0.5 }): 1. Parameter destructuring: scale = 0.5, rotation defaults to the page's /Rotate value 0, and the rest are 0/false; 2. scale absorbs userUnit, remaining 0.5; 3. Compute the page center point (297.5, 421); 4. After rotation normalization, take the rotation vector (1, 0, 0, -1), where rotateD = -1 performs the y-axis flip; 5. Compute the width and height 297.5 x 421 and the canvas offsets (148.75, 210.5); 6. Assemble transform = [0.5, 0, 0, -0.5, 0, 421], completing the "PDF coordinates → canvas coordinates" mapping. viewport also carries methods like convertToViewportPoint / convertToPdfPoint (bidirectional coordinate conversion, used for text selection and annotation positioning: view source) and clone (deriving a new viewport: view source), which the demo does not use, so we will not expand on them here. With the viewport in hand, the demo packages it together with canvasContext and passes them to page.render(renderContext) — the transform matrix will be applied to the canvas context there. That will be covered in the next article. --- About me: Creator of StreamPDF, focused on research and development in AI and PDF. Original article: PDF.js Deep Dive from Demo 4: getViewport
Native edges: principles for native apps with a shared core For a long time, building a client for every platform you cared about was expensive. You wrote every screen twice, fixed every bug twice, and either staffed a team per platform or decided which platforms your users would have to live without. So the industry did what it usually does and found ways to make that cost bearable. Electron, React Native, and more recently Tauri came out of that. They gave teams a dynamic runtime, hot reloading, and the whole ecosystem of web libraries, so one team could ship to several platforms at once. And it worked: Slack, Linear, Discord, Notion, and Visual Studio Code run on a web stack, and many of us have them open all day. Raycast went the same way to get to Windows. Raycast 2.0 shares a React and TypeScript frontend between macOS and Windows and loads it in each platform's web view, and the team was open about why: hiring, the UI components the web already has, consistent extensions, and iteration speed. Those are good reasons. Coding agents changed part of that math. Writing a SwiftUI view and then its Jetpack Compose twin used to be the expensive part, and today an agent writes most of it while you review. What's still expensive is compiling it, and if you've ever sat waiting on Xcode or Gradle, you know how much of a day goes into that. At Tuist we've spent years chipping away at that problem, with caching, selective testing, runners to run the builds on, and now Once, with remote execution coming next. Once native builds are fast too, iteration speed becomes a much weaker argument for a web runtime. The other reasons, like the ecosystem or hiring, may still apply to you, and I don't expect anyone to rewrite a large codebase because the economics moved. But it got us thinking about how we'd build a multi-platform app if we started one today, so we wrote down the principles we'd follow and built a small sample app to see whether they hold up. None of this is entirely new to us. Almost ten years ago we were exploring modular architectures for iOS apps and came up with microfeatures, which many teams adopted and which we later renamed The Modular Architecture. Tuist itself grew out of that work. This post takes the same ideas about boundaries and build graphs and stretches them across platforms. We call this set of principles native edges. An app has two layers, and both ship inside it. The headless core holds the business logic and has no UI at all, and the native edges are what users see and touch, one per platform, each connecting the core to its platform. We stayed away from frontend and backend on purpose, because most people hear those words and think UI and server. The principles are: 1. Keep business logic in a headless core. 2. Choose a core language that runs everywhere you ship. 3. Connect the edges to the core through a small, typed API. 4. Make every screen reachable by an address. 5. Model the whole app in one build graph. You don't have to take all of them. Some projects will have good reasons to keep a piece of logic in Swift, to share less, or to use different tools than we do, and that's fine. The sample app To see whether the principles hold up end to end, we built Native Edges, a client-side agent. You sign in with your ChatGPT account and talk to an agent whose loop, tools, and conversation history live on your device, while the model runs in the cloud. There's an iOS app in SwiftUI, an Android app in Kotlin and Jetpack Compose, and a command-line tool in Rust, and all three sit on one headless core, also written in Rust. The core handles the device-code sign-in and token refresh, runs the agent loop and its requests to the model, provides the tools the agent uses to read earlier conversations, and stores the history in SQLite through Diesel. Here's the iOS app, built and launched on a simulator with once run. It signs in with a device code, lists the stored conversations, and asks the agent something that makes it go back to earlier ones. Then the CLI lists the app's routes and builds the URL for one conversation, and the simulator opens it: ▶️ Watch the demo on the original post We'll keep coming back to the sample throughout the post. Here are its three edges, plus two more that the same core could support but the sample doesn't include: - Native edges: iOS (SwiftUI + Swift), Android (Jetpack Compose + Kotlin), CLI (terminal), plus Linux and Windows (GPUI) and Web (HTML + JavaScript), which aren't in the sample. - Headless core in Rust: route catalog and deep links, sign-in and sessions, agent loop and model API, conversation history. - Bridges: iOS calls the core through UniFFI Swift bindings, Android through UniFFI Kotlin bindings + JNA, the CLI and desktop through plain Rust calls, and the web through WebAssembly. (See the diagram on the original post) 1. Keep business logic in a headless core The rule we follow is simple to say: put in the edge whatever only the platform can do, and put everything else in the core. The edge is the layer users touch. For apps, that means a UI written in a framework that compiles for the platform and follows its conventions, which today is SwiftUI on Apple platforms and Jetpack Compose on Android. Anything you can only reach through the platform's frameworks belongs there too: the Keychain or the Android Keystore, presenting a browser for an OAuth flow, registering for push notifications, Live Activities, share extensions, or on-device speech recognition. These are the details users notice when an app gets them wrong, so we'd rather each platform own them. A command-line tool is an edge too, one whose platform happens to be the terminal, and so is a web app, with the core compiled to WebAssembly and the browser as its platform. The core is the logic that's the same everywhere: which fields of a record can be edited, how a list is paginated, how an OAuth token is refreshed, how history is reconciled after a reconnect, or making sure a late response from a cancelled request doesn't overwrite newer results. Because the core has no UI and doesn't depend on any platform framework, you can cover most of the app's behavior with unit tests that run in seconds, without a simulator, an emulator, or UI automation, and those tests cover every platform at once. Each edge still needs some tests for rendering and platform integration, but far fewer than before. Ghostty, the terminal emulator by Mitchell Hashimoto, is one of my favorite examples of this split. Its core, libghostty, is written in Zig, exposes a C API, and handles terminal emulation, fonts, and rendering. On top of it sit a macOS app written in Swift with AppKit and SwiftUI and a Linux app that uses GTK. Ghostty's core does more than ours, since it includes rendering, but the idea is the same: one shared engine, and on each platform an app that feels like it belongs there. 2. Choose a core language that runs everywhere you ship Look for a language with good toolchains and a good standard library on every platform you support, so you don't find out halfway through a feature that the API you need doesn't exist on one of them. Rust does well here. It compiles to iOS, Android, macOS, Linux, Windows, and WebAssembly, and its crates cover almost everything a client needs. The browser is a bit of a special case: the core runs there as WebAssembly, but on wasm32-unknown-unknown parts of the standard library, like the file system and threads, don't work, so a web edge needs crates that go through the browser's APIs instead, like fetch for networking or IndexedDB for storage. Zig is another good option, with explicit allocators and cross-compilation built into its toolchain, and Go can work too, although it brings a garbage collector with it. Memory is one of the main reasons we lean towards Rust and Zig. Neither has a garbage collector, so you decide when memory is allocated and freed, which matters in places with strict limits, like watches, embedded devices, or the app extensions and widgets the system kills when they use too much. It also means your app doesn't run a second garbage collector next to Swift's reference counting or the Android runtime's own collector. You still have to respect ownership rules at the boundary and measure memory on constrained targets, but you're the one in control. A question we get a lot is why we don't use Kotlin Multiplatform on Apple platforms, or Swift on Android. Both work, and teams ship real apps with them. Our concern is that a language built for one platform brings its runtime and memory model along to the others, and its standard library and tooling tend to be most complete on the platform where it started. Rust and Zig don't have a home platform in that sense, and for a shared core that's what we want. I also think the choice of language will matter less and less. As models write more of the code, the questions that remain are whether the language fits the job and whether agents are good at writing and reasoning about it. Rust does well on both: its compiler is strict and its error messages are detailed, so when an agent gets something wrong, it finds out right away and knows why. If we picked a stack today, it would look like this: | Platform | Native edge: UI | Native edge: platform services | Headless core | | --- | --- | --- | --- | | iOS, iPadOS, macOS | SwiftUI | Swift | Rust | | Android | Jetpack Compose | Kotlin | Rust | | Linux, Windows | GPUI | Rust | Rust | | Command line | Terminal | Rust | Rust | | Web | HTML and JavaScript | Browser APIs | Rust compiled to WebAssembly | For desktop outside macOS, we'd go with GPUI, the UI framework the Zed team built for their editor. GPUI draws its own UI on the GPU instead of using the operating system's toolkit, compiles to a native binary, and integrates with each platform, and there are component libraries for it, like GPUI Component, for building desktop apps in the spirit of Zed. Since GPUI is Rust too, on Linux and Windows the edge and the core share a language and need no bridge at all. The command line is in the table because the terminal is a platform too, and developers can tell right away when a CLI starts fast, follows the terminal's conventions, and plays well with pipes and scripts. With a headless core, a CLI is a thin edge over logic you already have, so building one in Rust is cheap. Agents use a lot of software through CLIs, and I expect this edge to matter more and more as they do. 3. Connect the edges to the core through a small, typed API The sample uses UniFFI, a tool from Mozilla that generates Swift and Kotlin bindings from annotations in the Rust code. The core marks what it exports, and the edges call typed functions and get typed values back, with no C headers or JSON in between: #[derive(uniffi::Record)] pub struct Destination { pub route: String, pub params: Params, pub url: String, } #[derive(Debug, uniffi::Error)] #[uniffi(flat_error)] pub enum CoreError { /// No session, or it expired: show the sign-in screen. Unauthorized { message: String }, Failed { kind: String, message: String }, } /// Resolves a deep link into its destination and parents. #[uniffi::export] pub fn open(url: String) -> Result<Vec<Destination>, CoreError> { // ... } /// Sends a message and waits for the agent's answer. #[uniffi::export] pub fn send( id: Option<String>, text: String, ) -> Result<Conversation, CoreError> { // ... } In Swift, these are ordinary functions, and the Rust error becomes a Swift error you can catch. send blocks while the agent works, so the sample runs it off the main actor with a small background helper of its own: let stack = try EdgesCore.open(url: link) let reply = try await background { try EdgesCore.send(id: id, text: text) } Kotlin gets the same API, with CoreException.Unauthorized in place of the Swift error case, and the CLI calls the Rust functions directly. Adding a capability means exporting a function in Rust, and the apps get it with compiler-checked types. The core returns plain values, like a conversation and the URLs it links to, and leaves it to each edge to decide how to present them: a pushed view on iOS, a back-stack entry on Android, or printed output in the terminal. Sometimes the core needs something only the platform provides, so the API has to work in the other direction too. A token refresh rotates the session, and the new session has to land in the Keychain on iOS and in Keystore-backed encrypted storage on Android. For that we use the delegate pattern: the core declares what it needs as an interface, and each edge provides an implementation. In UniFFI, that's a Rust trait marked with_foreign, which Swift and Kotlin can implement: /// Provided by the edge: the Keychain on iOS, the Keystore /// on Android, and a private file in the terminal. #[uniffi::export(with_foreign)] pub trait SecureStorage: Send + Sync { fn read(&self, key: String) -> Option<String>; fn write(&self, key: String, value: String); fn delete(&self, key: String); } On iOS, the implementation is a small class over the Keychain, and the app hands it to the core at launch, along with a directory where the core keeps its SQLite database: final class Keychain: SecureStorage { func read(key: String) -> String? { /* SecItemCopyMatching */ } func write(key: String, value: String) { /* SecItemAdd */ } func delete(key: String) { /* SecItemDelete */ } } let directory = URL.applicationSupportDirectory.path _ = try launch(dataDir: directory, storage: Keychain()) Android passes a Keystore class that encrypts the value with an Android Keystore key, and the CLI passes one that writes files only you can read. The split of responsibilities is what we like here: the core decides what to store and when, such as a renewed session after a refresh, and the edge decides how the operating system protects it. The core's tests pass an in-memory implementation, so they don't need a Keychain or a simulator. We kept the trait simple for the sample, but in a production app write and delete should report failures so the core can handle them. 4. Make every screen reachable by an address Core tests cover the shared rules, but sometimes you want to test how an edge and the core work together, like whether a screen renders the data the core returns or whether a tap triggers the right call. Whether you or an agent runs that test, you want to get to the screen in one step, not ten. A CLI makes this easy, because every operation is a named command with options and arguments you can find in the code and run. Web apps are close, since every page has a URL that an agent can read from the route definitions and open. Mobile and desktop apps usually have neither, so getting to a screen means launching the app and tapping through every step in between, which is slow and breaks every time the UI changes. We think native apps deserve a declarative navigation system that does two things: 1. Makes routes discoverable. The app declares destinations as data, like an enum or a list of types, so someone reading the code, or an agent, can find every place the app can go. 2. Lets you open any route directly. A single entry point takes a route, usually as a URL, and builds the navigation state for it, so you can jump straight to a specific screen or record. Navigation itself belongs to the platform. Each one has strong conventions for stacks, tabs, sheets, transitions, and Back behavior, and users expect them. What the core can own is the route catalog and URL parsing, so every edge agrees on which destinations exist and what a link means. In the sample, each route has an identifier, a URL pattern, and a parent, so a deep link opens on top of the screens you'd normally pass through, and Back works as if you had tapped your way there: // Simplified from core/src/routes.rs. pub static ROUTES: &[Route] = &[ Route { id: "conversations", path: "conversations", parent: None, }, Route { id: "new", path: "conversations/new", parent: Some("conversations"), }, Route { id: "conversation", path: "conversations/{id}", parent: Some("conversations"), }, ]; Every edge asks the core what a URL means, and tapping a row opens that row's URL, so taps and deep links go through the same code. That's what makes this useful for agents: one can list the routes with the CLI and then open a specific conversation on a simulator or an emulator with a single command: edges routes xcrun simctl openurl booted edges://conversations/011d032b115d6baa adb shell am start -a android.intent.action.VIEW \ -d edges://conversations/011d032b115d6baa In the sample, each edge keeps its own database, so a link only opens a conversation that exists in that app. The same URLs can also arrive from notifications and shared links, so treat them as untrusted input: opening a route picks a screen, and the core and the server still check what the user can see. Each platform already has tools for the edge side: - iOS and macOS: model destinations as a Codable enum and drive a NavigationStack from a NavigationPath, then turn incoming URLs into that path in onOpenURL. Point-Free's swift-navigation models every destination, including sheets and alerts, as state, so opening a screen means constructing a value. On macOS, open <url> does what simctl openurl does on a simulator. - Android: Navigation Compose supports type-safe routes declared as @Serializable classes, with deep links mapped to them. In Navigation 3, the back stack is a list of keys that you own, and deep link matchers turn a URL into the initial back stack. - Desktop with GPUI: GPUI declares actions with names and dispatches them by name, which is how Zed's command palette lists everything the editor can do. Routes can follow the same pattern, with a command-line flag like --open <route> as the entry point. 5. Model the whole app in one build graph You can build all of this with the tools you already have, but we'd recommend a build system that can model the entire graph: the Rust core compiled for each platform, the Swift and Kotlin bindings generated from it, the apps that link them, and the tests for each of them. Xcode and Gradle work well inside their own ecosystems, and they treat everything outside as foreign. Rust usually gets in as a script phase or a custom task that runs Cargo, or as a prebuilt binary checked into the repository, and the outer build system sees one opaque step. Gradle can cache a custom task if you declare every input and output, but neither tool can schedule or cache the individual crates inside it, so the Rust side ends up rebuilding more than it should. Bazel and Buck2 can model all of this, which is why many teams use them. They also ask you to learn their rules and configure toolchains, and a lot of teams find the developer experience hard to live with. For the sample we used Once, the build system we're building at Tuist, which models the same kind of typed graph with remote caching and keeps the configuration in a single TOML file. It's early and still in canary, so expect rough edges. Here's an excerpt of the sample's once.toml: # The headless core, built once per edge. [[target]] name = "CoreIOS" kind = "rust_library" srcs = ["core/src/**/*.rs"] deps = ["./CoreIOSDependencies"] [target.attrs] crate_name = "edges_core" crate_root = "core/src/lib.rs" target = "aarch64-apple-ios-sim" crate_type = "staticlib" # The terminal edge. [[target]] name = "CLI" kind = "rust_binary" srcs = ["cli/src/**/*.rs"] deps = ["./Core", "./CoreDependencies"] # The SwiftUI edge, and the Swift bindings it calls. [[target]] name = "SwiftBindings" kind = "uniffi_bindings" deps = ["bindgen/Bindgen", "./CoreIOS"] [target.attrs] language = "swift" [[target]] name = "IOS" kind = "apple_application" srcs = ["ios/Sources/**/*.swift"] deps = ["./CoreIOS", "./SwiftBindings", "./SimulatorSDK"] [target.attrs] platform = "ios" bundle_id = "dev.tuist.edges" sdk_frameworks = ["Security"] # The Jetpack Compose edge. [[target]] name = "Android" kind = "android_binary" srcs = ["android/src/**/*.kt"] deps = ["./CoreAndroid", "./KotlinBindings", "./Compose"] [target.attrs] application_id = "dev.tuist.edges" min_sdk_version = 26 Rust libraries, Apple applications, Android binaries, the bindings between them, and their tests all live in the same graph. Once resolves third-party crates from Cargo.lock into per-platform dependency targets, so each crate compiles separately with its own cache key, instead of one cargo build compiling everything at once. The graph generates the bindings too, so nobody has to check them into the repository. Once doesn't have a built-in rule for UniFFI yet, so the sample adds one in a small Starlark module that builds uniffi-bindgen from source, runs it against the compiled core, and compiles the generated code into a Swift module for iOS and a Kotlin library for Android. Once caches that step like any other, keyed on the generator, its configuration, and the compiled core. This graph is also what caching relies on. Each step gets a key computed from its inputs, so a change to a SwiftUI view doesn't recompile the Rust core, and you can share the results between your machine, your teammates', CI, and agents working in other worktrees. When the work a build does grows with the size of your change and not the size of your app, native iteration starts to feel close to the hot reloading that made web runtimes so attractive. Linking, installing, and launching still take time, but you stop paying for a full rebuild on top. Keep the graph fast The shape of the graph matters as much as the build system. A build can only run in parallel what the graph lets it run in parallel, and Apple's WWDC talk on build parallelization calls the longest chain of dependencies the critical path: no extra cores or remote workers make the build shorter than that. So we keep the graph shallow and wide. We only add a dependency when we need it, put interfaces in their own modules so implementations compile side by side (Bruno Rocha explains the technique in Reducing iOS Build Times by using Interface Modules), and keep shared foundations small, because everything downstream rebuilds when they change. Spotify's account of moving its iOS app to Bazel shows what that buys you: with remote caching and the module isolation they did during the migration, their CI feedback loop went from 80 minutes to 20. The same goes for deciding what each edge includes. A widget doesn't need the agent, and a web build pays for every kilobyte it ships, but if each edge builds the core with its own Cargo features, the cache sees a different core per edge, and you end up building and keeping warm one graph per edge. Bazel's documentation on configurations warns about the same growth. We'd rather split the core into crates, like storage, networking, and the agent, and let each edge depend on the ones it needs, the way Mozilla's megazords link only the components an app uses. A crate's hash doesn't depend on who uses it, so adding or removing an edge doesn't invalidate anything shared. For dropping unused code from a binary, the linker's dead-code stripping does the job after the core is compiled and cached. Try both approaches below. Each edge links the crates it picks through a small umbrella crate, drawn dashed. Turn edges on and off or edit a file in storage, and watch which nodes get a new hash: 🧪 Try the interactive graph on the original post The sample's graph is small, but it has this shape: each edge depends only on its build of the core and its own platform libraries, and no edge depends on another. Adopting native edges Starting with one platform We like to start with one platform and move logic into the core as soon as it looks reusable, without trying to design the core up front. A few signs that something belongs there: it doesn't import any platform framework, another platform would need the same behavior, or you'd want to test it without launching the app. Some typical candidates: - The API client: routes, request and response types, validation, and error mapping. - Authentication: building the sign-in URL, exchanging codes for tokens, and refreshing them. - Pagination, search, and filtering rules for lists. - Form validation, like which fields are required and what a valid date range looks like. - The route catalog and deep-link parsing. - Local caching and reconciling data after a reconnect. When the second platform comes along, most of the work is building a new edge on top of a core that's already built and tested. And keep doing it as features change: when a Swift or Kotlin model starts making business decisions, that's your cue to move the logic into the core. If you already have clients Most teams won't start from scratch, and that's the more interesting case. If you already have clients on several platforms, each in its own language and built with its own tools, I'd start by modeling them in one build graph connected to a remote cache. That makes builds faster before any code moves, and it makes adding a Rust core affordable later. Then pick one piece that's duplicated across your clients, like the API client, authentication, or validation rules, and move it into the core, and while you're at it, think about what your app would look like as a CLI or a set of commands an agent can run. You keep shipping your clients the whole time, and over time you'll notice the edges getting thinner and thinner until they're mostly UI and calls to platform APIs. What we're doing at Tuist We're doing this ourselves. The native clients of Atlas, our internal operations platform, follow these principles: an iOS and macOS app in SwiftUI, an Android app in Kotlin, and a CLI, all on one Rust core. We've also started moving Tuist's own clients to the same model. When we looked across them, we found a lot of duplicated logic, most of it in Swift and some of it rewritten in Kotlin and other languages, and we're now moving that layer into a shared Rust core with the CLI and the apps as its edges. We'll write about it as the work progresses. It would be a dream for us to see more apps built natively again, apps that use less memory, feel at home on their platform, and run faster. This is early work, and we kept the sample small on purpose. The sample app and Once are on GitHub if you want to try the principles, and if you build something with them, we'd love to hear about it.
PDF.js Deep Dive from Demo 3: getPage Introduction In the demo of the first article, we obtained the pdf variable via await loadingTask.promise — that promise is resolved by the main thread's GetDoc handler after the worker sends back the GetDoc message; see article 13 for details — and it is an instance of PDFDocumentProxy. Back to the demo in the first article, lines 8-9: const page = await pdf.getPage(1); The getPage call This call passes a single numeric argument: | Parameter | Value | Meaning | | ------------ | ----- | ------------------------------------------ | | pageNumber | 1 | Page number; the first page is 1 (1-based) | getPage returns a Promise that resolves to a PDFPageProxy object. This article follows that single call through the full execution chain of getPage. According to the definition in api.js, the execution flow is as follows. Lines 773-775: PDFDocumentProxy.getPage: a one-line delegation getPage(pageNumber) { return this._transport.getPage(pageNumber); } The pdf object itself is just a facade; the real logic lives entirely on this._transport — the WorkerTransport instance created in article 2 (task._transport). Everything below happens inside WorkerTransport.getPage. Lines 3031-3037: Validate the arguments if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) { return Promise.reject(new Error('Invalid page request.')); } Plugging the demo values into each condition: | Condition | With pageNumber = 1 | Result | | ------------------------------------------- | ----------------------------------------------------------- | ------- | | !Number.isInteger(pageNumber) | Number.isInteger(1) is true, so the negation is false | Not hit | | pageNumber <= 0 | 1 <= 0 is false | Not hit | | pageNumber > this.pagesMapper.pagesNumber | pagesNumber is 756, 1 > 756 is false | Not hit | pagesNumber is 756, written from pdfInfo.numPages when the document becomes ready: view source. The demo PDF in this article has 756 pages, so validation passes. If you pass 0, a negative number, a decimal, or a number beyond the page count, this returns a rejected promise immediately. > Q: Why use Promise.reject instead of throw? > A: To keep the asynchronous semantics consistent. Callers of getPage always await a promise, so argument errors go through the same error channel. Callers don't have to handle both a synchronous throw and an asynchronous rejection. Lines 3038-3039: Convert the page number const pageIndex = pageNumber - 1; // 1 - 1 = 0 const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0 These two lines produce three page numbering schemes, each serving a different consumer: | Variable | Perspective | Base | Value here | Purpose | | -------------- | -------------------------- | ------- | ---------- | ------------------------------------- | | pageNumber | User | 1-based | 1 | The public getPage argument | | pageIndex | Main thread | 0-based | 0 | Key of #pageCache / #pagePromises | | newPageIndex | Worker (original document) | 0-based | 0 | Payload of the GetPage message | pagesMapper.getPageId(1) returns 1. The demo never moves, deletes, or copies pages, so the mapping is an identity mapping (#pageNumberToId is null), and therefore newPageIndex === pageIndex === 0. The three only diverge once the document has been edited. PagesMapper: translating between user page numbers and original page order PagesMapper was created with new PagesMapper() during argument parsing in article 2: view source. It exposes just two things: - pagesNumber: the current total page count. Once assigned, the internal #pageNumberToId / #prevPageNumbers are reset to null, falling back to the identity mapping; - getPageId(pageNumber) / getPageNumber(pageId): two-way translation (L462-L464, L443-L454). Under the identity mapping they return their input unchanged; only an actual page reorder builds a real table. > Q: Why go through PagesMapper at all? > A: To support page reordering. In an editor scenario, users can move, delete, and copy pages, after which "page 3" may no longer be the original document's page 3. The worker side only understands the original document's page order, so what gets sent to the worker is newPageIndex, while the two main-thread caches are keyed by the user-facing pageIndex. Under an identity mapping the two are identical and can be ignored. Note: PagesMapper has nothing to do with the PDF file itself — the PDF contains no such data; it exists purely to hold state while editing. One sentence to distinguish them: pageIndex is the cache key on the main thread, while newPageIndex is the key sent to the worker. Lines 3041-3044: Check the cache const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0 if (cachedPromise) { return cachedPromise; } #pagePromises is a Map<pageIndex, Promise<PDFPageProxy>>. On the first call there is no cache hit, so we continue. > Q: What does the cache actually do? > A: In practice, calling await pdf.getPage(1) twice in a row returns the same PDFPageProxy object (page === page2 is true). The second call hits the cached promise directly and no message is sent to the worker. Three layers of cache: separate stores, separate jobs WorkerTransport declares all three Maps as class fields in one place: view source #pageCache = new Map(); #pagePromises = new Map(); #pageRefCache = new Map(); | Field | Key → Value | Responsibility | Typical use | | --------------- | ----------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | #pageCache | pageIndex → PDFPageProxy | Index of created page objects | StartRenderPage looks up a page by index (api.js#L2805); startCleanup iterates it (api.js#L3227) | | #pagePromises | pageIndex → Promise<PDFPageProxy> | Deduplicates in-flight requests and repeat calls | Read at the top of getPage (line 3041 here), written at the end (line 3067) | | #pageRefCache | refStr → pageIndex | Reverse lookup from object reference to page index | Written by getPage (line 3054), read by cachedPageNumber (api.js#L3245-L3255) | All three share the same lifetime and are cleared when the transport is destroyed: destroy() clears them in turn (api.js#L2611-L2616). > Q: Why keep #pagePromises when #pageCache already holds the page object? > A: They solve different problems. #pagePromises is written before the message even leaves, so that "several getPage calls arriving at the same time only send one GetPage message". #pageCache is only written after the reply arrives, so that transport-internal events can look up objects by page index. Caching the promise rather than the page is precisely what covers the window before the reply arrives. Lines 3045-3048: Send the GetPage message to the worker const promise = this.messageHandler .sendWithPromise('GetPage', { pageIndex: newPageIndex, // 0 }) .then(pageInfo => { // The reply handling is covered in the next section }); messageHandler is the MessageHandler created in article 2. The communication mechanism of sendWithPromise is covered in article 6. All you need to know here is that it sends the message to the worker thread and returns a promise that waits for the worker's reply. When the worker receives GetPage: view source handler.on('GetPage', async function ({ pageIndex }) { const page = await pdfManager.getPage(pageIndex); const [rotate, ref, userUnit, view] = await Promise.all([ pdfManager.ensure(page, 'rotate'), pdfManager.ensure(page, 'ref'), pdfManager.ensure(page, 'userUnit'), pdfManager.ensure(page, 'view'), ]); return { rotate, ref, refStr: ref?.toString() ?? null, userUnit, view, }; }); How the worker parses the page object internally is covered in article 14. The reply for the demo's first page is: pageInfo = { rotate: 0, ref: { num: 4, gen: 0 }, // after structured cloning across threads, the Ref instance degrades to a plain object refStr: '4R', // the result of ref.toString() on the worker side userUnit: 1, view: [0, 0, 595, 842], }; | Field | Value here | Meaning | Matching PDFPageProxy getter | | ---------- | -------------------- | ------------------------------------------------- | ------------------------------ | | rotate | 0 | Clockwise rotation in degrees | rotate | | ref | { num: 4, gen: 0 } | Object reference (the entry Ref of this page in the PDF file) | ref | | refStr | "4R" | String form of the reference, used as a cache key | None (internal only) | | userUnit | 1 | User unit conversion factor | userUnit | | view | [0, 0, 595, 842] | Visible area (PDF user space) | view | The 595 × 842 of view is exactly A4 paper size in 1/72-inch units, matching the demo's PDF specification. > Q: What is ref: { num: 4, gen: 0 }? Why isn't it a Ref instance? > > A: It is PDF's Indirect Reference — every object in a PDF file has a unique (num, gen) number, and the page object is no exception. num = 4 means "object #4", gen = 0 is the generation number (almost always 0 in new PDFs; it only bumps up after incremental updates). > > On the worker side this was a real Ref instance: view source created via Ref.get(4, 0), with a cached private #str = '4R'. But it cannot cross threads: Web Worker postMessage uses Structured Clone, which only copies enumerable own properties. Ref's #str is a private field — it doesn't clone — and class instances degrade to plain objects, leaving only the two public fields num and gen. That's why the main thread receives { num: 4, gen: 0 }. > > The main-thread PDFPageProxy.ref getter (api.js#L1399) simply returns this plain object — it never reconstructs a Ref, because the main thread only ever needs to read num / gen and never sends them back as-is. Later messages like GetPageIndex or the editor's SaveDocument rebuild a Ref on the worker side from these two fields. > > Why pre-compute refStr on the worker side? Precisely because Ref#toString() is lost after crossing threads — the main thread holds only a plain object and can't call toString. So the worker pre-computes refStr: ref?.toString() ?? null in its return value. Two fields, two roles: ref for callers that need to split num / gen, refStr for cache lookups that need a string key. > Q: Why is refStr "4R" rather than "4 0 R"? > > A: Ref#toString returns the compact string cached inside the object: numR when gen is zero, numRgen otherwise: view source. The reverse-lookup side, cachedPageNumber, builds its key as ${ref.num}R as well, so both sides use the same format. > Q: Why only these 4 properties? > > A: They are all metadata of the page: rotation angle, object reference, unit conversion factor, and visible area. Reading them only requires parsing the page object dictionary, without touching the page content stream. The real content parsing (drawing commands) is deferred to the GetOperatorList message at render time. This is why getPage can return quickly. > > These 4 properties map one-to-one to getters on PDFPageProxy: rotate, ref, userUnit, and view. The next article's getViewport will use two of them. Lines 3049-3068: Handle the reply and create the PDFPageProxy Once the reply arrives, we enter the .then callback: .then(pageInfo => { if (this.destroyed) { throw new Error("Transport destroyed"); } if (pageInfo.refStr) { this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0 } const page = new PDFPageProxy( pageIndex, // 0 pageInfo, this, this.pagesMapper, this._params.pdfBug // false ); this.#pageCache.set(pageIndex, page); return page; }); this.#pagePromises.set(pageIndex, promise); // line 3067 return promise; // line 3068 Four things happen in sequence: 1. destroyed check: while waiting for the reply, the document may already have been destroyed (loadingTask.destroy()); if so, throw an error; 2. #pageRefCache reverse-lookup cache: "4R" → 0. Used by cachedPageNumber(ref) for reverse lookups — for example, when an outline link jumps to a page, the PDF internally locates pages by object reference (4 0 R), which must be translated back to a page number; 3. Create the PDFPageProxy and store it in #pageCache; 4. Store the promise itself in #pagePromises. > Q: Why does #pageRefCache store newPageIndex rather than pageIndex? > > A: Because it records "which page of the original document this object reference points to", which has nothing to do with the user's perspective. During reverse lookup, cachedPageNumber first obtains the original page index, then translates it back to the user page number via pagesMapper.getPageNumber(pageIndex + 1) (api.js#L3245-L3255). Under an identity mapping the two happen to be equal, but semantically they are not interchangeable. The PDFPageProxy constructor: assignment only, no parsing View source constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) { this._pageIndex = pageIndex; // 0 this._pageInfo = pageInfo; // the pageInfo received above this._transport = transport; this._stats = pdfBug ? new StatTimer() : null; // false, so performance stats are not enabled this._pdfBug = pdfBug; this.commonObjs = transport.commonObjs; // resource container shared across the whole document (fonts, etc.) this.objs = new PDFObjects(); // resource container private to this page this._intentStates = new Map(); // rendering intent state cache, used by render in article 5 this.destroyed = false; this.recordedBBoxes = null; // recorded regions for the editor scenario this.#pagesMapper = pagesMapper; this.imageCoordinates = null; // image coordinate mapping, only populated after rendering } The whole constructor contains not a single await and does no parsing at all; it only moves references around and seeds placeholders: - _pageIndex is exposed to the outside as pageNumber via a getter returning _pageIndex + 1; - _pageInfo is the pageInfo above; all four getters read from it; - commonObjs and objs are resource containers used later during rendering; PDFObjects will be covered in a separate article; - recordedBBoxes / imageCoordinates stay null until the first render. This is also why the main-thread cost of getPage is so small: the heavy lifting happens entirely on the worker thread, and the main thread only assembles the object. At this point, the promise returned by getPage(1) resolves to page, and the demo's await receives it. Summary In this section we followed the demo through the complete getPage(1) call: 1. PDFDocumentProxy.getPage delegates in one line to WorkerTransport.getPage; 2. Argument validation: 1 is a valid page number (756 pages in total); 3. Page number conversion: pageNumber(1) → pageIndex(0) → newPageIndex(0); under the identity mapping all three agree; 4. The #pagePromises cache is checked and missed on the first call; 5. A GetPage message is sent to the worker, and rotate / ref / refStr / userUnit / view come back; 6. #pageRefCache is written, the PDFPageProxy is created, and #pageCache is populated; 7. The promise is stored in #pagePromises and returned; the demo's await yields page. With page in hand, the demo's next step is page.getViewport({ scale: 0.5 }) — which will use the view and userUnit from this article's pageInfo. That will be covered in the next article. --- About me: Creator of StreamPDF, focused on research and development in AI and PDF. Original article: streampdf.com
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
PDF Reader in JavaScript
such a great tool mozilla/pdf.js
@firefox I'd be more inclined if you considered DBSC like your main competitor. standards-positions#912
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