diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 5d401a7..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,98 +0,0 @@ -version: 2.1 - -executors: - default: - docker: - - image: circleci/node:10 - working_directory: ~/project - -commands: - attach_project: - steps: - - attach_workspace: - at: ~/project - -jobs: - install-dependencies: - executor: default - steps: - - checkout - - attach_project - - restore_cache: - keys: - - dependencies-{{ checksum "package.json" }} - - dependencies- - - restore_cache: - keys: - - dependencies-example-{{ checksum "example/package.json" }} - - dependencies-example- - - run: - name: Install dependencies - command: | - yarn install --cwd example --frozen-lockfile - yarn install --frozen-lockfile - - save_cache: - key: dependencies-{{ checksum "package.json" }} - paths: node_modules - - save_cache: - key: dependencies-example-{{ checksum "example/package.json" }} - paths: example/node_modules - - persist_to_workspace: - root: . - paths: . - - lint: - executor: default - steps: - - attach_project - - run: - name: Lint files - command: | - yarn lint - - typescript: - executor: default - steps: - - attach_project - - run: - name: Typecheck files - command: | - yarn typescript - - unit-tests: - executor: default - steps: - - attach_project - - run: - name: Run unit tests - command: | - yarn test --coverage - - store_artifacts: - path: coverage - destination: coverage - - build-package: - executor: default - steps: - - attach_project - - run: - name: Build package - command: | - yarn prepare - -workflows: - build-and-test: - jobs: - - install-dependencies - - lint: - requires: - - install-dependencies - - typescript: - requires: - - install-dependencies - - unit-tests: - requires: - - install-dependencies - - build-package: - requires: - - install-dependencies diff --git a/.gitattributes b/.gitattributes index 030ef14..e27f70f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ *.pbxproj -text # specific for windows script files -*.bat text eol=crlf \ No newline at end of file +*.bat text eol=crlf diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 8159413..45a5d0a 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -23,10 +23,10 @@ jobs: - run: corepack enable - uses: actions/setup-node@v4 with: - node-version: 20 - - run: yarn && yarn example + node-version: 24 + - run: yarn if: steps.cache-node.outputs.cache-hit != 'true' - run: yarn lint - - run: yarn typescript + - run: yarn typecheck - run: yarn test --coverage - run: yarn prepare diff --git a/.gitignore b/.gitignore index a7d5c48..67f3212 100644 --- a/.gitignore +++ b/.gitignore @@ -27,13 +27,17 @@ DerivedData *.hmap *.ipa *.xcuserstate -**/.xcode.env.local project.xcworkspace +**/.xcode.env.local # Android/IJ # -.idea +.classpath +.cxx .gradle +.idea +.project +.settings local.properties android.iml @@ -41,6 +45,9 @@ android.iml # example/ios/Pods +# Ruby +example/vendor/ + # node.js # node_modules/ @@ -54,8 +61,26 @@ buck-out/ android/app/libs android/keystores/debug.keystore +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # Expo -.expo/* +.expo/ + +# Turborepo +.turbo/ # generated by bob lib/ + +# React Native Codegen +ios/generated +android/generated + +# React Native Nitro Modules +nitrogen/ diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index fedc0f1..0000000 --- a/.yarnrc +++ /dev/null @@ -1,3 +0,0 @@ -# Override Yarn command so we can automatically setup the repo on running `yarn` - -yarn-path "scripts/bootstrap.js" diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f..a2447c1 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,2 @@ +nmHoistingLimits: workspaces nodeLinker: node-modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66069c7..7a33e06 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,30 @@ We want this community to be friendly and respectful to each other. Please follo ## Development workflow -To get started with the project, run `yarn` in the root directory to install the required dependencies for each package: +This project is a monorepo managed using [Yarn workspaces](https://yarnpkg.com/features/workspaces). It contains the following packages: + +- The library package in the root directory. +- An example app in the `example/` directory. + +To get started with the project, make sure you have the correct version of [Node.js](https://nodejs.org/) installed. See the [`.nvmrc`](./.nvmrc) file for the version used in this project. + +Run `yarn` in the root directory to install the required dependencies for each package: ```sh yarn ``` -While developing, you can run the [example app](/example/) to test your changes. +> Since the project relies on Yarn workspaces, you cannot use [`npm`](https://github.com/npm/cli) for development without manually migrating. + +The [example app](/example/) demonstrates usage of the library. You need to run it to test any changes you make. + +It is configured to use the local version of the library, so any changes you make to the library's source code will be reflected in the example app. Changes to the library's JavaScript code will be reflected in the example app without a rebuild, but native code changes will require a rebuild of the example app. + +If you want to use Android Studio or Xcode to edit the native code, you can open the `example/android` or `example/ios` directories respectively in those editors. To edit the Objective-C or Swift files, open `example/ios/PdfLightExample.xcworkspace` in Xcode and find the source files at `Pods > Development Pods > react-native-pdf-light`. + +To edit the Java or Kotlin files, open `example/android` in Android studio and find the source files at `react-native-pdf-light` under `Android`. + +You can use various commands from the root directory to work with the project. To start the packager: @@ -30,10 +47,23 @@ To run the example app on iOS: yarn example ios ``` -Make sure your code passes TypeScript and ESLint. Run the following to verify: +To confirm that the app is running with the new architecture, you can check the Metro logs for a message like this: + +```sh +Running "PdfLightExample" with {"fabric":true,"initialProps":{"concurrentRoot":true},"rootTag":1} +``` + +Note the `"fabric":true` and `"concurrentRoot":true` properties. + +Make sure your code passes TypeScript: + +```sh +yarn typecheck +``` + +To check for linting errors, run the following: ```sh -yarn typescript yarn lint ``` @@ -49,9 +79,6 @@ Remember to add tests for your change if possible. Run the unit tests by: yarn test ``` -To edit the Objective-C files, open `example/ios/PdfViewerExample.xcworkspace` in XCode and find the source files at `Pods > Development Pods > react-native-pdf-viewer`. - -To edit the Kotlin files, open `example/android` in Android studio and find the source files at `reactnativepdfviewer` under `Android`. ### Commit message convention @@ -66,29 +93,33 @@ We follow the [conventional commits specification](https://www.conventionalcommi Our pre-commit hooks verify that your commit message matches this format when committing. -### Linting and tests -[ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [TypeScript](https://www.typescriptlang.org/) +### Publishing to npm + +We use [release-it](https://github.com/release-it/release-it) to make it easier to publish new versions. It handles common tasks like bumping version based on semver, creating tags and releases etc. + +To publish new versions, run the following: -We use [TypeScript](https://www.typescriptlang.org/) for type checking, [ESLint](https://eslint.org/) with [Prettier](https://prettier.io/) for linting and formatting the code, and [Jest](https://jestjs.io/) for testing. +```sh +yarn release +``` -Our pre-commit hooks verify that the linter and tests pass when committing. ### Scripts The `package.json` file contains various scripts for common tasks: -- `yarn bootstrap`: setup project by installing all dependencies and pods. -- `yarn typescript`: type-check files with TypeScript. -- `yarn lint`: lint files with ESLint. -- `yarn test`: run unit tests with Jest. +- `yarn`: setup project by installing dependencies. +- `yarn typecheck`: type-check files with TypeScript. +- `yarn lint`: lint files with [ESLint](https://eslint.org/). +- `yarn test`: run unit tests with [Jest](https://jestjs.io/). - `yarn example start`: start the Metro server for the example app. - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. ### Sending a pull request -> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). +> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github). When you're sending a pull request: diff --git a/README.md b/README.md index 6d04b4f..4c6824c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,13 @@ npm install react-native-pdf-light If iOS build fails with `Undefined symbol: __swift_FORCE_LOAD_...`, add an empty `.swift` file to the xcode project. +### Compatibility + +| React Native | react-native-pdf-light | +| ------------ | ---------------------- | +| old arch | 1.x.x, 2.x.x | +| new arch | 3.x.x | + ## Usage ```js diff --git a/Zoom/package.json b/Zoom/package.json deleted file mode 100644 index 9599779..0000000 --- a/Zoom/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "main": "../lib/commonjs/ZoomPdfView", - "module": "../lib/module/ZoomPdfView", - "react-native": "../src/ZoomPdfView", - "types": "../lib/typescript/ZoomPdfView.d.ts" -} diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt new file mode 100644 index 0000000..17f9794 --- /dev/null +++ b/android/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +file(GLOB common_SRCS CONFIGURE_DEPENDS ../cpp/*.cpp) +file(GLOB react_codegen_SRCS CONFIGURE_DEPENDS + build/generated/source/codegen/jni/*.cpp + build/generated/source/codegen/jni/react/renderer/components/PdfViewSpec/*.cpp +) + +add_library( + react_codegen_PdfViewSpec + OBJECT + ${common_SRCS} + ${react_codegen_SRCS} +) + +target_include_directories(react_codegen_PdfViewSpec PUBLIC ../cpp build/generated/source/codegen/jni) + +target_link_libraries( + react_codegen_PdfViewSpec + fbjni + jsi + reactnative +) + +target_compile_reactnative_options(react_codegen_PdfViewSpec PRIVATE) diff --git a/android/build.gradle b/android/build.gradle index d8e3a74..1f11942 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,7 @@ buildscript { - // Buildscript is evaluated before everything else so we can't use getExtOrDefault - def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['PdfViewer_kotlinVersion'] + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['PdfLight_' + name] + } repositories { google() @@ -8,128 +9,75 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + classpath "com.android.tools.build:gradle:8.7.2" // noinspection DifferentKotlinGradleVersion - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + classpath "org.jetbrains.kotlin:kotlin-serialization:${getExtOrDefault('kotlinVersion')}" } } -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' -apply plugin: 'kotlinx-serialization' -def getExtOrDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['PdfViewer_' + name] -} +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "kotlinx-serialization" + +apply plugin: "com.facebook.react" def getExtOrIntegerDefault(name) { - return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties['PdfViewer_' + name]).toInteger() + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["PdfLight_" + name]).toInteger() } android { - compileSdkVersion getExtOrIntegerDefault('compileSdkVersion') - buildToolsVersion getExtOrDefault('buildToolsVersion') + namespace "com.alpha0010.pdf" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + defaultConfig { - minSdkVersion 21 - targetSdkVersion getExtOrIntegerDefault('targetSdkVersion') - versionCode 1 - versionName "1.0" + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + buildFeatures { + buildConfig true } buildTypes { release { minifyEnabled false - consumerProguardFiles 'proguard-rules.pro' + consumerProguardFiles "proguard-rules.pro" } } + lintOptions { - disable 'GradleCompatible' + disable "GradleCompatible" } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } } repositories { mavenCentral() google() - - def found = false - def defaultDir = null - def androidSourcesName = 'React Native sources' - - if (rootProject.ext.has('reactNativeAndroidRoot')) { - defaultDir = rootProject.ext.get('reactNativeAndroidRoot') - } else { - defaultDir = new File( - projectDir, - '/../../../node_modules/react-native/android' - ) - } - - if (defaultDir.exists()) { - maven { - url defaultDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${defaultDir.canonicalPath}") - found = true - } else { - def parentDir = rootProject.projectDir - - 1.upto(5, { - if (found) return true - parentDir = parentDir.parentFile - - def androidSourcesDir = new File( - parentDir, - 'node_modules/react-native' - ) - - def androidPrebuiltBinaryDir = new File( - parentDir, - 'node_modules/react-native/android' - ) - - if (androidPrebuiltBinaryDir.exists()) { - maven { - url androidPrebuiltBinaryDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${androidPrebuiltBinaryDir.canonicalPath}") - found = true - } else if (androidSourcesDir.exists()) { - maven { - url androidSourcesDir.toString() - name androidSourcesName - } - - logger.info(":${project.name}:reactNativeAndroidRoot ${androidSourcesDir.canonicalPath}") - found = true - } - }) - } - - if (!found) { - throw new GradleException( - "${project.name}: unable to locate React Native android sources. " + - "Ensure you have you installed React Native as a dependency in your project and try again." - ) - } } -def kotlin_version = getExtOrDefault('kotlinVersion') -def kotlin_coroutines_version = getExtOrDefault('kotlinCoroutinesVersion') -def kotlin_json_version = getExtOrDefault('kotlinJsonVersion') +def kotlin_version = getExtOrDefault("kotlinVersion") +def kotlin_coroutines_version = getExtOrDefault("kotlinCoroutinesVersion") +def kotlin_json_version = getExtOrDefault("kotlinJsonVersion") dependencies { - // noinspection GradleDynamicVersion - api 'com.facebook.react:react-native:+' + implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlin_json_version" diff --git a/android/gradle.properties b/android/gradle.properties index 46f19d4..5df4e6f 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,7 @@ -PdfViewer_kotlinVersion=1.6.10 -PdfViewer_kotlinCoroutinesVersion=1.6.0 -PdfViewer_kotlinJsonVersion=1.3.2 -PdfViewer_compileSdkVersion=29 -PdfViewer_buildToolsVersion=29.0.2 -PdfViewer_targetSdkVersion=29 +PdfLight_kotlinVersion=2.0.21 +PdfLight_kotlinCoroutinesVersion=1.10.2 +PdfLight_kotlinJsonVersion=1.9.0 +PdfLight_minSdkVersion=24 +PdfLight_targetSdkVersion=34 +PdfLight_compileSdkVersion=35 +PdfLight_ndkVersion=27.1.12297006 diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index aec9c5a..a2f47b6 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,4 +1,2 @@ - - + diff --git a/android/src/main/java/com/alpha0010/pdf/PdfUtilModule.kt b/android/src/main/java/com/alpha0010/pdf/PdfUtilModule.kt index 6581755..f8e559a 100644 --- a/android/src/main/java/com/alpha0010/pdf/PdfUtilModule.kt +++ b/android/src/main/java/com/alpha0010/pdf/PdfUtilModule.kt @@ -2,21 +2,20 @@ package com.alpha0010.pdf import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor -import com.facebook.react.bridge.* -import java.io.* +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import java.io.File +import java.io.FileNotFoundException import java.util.concurrent.locks.Lock import kotlin.concurrent.withLock -class PdfUtilModule(reactContext: ReactApplicationContext, private val pdfMutex: Lock) : ReactContextBaseJavaModule(reactContext) { - override fun getName(): String { - return "RNPdfUtil" - } - +class PdfUtilModule(reactContext: ReactApplicationContext, private val pdfMutex: Lock) : + NativePdfUtilSpec(reactContext) { /** * Get the number of pages of a pdf. */ - @ReactMethod - fun getPageCount(source: String, promise: Promise) { + override fun getPageCount(source: String, promise: Promise) { val file = File(source) val fd: ParcelFileDescriptor try { @@ -46,8 +45,7 @@ class PdfUtilModule(reactContext: ReactApplicationContext, private val pdfMutex: /** * Get the dimensions of every page. */ - @ReactMethod - fun getPageSizes(source: String, promise: Promise) { + override fun getPageSizes(source: String, promise: Promise) { val file = File(source) val fd = try { ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) diff --git a/android/src/main/java/com/alpha0010/pdf/PdfView.kt b/android/src/main/java/com/alpha0010/pdf/PdfView.kt index 103c8ea..7fa68f4 100644 --- a/android/src/main/java/com/alpha0010/pdf/PdfView.kt +++ b/android/src/main/java/com/alpha0010/pdf/PdfView.kt @@ -2,20 +2,32 @@ package com.alpha0010.pdf import android.annotation.SuppressLint import android.content.Context -import android.graphics.* +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor +import android.util.LruCache +import android.util.Size import android.util.TypedValue import android.util.TypedValue.COMPLEX_UNIT_DIP import android.view.View +import androidx.core.graphics.createBitmap +import androidx.core.graphics.toColorInt import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.events.RCTEventEmitter +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import java.io.File import java.io.FileNotFoundException @@ -36,15 +48,86 @@ enum class ResizeMode(val jsName: String) { const val SLICES = 8 @SuppressLint("ViewConstructor") -class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { +class PdfView( + context: Context, + private val measureCache: LruCache, + private val pdfMutex: Lock +) : View(context) { private var mAnnotation = emptyList() - private val mBitmaps = MutableList(SLICES) { Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } + private val mBitmaps = MutableList(SLICES) { createBitmap(1, 1) } private var mDirty = false private var mPage = 0 + private var mPageMeasure = Size(1, 1) private var mResizeMode = ResizeMode.CONTAIN private var mSource = "" + private var mStateWrapper: StateWrapper? = null private val mViewRects = List(SLICES) { Rect() } + fun setStateWrapper(stateWrapper: StateWrapper?) { + mStateWrapper = stateWrapper + measurePdf() + } + + /** + * Push sizing info to the shadow node. + */ + private fun measurePdf() { + if (mStateWrapper == null) { + return + } + // Attempt to get page dimensions from cache, to avoid disk I/O. + val cacheKey = "$mPage-$mSource" + val cachedSize = measureCache[cacheKey] + if (cachedSize != null) { + if (cachedSize != mPageMeasure) { + mPageMeasure = cachedSize + mStateWrapper?.updateState(Arguments.createMap().apply { + putInt("width", mPageMeasure.width) + putInt("height", mPageMeasure.height) + }) + } + return + } + + // It appears that this cannot be pushed to a background thread due to + // the call to `dirty()`. + val file = File(mSource) + val fd = try { + ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } catch (e: FileNotFoundException) { + return + } + val pageSize = pdfMutex.withLock { + val renderer = try { + PdfRenderer(fd) + } catch (e: Exception) { + fd.close() + return + } + val page = try { + renderer.openPage(mPage) + } catch (e: Exception) { + renderer.close() + fd.close() + return + } + val res = Size(page.width, page.height) + page.close() + renderer.close() + return@withLock res + } + fd.close() + + measureCache.put(cacheKey, pageSize) + if (pageSize != mPageMeasure) { + mPageMeasure = pageSize + mStateWrapper?.updateState(Arguments.createMap().apply { + putInt("width", mPageMeasure.width) + putInt("height", mPageMeasure.height) + }) + } + } + fun setAnnotation(source: String, file: Boolean) { if (source.isEmpty()) { if (mAnnotation.isNotEmpty()) { @@ -69,6 +152,7 @@ class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { fun setPage(page: Int) { mPage = page mDirty = true + measurePdf() } fun setResizeMode(mode: String) { @@ -88,6 +172,7 @@ class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { fun setSource(source: String) { mSource = source mDirty = true + measurePdf() } private fun computeDestRect(srcWidth: Int, srcHeight: Int): RectF { @@ -107,7 +192,7 @@ class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { androidColor = "#" + hex.takeLast(2) + hex.drop(1).take(6) } return try { - Color.parseColor(androidColor) + androidColor.toColorInt() } catch (e: Exception) { Color.BLACK } @@ -228,7 +313,7 @@ class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { // Api requires bitmap have alpha channel; fill with white so rendered // bitmap is opaque. val rendered = try { - Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + createBitmap(width, height) } catch (e: OutOfMemoryError) { pdfPage.close() renderer.close() @@ -303,24 +388,37 @@ class PdfView(context: Context, private val pdfMutex: Lock) : View(context) { } private fun onError(message: String) { - val event = Arguments.createMap() - event.putString("message", message) val reactContext = context as ReactContext - reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent( - id, "onPdfError", event + UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent( + OnErrorEvent(UIManagerHelper.getSurfaceId(reactContext), id, message) ) } + inner class OnErrorEvent(surfaceId: Int, viewId: Int, message: String) : + Event(surfaceId, viewId) { + private val payload = Arguments.createMap().apply { putString("message", message) } + override fun getEventName() = "onPdfError" + override fun getEventData() = payload + } + private fun onLoadComplete(pageWidth: Int, pageHeight: Int) { - val event = Arguments.createMap() - event.putInt("width", pageWidth) - event.putInt("height", pageHeight) val reactContext = context as ReactContext - reactContext.getJSModule(RCTEventEmitter::class.java).receiveEvent( - id, "onPdfLoadComplete", event + UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)?.dispatchEvent( + OnLoadCompleteEvent(UIManagerHelper.getSurfaceId(reactContext), id, pageWidth, pageHeight) ) } + inner class OnLoadCompleteEvent(surfaceId: Int, viewId: Int, width: Int, height: Int) : + Event(surfaceId, viewId) { + private val payload = Arguments.createMap().apply { + putInt("width", width) + putInt("height", height) + } + + override fun getEventName() = "onPdfLoadComplete" + override fun getEventData() = payload + } + override fun onDraw(canvas: Canvas) { mBitmaps.zip(mViewRects) { bitmap, viewRect -> if (!viewRect.isEmpty) { diff --git a/android/src/main/java/com/alpha0010/pdf/PdfViewManager.kt b/android/src/main/java/com/alpha0010/pdf/PdfViewManager.kt index 50e3c02..30c896a 100644 --- a/android/src/main/java/com/alpha0010/pdf/PdfViewManager.kt +++ b/android/src/main/java/com/alpha0010/pdf/PdfViewManager.kt @@ -2,23 +2,29 @@ package com.alpha0010.pdf import android.util.LruCache import android.util.Size -import com.facebook.react.uimanager.BaseViewManager +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.PdfViewManagerDelegate +import com.facebook.react.viewmanagers.PdfViewManagerInterface import java.util.concurrent.locks.Lock -class PdfViewManager(private val pdfMutex: Lock) : BaseViewManager() { +@ReactModule(name = PdfViewManager.NAME) +class PdfViewManager(private val pdfMutex: Lock) : SimpleViewManager(), + PdfViewManagerInterface { + private val mDelegate: ViewManagerDelegate = PdfViewManagerDelegate(this) private val mMeasureCache = LruCache(128) - override fun getName() = "RNPdfView" + override fun getDelegate() = mDelegate - override fun createViewInstance(reactContext: ThemedReactContext): PdfView { - return PdfView(reactContext, pdfMutex) - } - - override fun createShadowNodeInstance() = PdfViewShadowNode(mMeasureCache, pdfMutex) + override fun getName() = NAME - override fun getShadowNodeClass() = PdfViewShadowNode::class.java + public override fun createViewInstance(context: ThemedReactContext) = + PdfView(context, mMeasureCache, pdfMutex) override fun getExportedCustomBubblingEventTypeConstants(): MutableMap { return mutableMapOf( @@ -31,32 +37,39 @@ class PdfViewManager(private val pdfMutex: Lock) : BaseViewManager { - return listOf(PdfUtilModule(reactContext, pdfMutex)) + override fun createViewManagers(reactContext: ReactApplicationContext) = + listOf(PdfViewManager(pdfMutex)) + + override fun getModule( + name: String, + reactContext: ReactApplicationContext + ): NativeModule? { + return when (name) { + NativePdfUtilSpec.NAME -> PdfUtilModule(reactContext, pdfMutex) + PdfViewManager.NAME -> PdfViewManager(pdfMutex) + else -> null + } } - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return listOf>(PdfViewManager(pdfMutex)) + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + NativePdfUtilSpec.NAME to ReactModuleInfo( + name = NativePdfUtilSpec.NAME, + className = NativePdfUtilSpec.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ), + PdfViewManager.NAME to ReactModuleInfo( + name = PdfViewManager.NAME, + className = PdfViewManager.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ) + ) } } diff --git a/android/src/main/java/com/alpha0010/pdf/PdfViewShadowNode.kt b/android/src/main/java/com/alpha0010/pdf/PdfViewShadowNode.kt deleted file mode 100644 index 90386c8..0000000 --- a/android/src/main/java/com/alpha0010/pdf/PdfViewShadowNode.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.alpha0010.pdf - -import android.graphics.pdf.PdfRenderer -import android.os.ParcelFileDescriptor -import android.util.LruCache -import android.util.Size -import com.facebook.react.uimanager.LayoutShadowNode -import com.facebook.react.uimanager.annotations.ReactProp -import com.facebook.yoga.YogaMeasureFunction -import com.facebook.yoga.YogaMeasureMode -import com.facebook.yoga.YogaMeasureOutput -import com.facebook.yoga.YogaNode -import java.io.File -import java.io.FileNotFoundException -import java.util.concurrent.locks.Lock -import kotlin.concurrent.withLock - -class PdfViewShadowNode(measureCache: LruCache, private val pdfMutex: Lock) : LayoutShadowNode(), YogaMeasureFunction { - private val mMeasureCache = measureCache - private var mPage = 0 - private var mPageHeight = 1 - private var mPageWidth = 1 - private var mSource = "" - - init { - setMeasureFunction(this) - } - - override fun measure(node: YogaNode, width: Float, widthMode: YogaMeasureMode, height: Float, heightMode: YogaMeasureMode): Long { - val aspectRatio = mPageWidth.toFloat() / mPageHeight.toFloat() - val targetWidth = height * aspectRatio - if (widthMode == YogaMeasureMode.UNDEFINED || width < 1) { - if (heightMode == YogaMeasureMode.UNDEFINED || height < 1) { - // No restrictions on dimensions? Use pdf dimensions. - return YogaMeasureOutput.make(mPageWidth, mPageHeight) - } - // No width requirements? Scale page to match yoga requested height. - return YogaMeasureOutput.make(targetWidth, height) - } - - if (targetWidth <= width) { - // When scaled to match yoga requested height, page scaled width is - // within yoga width bounds. Scale page to match yoga requested height. - return YogaMeasureOutput.make(targetWidth, height) - } - // Scale page to match yoga requested width. - return YogaMeasureOutput.make(width, width / aspectRatio) - } - - private fun measurePdf() { - // Attempt to get page dimensions from cache, to avoid disk I/O. - val cacheKey = "$mPage-$mSource" - val cachedSize = mMeasureCache[cacheKey] - if (cachedSize != null) { - if (mPageHeight != cachedSize.height || mPageWidth != cachedSize.width) { - mPageHeight = cachedSize.height - mPageWidth = cachedSize.width - dirty() - } - return - } - - // It appears that this cannot be pushed to a background thread due to - // the call to `dirty()`. - val file = File(mSource) - val fd = try { - ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) - } catch (e: FileNotFoundException) { - return - } - val pageSize = pdfMutex.withLock { - val renderer = try { - PdfRenderer(fd) - } catch (e: Exception) { - fd.close() - return - } - val page = try { - renderer.openPage(mPage) - } catch (e: Exception) { - renderer.close() - fd.close() - return - } - val res = Size(page.width, page.height) - page.close() - renderer.close() - return@withLock res - } - fd.close() - - mPageHeight = pageSize.height - mPageWidth = pageSize.width - mMeasureCache.put(cacheKey, pageSize) - - dirty() - } - - /** - * Page (0-indexed) of document to display. - */ - @ReactProp(name = "page", defaultInt = 0) - fun setPage(page: Int) { - if (mPage != page) { - mPage = page - measurePdf() - } - } - - /** - * Document to display. - */ - @ReactProp(name = "source") - fun setSource(source: String?) { - if (source != null && mSource != source) { - mSource = source - measurePdf() - } - } -} diff --git a/babel.config.js b/babel.config.js index f842b77..0c05fd6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,12 @@ module.exports = { - presets: ['module:metro-react-native-babel-preset'], + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], }; diff --git a/cpp/PdfViewShadowNode.cpp b/cpp/PdfViewShadowNode.cpp new file mode 100644 index 0000000..11ba16f --- /dev/null +++ b/cpp/PdfViewShadowNode.cpp @@ -0,0 +1,42 @@ +#include "PdfViewShadowNode.h" + +#include + +namespace facebook::react { + +extern const char PdfViewComponentName[] = "PdfView"; + +ShadowNodeTraits PdfViewShadowNode::BaseTraits() +{ + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; +} + +Size PdfViewShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + int pageWidth = getStateData().getPageWidth(); + int pageHeight = getStateData().getPageHeight(); + Float aspectRatio = static_cast(pageWidth) / pageHeight; + Float targetWidth = layoutConstraints.maximumSize.height * aspectRatio; + if (std::isinf(layoutConstraints.maximumSize.width) || layoutConstraints.maximumSize.width < 1) { + if (std::isinf(layoutConstraints.maximumSize.height) || layoutConstraints.maximumSize.height < 1) { + // No restrictions on dimensions? Use pdf dimensions. + return {static_cast(pageWidth), static_cast(pageHeight)}; + } + // No width requirements? Scale page to match requested height. + return {targetWidth, layoutConstraints.maximumSize.height}; + } + + if (targetWidth <= layoutConstraints.maximumSize.width) { + // When scaled to match requested height, page scaled width is + // within width bounds. Scale page to match requested height. + return {targetWidth, layoutConstraints.maximumSize.height}; + } + // Scale page to match requested width. + return {layoutConstraints.maximumSize.width, layoutConstraints.maximumSize.width / aspectRatio}; +} + +} // namespace facebook::react diff --git a/cpp/PdfViewShadowNode.h b/cpp/PdfViewShadowNode.h new file mode 100644 index 0000000..6f562ac --- /dev/null +++ b/cpp/PdfViewShadowNode.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +#include "PdfViewState.h" + +namespace facebook::react { + +JSI_EXPORT extern const char PdfViewComponentName[]; + +/* + * `ShadowNode` for component. + */ +class PdfViewShadowNode final + : public ConcreteViewShadowNode< + PdfViewComponentName, + PdfViewProps, + PdfViewEventEmitter, + PdfViewState> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + static ShadowNodeTraits BaseTraits(); + + Size measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const override; +}; + +} // namespace facebook::react diff --git a/cpp/PdfViewState.cpp b/cpp/PdfViewState.cpp new file mode 100644 index 0000000..3c18c47 --- /dev/null +++ b/cpp/PdfViewState.cpp @@ -0,0 +1,18 @@ +#include "PdfViewState.h" + +namespace facebook::react { + +PdfViewState::PdfViewState() : width(1), height(1) {} + +PdfViewState::PdfViewState(int _width, int _height) : width(_width), height(_height) {} + +#ifdef RN_SERIALIZABLE_STATE +PdfViewState::PdfViewState(const PdfViewState& previousState, folly::dynamic data) + : width(data["width"].getInt()), height(data["height"].getInt()) {} + +folly::dynamic PdfViewState::getDynamic() const { + return folly::dynamic::object("width", width)("height", height); +} +#endif + +} // namespace facebook::react diff --git a/cpp/PdfViewState.h b/cpp/PdfViewState.h new file mode 100644 index 0000000..3e6e9b3 --- /dev/null +++ b/cpp/PdfViewState.h @@ -0,0 +1,26 @@ +#pragma once + +#ifdef RN_SERIALIZABLE_STATE +#include +#endif + +namespace facebook::react { + +class PdfViewState { +private: + int width; + int height; + +public: + PdfViewState(); + PdfViewState(int _width, int _height); +#ifdef RN_SERIALIZABLE_STATE + PdfViewState(const PdfViewState& previousState, folly::dynamic data); + folly::dynamic getDynamic() const; +#endif + + int getPageWidth() const { return width; } + int getPageHeight() const { return height; } +}; + +} // namespace facebook::react diff --git a/cpp/react/renderer/components/PdfViewSpec/ComponentDescriptors.h b/cpp/react/renderer/components/PdfViewSpec/ComponentDescriptors.h new file mode 100644 index 0000000..ca05e32 --- /dev/null +++ b/cpp/react/renderer/components/PdfViewSpec/ComponentDescriptors.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +#include "PdfViewShadowNode.h" + +namespace facebook::react { + +using PdfViewComponentDescriptor = ConcreteComponentDescriptor; + +} // namespace facebook::react diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..16b00bb --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,29 @@ +import { fixupConfigRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import prettier from 'eslint-plugin-prettier'; +import { defineConfig } from 'eslint/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default defineConfig([ + { + extends: fixupConfigRules(compat.extends('@react-native', 'prettier')), + plugins: { prettier }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'prettier/prettier': 'error', + }, + }, + { + ignores: ['node_modules/', 'lib/'], + }, +]); diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 0000000..5151523 --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,17 @@ +source 'https://rubygems.org' + +# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version +ruby ">= 2.6.10" + +# Exclude problematic versions of cocoapods and activesupport that causes build failures. +gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'xcodeproj', '< 1.26.0' +gem 'concurrent-ruby', '< 1.3.4' + +# Ruby 3.4.0 has removed some libraries from the standard library. +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' +gem 'nkf' diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..3e2c3f8 --- /dev/null +++ b/example/README.md @@ -0,0 +1,97 @@ +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). + +# Getting Started + +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. + +## Step 1: Start Metro + +First, you will need to run **Metro**, the JavaScript build tool for React Native. + +To start the Metro dev server, run the following command from the root of your React Native project: + +```sh +# Using npm +npm start + +# OR using Yarn +yarn start +``` + +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android + +```sh +# Using npm +npm run android + +# OR using Yarn +yarn android +``` + +### iOS + +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). + +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install +``` + +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` + +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). + +```sh +# Using npm +npm run ios + +# OR using Yarn +yarn ios +``` + +If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. + +This is one way to run your app — you can also build it directly from Android Studio or Xcode. + +## Step 3: Modify your app + +Now that you have successfully run the app, let's make changes! + +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). +- **iOS**: Press R in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: + +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index ed67138..31cfd97 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -49,6 +49,7 @@ react { // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] + /* Autolinking */ autolinkLibrariesWithApp() } @@ -62,23 +63,23 @@ def enableProguardInReleaseBuilds = false * The preferred build flavor of JavaScriptCore (JSC) * * For example, to use the international variant, you can use: - * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+` * * The international variant includes ICU i18n library and necessary data * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that * give correct results when using with locales other than en-US. Note that * this variant is about 6MiB larger per architecture than default. */ -def jscFlavor = 'org.webkit:android-jsc:+' +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' android { ndkVersion rootProject.ext.ndkVersion buildToolsVersion rootProject.ext.buildToolsVersion compileSdk rootProject.ext.compileSdkVersion - namespace "com.example.reactnativepdfviewer" + namespace "pdflight.example" defaultConfig { - applicationId "com.example.reactnativepdfviewer" + applicationId "pdflight.example" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 4122f36..e189252 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="false" - android:theme="@style/AppTheme"> + android:theme="@style/AppTheme" + android:supportsRtl="true"> + android:insetBottom="@dimen/abc_edit_text_inset_bottom_material" + >