From 2ea194d20b6e44dc8061fead28ac667717b5c643 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 24 Apr 2026 16:23:54 +0300 Subject: [PATCH 1/2] 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() 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 --- pdfium/build.gradle.kts | 4 + .../nucleusframework/pdfium/PdfDocument.js.kt | 91 ------------------- .../dev/nucleusframework/pdfium/PdfiumGlue.kt | 55 ----------- .../pdfium/PlatformBridge.js.kt | 30 ++++++ .../pdfium/ClipEntryText.wasmJs.kt | 6 -- .../pdfium/PlatformBridge.wasmJs.kt | 30 ++++++ .../nucleusframework/pdfium/ClipEntryText.kt} | 0 .../pdfium/PdfDocument.web.kt} | 57 ++++++------ .../dev/nucleusframework/pdfium/PdfiumGlue.kt | 21 ++--- .../nucleusframework/pdfium/SkikoZeroCopy.kt | 36 ++++++++ .../pdfium/WebTypedArrayBridge.kt | 30 ++++++ 11 files changed, 167 insertions(+), 193 deletions(-) delete mode 100644 pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.js.kt delete mode 100644 pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt create mode 100644 pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.js.kt delete mode 100644 pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.wasmJs.kt create mode 100644 pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.wasmJs.kt rename pdfium/src/{jsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.js.kt => webMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.kt} (100%) rename pdfium/src/{wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.wasmJs.kt => webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt} (56%) rename pdfium/src/{wasmJsMain => webMain}/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt (65%) create mode 100644 pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/SkikoZeroCopy.kt create mode 100644 pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/WebTypedArrayBridge.kt diff --git a/pdfium/build.gradle.kts b/pdfium/build.gradle.kts index b0812d4..0a1596c 100644 --- a/pdfium/build.gradle.kts +++ b/pdfium/build.gradle.kts @@ -101,6 +101,10 @@ kotlin { jvm() js { + // ES-module output so a single `@file:JsModule("./pdfium_glue.mjs")` declaration + // (shared with wasmJs in webMain) works without a jsMain-only `@JsNonModule` + // companion annotation. + useEsModules() browser { // Karma pulls from github.com and fails SSL on some hosts; we run no JS tests. testTask { enabled = false } diff --git a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.js.kt b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.js.kt deleted file mode 100644 index 09d0873..0000000 --- a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.js.kt +++ /dev/null @@ -1,91 +0,0 @@ -package dev.nucleusframework.pdfium - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import kotlinx.coroutines.await -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.ImageInfo -import org.khronos.webgl.Int8Array -import org.khronos.webgl.get -import org.khronos.webgl.set - -/** - * Kotlin/JS PdfDocument actual. Delegates to the same worker-based `pdfium_glue.mjs` - * RPC as the wasmJs actual, differing only in the typed-array ⇄ Kotlin array bridges - * (Kotlin/JS's primitive arrays are JS typed arrays under the hood, so `unsafeCast` - * yields a zero-copy reinterpretation; Kotlin/Wasm owns separate linear memory and - * needs an explicit copy). - */ -internal actual class PdfDocument internal constructor( - private val docPtr: Int, - actual val pageCount: Int, - actual val metadata: PdfMetadata, -) { - actual suspend fun pageSize(pageIndex: Int): PageSize { - val r = pageSize(docPtr, pageIndex).await() - return PageSize(r.widthPoints, r.heightPoints) - } - - actual suspend fun renderPage( - pageIndex: Int, - widthPx: Int, - heightPx: Int, - quality: RenderQuality, - ): ImageBitmap { - val r = renderPage(docPtr, pageIndex, widthPx, heightPx, quality.toFlags()).await() - // Kotlin/JS's ByteArray runtime type IS Int8Array — reinterpret directly. - val pixels = Int8Array(r.pixels).unsafeCast() - val info = ImageInfo.makeN32(widthPx, heightPx, ColorAlphaType.PREMUL) - val bitmap = Bitmap() - val installed = bitmap.installPixels(info, pixels, widthPx * 4) - check(installed) { "Skia installPixels returned false" } - return bitmap.asComposeImageBitmap() - } - - actual suspend fun pageText(pageIndex: Int): String = - pageText(docPtr, pageIndex).await().text - - actual suspend fun pageTextLayout(pageIndex: Int): PageTextLayout { - val r = pageTextLayout(docPtr, pageIndex).await() - val size = PageSize(r.widthPoints, r.heightPoints) - // FloatArray ⇄ Float32Array and IntArray ⇄ Int32Array share representation on JS/IR. - val rectBoxes = r.rectBoxes.unsafeCast() - val rectTexts = r.rectTexts - val charCodepoints = r.charCodepoints.unsafeCast() - val charBoxes = r.charBoxes.unsafeCast() - return PageTextLayout(pageIndex, size, rectBoxes, rectTexts, charCodepoints, charBoxes) - } - - actual fun close() { - // Fire-and-forget; the worker frees both the doc handle and the buffer ptr. - closeDocument(docPtr) - } - - private fun RenderQuality.toFlags(): Int = when (this) { - RenderQuality.PREVIEW -> 0 - RenderQuality.FULL -> FPDF_ANNOT - } - - companion object { - private const val FPDF_ANNOT: Int = 0x01 - } -} - -internal actual suspend fun openPdfDocument(bytes: ByteArray, password: String?): PdfDocument { - // ByteArray IS Int8Array on Kotlin/JS, so we read its `.buffer` directly — no copy. - val buffer = bytes.unsafeCast().buffer - val r = openDocument(buffer, password).await() - return PdfDocument( - docPtr = r.doc, - pageCount = r.pageCount, - metadata = PdfMetadata( - title = r.title, - author = r.author, - subject = r.subject, - keywords = r.keywords, - creator = r.creator, - producer = r.producer, - ), - ) -} diff --git a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt deleted file mode 100644 index cc5e009..0000000 --- a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt +++ /dev/null @@ -1,55 +0,0 @@ -@file:JsModule("./pdfium_glue.mjs") -@file:JsNonModule - -package dev.nucleusframework.pdfium - -import kotlin.js.Promise -import org.khronos.webgl.ArrayBuffer -import org.khronos.webgl.Float32Array -import org.khronos.webgl.Int32Array - -/** - * Kotlin/JS bindings to `pdfium_glue.mjs`. Mirrors the wasmJs bindings but with - * JS-native typing: plain `Array` for string vectors, concrete `Promise` - * (no `JsAny?` wrapper) and the `@JsNonModule` annotation the JS/IR backend requires - * when accessing a module declaration under UMD output. - */ -internal external interface OpenResult { - val doc: Int - val pageCount: Int - val title: String? - val author: String? - val subject: String? - val keywords: String? - val creator: String? - val producer: String? -} - -internal external interface PageSizeResult { - val widthPoints: Float - val heightPoints: Float -} - -internal external interface RenderResult { - val pixels: ArrayBuffer -} - -internal external interface TextResult { - val text: String -} - -internal external interface TextLayoutResult { - val widthPoints: Float - val heightPoints: Float - val rectBoxes: Float32Array - val rectTexts: Array - val charCodepoints: Int32Array - val charBoxes: Float32Array -} - -internal external fun openDocument(buffer: ArrayBuffer, password: String?): Promise -internal external fun closeDocument(doc: Int): Promise -internal external fun pageSize(doc: Int, pageIndex: Int): Promise -internal external fun renderPage(doc: Int, pageIndex: Int, w: Int, h: Int, flags: Int): Promise -internal external fun pageText(doc: Int, pageIndex: Int): Promise -internal external fun pageTextLayout(doc: Int, pageIndex: Int): Promise diff --git a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.js.kt b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.js.kt new file mode 100644 index 0000000..b58050b --- /dev/null +++ b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.js.kt @@ -0,0 +1,30 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package dev.nucleusframework.pdfium + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.Promise +import kotlinx.coroutines.await +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Float32Array +import org.khronos.webgl.Int32Array +import org.khronos.webgl.Int8Array + +// FloatArray ⇄ Float32Array, IntArray ⇄ Int32Array and ByteArray ⇄ Int8Array share +// runtime representation on Kotlin/JS (IR) — reinterpret directly, no copy. + +internal actual fun ByteArray.toJsArrayBuffer(): ArrayBuffer = + this.unsafeCast().buffer + +internal actual fun Float32Array.toSharedFloatArray(): FloatArray = + this.unsafeCast() + +internal actual fun Int32Array.toSharedIntArray(): IntArray = + this.unsafeCast() + +internal actual suspend fun Promise.awaitTyped(): T = + this.await().unsafeCast() + +internal actual suspend fun awaitSkiko(): JsAny = + org.jetbrains.skiko.wasm.awaitSkiko.await() diff --git a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.wasmJs.kt b/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.wasmJs.kt deleted file mode 100644 index fb52c9c..0000000 --- a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.wasmJs.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.nucleusframework.pdfium - -import androidx.compose.ui.platform.ClipEntry - -actual fun textClipEntry(text: String): ClipEntry = - ClipEntry.withPlainText(text) diff --git a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.wasmJs.kt b/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.wasmJs.kt new file mode 100644 index 0000000..4d12c70 --- /dev/null +++ b/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PlatformBridge.wasmJs.kt @@ -0,0 +1,30 @@ +package dev.nucleusframework.pdfium + +import kotlin.js.JsAny +import kotlin.js.Promise +import kotlinx.coroutines.await +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Float32Array +import org.khronos.webgl.Int32Array +import org.khronos.webgl.toFloatArray +import org.khronos.webgl.toInt8Array +import org.khronos.webgl.toIntArray + +// Kotlin/Wasm owns its own linear memory, separate from the JS engine heap where typed +// arrays live — every conversion here is an unavoidable bulk copy. + +internal actual fun ByteArray.toJsArrayBuffer(): ArrayBuffer = + this.toInt8Array().buffer + +internal actual fun Float32Array.toSharedFloatArray(): FloatArray = + this.toFloatArray() + +internal actual fun Int32Array.toSharedIntArray(): IntArray = + this.toIntArray() + +internal actual suspend fun Promise.awaitTyped(): T = + this.await() + +@Suppress("INVISIBLE_REFERENCE") +internal actual suspend fun awaitSkiko(): JsAny = + org.jetbrains.skiko.wasm.awaitSkiko.await() diff --git a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.js.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.kt similarity index 100% rename from pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.js.kt rename to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/ClipEntryText.kt diff --git a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.wasmJs.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt similarity index 56% rename from pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.wasmJs.kt rename to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt index 8b45497..0c71307 100644 --- a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.wasmJs.kt +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfDocument.web.kt @@ -1,27 +1,25 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + package dev.nucleusframework.pdfium import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap -import kotlinx.coroutines.await +import kotlin.js.ExperimentalWasmJsInterop import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.Image import org.jetbrains.skia.ImageInfo -import org.khronos.webgl.Int8Array -import org.khronos.webgl.toByteArray -import org.khronos.webgl.toFloatArray -import org.khronos.webgl.toInt8Array -import org.khronos.webgl.toIntArray /** - * wasmJs PDF document. pdfium.wasm runs inside a dedicated Web Worker (spawned by - * `pdfium_glue.mjs`) so the browser's main thread stays free to paint frames while a - * render is in flight. Everything here is just a thin [await] bridge from Kotlin - * suspension onto the RPC promises the worker returns. + * Shared web (js + wasmJs) `PdfDocument` actual. pdfium.wasm runs inside a dedicated + * Web Worker (spawned by `pdfium_glue.mjs`) so the browser's main thread stays free to + * paint frames while a render is in flight. Everything here is just a thin + * [awaitTyped] bridge from Kotlin suspension onto the RPC promises the worker returns. * - * The only unavoidable main-thread copy happens inside [renderPage]: the worker's - * pixel `ArrayBuffer` arrives transferred (zero-copy), then we bulk-copy once into a - * Kotlin [ByteArray] for Skiko's `installPixels` (Skia has its own wasm heap and - * exposes no ArrayBuffer-direct install on this Skiko version). + * Rendered pixel buffers arrive as transferred [org.khronos.webgl.ArrayBuffer]s and are + * written straight into Skia's wasm linear memory via [passToSkiko] — no intermediate + * Kotlin [ByteArray]. Platform-specific typed-array bridges live in + * [WebTypedArrayBridge]. */ internal actual class PdfDocument internal constructor( private val docPtr: Int, @@ -29,7 +27,7 @@ internal actual class PdfDocument internal constructor( actual val metadata: PdfMetadata, ) { actual suspend fun pageSize(pageIndex: Int): PageSize { - val r = pageSize(docPtr, pageIndex).await() + val r = pageSize(docPtr, pageIndex).awaitTyped() return PageSize(r.widthPoints, r.heightPoints) } @@ -39,26 +37,24 @@ internal actual class PdfDocument internal constructor( heightPx: Int, quality: RenderQuality, ): ImageBitmap { - val r = renderPage(docPtr, pageIndex, widthPx, heightPx, quality.toFlags()).await() + val r = renderPage(docPtr, pageIndex, widthPx, heightPx, quality.toFlags()).awaitTyped() // pdfium writes BGRA, matching Skia's native N32 colour type on wasm — no swizzle. - val pixels = Int8Array(r.pixels).toByteArray() + val skikoData = r.pixels.passToSkiko() val info = ImageInfo.makeN32(widthPx, heightPx, ColorAlphaType.PREMUL) - val bitmap = Bitmap() - val installed = bitmap.installPixels(info, pixels, widthPx * 4) - check(installed) { "Skia installPixels returned false" } - return bitmap.asComposeImageBitmap() + val image = Image.makeRaster(info, skikoData, widthPx * 4) + return Bitmap.makeFromImage(image).asComposeImageBitmap() } actual suspend fun pageText(pageIndex: Int): String = - pageText(docPtr, pageIndex).await().text + pageText(docPtr, pageIndex).awaitTyped().text actual suspend fun pageTextLayout(pageIndex: Int): PageTextLayout { - val r = pageTextLayout(docPtr, pageIndex).await() + val r = pageTextLayout(docPtr, pageIndex).awaitTyped() val size = PageSize(r.widthPoints, r.heightPoints) - val rectBoxes = r.rectBoxes.toFloatArray() + val rectBoxes = r.rectBoxes.toSharedFloatArray() val rectTexts = Array(r.rectTexts.length) { i -> r.rectTexts[i]?.toString().orEmpty() } - val charCodepoints = r.charCodepoints.toIntArray() - val charBoxes = r.charBoxes.toFloatArray() + val charCodepoints = r.charCodepoints.toSharedIntArray() + val charBoxes = r.charBoxes.toSharedFloatArray() return PageTextLayout(pageIndex, size, rectBoxes, rectTexts, charCodepoints, charBoxes) } @@ -78,10 +74,11 @@ internal actual class PdfDocument internal constructor( } internal actual suspend fun openPdfDocument(bytes: ByteArray, password: String?): PdfDocument { - // Move the PDF bytes into a JS ArrayBuffer (bulk copy) and transfer ownership to - // the worker — the main thread has no further use for them. - val buffer = bytes.toInt8Array().buffer - val r = openDocument(buffer, password).await() + // On wasmJs this is a bulk copy into a JS ArrayBuffer, on jsMain it's a zero-copy + // reinterpretation (ByteArray IS Int8Array). Either way we transfer ownership to + // the worker — the main thread has no further use for the bytes. + val buffer = bytes.toJsArrayBuffer() + val r = openDocument(buffer, password).awaitTyped() return PdfDocument( docPtr = r.doc, pageCount = r.pageCount, diff --git a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt similarity index 65% rename from pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt rename to pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt index 407234d..02acb08 100644 --- a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt @@ -1,24 +1,23 @@ @file:JsModule("./pdfium_glue.mjs") +@file:OptIn(ExperimentalWasmJsInterop::class) package dev.nucleusframework.pdfium +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsArray +import kotlin.js.JsString import kotlin.js.Promise import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Float32Array import org.khronos.webgl.Int32Array /** - * External bindings to `pdfium_glue.mjs`. Every call goes over a Web Worker boundary — - * the JS side returns a [Promise] that resolves once the worker posts back its result. - * Typed arrays (`Float32Array`, `Int32Array`) and the pixel `ArrayBuffer` are moved via - * `postMessage` transferables, so they reach the main thread without a structured-clone - * copy. Handles (document / page pointers) are plain [Int]s into the worker's pdfium - * heap; the main thread never dereferences them. - * - * The `Promise` return type matches the shape expected by - * `kotlinx.coroutines.await`'s signature on wasmJs (which is declared as - * `Promise.await(): T`); the actual fulfilment values implement the typed - * result interfaces below and are cast at call sites. + * External bindings to `pdfium_glue.mjs`. Shared between jsMain and wasmJsMain via the + * `webMain` source set — all interfaces extend [JsAny] so the declarations are valid on + * both platforms. The RPC functions uniformly return `Promise` because + * `kotlinx.coroutines.await` on wasmJs is declared on that receiver type; call sites go + * through [awaitTyped] to recover the typed fulfilment value. */ internal external interface OpenResult : JsAny { val doc: Int diff --git a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/SkikoZeroCopy.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/SkikoZeroCopy.kt new file mode 100644 index 0000000..dea7abe --- /dev/null +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/SkikoZeroCopy.kt @@ -0,0 +1,36 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package dev.nucleusframework.pdfium + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import org.jetbrains.skia.Data +import org.jetbrains.skia.impl.NativePointer +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Int8Array + +/** + * Copy the contents of a (typically worker-transferred) [ArrayBuffer] straight into + * Skia's wasm linear memory. Pattern lifted from coil3.decode.WebWorker: + * 1. allocate uninitialised storage inside Skia's heap via [Data.makeUninitialized], + * 2. fetch Skia's wasm memory buffer, + * 3. `Int8Array.set` the source bytes at the offset of the freshly allocated [Data]. + * + * The returned [Data] can be handed to [org.jetbrains.skia.Image.makeRaster] without + * any further copy — skipping the usual `ByteArray` + `installPixels` round-trip. + */ +internal suspend fun ArrayBuffer.passToSkiko(): Data { + val data = Data.makeUninitialized(byteLength) + val skikoMemory = getSkikoMemory(awaitSkiko()) + skikoMemory.set(this, data.writableData()) + return data +} + +internal expect suspend fun awaitSkiko(): JsAny + +private fun getSkikoMemory(skikoWasm: JsAny): ArrayBuffer = + js("skikoWasm.wasmExports.memory.buffer") + +private fun ArrayBuffer.set(data: ArrayBuffer, offset: NativePointer) { + Int8Array(this).set(Int8Array(data), offset) +} diff --git a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/WebTypedArrayBridge.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/WebTypedArrayBridge.kt new file mode 100644 index 0000000..f1f4836 --- /dev/null +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/WebTypedArrayBridge.kt @@ -0,0 +1,30 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package dev.nucleusframework.pdfium + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.Promise +import org.khronos.webgl.ArrayBuffer +import org.khronos.webgl.Float32Array +import org.khronos.webgl.Int32Array + +/** + * Platform bridges the `webMain` code leans on to stay identical across jsMain and + * wasmJsMain. + * + * * The typed-array conversions are a zero-cost `unsafeCast` on Kotlin/JS (IR), where + * `FloatArray`/`IntArray`/`ByteArray` already ARE the matching typed arrays at + * runtime. On Kotlin/Wasm the two live in different linear-memory heaps and a bulk + * copy is unavoidable. + * * [awaitTyped] papers over the incompatible `kotlinx.coroutines.await` signatures + * between jsMain (class-level generic `Promise.await(): T`) and wasmJsMain + * (method-level generic `Promise.await(): T`). + */ +internal expect fun ByteArray.toJsArrayBuffer(): ArrayBuffer + +internal expect fun Float32Array.toSharedFloatArray(): FloatArray + +internal expect fun Int32Array.toSharedIntArray(): IntArray + +internal expect suspend fun Promise.awaitTyped(): T From f7c945162246721c243e3199bcd4ea95c14de739 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Fri, 24 Apr 2026 16:33:06 +0300 Subject: [PATCH 2/2] docs: update README with enhanced web zero-copy details and platform specifics Expand README to include clarification on web zero-copy rendering via Skia's wasm heap and the use of `Data.makeUninitialized`. Consolidate and refine platform-specific implementation details for `jvmMain`, `androidMain`, `iosMain`, and `webMain`. --- README.md | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2bf3bd7..cb3f5fe 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,21 @@ A Kotlin Multiplatform PDF rendering and text-extraction library built on top of [bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries) and -Compose Multiplatform. Zero-copy render pipeline on JVM / Android / iOS (Web -posts pixels from a worker), a Compose-first API, and a sample desktop/mobile -reader with thumbnails, progressive rendering, and selectable text. +Compose Multiplatform. Zero-copy render pipeline on every target — on the web +the transferred pixel `ArrayBuffer` is written straight into Skia's wasm heap, +no intermediate Kotlin `ByteArray`. A Compose-first API and a sample +desktop/mobile reader with thumbnails, progressive rendering, and selectable +text round it out. ## Features - **Compose Multiplatform composables** — drop `PdfPage` or `PdfThumbnail` into any Compose UI. -- **Zero-copy rendering** on JVM / Android / iOS: PDFium writes directly into - Skia / Android `Bitmap` pixel memory. +- **Zero-copy rendering** on every target. JVM / Android / iOS hand PDFium a + raw pixel pointer into Skia / Android `Bitmap` memory. Web allocates the + destination buffer inside Skia's wasm heap via `Data.makeUninitialized` and + writes the worker's transferred `ArrayBuffer` straight in — no Kotlin + `ByteArray` round-trip, no `installPixels` second copy. - **Progressive rendering** (preview → full) with a debounced size flow, so scroll and zoom feel instant. - **Two-tier LRU cache** (reader bitmaps + thumbnails) with off-screen prefetch. @@ -594,10 +599,13 @@ screenH = (top - bottom) × scaleY │ PdfThumbnail ─┘ │ │ PdfRenderCache PageTextLayout textClipEntry (expect) │ ├──────────────────┬───────────────┬─────────────┬─────────────────────────────┤ -│ jvmMain │ androidMain │ iosMain │ jsMain / wasmJsMain (web) │ -│ JNI glue │ JNI + NDK │ cinterop │ pdfium.wasm in a Web Worker │ -│ → Skia Bitmap │ AndroidBitmap │ libpdfium.a │ RPC via postMessage, │ -│ zero-copy │ zero-copy │ + Skia │ transferable pixels → Skia │ +│ jvmMain │ androidMain │ iosMain │ webMain (js + wasmJs) │ +│ JNI glue │ JNI + NDK │ cinterop │ pdfium.wasm in a Web │ +│ → Skia Bitmap │ AndroidBitmap │ libpdfium.a │ Worker; RPC via │ +│ zero-copy │ zero-copy │ + Skia │ postMessage transferables │ +│ │ │ │ → Skia heap zero-copy │ +│ │ │ │ jsMain / wasmJsMain just │ +│ │ │ │ host a small PlatformBridge │ └──────────────────┴───────────────┴─────────────┴─────────────────────────────┘ ``` @@ -613,6 +621,13 @@ Key facts: `Bitmap.peekPixels().addr` and pass it to `FPDFBitmap_CreateEx`. PDFium writes BGRA pixels straight into Skia's bitmap memory. On Android we lock the `android.graphics.Bitmap` via `AndroidBitmap_lockPixels` and do the same. + On web, the pdfium worker transfers the pixel `ArrayBuffer` to the main + thread; we allocate the destination inside Skia's own wasm heap via + `Data.makeUninitialized` and copy the transferred buffer directly there with + a typed-array `.set()` on the Skia memory view obtained through + `org.jetbrains.skiko.wasm.awaitSkiko`. One memcpy into the final + destination, no `installPixels` round-trip. Pattern cribbed from + [coil3.decode.WebWorker](https://github.com/coil-kt/coil/blob/69b8383a3f95300ddb466afdbe9c54ce2eccb652/coil-core/src/jsCommonMain/kotlin/coil3/decode/WebWorker.kt). - **Native binary delivery.** `pdfium/build.gradle.kts` registers a set of Gradle tasks that download the bblanchon archives, extract them, and stage @@ -707,12 +722,12 @@ if available. bounding boxes, not glyph positioning from the embedded PDF font. Copied text is exact, but highlight rectangles can differ slightly from what Chrome / PDF.js render when they can access the original font metrics. -- **Web: no zero-copy to Skia.** On wasmJs/JS, `pdfium.wasm` runs inside a - dedicated Web Worker (so the main thread never blocks). Pixels are posted - to the main thread via `postMessage` transferables and bulk-copied once - into a Skia `Bitmap` via `installPixels` — the only unavoidable copy - in the pipeline, since Skia has its own wasm heap with no direct - `ArrayBuffer` install. +- **Web: still one memcpy per render.** "Zero-copy" here means "no + intermediate Kotlin `ByteArray`, no `installPixels`" — the worker's + transferred `ArrayBuffer` is written straight into Skia's wasm heap. A true + zero-memcpy pipeline would need `SharedArrayBuffer` (which in turn requires + COOP/COEP headers) so the pdfium worker and Skia share one linear memory. + Not currently implemented. - **Licensing.** PDFium is dual-licensed BSD-3-Clause / Apache-2.0 (see PDFium's `LICENSE`). bblanchon's binaries carry that license forward. If you ship this code, include the upstream PDFium notices.