diff --git a/.github/workflows/pdfium-auto-release.yaml b/.github/workflows/pdfium-auto-release.yaml index 291145e..abb7593 100644 --- a/.github/workflows/pdfium-auto-release.yaml +++ b/.github/workflows/pdfium-auto-release.yaml @@ -16,7 +16,7 @@ jobs: detect: runs-on: ubuntu-latest outputs: - needs_bump: ${{ steps.compare.outputs.needs_bump }} + needs_bump: ${{ steps.compare.outputs.needs_bump == 'true' && steps.ios_static.outputs.ready == 'true' }} chromium_tag: ${{ steps.upstream.outputs.tag_name }} full_version: ${{ steps.upstream.outputs.full_version }} branch: ${{ steps.push.outputs.branch }} @@ -57,8 +57,29 @@ jobs: echo "needs_bump=true" >> "$GITHUB_OUTPUT" fi - - name: Check tag does not already exist + - name: Require the static iOS build to be published + # iOS links against a static libpdfium.a that bblanchon doesn't ship; + # NucleusFramework/pdfium-binaries builds it from the same tag and + # publishes it as `ios-static-` at 01:00 UTC. If it isn't there + # yet, skip this run rather than bumping to a version the iOS cinterop + # cannot resolve — the next cron run picks it up. if: steps.compare.outputs.needs_bump == 'true' + id: ios_static + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + build="${{ steps.upstream.outputs.chromium_branch }}" + if gh release view "ios-static-$build" --repo NucleusFramework/pdfium-binaries >/dev/null 2>&1; then + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "Static iOS build ios-static-$build is available." + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::ios-static-$build not published yet — deferring the bump to the next run." + fi + + - name: Check tag does not already exist + if: steps.compare.outputs.needs_bump == 'true' && steps.ios_static.outputs.ready == 'true' run: | set -euo pipefail tag="v${{ steps.upstream.outputs.full_version }}" @@ -68,7 +89,7 @@ jobs: fi - name: Push bump branch - if: steps.compare.outputs.needs_bump == 'true' + if: steps.compare.outputs.needs_bump == 'true' && steps.ios_static.outputs.ready == 'true' id: push run: | set -euo pipefail diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index 5e45b64..ea83c73 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -58,3 +58,24 @@ jobs: - name: Run :pdfium:check run: ./gradlew :pdfium:check --continue + + packaging-ios: + # Apple cinterop cannot run on Linux (KGP disables cross compilation for + # targets declaring cinterops), so the iOS half of the packaging regression + # — libpdfium.a inside the published klib, issue #11 — only runs here. + runs-on: macos-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run :pdfium:packagingTest + run: ./gradlew :pdfium:packagingTest diff --git a/.gitignore b/.gitignore index 61e14ec..f1063ee 100644 --- a/.gitignore +++ b/.gitignore @@ -20,13 +20,20 @@ node_modules/ *.so *.dylib *.dll +# Windows import libraries staged by :pdfium:installPdfiumJvmResources — `*.dll` +# doesn't match `pdfium.dll.lib`, which is why they used to be committed. +*.dll.lib + +# iOS PDFium static archives staged by :pdfium:installPdfiumIos from the +# paulocoutinhox/pdfium-lib release. Redownloaded on every build. +pdfium/src/nativeInterop/libs/ # PDFium wasm artefacts staged by the :pdfium:installPdfiumWasm and # :pdfium:generatePdfiumWasmRuntime Gradle tasks (source is the bblanchon # pdfium-wasm tarball downloaded at build time). Regenerated on every build. -pdfium/src/webMain/resources/pdfium/pdfium.wasm -pdfium/src/webMain/resources/pdfium/pdfium.js -pdfium/src/webMain/resources/pdfium/pdfium_runtime.mjs +pdfium/src/webMain/resources/pdfium.wasm +pdfium/src/webMain/resources/pdfium.js +pdfium/src/webMain/resources/pdfium_runtime.mjs # Root-level test PDFs. /*.pdf diff --git a/README.md b/README.md index 2d09b1a..d42b940 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,36 @@ compose.desktop { } ``` +### iOS packaging + +Nothing to do. A static `libpdfium.a` is packed into the published iOS cinterop +klib, so it is linked straight into your framework — no `linkerOpts`, no +`LIBRARY_SEARCH_PATHS`, no `ThirdParty/` drop, nothing to embed or codesign, and +no extra Xcode build phase. The stock Compose one is enough: + +``` +./gradlew :composeApp:embedAndSignAppleFrameworkForXcode +``` + +Both `isStatic = true` and `isStatic = false` frameworks work. + +The static archive is not something bblanchon publishes (they ship Apple +platforms as a `.dylib`, which a klib cannot carry). It is built from their own +harness with `build_type=static` by +[NucleusFramework/pdfium-binaries](https://github.com/NucleusFramework/pdfium-binaries), +a fork that tracks upstream releases daily and publishes an `ios-static-` +release per PDFium version; `:pdfium:installPdfiumIos` downloads the archive +matching the `pdfium-bblanchon` pin. + ### Web packaging (wasmJS / JS) -For the browser targets, the `pdfium.wasm` + worker assets are published as -classpath resources inside the library artifact and served from the module -root. If you bundle your app with the default Kotlin/JS webpack pipeline, no -extra configuration is needed — the `@JsModule("./pdfium_glue.mjs")` imports -resolve against your webpack output directory. Remember to serve the site over -HTTPS (or `localhost`): the Web Clipboard API used by text copy only works in -secure contexts. +For the browser targets, `pdfium.wasm`, `pdfium_worker.mjs`, +`pdfium_runtime.mjs` and `pdfium_glue.mjs` are published as classpath +resources at the artefact root and served from the bundle root. The glue is +eval'd from Kotlin (no webpack `./pdfium_glue.mjs` resolution), so a +consumer using the default Kotlin/JS webpack pipeline needs no extra copy +task. Remember to serve the site over HTTPS (or `localhost`): the Web +Clipboard API used by text copy only works in secure contexts. ## Getting started — a tour @@ -670,9 +691,12 @@ Key facts: - **Native binary delivery.** `pdfium/build.gradle.kts` registers a set of Gradle tasks that download the bblanchon archives, extract them, and stage - them as classpath resources (JVM) / jniLibs (Android) / static libs - (iOS cinterop). The JNI glue is rebuilt from `pdfium_jni.cpp` via - `build-linux.sh` / `build-macos.sh` / `build-windows.bat`. + them as classpath resources (JVM) / jniLibs (Android) / root-level wasm+JS + assets (web). iOS instead downloads the static archive from the + `pdfium-binaries` fork and lets cinterop pack it into the klib + (`staticLibraries` in `pdfium.def`). The JNI glue is rebuilt from + `pdfium_jni.cpp` via `build-linux.sh` / `build-macos.sh` / + `build-windows.bat`. - **Shared document buffer.** The JVM/Android path copies the PDF bytes into a native buffer once via `nAllocBuffer`, then hands that buffer address to @@ -739,7 +763,9 @@ drops `libpdfium.so` into `src/androidMain/jniLibs//`. ### iOS Open `iosApp/` in Xcode and run. The Gradle side has to run on a macOS host -for the cinterop + framework link to succeed. +for the framework link to succeed (the cinterop itself cross-compiles from any +host). The Xcode "Compile Kotlin Framework" phase is the stock +`embedAndSignAppleFrameworkForXcode` — PDFium is already inside the framework. ### Smoke test diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 69b9c22..cc7f9fd 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -127,10 +127,10 @@ dependencies { } // The :pdfium module ships pdfium_glue.mjs, pdfium_runtime.mjs (wrapped emscripten JS), -// and pdfium.wasm. We copy the trio flat into composeApp's incremental sync dir (next -// to composeApp.mjs) so `@JsModule("./pdfium_glue.mjs")` resolves against it — the same -// layout kotlin-wasm-examples/browser-c-interop uses for its own .mjs/.wasm pair. -val pdfiumWasmDir = project(":pdfium").layout.projectDirectory.dir("src/webMain/resources/pdfium") +// pdfium_worker.mjs and pdfium.wasm at the resource root. Copy them flat into +// composeApp's incremental sync dir (next to composeApp.mjs) and processedResources +// so the worker URL `pdfium_worker.mjs` and wasm fetch resolve at the bundle root. +val pdfiumWasmDir = project(":pdfium").layout.projectDirectory.dir("src/webMain/resources") val isProductionWeb = project.hasProperty("isProduction") || project.gradle.startParameter.taskNames.any { it.endsWith("Distribution") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 71860c4..c2d30ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,9 @@ junit = "4.13.2" kotlin = "2.3.20" kotlinx-coroutines = "1.10.2" material3 = "1.10.0-alpha05" +# Drives every platform. iOS additionally needs a static build, which +# NucleusFramework/pdfium-binaries publishes per chromium build as +# `ios-static-` (see :pdfium:installPdfiumIos). pdfium-bblanchon = "chromium/8021" download-plugin = "5.6.0" okio = "3.9.1" diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig index 0a46cb3..57a9961 100644 --- a/iosApp/Configuration/Config.xcconfig +++ b/iosApp/Configuration/Config.xcconfig @@ -6,8 +6,5 @@ PRODUCT_BUNDLE_IDENTIFIER=dev.nucleusframework.pdf.pdf$(TEAM_ID) CURRENT_PROJECT_VERSION=1 MARKETING_VERSION=1.0 -// PDFium dylib is staged by Gradle (:pdfium:installPdfiumIos) and embedded at build time -// by :pdfium:embedPdfiumDylibForXcode. These search paths let Xcode's linker resolve -// the -lpdfium directive that Kotlin/Native bakes into ComposeApp.framework. -LIBRARY_SEARCH_PATHS[sdk=iphoneos*] = $(inherited) $(SRCROOT)/../pdfium/src/nativeInterop/libs/ios-arm64 -LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*] = $(inherited) $(SRCROOT)/../pdfium/src/nativeInterop/libs/ios-simulator-arm64 \ No newline at end of file +// No PDFium linker setup: libpdfium.a is packed inside the cinterop klib and +// statically linked into ComposeApp.framework. diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 9e29e41..b2a104b 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -152,7 +152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :example:embedAndSignAppleFrameworkForXcode :pdfium:embedPdfiumDylibForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :example:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/pdfium/build.gradle.kts b/pdfium/build.gradle.kts index 26f9993..d8e2eb3 100644 --- a/pdfium/build.gradle.kts +++ b/pdfium/build.gradle.kts @@ -6,15 +6,17 @@ import org.apache.tools.ant.taskdefs.condition.Os import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction +import org.gradle.process.CommandLineArgumentProvider import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -64,17 +66,23 @@ val androidTriplets: List> = listOf( "x86" to "pdfium-android-x86", ) -val iosTriplets: List> = listOf( - "ios-arm64" to "pdfium-ios-device-arm64", - "ios-simulator-arm64" to "pdfium-ios-simulator-arm64", -) +// iOS needs a *static* libpdfium so cinterop can pack it into the klib and Maven +// consumers link without any -L or embedded framework (issue #11). bblanchon ships +// Apple platforms as a dylib only, so NucleusFramework/pdfium-binaries (a fork of +// their build harness) runs the same steps with `build_type=static` and publishes +// the archives per chromium build. +val pdfiumIosBuild = pdfiumVersion.substringAfterLast('/') +val pdfiumIosArchive = "pdfium-ios-static-$pdfiumIosBuild" +val pdfiumIosUrl = "https://github.com/NucleusFramework/pdfium-binaries/releases/download/" + + "ios-static-$pdfiumIosBuild/$pdfiumIosArchive.tgz" + +val iosTriplets: List = listOf("ios-arm64", "ios-simulator-arm64") val wasmArchive = "pdfium-wasm" val allArchives: Set = (jvmTriplets.map { it.archive } + androidTriplets.map { it.second } + - iosTriplets.map { it.second } + listOf(wasmArchive)).toSet() val downloadTasks: Map> = allArchives.associateWith { archive -> @@ -96,6 +104,21 @@ val extractTasks: Map> = allArchives.associateWith { } } +val downloadPdfiumIos = tasks.register("downloadPdfiumIos") { + src(pdfiumIosUrl) + dest(pdfiumDownloadsDir.map { it.file("$pdfiumIosArchive.tgz") }) + overwrite(false) + onlyIfModified(true) + retries(2) +} + +// The archive unpacks to `/{include,lib/}`. +val extractPdfiumIos = tasks.register("extractPdfiumIos") { + dependsOn(downloadPdfiumIos) + from({ tarTree(resources.gzip(downloadPdfiumIos.get().dest)) }) + into(pdfiumExtractDir.map { it.dir("ios-static") }) +} + kotlin { jvmToolchain(17) @@ -275,84 +298,48 @@ abstract class InstallHeadersTask : DefaultTask() { } } -abstract class EmbedPdfiumDylibTask : DefaultTask() { - @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val stagedLibsRoot: DirectoryProperty - - @get:Input abstract val platformName: Property - @get:Input abstract val targetBuildDir: Property - @get:Input abstract val frameworksFolderPath: Property - @get:Input @get:Optional abstract val signIdentity: Property - @get:Input @get:Optional abstract val signingRequired: Property - - @get:javax.inject.Inject abstract val execOps: org.gradle.process.ExecOperations - - @TaskAction - fun run() { - val platform = platformName.get() - require(platform.isNotBlank()) { "PLATFORM_NAME not set — run this task from Xcode." } - val triplet = when { - platform.startsWith("iphonesimulator") -> "ios-simulator-arm64" - platform.startsWith("iphoneos") -> "ios-arm64" - else -> error("Unsupported PLATFORM_NAME: '$platform'") - } - val dylib = stagedLibsRoot.get().dir(triplet).file("libpdfium.dylib").asFile - require(dylib.exists()) { "libpdfium.dylib missing at ${dylib.absolutePath}" } - - val buildDir = targetBuildDir.get() - val frameworks = frameworksFolderPath.get() - require(buildDir.isNotBlank() && frameworks.isNotBlank()) { - "TARGET_BUILD_DIR / FRAMEWORKS_FOLDER_PATH must be set by Xcode." - } - val dest = File("$buildDir/$frameworks").apply { mkdirs() }.resolve(dylib.name) - dylib.copyTo(dest, overwrite = true) - - if (signingRequired.orNull != "NO") { - val identity = signIdentity.orNull?.takeIf { it.isNotBlank() } ?: "-" - execOps.exec { - commandLine("codesign", "--force", "--sign", identity, dest.absolutePath) - } - } - } -} - +/** + * Stages one static `libpdfium.a` per Konan target plus the matching public + * headers. cinterop then packs the archive into the klib, so the published + * library carries the iOS machine code and consumers need no linker + * configuration at all (issue #11). + */ abstract class InstallIosTask : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection - @get:Input abstract val triplets: ListProperty // "triplet|archive" - @get:Input abstract val archiveToDir: org.gradle.api.provider.MapProperty + @get:Input abstract val releaseRoot: Property + @get:Input abstract val triplets: ListProperty @get:OutputDirectory abstract val outputRoot: DirectoryProperty - - @get:javax.inject.Inject abstract val execOps: org.gradle.process.ExecOperations + @get:OutputDirectory abstract val headersDir: DirectoryProperty @TaskAction fun run() { + val release = File(releaseRoot.get()) val root = outputRoot.get().asFile - val mapping = archiveToDir.get() - triplets.get().forEach { pair -> - val (triplet, archive) = pair.split('|', limit = 2) - val libDirPath = mapping[archive] ?: return@forEach - val libDir = File("$libDirPath/lib") - val target = root.resolve(triplet).apply { mkdirs() } - libDir.listFiles()?.forEach { src -> - if (src.name.endsWith(".dylib")) { - val dst = target.resolve(src.name) - src.copyTo(dst, overwrite = true) - // bblanchon ships the dylib with install_name "./libpdfium.dylib". - // Rewrite to @rpath so consumer apps can embed it under Frameworks/. - execOps.exec { - commandLine("install_name_tool", "-id", "@rpath/${src.name}", dst.absolutePath) - } - } - } + triplets.get().forEach { triplet -> + val archive = release.resolve("lib/$triplet/libpdfium.a") + require(archive.exists()) { "pdfium: ${archive.absolutePath} missing" } + val target = root.resolve(triplet) + target.deleteRecursively() + target.mkdirs() + archive.copyTo(target.resolve("libpdfium.a"), overwrite = true) } + // Bind the cinterop to the headers the staged archives were built from, + // so no stub can reference a symbol the archive doesn't define. + val include = release.resolve("include") + require(include.isDirectory) { "pdfium: ${include.absolutePath} missing" } + include.copyRecursively(headersDir.get().asFile.apply { mkdirs() }, overwrite = true) } } val nativeJniResourceDir = layout.projectDirectory.dir("src/jvmMain/resources/pdfium/native") val androidJniLibsDir = layout.projectDirectory.dir("src/androidMain/jniLibs") val iosStaticLibsDir = layout.projectDirectory.dir("src/nativeInterop/libs") -val wasmResourceDir = layout.projectDirectory.dir("src/webMain/resources/pdfium") +val iosHeadersDir = layout.buildDirectory.dir("pdfium/ios-include") +// Flat resource root so webpack/dev-server resolve `./pdfium_glue.mjs` and +// `pdfium_worker.mjs` from the bundle root (issue #11). Nested `pdfium/` made +// the files invisible to `@JsModule("./pdfium_glue.mjs")`. +val wasmResourceDir = layout.projectDirectory.dir("src/webMain/resources") val stagedHeadersDir = layout.buildDirectory.dir("pdfium/include") fun extractedDir(archive: String) = pdfiumExtractDir.map { it.dir(archive) } @@ -454,30 +441,70 @@ val generatePdfiumWasmRuntime = tasks.register("generatePdfiumWasmRuntime") { } } -val installPdfiumIos = tasks.register("installPdfiumIos") { - group = "pdfium" - description = "Install iOS PDFium dynamic libs for cinterop." - iosTriplets.forEach { (_, archive) -> - sources.from(extractTasks.getValue(archive).map { it.outputs.files }) +abstract class GeneratePdfiumGlueSourceTask : DefaultTask() { + @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val glueMjs: RegularFileProperty + + @get:org.gradle.api.tasks.OutputFile + abstract val outputKt: RegularFileProperty + + @TaskAction + fun run() { + val jsText = glueMjs.get().asFile.readText() + val escaped = buildString(jsText.length + 64) { + append('"') + for (c in jsText) when (c) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + in '\u0000'..'\u001F' -> append("\\u").append(c.code.toString(16).padStart(4, '0')) + '\u2028' -> append("\\u2028") + '\u2029' -> append("\\u2029") + else -> append(c) + } + append('"') + } + val out = outputKt.get().asFile + out.parentFile.mkdirs() + out.writeText( + """ + |package dev.nucleusframework.pdfium + | + |internal const val PDFIUM_GLUE_JS: String = $escaped + | + """.trimMargin(), + ) } - dependsOn(installPdfiumHeaders) - triplets.set(iosTriplets.map { "${it.first}|${it.second}" }) - archiveToDir.set(iosTriplets.associate { it.second to pdfiumExtractDir.get().dir(it.second).asFile.absolutePath }) - outputRoot.set(iosStaticLibsDir) } -val embedPdfiumDylibForXcode = tasks.register("embedPdfiumDylibForXcode") { +val pdfiumGlueGeneratedDir = layout.buildDirectory.dir("generated/pdfiumGlue/kotlin") +val generatePdfiumGlueSource = tasks.register("generatePdfiumGlueSource") { group = "pdfium" - description = "Copy & sign libpdfium.dylib into the iOS app bundle. Invoked from Xcode build phase." - dependsOn(installPdfiumIos) - outputs.upToDateWhen { false } + description = "Embed pdfium_glue.mjs as a Kotlin string so web targets eval it without webpack (issue #11)." + glueMjs.set(layout.projectDirectory.file("src/webMain/resources/pdfium_glue.mjs")) + outputKt.set(pdfiumGlueGeneratedDir.map { it.file("dev/nucleusframework/pdfium/PdfiumGlueSource.kt") }) + mustRunAfter(installPdfiumWasm, generatePdfiumWasmRuntime) +} + +kotlin.sourceSets.getByName("webMain").kotlin.srcDir(pdfiumGlueGeneratedDir) +tasks.withType>().configureEach { + if ("Js" in name || "Wasm" in name || "Web" in name || "Metadata" in name) { + dependsOn(generatePdfiumGlueSource) + } +} - stagedLibsRoot.set(iosStaticLibsDir) - platformName.set(providers.environmentVariable("PLATFORM_NAME").orElse("")) - targetBuildDir.set(providers.environmentVariable("TARGET_BUILD_DIR").orElse("")) - frameworksFolderPath.set(providers.environmentVariable("FRAMEWORKS_FOLDER_PATH").orElse("")) - signIdentity.set(providers.environmentVariable("EXPANDED_CODE_SIGN_IDENTITY").orElse("")) - signingRequired.set(providers.environmentVariable("CODE_SIGNING_REQUIRED").orElse("")) +val installPdfiumIos = tasks.register("installPdfiumIos") { + group = "pdfium" + description = "Stage the static PDFium archives + headers for the iOS cinterop." + sources.from(extractPdfiumIos.map { it.outputs.files }) + releaseRoot.set(pdfiumExtractDir.get().dir("ios-static/$pdfiumIosArchive").asFile.absolutePath) + triplets.set(iosTriplets) + outputRoot.set(iosStaticLibsDir) + headersDir.set(iosHeadersDir) } // ---------- JNI glue compilation ---------- @@ -563,12 +590,79 @@ tasks.matching { it.name == "preBuild" || it.name == "preDebugBuild" || it.name dependsOn(installPdfiumAndroidJniLibs, installPdfiumHeaders) } -if (Os.isFamily(Os.FAMILY_MAC)) { - tasks.matching { it.name.startsWith("cinteropPdfium") }.configureEach { - dependsOn(installPdfiumIos) +tasks.matching { it.name.startsWith("cinteropPdfium") }.configureEach { + dependsOn(installPdfiumIos) +} + +// ---------- Packaged-binaries regression (issue #11) ---------- + +abstract class PackagedBinariesArgumentProvider : CommandLineArgumentProvider { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val iosKlibs: ConfigurableFileCollection + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val webRoots: ConfigurableFileCollection + + @get:Input + abstract val iosSupported: Property + + override fun asArguments(): MutableIterable = mutableListOf( + "-Dpdfium.ios.supported=${iosSupported.get()}", + "-Dpdfium.ios.cinterop.klibs=${iosKlibs.files.joinToString(File.pathSeparator)}", + "-Dpdfium.web.klibs=${webRoots.files.joinToString(File.pathSeparator)}", + ) +} + +tasks.named("jvmTest") { + filter { + excludeTestsMatching("dev.nucleusframework.pdfium.PackagedBinariesTest") + } +} + +// The klibs Maven consumers receive are the packed ones in build/libs, produced +// by the `…Cinterop-pdfiumKlib` tasks — not by cinterop itself, which only writes +// the unpacked directory under build/classes. Assert against the former. +val iosCinteropKlibTaskNames = listOf( + "iosArm64Cinterop-pdfiumKlib", + "iosSimulatorArm64Cinterop-pdfiumKlib", +) + +// KGP disables cross compilation for targets that declare cinterops, so the iOS +// klibs simply don't exist off a macOS host. The web half still runs everywhere; +// the iOS half is covered by the `packaging-ios` CI job and by the publish run. +val iosPackagingSupported = Os.isFamily(Os.FAMILY_MAC) + +tasks.register("packagingTest") { + group = "verification" + description = "Assert iOS/web PDFium binaries are packaged into published klibs (issue #11)." + val jvmTest = tasks.named("jvmTest") + testClassesDirs = jvmTest.map { it.testClassesDirs }.get() + classpath = jvmTest.map { it.classpath }.get() + filter { includeTestsMatching("dev.nucleusframework.pdfium.PackagedBinariesTest") } + + if (iosPackagingSupported) iosCinteropKlibTaskNames.forEach { dependsOn(it) } + dependsOn("wasmJsProcessResources", "jsProcessResources") + + val args = objects.newInstance() + args.iosSupported.set(iosPackagingSupported) + if (iosPackagingSupported) { + args.iosKlibs.from( + layout.buildDirectory.dir("libs").map { dir -> + dir.asFileTree.matching { include("*Cinterop*.klib") } + }, + ) + args.iosKlibs.builtBy(iosCinteropKlibTaskNames.map { tasks.named(it) }) } + args.webRoots.from(layout.buildDirectory.dir("processedResources/wasmJs/main")) + args.webRoots.from(layout.buildDirectory.dir("processedResources/js/main")) + args.webRoots.builtBy("wasmJsProcessResources", "jsProcessResources") + jvmArgumentProviders.add(args) } +tasks.named("check") { dependsOn("packagingTest") } + // ---------- Smoke test ---------- val smokeTestRuntime by configurations.creating { diff --git a/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.js.kt b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.js.kt new file mode 100644 index 0000000..5f80162 --- /dev/null +++ b/pdfium/src/jsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.js.kt @@ -0,0 +1,6 @@ +package dev.nucleusframework.pdfium + +internal actual fun evalJs(source: String) { + val src = source + js("eval(src)") +} diff --git a/pdfium/src/jvmMain/resources/pdfium/native/win32-arm64/pdfium.dll.lib b/pdfium/src/jvmMain/resources/pdfium/native/win32-arm64/pdfium.dll.lib deleted file mode 100644 index e7e65d9..0000000 Binary files a/pdfium/src/jvmMain/resources/pdfium/native/win32-arm64/pdfium.dll.lib and /dev/null differ diff --git a/pdfium/src/jvmMain/resources/pdfium/native/win32-x86-64/pdfium.dll.lib b/pdfium/src/jvmMain/resources/pdfium/native/win32-x86-64/pdfium.dll.lib deleted file mode 100644 index 549fe58..0000000 Binary files a/pdfium/src/jvmMain/resources/pdfium/native/win32-x86-64/pdfium.dll.lib and /dev/null differ diff --git a/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/PackagedBinariesTest.kt b/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/PackagedBinariesTest.kt new file mode 100644 index 0000000..6e292d5 --- /dev/null +++ b/pdfium/src/jvmTest/kotlin/dev/nucleusframework/pdfium/PackagedBinariesTest.kt @@ -0,0 +1,100 @@ +package dev.nucleusframework.pdfium + +import java.io.File +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Regression for https://github.com/NucleusFramework/ComposePdfReader/issues/11 + * + * Maven consumers of `:pdfium` were getting `ld: library 'pdfium' not found` on iOS + * and webpack `Can't resolve './pdfium_glue.mjs'` on web, because the published + * klibs shipped Kotlin bindings but not the native/wasm binaries the linker and + * bundler actually need. Android / JVM already embed their natives; this test + * locks in the same for iOS cinterop and the JS/Wasm artefacts. + */ +class PackagedBinariesTest { + + @Test + fun iosCinteropKlibsEmbedLibpdfium() { + if (System.getProperty("pdfium.ios.supported") != "true") { + // Apple cinterop needs a macOS host, so there is nothing to inspect + // here. The `packaging-ios` CI job runs this same test on macOS. + println("iOS klibs are not built on this host — skipping") + return + } + val klibs = artefactFiles("pdfium.ios.cinterop.klibs") + assertTrue(klibs.isNotEmpty(), "no iOS cinterop klibs were handed to the test") + klibs.forEach { klib -> + val names = artefactNames(klib) + assertTrue( + names.any { it.substringAfterLast('/') == "libpdfium.a" }, + "${klib.name} is missing libpdfium.a (issue #11). Entries:\n${names.joinToString("\n")}", + ) + val manifest = artefactText(klib, "default/manifest") + assertTrue( + !manifest.contains("-lpdfium"), + "${klib.name} exports `-lpdfium` instead of relying on the packed archive, " + + "so Maven consumers fail with `ld: library 'pdfium' not found`.\n$manifest", + ) + } + } + + @Test + fun webKlibsShipGlueWorkerRuntimeAndWasmAtArchiveRoot() { + val roots = artefactFiles("pdfium.web.klibs") + assertTrue(roots.isNotEmpty(), "no JS/Wasm resource roots were handed to the test") + val required = listOf( + "pdfium_glue.mjs", + "pdfium_worker.mjs", + "pdfium_runtime.mjs", + "pdfium.wasm", + ) + roots.forEach { root -> + val names = artefactNames(root) + required.forEach { file -> + // Webpack resolves `@JsModule("./pdfium_glue.mjs")` against the kotlin + // package dir / dist root. A nested `pdfium/pdfium_glue.mjs` is + // invisible to that import (issue #11). + assertTrue( + names.contains(file), + "${root.name} is missing root entry `$file` (issue #11). Entries:\n" + + names.filter { + it.contains("pdfium") || it.endsWith(".mjs") || it.endsWith(".wasm") + }.joinToString("\n"), + ) + } + } + } +} + +private fun artefactFiles(property: String): List { + val raw = System.getProperty(property) + ?: fail("system property `$property` is not set — run via :pdfium:packagingTest") + return raw.split(File.pathSeparator) + .filter { it.isNotBlank() } + .map { File(it) } + .filter { it.exists() } +} + +private fun artefactNames(file: File): List = when { + file.isDirectory -> file.walkTopDown() + .filter { it.isFile } + .map { it.relativeTo(file).invariantSeparatorsPath } + .toList() + else -> ZipFile(file).use { zip -> zip.entries().toList().map { it.name } } +} + +private fun artefactText(file: File, name: String): String = when { + file.isDirectory -> { + val child = file.resolve(name) + if (!child.isFile) fail("${file.name} has no `$name`") + child.readText() + } + else -> ZipFile(file).use { zip -> + val entry = zip.getEntry(name) ?: fail("${file.name} has no `$name`") + zip.getInputStream(entry).bufferedReader().readText() + } +} diff --git a/pdfium/src/nativeInterop/cinterop/pdfium.def b/pdfium/src/nativeInterop/cinterop/pdfium.def index 3d095c5..e025972 100644 --- a/pdfium/src/nativeInterop/cinterop/pdfium.def +++ b/pdfium/src/nativeInterop/cinterop/pdfium.def @@ -3,15 +3,17 @@ language = C headers = fpdfview.h fpdf_doc.h fpdf_formfill.h fpdf_text.h headerFilter = fpdf*.h -# Headers are staged by the Gradle task `installPdfiumHeaders` under build/pdfium/include. -compilerOpts = -Ibuild/pdfium/include +# Headers and archives are staged by `installPdfiumIos` from the +# paulocoutinhox/pdfium-lib release — the only PDFium build that publishes a +# *static* iOS library (bblanchon is dylib-only on Apple platforms). +compilerOpts = -Ibuild/pdfium/ios-include -# iOS device (arm64). bblanchon ships a dynamic lib; consumer app must embed libpdfium.dylib. +# cinterop packs the archive into the klib (default/targets//included/), +# so the published artefact carries the machine code and consumers link with no +# -L, no ThirdParty drop and nothing to embed or sign (issue #11). +staticLibraries = libpdfium.a libraryPaths.ios_arm64 = src/nativeInterop/libs/ios-arm64 -linkerOpts.ios_arm64 = -lpdfium - -# iOS simulator (arm64). libraryPaths.ios_simulator_arm64 = src/nativeInterop/libs/ios-simulator-arm64 -linkerOpts.ios_simulator_arm64 = -lpdfium +# PDFium only needs CoreGraphics/CoreFoundation plus the C++ runtime. linkerOpts = -framework CoreGraphics -framework Foundation -lc++ diff --git a/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.wasmJs.kt b/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.wasmJs.kt new file mode 100644 index 0000000..1bddafb --- /dev/null +++ b/pdfium/src/wasmJsMain/kotlin/dev/nucleusframework/pdfium/GlueEval.wasmJs.kt @@ -0,0 +1,3 @@ +package dev.nucleusframework.pdfium + +internal actual fun evalJs(source: String): Unit = js("eval(source)") diff --git a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt index 4f16676..832f835 100644 --- a/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt +++ b/pdfium/src/webMain/kotlin/dev/nucleusframework/pdfium/PdfiumGlue.kt @@ -1,4 +1,3 @@ -@file:JsModule("./pdfium_glue.mjs") @file:OptIn(ExperimentalWasmJsInterop::class) package dev.nucleusframework.pdfium @@ -6,6 +5,7 @@ package dev.nucleusframework.pdfium import kotlin.js.ExperimentalWasmJsInterop import kotlin.js.JsAny import kotlin.js.JsArray +import kotlin.js.JsName import kotlin.js.JsString import kotlin.js.Promise import org.khronos.webgl.ArrayBuffer @@ -13,9 +13,13 @@ import org.khronos.webgl.Float32Array import org.khronos.webgl.Int32Array /** - * 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 + * Bindings to the pdfium Web Worker RPC. Shared between jsMain and wasmJsMain via + * `webMain`. The glue script is eval'd into globalThis (see [evalJs] / [PDFIUM_GLUE_JS]) + * instead of `@JsModule("./pdfium_glue.mjs")`, because that relative import is resolved + * by webpack against the consumer's kotlin package dir and is invisible when the file + * only exists as a klib resource (issue #11). + * + * 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. */ @@ -62,10 +66,62 @@ internal external interface PageLinksResult : JsAny { val destPages: Int32Array } -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 -internal external fun pageLinks(doc: Int, pageIndex: Int): Promise +internal expect fun evalJs(source: String) + +private val pdfiumGlueLoaded: Unit by lazy { evalJs(PDFIUM_GLUE_JS) } + +internal fun openDocument(buffer: ArrayBuffer, password: String?): Promise { + pdfiumGlueLoaded + return pdfiumOpenDocument(buffer, password) +} + +internal fun closeDocument(doc: Int): Promise { + pdfiumGlueLoaded + return pdfiumCloseDocument(doc) +} + +internal fun pageSize(doc: Int, pageIndex: Int): Promise { + pdfiumGlueLoaded + return pdfiumPageSize(doc, pageIndex) +} + +internal fun renderPage(doc: Int, pageIndex: Int, w: Int, h: Int, flags: Int): Promise { + pdfiumGlueLoaded + return pdfiumRenderPage(doc, pageIndex, w, h, flags) +} + +internal fun pageText(doc: Int, pageIndex: Int): Promise { + pdfiumGlueLoaded + return pdfiumPageText(doc, pageIndex) +} + +internal fun pageTextLayout(doc: Int, pageIndex: Int): Promise { + pdfiumGlueLoaded + return pdfiumPageTextLayout(doc, pageIndex) +} + +internal fun pageLinks(doc: Int, pageIndex: Int): Promise { + pdfiumGlueLoaded + return pdfiumPageLinks(doc, pageIndex) +} + +@JsName("pdfiumOpenDocument") +internal external fun pdfiumOpenDocument(buffer: ArrayBuffer, password: String?): Promise + +@JsName("pdfiumCloseDocument") +internal external fun pdfiumCloseDocument(doc: Int): Promise + +@JsName("pdfiumPageSize") +internal external fun pdfiumPageSize(doc: Int, pageIndex: Int): Promise + +@JsName("pdfiumRenderPage") +internal external fun pdfiumRenderPage(doc: Int, pageIndex: Int, w: Int, h: Int, flags: Int): Promise + +@JsName("pdfiumPageText") +internal external fun pdfiumPageText(doc: Int, pageIndex: Int): Promise + +@JsName("pdfiumPageTextLayout") +internal external fun pdfiumPageTextLayout(doc: Int, pageIndex: Int): Promise + +@JsName("pdfiumPageLinks") +internal external fun pdfiumPageLinks(doc: Int, pageIndex: Int): Promise diff --git a/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs b/pdfium/src/webMain/resources/pdfium_glue.mjs similarity index 79% rename from pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs rename to pdfium/src/webMain/resources/pdfium_glue.mjs index 6b19a76..1af1e9e 100644 --- a/pdfium/src/webMain/resources/pdfium/pdfium_glue.mjs +++ b/pdfium/src/webMain/resources/pdfium_glue.mjs @@ -52,32 +52,35 @@ async function rpc(op, args, transfer) { // ---- exported API --------------------------------------------------------------------- -export function openDocument(buffer, password) { +// Exposed on globalThis so Kotlin/JS and Kotlin/Wasm can call them without a +// webpack `@JsModule("./pdfium_glue.mjs")` resolution (issue #11). The script +// is eval'd once from Kotlin; ES-module `export` is invisible to that path. +globalThis.pdfiumOpenDocument = function (buffer, password) { // `buffer` is the raw PDF ArrayBuffer — transfer it so the main thread doesn't keep // a second copy alive for the document's lifetime. return rpc('open', { buffer, password }, [buffer]); -} +}; -export function closeDocument(doc) { +globalThis.pdfiumCloseDocument = function (doc) { return rpc('close', { doc }); -} +}; -export function pageSize(doc, pageIndex) { +globalThis.pdfiumPageSize = function (doc, pageIndex) { return rpc('pageSize', { doc, pageIndex }); -} +}; -export function renderPage(doc, pageIndex, w, h, flags) { +globalThis.pdfiumRenderPage = function (doc, pageIndex, w, h, flags) { return rpc('render', { doc, pageIndex, w, h, flags }); -} +}; -export function pageText(doc, pageIndex) { +globalThis.pdfiumPageText = function (doc, pageIndex) { return rpc('text', { doc, pageIndex }); -} +}; -export function pageTextLayout(doc, pageIndex) { +globalThis.pdfiumPageTextLayout = function (doc, pageIndex) { return rpc('layout', { doc, pageIndex }); -} +}; -export function pageLinks(doc, pageIndex) { +globalThis.pdfiumPageLinks = function (doc, pageIndex) { return rpc('links', { doc, pageIndex }); -} +}; diff --git a/pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs b/pdfium/src/webMain/resources/pdfium_worker.mjs similarity index 100% rename from pdfium/src/webMain/resources/pdfium/pdfium_worker.mjs rename to pdfium/src/webMain/resources/pdfium_worker.mjs