Skip to content

Commit 0bc630a

Browse files
authored
Merge pull request #2 from kdroidFilter/feat/web-zero-copy
perf(web): zero-copy pixel path + consolidate js/wasmJs into webMain
2 parents 783cba3 + f7c9451 commit 0bc630a

12 files changed

Lines changed: 197 additions & 208 deletions

File tree

README.md

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22

33
A Kotlin Multiplatform PDF rendering and text-extraction library built on top of
44
[bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries) and
5-
Compose Multiplatform. Zero-copy render pipeline on JVM / Android / iOS (Web
6-
posts pixels from a worker), a Compose-first API, and a sample desktop/mobile
7-
reader with thumbnails, progressive rendering, and selectable text.
5+
Compose Multiplatform. Zero-copy render pipeline on every target — on the web
6+
the transferred pixel `ArrayBuffer` is written straight into Skia's wasm heap,
7+
no intermediate Kotlin `ByteArray`. A Compose-first API and a sample
8+
desktop/mobile reader with thumbnails, progressive rendering, and selectable
9+
text round it out.
810

911
## Features
1012

1113
- **Compose Multiplatform composables** — drop `PdfPage` or `PdfThumbnail` into
1214
any Compose UI.
13-
- **Zero-copy rendering** on JVM / Android / iOS: PDFium writes directly into
14-
Skia / Android `Bitmap` pixel memory.
15+
- **Zero-copy rendering** on every target. JVM / Android / iOS hand PDFium a
16+
raw pixel pointer into Skia / Android `Bitmap` memory. Web allocates the
17+
destination buffer inside Skia's wasm heap via `Data.makeUninitialized` and
18+
writes the worker's transferred `ArrayBuffer` straight in — no Kotlin
19+
`ByteArray` round-trip, no `installPixels` second copy.
1520
- **Progressive rendering** (preview → full) with a debounced size flow, so
1621
scroll and zoom feel instant.
1722
- **Two-tier LRU cache** (reader bitmaps + thumbnails) with off-screen prefetch.
@@ -594,10 +599,13 @@ screenH = (top - bottom) × scaleY
594599
│ PdfThumbnail ─┘ │
595600
│ PdfRenderCache PageTextLayout textClipEntry (expect) │
596601
├──────────────────┬───────────────┬─────────────┬─────────────────────────────┤
597-
│ jvmMain │ androidMain │ iosMain │ jsMain / wasmJsMain (web) │
598-
│ JNI glue │ JNI + NDK │ cinterop │ pdfium.wasm in a Web Worker │
599-
│ → Skia Bitmap │ AndroidBitmap │ libpdfium.a │ RPC via postMessage, │
600-
│ zero-copy │ zero-copy │ + Skia │ transferable pixels → Skia │
602+
│ jvmMain │ androidMain │ iosMain │ webMain (js + wasmJs) │
603+
│ JNI glue │ JNI + NDK │ cinterop │ pdfium.wasm in a Web │
604+
│ → Skia Bitmap │ AndroidBitmap │ libpdfium.a │ Worker; RPC via │
605+
│ zero-copy │ zero-copy │ + Skia │ postMessage transferables │
606+
│ │ │ │ → Skia heap zero-copy │
607+
│ │ │ │ jsMain / wasmJsMain just │
608+
│ │ │ │ host a small PlatformBridge │
601609
└──────────────────┴───────────────┴─────────────┴─────────────────────────────┘
602610
```
603611

@@ -613,6 +621,13 @@ Key facts:
613621
`Bitmap.peekPixels().addr` and pass it to `FPDFBitmap_CreateEx`. PDFium
614622
writes BGRA pixels straight into Skia's bitmap memory. On Android we lock
615623
the `android.graphics.Bitmap` via `AndroidBitmap_lockPixels` and do the same.
624+
On web, the pdfium worker transfers the pixel `ArrayBuffer` to the main
625+
thread; we allocate the destination inside Skia's own wasm heap via
626+
`Data.makeUninitialized` and copy the transferred buffer directly there with
627+
a typed-array `.set()` on the Skia memory view obtained through
628+
`org.jetbrains.skiko.wasm.awaitSkiko`. One memcpy into the final
629+
destination, no `installPixels` round-trip. Pattern cribbed from
630+
[coil3.decode.WebWorker](https://github.com/coil-kt/coil/blob/69b8383a3f95300ddb466afdbe9c54ce2eccb652/coil-core/src/jsCommonMain/kotlin/coil3/decode/WebWorker.kt).
616631

617632
- **Native binary delivery.** `pdfium/build.gradle.kts` registers a set of
618633
Gradle tasks that download the bblanchon archives, extract them, and stage
@@ -707,12 +722,12 @@ if available.
707722
bounding boxes, not glyph positioning from the embedded PDF font. Copied
708723
text is exact, but highlight rectangles can differ slightly from what
709724
Chrome / PDF.js render when they can access the original font metrics.
710-
- **Web: no zero-copy to Skia.** On wasmJs/JS, `pdfium.wasm` runs inside a
711-
dedicated Web Worker (so the main thread never blocks). Pixels are posted
712-
to the main thread via `postMessage` transferables and bulk-copied once
713-
into a Skia `Bitmap` via `installPixels` — the only unavoidable copy
714-
in the pipeline, since Skia has its own wasm heap with no direct
715-
`ArrayBuffer` install.
725+
- **Web: still one memcpy per render.** "Zero-copy" here means "no
726+
intermediate Kotlin `ByteArray`, no `installPixels`" — the worker's
727+
transferred `ArrayBuffer` is written straight into Skia's wasm heap. A true
728+
zero-memcpy pipeline would need `SharedArrayBuffer` (which in turn requires
729+
COOP/COEP headers) so the pdfium worker and Skia share one linear memory.
730+
Not currently implemented.
716731
- **Licensing.** PDFium is dual-licensed BSD-3-Clause / Apache-2.0 (see
717732
PDFium's `LICENSE`). bblanchon's binaries carry that license forward. If
718733
you ship this code, include the upstream PDFium notices.

pdfium/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ kotlin {
101101
jvm()
102102

103103
js {
104+
// ES-module output so a single `@file:JsModule("./pdfium_glue.mjs")` declaration
105+
// (shared with wasmJs in webMain) works without a jsMain-only `@JsNonModule`
106+
// companion annotation.
107+
useEsModules()
104108
browser {
105109
// Karma pulls from github.com and fails SSL on some hosts; we run no JS tests.
106110
testTask { enabled = false }

pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.js.kt

Lines changed: 0 additions & 91 deletions
This file was deleted.

pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt

Lines changed: 0 additions & 55 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
@file:OptIn(ExperimentalWasmJsInterop::class)
2+
3+
package dev.nucleusframework.pdfium
4+
5+
import kotlin.js.ExperimentalWasmJsInterop
6+
import kotlin.js.JsAny
7+
import kotlin.js.Promise
8+
import kotlinx.coroutines.await
9+
import org.khronos.webgl.ArrayBuffer
10+
import org.khronos.webgl.Float32Array
11+
import org.khronos.webgl.Int32Array
12+
import org.khronos.webgl.Int8Array
13+
14+
// FloatArray ⇄ Float32Array, IntArray ⇄ Int32Array and ByteArray ⇄ Int8Array share
15+
// runtime representation on Kotlin/JS (IR) — reinterpret directly, no copy.
16+
17+
internal actual fun ByteArray.toJsArrayBuffer(): ArrayBuffer =
18+
this.unsafeCast<Int8Array>().buffer
19+
20+
internal actual fun Float32Array.toSharedFloatArray(): FloatArray =
21+
this.unsafeCast<FloatArray>()
22+
23+
internal actual fun Int32Array.toSharedIntArray(): IntArray =
24+
this.unsafeCast<IntArray>()
25+
26+
internal actual suspend fun <T : JsAny> Promise<JsAny?>.awaitTyped(): T =
27+
this.await().unsafeCast<T>()
28+
29+
internal actual suspend fun awaitSkiko(): JsAny =
30+
org.jetbrains.skiko.wasm.awaitSkiko.await()

pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.wasmJs.kt

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package dev.nucleusframework.pdfium
2+
3+
import kotlin.js.JsAny
4+
import kotlin.js.Promise
5+
import kotlinx.coroutines.await
6+
import org.khronos.webgl.ArrayBuffer
7+
import org.khronos.webgl.Float32Array
8+
import org.khronos.webgl.Int32Array
9+
import org.khronos.webgl.toFloatArray
10+
import org.khronos.webgl.toInt8Array
11+
import org.khronos.webgl.toIntArray
12+
13+
// Kotlin/Wasm owns its own linear memory, separate from the JS engine heap where typed
14+
// arrays live — every conversion here is an unavoidable bulk copy.
15+
16+
internal actual fun ByteArray.toJsArrayBuffer(): ArrayBuffer =
17+
this.toInt8Array().buffer
18+
19+
internal actual fun Float32Array.toSharedFloatArray(): FloatArray =
20+
this.toFloatArray()
21+
22+
internal actual fun Int32Array.toSharedIntArray(): IntArray =
23+
this.toIntArray()
24+
25+
internal actual suspend fun <T : JsAny> Promise<JsAny?>.awaitTyped(): T =
26+
this.await()
27+
28+
@Suppress("INVISIBLE_REFERENCE")
29+
internal actual suspend fun awaitSkiko(): JsAny =
30+
org.jetbrains.skiko.wasm.awaitSkiko.await()

pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.js.kt renamed to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.kt

File renamed without changes.

0 commit comments

Comments
 (0)