Skip to content

Commit 2ea194d

Browse files
committed
perf(web): zero-copy pixel path + consolidate js/wasmJs into webMain
Write pdfium's pixel ArrayBuffer straight into Skia's wasm linear memory (via Data.makeUninitialized + Int8Array.set on awaitSkiko's memory buffer, pattern from coil3.decode.WebWorker), removing the intermediate Kotlin ByteArray + installPixels copy. One memcpy on the main thread instead of two. Also consolidate the js + wasmJs sources into a shared webMain: - PdfDocument, PdfiumGlue externals, ClipEntryText, and the Skiko zero-copy helper all live in webMain - jsMain/wasmJsMain reduced to a ~30-line PlatformBridge covering the three structural differences between the targets: typed-array to Kotlin primitive conversion (zero-cost unsafeCast on js vs bulk copy on wasmJs), the kotlinx.coroutines.await signature mismatch (papered over by an awaitTyped<T>() expect/actual), and Skiko's platform- specific awaitSkiko - pdfium js target switched to useEsModules() so a single @jsmodule annotation works for both targets without a jsMain-only @JsNonModule
1 parent 783cba3 commit 2ea194d

11 files changed

Lines changed: 167 additions & 193 deletions

File tree

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.

pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.wasmJs.kt renamed to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,33 @@
1+
@file:OptIn(ExperimentalWasmJsInterop::class)
2+
13
package dev.nucleusframework.pdfium
24

35
import androidx.compose.ui.graphics.ImageBitmap
46
import androidx.compose.ui.graphics.asComposeImageBitmap
5-
import kotlinx.coroutines.await
7+
import kotlin.js.ExperimentalWasmJsInterop
68
import org.jetbrains.skia.Bitmap
79
import org.jetbrains.skia.ColorAlphaType
10+
import org.jetbrains.skia.Image
811
import org.jetbrains.skia.ImageInfo
9-
import org.khronos.webgl.Int8Array
10-
import org.khronos.webgl.toByteArray
11-
import org.khronos.webgl.toFloatArray
12-
import org.khronos.webgl.toInt8Array
13-
import org.khronos.webgl.toIntArray
1412

1513
/**
16-
* wasmJs PDF document. pdfium.wasm runs inside a dedicated Web Worker (spawned by
17-
* `pdfium_glue.mjs`) so the browser's main thread stays free to paint frames while a
18-
* render is in flight. Everything here is just a thin [await] bridge from Kotlin
19-
* suspension onto the RPC promises the worker returns.
14+
* Shared web (js + wasmJs) `PdfDocument` actual. pdfium.wasm runs inside a dedicated
15+
* Web Worker (spawned by `pdfium_glue.mjs`) so the browser's main thread stays free to
16+
* paint frames while a render is in flight. Everything here is just a thin
17+
* [awaitTyped] bridge from Kotlin suspension onto the RPC promises the worker returns.
2018
*
21-
* The only unavoidable main-thread copy happens inside [renderPage]: the worker's
22-
* pixel `ArrayBuffer` arrives transferred (zero-copy), then we bulk-copy once into a
23-
* Kotlin [ByteArray] for Skiko's `installPixels` (Skia has its own wasm heap and
24-
* exposes no ArrayBuffer-direct install on this Skiko version).
19+
* Rendered pixel buffers arrive as transferred [org.khronos.webgl.ArrayBuffer]s and are
20+
* written straight into Skia's wasm linear memory via [passToSkiko] — no intermediate
21+
* Kotlin [ByteArray]. Platform-specific typed-array bridges live in
22+
* [WebTypedArrayBridge].
2523
*/
2624
internal actual class PdfDocument internal constructor(
2725
private val docPtr: Int,
2826
actual val pageCount: Int,
2927
actual val metadata: PdfMetadata,
3028
) {
3129
actual suspend fun pageSize(pageIndex: Int): PageSize {
32-
val r = pageSize(docPtr, pageIndex).await<PageSizeResult>()
30+
val r = pageSize(docPtr, pageIndex).awaitTyped<PageSizeResult>()
3331
return PageSize(r.widthPoints, r.heightPoints)
3432
}
3533

@@ -39,26 +37,24 @@ internal actual class PdfDocument internal constructor(
3937
heightPx: Int,
4038
quality: RenderQuality,
4139
): ImageBitmap {
42-
val r = renderPage(docPtr, pageIndex, widthPx, heightPx, quality.toFlags()).await<RenderResult>()
40+
val r = renderPage(docPtr, pageIndex, widthPx, heightPx, quality.toFlags()).awaitTyped<RenderResult>()
4341
// pdfium writes BGRA, matching Skia's native N32 colour type on wasm — no swizzle.
44-
val pixels = Int8Array(r.pixels).toByteArray()
42+
val skikoData = r.pixels.passToSkiko()
4543
val info = ImageInfo.makeN32(widthPx, heightPx, ColorAlphaType.PREMUL)
46-
val bitmap = Bitmap()
47-
val installed = bitmap.installPixels(info, pixels, widthPx * 4)
48-
check(installed) { "Skia installPixels returned false" }
49-
return bitmap.asComposeImageBitmap()
44+
val image = Image.makeRaster(info, skikoData, widthPx * 4)
45+
return Bitmap.makeFromImage(image).asComposeImageBitmap()
5046
}
5147

5248
actual suspend fun pageText(pageIndex: Int): String =
53-
pageText(docPtr, pageIndex).await<TextResult>().text
49+
pageText(docPtr, pageIndex).awaitTyped<TextResult>().text
5450

5551
actual suspend fun pageTextLayout(pageIndex: Int): PageTextLayout {
56-
val r = pageTextLayout(docPtr, pageIndex).await<TextLayoutResult>()
52+
val r = pageTextLayout(docPtr, pageIndex).awaitTyped<TextLayoutResult>()
5753
val size = PageSize(r.widthPoints, r.heightPoints)
58-
val rectBoxes = r.rectBoxes.toFloatArray()
54+
val rectBoxes = r.rectBoxes.toSharedFloatArray()
5955
val rectTexts = Array(r.rectTexts.length) { i -> r.rectTexts[i]?.toString().orEmpty() }
60-
val charCodepoints = r.charCodepoints.toIntArray()
61-
val charBoxes = r.charBoxes.toFloatArray()
56+
val charCodepoints = r.charCodepoints.toSharedIntArray()
57+
val charBoxes = r.charBoxes.toSharedFloatArray()
6258
return PageTextLayout(pageIndex, size, rectBoxes, rectTexts, charCodepoints, charBoxes)
6359
}
6460

@@ -78,10 +74,11 @@ internal actual class PdfDocument internal constructor(
7874
}
7975

8076
internal actual suspend fun openPdfDocument(bytes: ByteArray, password: String?): PdfDocument {
81-
// Move the PDF bytes into a JS ArrayBuffer (bulk copy) and transfer ownership to
82-
// the worker — the main thread has no further use for them.
83-
val buffer = bytes.toInt8Array().buffer
84-
val r = openDocument(buffer, password).await<OpenResult>()
77+
// On wasmJs this is a bulk copy into a JS ArrayBuffer, on jsMain it's a zero-copy
78+
// reinterpretation (ByteArray IS Int8Array). Either way we transfer ownership to
79+
// the worker — the main thread has no further use for the bytes.
80+
val buffer = bytes.toJsArrayBuffer()
81+
val r = openDocument(buffer, password).awaitTyped<OpenResult>()
8582
return PdfDocument(
8683
docPtr = r.doc,
8784
pageCount = r.pageCount,

pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt renamed to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,23 @@
11
@file:JsModule("./pdfium_glue.mjs")
2+
@file:OptIn(ExperimentalWasmJsInterop::class)
23

34
package dev.nucleusframework.pdfium
45

6+
import kotlin.js.ExperimentalWasmJsInterop
7+
import kotlin.js.JsAny
8+
import kotlin.js.JsArray
9+
import kotlin.js.JsString
510
import kotlin.js.Promise
611
import org.khronos.webgl.ArrayBuffer
712
import org.khronos.webgl.Float32Array
813
import org.khronos.webgl.Int32Array
914

1015
/**
11-
* External bindings to `pdfium_glue.mjs`. Every call goes over a Web Worker boundary —
12-
* the JS side returns a [Promise] that resolves once the worker posts back its result.
13-
* Typed arrays (`Float32Array`, `Int32Array`) and the pixel `ArrayBuffer` are moved via
14-
* `postMessage` transferables, so they reach the main thread without a structured-clone
15-
* copy. Handles (document / page pointers) are plain [Int]s into the worker's pdfium
16-
* heap; the main thread never dereferences them.
17-
*
18-
* The `Promise<JsAny?>` return type matches the shape expected by
19-
* `kotlinx.coroutines.await`'s signature on wasmJs (which is declared as
20-
* `Promise<JsAny?>.await(): T`); the actual fulfilment values implement the typed
21-
* result interfaces below and are cast at call sites.
16+
* External bindings to `pdfium_glue.mjs`. Shared between jsMain and wasmJsMain via the
17+
* `webMain` source set — all interfaces extend [JsAny] so the declarations are valid on
18+
* both platforms. The RPC functions uniformly return `Promise<JsAny?>` because
19+
* `kotlinx.coroutines.await` on wasmJs is declared on that receiver type; call sites go
20+
* through [awaitTyped] to recover the typed fulfilment value.
2221
*/
2322
internal external interface OpenResult : JsAny {
2423
val doc: Int

0 commit comments

Comments
 (0)