From 77d4b4bd87d89f75d601d308d5335e1c5af52b56 Mon Sep 17 00:00:00 2001 From: Rakshit Kumar Singh Date: Tue, 21 Apr 2026 04:46:15 +0530 Subject: [PATCH] Replace dependencies with system apis (#8) * pdf dep rem * remove stale package * replace with native share * rename share module * revert: restore react-native-share (native replacement had worse perf, negligible size gain) * cosmetic --- .../main/java/com/lensapp/MainApplication.kt | 6 +- .../java/com/lensapp/PdfGeneratorModule.kt | 55 +++++++++ .../java/com/lensapp/PdfGeneratorPackage.kt | 14 +++ ios/LensApp/PdfGenerator.h | 4 + ios/LensApp/PdfGenerator.m | 58 ++++++++++ package-lock.json | 14 --- package.json | 1 - patches/react-native-html-to-pdf+1.3.0.patch | 45 -------- src/__tests__/generatePdf.test.ts | 106 ++++++------------ src/screens/ViewerScreen.tsx | 1 + src/utils/generatePdf.ts | 58 +--------- 11 files changed, 173 insertions(+), 189 deletions(-) create mode 100644 android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt create mode 100644 android/app/src/main/java/com/lensapp/PdfGeneratorPackage.kt create mode 100644 ios/LensApp/PdfGenerator.h create mode 100644 ios/LensApp/PdfGenerator.m delete mode 100644 patches/react-native-html-to-pdf+1.3.0.patch diff --git a/android/app/src/main/java/com/lensapp/MainApplication.kt b/android/app/src/main/java/com/lensapp/MainApplication.kt index 8c07f7d..5cc498d 100644 --- a/android/app/src/main/java/com/lensapp/MainApplication.kt +++ b/android/app/src/main/java/com/lensapp/MainApplication.kt @@ -17,9 +17,9 @@ class MainApplication : Application(), ReactApplication { override val reactNativeHost: ReactNativeHost = object : DefaultReactNativeHost(this) { override fun getPackages(): List { - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - return PackageList(this).packages + val packages = PackageList(this).packages.toMutableList() + packages.add(PdfGeneratorPackage()) + return packages } override fun getJSMainModuleName(): String = "index" diff --git a/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt b/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt new file mode 100644 index 0000000..35abdd1 --- /dev/null +++ b/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt @@ -0,0 +1,55 @@ +package com.lensapp + +import android.graphics.BitmapFactory +import android.graphics.RectF +import android.graphics.pdf.PdfDocument +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableArray +import java.io.File +import java.io.FileOutputStream + +class PdfGeneratorModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName() = "PdfGenerator" + + @ReactMethod + fun generate(imagePaths: ReadableArray, fileName: String, promise: Promise) { + try { + val outDir = File(reactContext.filesDir, "pdfs").also { it.mkdirs() } + val outFile = File(outDir, "$fileName.pdf") + val doc = PdfDocument() + + for (i in 0 until imagePaths.size()) { + val path = imagePaths.getString(i)!!.removePrefix("file://") + val bitmap = BitmapFactory.decodeFile(path) + ?: throw Exception("Failed to decode image: $path") + + // A4 at 72 DPI: 595 x 842 points + val pageWidth = 595 + val pageHeight = 842 + val pageInfo = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, i + 1).create() + val page = doc.startPage(pageInfo) + + val scale = minOf(pageWidth.toFloat() / bitmap.width, pageHeight.toFloat() / bitmap.height) + val scaledW = bitmap.width * scale + val scaledH = bitmap.height * scale + val left = (pageWidth - scaledW) / 2f + val top = (pageHeight - scaledH) / 2f + + page.canvas.drawBitmap(bitmap, null, RectF(left, top, left + scaledW, top + scaledH), null) + bitmap.recycle() + doc.finishPage(page) + } + + FileOutputStream(outFile).use { doc.writeTo(it) } + doc.close() + promise.resolve("file://${outFile.absolutePath}") + } catch (e: Exception) { + promise.reject("PDF_ERROR", e.message, e) + } + } +} diff --git a/android/app/src/main/java/com/lensapp/PdfGeneratorPackage.kt b/android/app/src/main/java/com/lensapp/PdfGeneratorPackage.kt new file mode 100644 index 0000000..9f7205f --- /dev/null +++ b/android/app/src/main/java/com/lensapp/PdfGeneratorPackage.kt @@ -0,0 +1,14 @@ +package com.lensapp + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class PdfGeneratorPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(PdfGeneratorModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/ios/LensApp/PdfGenerator.h b/ios/LensApp/PdfGenerator.h new file mode 100644 index 0000000..6b1823c --- /dev/null +++ b/ios/LensApp/PdfGenerator.h @@ -0,0 +1,4 @@ +#import + +@interface PdfGenerator : NSObject +@end diff --git a/ios/LensApp/PdfGenerator.m b/ios/LensApp/PdfGenerator.m new file mode 100644 index 0000000..e88464e --- /dev/null +++ b/ios/LensApp/PdfGenerator.m @@ -0,0 +1,58 @@ +#import "PdfGenerator.h" +#import + +@implementation PdfGenerator + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(generate:(NSArray *)imagePaths + fileName:(NSString *)fileName + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSArray *docPaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *pdfsDir = [docPaths[0] stringByAppendingPathComponent:@"pdfs"]; + [[NSFileManager defaultManager] createDirectoryAtPath:pdfsDir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + NSString *outPath = [[pdfsDir stringByAppendingPathComponent:fileName] + stringByAppendingPathExtension:@"pdf"]; + + // A4 at 72 DPI: 595 x 842 points + CGRect pageRect = CGRectMake(0, 0, 595, 842); + UIGraphicsPDFRendererFormat *format = [UIGraphicsPDFRendererFormat defaultFormat]; + UIGraphicsPDFRenderer *renderer = [[UIGraphicsPDFRenderer alloc] initWithBounds:pageRect format:format]; + + NSError *error; + BOOL success = [renderer writePDFToURL:[NSURL fileURLWithPath:outPath] + withActions:^(UIGraphicsPDFRendererContext *ctx) { + for (NSString *rawPath in imagePaths) { + NSString *path = [rawPath hasPrefix:@"file://"] ? [rawPath substringFromIndex:7] : rawPath; + UIImage *image = [UIImage imageWithContentsOfFile:path]; + if (!image) continue; + + [ctx beginPage]; + + CGFloat scale = MIN(pageRect.size.width / image.size.width, + pageRect.size.height / image.size.height); + CGFloat w = image.size.width * scale; + CGFloat h = image.size.height * scale; + CGFloat x = (pageRect.size.width - w) / 2; + CGFloat y = (pageRect.size.height - h) / 2; + + [image drawInRect:CGRectMake(x, y, w, h)]; + } + } error:&error]; + + if (success) { + resolve([@"file://" stringByAppendingString:outPath]); + } else { + reject(@"PDF_ERROR", error.localizedDescription, error); + } + }); +} + +@end diff --git a/package-lock.json b/package-lock.json index 88669fa..057551f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "react-native": "0.73.0", "react-native-document-scanner-plugin": "^2.0.4", "react-native-fs": "^2.20.0", - "react-native-html-to-pdf": "^1.3.0", "react-native-share": "^12.2.6" }, "devDependencies": { @@ -12418,19 +12417,6 @@ } } }, - "node_modules/react-native-html-to-pdf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-native-html-to-pdf/-/react-native-html-to-pdf-1.3.0.tgz", - "integrity": "sha512-UCbQO1k5yeajaJrEF7Wqq8ojdgpxsTbICQvtorXmuO1LkU/a6B3/tZyLes4zXCiq/5nTMTNA/k9m5bYStg5S0Q==", - "license": "MIT", - "workspaces": [ - "example" - ], - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/react-native-share": { "version": "12.2.6", "resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-12.2.6.tgz", diff --git a/package.json b/package.json index d385b30..eef4f72 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "react-native": "0.73.0", "react-native-document-scanner-plugin": "^2.0.4", "react-native-fs": "^2.20.0", - "react-native-html-to-pdf": "^1.3.0", "react-native-share": "^12.2.6" }, "devDependencies": { diff --git a/patches/react-native-html-to-pdf+1.3.0.patch b/patches/react-native-html-to-pdf+1.3.0.patch deleted file mode 100644 index 77ebf2e..0000000 --- a/patches/react-native-html-to-pdf+1.3.0.patch +++ /dev/null @@ -1,45 +0,0 @@ -diff --git a/node_modules/react-native-html-to-pdf/android/src/main/java/com/htmltopdf/HtmlToPdfPackage.kt b/node_modules/react-native-html-to-pdf/android/src/main/java/com/htmltopdf/HtmlToPdfPackage.kt -index 5fe5a8d..d2586e0 100644 ---- a/node_modules/react-native-html-to-pdf/android/src/main/java/com/htmltopdf/HtmlToPdfPackage.kt -+++ b/node_modules/react-native-html-to-pdf/android/src/main/java/com/htmltopdf/HtmlToPdfPackage.kt -@@ -1,33 +1,16 @@ - package com.htmltopdf - --import com.facebook.react.BaseReactPackage -+import com.facebook.react.ReactPackage - import com.facebook.react.bridge.NativeModule - import com.facebook.react.bridge.ReactApplicationContext --import com.facebook.react.module.model.ReactModuleInfo --import com.facebook.react.module.model.ReactModuleInfoProvider --import java.util.HashMap -+import com.facebook.react.uimanager.ViewManager - --class HtmlToPdfPackage : BaseReactPackage() { -- override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { -- return if (name == HtmlToPdfModule.NAME) { -- HtmlToPdfModule(reactContext) -- } else { -- null -- } -+class HtmlToPdfPackage : ReactPackage { -+ override fun createNativeModules(reactContext: ReactApplicationContext): List { -+ return listOf(HtmlToPdfModule(reactContext)) - } - -- override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { -- return ReactModuleInfoProvider { -- val moduleInfos: MutableMap = HashMap() -- moduleInfos[HtmlToPdfModule.NAME] = ReactModuleInfo( -- HtmlToPdfModule.NAME, -- HtmlToPdfModule.NAME, -- false, // canOverrideExistingModule -- false, // needsEagerInit -- false, // isCxxModule -- true // isTurboModule -- ) -- moduleInfos -- } -+ override fun createViewManagers(reactContext: ReactApplicationContext): List> { -+ return emptyList() - } - } diff --git a/src/__tests__/generatePdf.test.ts b/src/__tests__/generatePdf.test.ts index 6e43fa2..9a29d10 100644 --- a/src/__tests__/generatePdf.test.ts +++ b/src/__tests__/generatePdf.test.ts @@ -1,19 +1,17 @@ -// Mock native modules before importing the module under test -jest.mock('react-native-fs', () => ({ - DocumentDirectoryPath: '/mock/documents', - readFile: jest.fn().mockResolvedValue('base64data'), - mkdir: jest.fn().mockResolvedValue(undefined), +jest.mock('react-native', () => ({ + NativeModules: { + PdfGenerator: { + generate: jest.fn(), + }, + }, })); -jest.mock('react-native-html-to-pdf', () => ({ - generatePDF: jest.fn(), -})); - -import RNFS from 'react-native-fs'; -import {generatePDF as mockGeneratePDF} from 'react-native-html-to-pdf'; +import {NativeModules} from 'react-native'; import {generatePdf} from '../utils/generatePdf'; import {ScannedPage} from '../types'; +const mockGenerate = NativeModules.PdfGenerator.generate as jest.Mock; + const mockPages: ScannedPage[] = [ {id: 'p1', uri: 'file:///mock/scans/scan1.jpg', width: 1240, height: 1754}, {id: 'p2', uri: '/mock/scans/scan2.jpg', width: 1240, height: 1754}, @@ -24,73 +22,35 @@ beforeEach(() => { }); describe('generatePdf', () => { - it('strips file:// before reading and passes base64 to HTML', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({ - filePath: '/mock/documents/pdfs/test.pdf', - }); + it('passes all image URIs to the native module', async () => { + mockGenerate.mockResolvedValue('file:///mock/pdfs/test.pdf'); - await generatePdf(mockPages, 'test doc'); + await generatePdf(mockPages, 'test'); - // Should have been called for each page - expect(RNFS.readFile).toHaveBeenCalledTimes(2); - // file:// prefix stripped for first page - expect(RNFS.readFile).toHaveBeenCalledWith('/mock/scans/scan1.jpg', 'base64'); - // path without prefix used as-is for second page - expect(RNFS.readFile).toHaveBeenCalledWith('/mock/scans/scan2.jpg', 'base64'); - }); - - it('passes a relative directory name, not an absolute path', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({ - filePath: '/mock/documents/pdfs/test.pdf', - }); - - await generatePdf(mockPages, 'My Doc'); - - const call = (mockGeneratePDF as jest.Mock).mock.calls[0][0]; - expect(call.directory).toBe('pdfs'); - expect(call.directory).not.toContain('/'); - }); - - it('sanitises the file name', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({ - filePath: '/mock/documents/pdfs/test.pdf', - }); - - await generatePdf(mockPages, 'Scan 2024/06/15 — page 1'); - - const call = (mockGeneratePDF as jest.Mock).mock.calls[0][0]; - expect(call.fileName).not.toMatch(/[^a-z0-9_\-]/i); - }); - - it('returns a file:// URI', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({ - filePath: '/mock/documents/pdfs/test.pdf', - }); - - const result = await generatePdf(mockPages, 'test'); - expect(result).toBe('file:///mock/documents/pdfs/test.pdf'); - }); - - it('does not double-prefix an already file:// path', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({ - filePath: 'file:///mock/documents/pdfs/test.pdf', - }); - - const result = await generatePdf(mockPages, 'test'); - expect(result).toBe('file:///mock/documents/pdfs/test.pdf'); - expect(result).not.toContain('file://file://'); - }); - - it('throws when the library returns no filePath', async () => { - (mockGeneratePDF as jest.Mock).mockResolvedValue({filePath: null}); - - await expect(generatePdf(mockPages, 'test')).rejects.toThrow( - 'PDF generation failed', + expect(mockGenerate).toHaveBeenCalledWith( + ['file:///mock/scans/scan1.jpg', '/mock/scans/scan2.jpg'], + 'test', ); }); - it('throws when the library rejects', async () => { - (mockGeneratePDF as jest.Mock).mockRejectedValue(new Error('native crash')); + it('sanitises the file name before passing to native module', async () => { + mockGenerate.mockResolvedValue('file:///mock/pdfs/test.pdf'); + + await generatePdf(mockPages, 'Scan 2024/06/15 — page 1'); + + const fileName = mockGenerate.mock.calls[0][1]; + expect(fileName).not.toMatch(/[^a-z0-9_\-]/i); + }); + + it('returns the path resolved by the native module', async () => { + mockGenerate.mockResolvedValue('file:///mock/pdfs/test.pdf'); + + const result = await generatePdf(mockPages, 'test'); + expect(result).toBe('file:///mock/pdfs/test.pdf'); + }); + + it('throws when the native module rejects', async () => { + mockGenerate.mockRejectedValue(new Error('native crash')); await expect(generatePdf(mockPages, 'test')).rejects.toThrow('native crash'); }); diff --git a/src/screens/ViewerScreen.tsx b/src/screens/ViewerScreen.tsx index fccd840..85b68d4 100644 --- a/src/screens/ViewerScreen.tsx +++ b/src/screens/ViewerScreen.tsx @@ -36,6 +36,7 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro const sharePdf = async (pageIndices?: number[]) => { setGenerating(true); + await new Promise(resolve => setTimeout(resolve, 0)); try { const pages = pageIndices ? pageIndices.map(i => document.pages[i]).filter(Boolean) diff --git a/src/utils/generatePdf.ts b/src/utils/generatePdf.ts index 5780f61..a9ccb04 100644 --- a/src/utils/generatePdf.ts +++ b/src/utils/generatePdf.ts @@ -1,58 +1,10 @@ -import {generatePDF as rnGeneratePDF} from 'react-native-html-to-pdf'; -import RNFS from 'react-native-fs'; +import {NativeModules} from 'react-native'; import {ScannedPage} from '../types'; -// On Android, `directory` is a relative subdirectory name appended to -// getExternalFilesDir()/filesDir — NOT an absolute path. -const PDF_SUBDIR = 'pdfs'; - -async function pageToBase64(uri: string): Promise { - const path = uri.startsWith('file://') ? uri.slice(7) : uri; - return RNFS.readFile(path, 'base64'); -} - -function toFileUri(p: string): string { - return p.startsWith('file://') ? p : `file://${p}`; -} - -export async function generatePdf( - pages: ScannedPage[], - name: string, -): Promise { - const imageHtml = await Promise.all( - pages.map(async p => { - const b64 = await pageToBase64(p.uri); - return `
`; - }), - ); - - const html = ` - - - - - - ${imageHtml.join('\n')} -`; +const {PdfGenerator} = NativeModules; +export async function generatePdf(pages: ScannedPage[], name: string): Promise { + const imagePaths = pages.map(p => p.uri); const fileName = name.replace(/[^a-z0-9_\-]/gi, '_'); - - const result = await rnGeneratePDF({ - html, - fileName, - directory: PDF_SUBDIR, - base64: false, - }); - - if (!result.filePath) { - throw new Error('PDF generation failed: library returned no file path'); - } - - return toFileUri(result.filePath); + return PdfGenerator.generate(imagePaths, fileName); }