diff --git a/App.tsx b/App.tsx index 4108164..0eb75bc 100644 --- a/App.tsx +++ b/App.tsx @@ -29,8 +29,8 @@ export default function App() { }, []); const handleSaveDocument = useCallback( - (pages: ScannedPage[], _filter: FilterMode) => { - const doc = createDocument(pages); + (pages: ScannedPage[], filter: FilterMode) => { + const doc = createDocument(pages, filter); setPendingPages([]); setViewingDoc(doc); setScreen('viewer'); diff --git a/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt b/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt index 35abdd1..f3674ee 100644 --- a/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt +++ b/android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt @@ -1,6 +1,11 @@ package com.lensapp +import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint import android.graphics.RectF import android.graphics.pdf.PdfDocument import com.facebook.react.bridge.Promise @@ -16,8 +21,15 @@ class PdfGeneratorModule(private val reactContext: ReactApplicationContext) : override fun getName() = "PdfGenerator" + private fun applyFilter(src: Bitmap, matrixValues: ReadableArray): Bitmap { + val floats = FloatArray(20) { matrixValues.getDouble(it).toFloat() } + val out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888) + Canvas(out).drawBitmap(src, 0f, 0f, Paint().apply { colorFilter = ColorMatrixColorFilter(ColorMatrix(floats)) }) + return out + } + @ReactMethod - fun generate(imagePaths: ReadableArray, fileName: String, promise: Promise) { + fun generate(imagePaths: ReadableArray, fileName: String, matrix: ReadableArray, promise: Promise) { try { val outDir = File(reactContext.filesDir, "pdfs").also { it.mkdirs() } val outFile = File(outDir, "$fileName.pdf") @@ -25,8 +37,10 @@ class PdfGeneratorModule(private val reactContext: ReactApplicationContext) : for (i in 0 until imagePaths.size()) { val path = imagePaths.getString(i)!!.removePrefix("file://") - val bitmap = BitmapFactory.decodeFile(path) + val raw = BitmapFactory.decodeFile(path) ?: throw Exception("Failed to decode image: $path") + val bitmap = applyFilter(raw, matrix) + if (raw !== bitmap) raw.recycle() // A4 at 72 DPI: 595 x 842 points val pageWidth = 595 diff --git a/ios/LensApp/PdfGenerator.m b/ios/LensApp/PdfGenerator.m index e88464e..9cdbd8d 100644 --- a/ios/LensApp/PdfGenerator.m +++ b/ios/LensApp/PdfGenerator.m @@ -1,12 +1,29 @@ #import "PdfGenerator.h" #import +#import @implementation PdfGenerator RCT_EXPORT_MODULE(); +- (UIImage *)applyMatrix:(UIImage *)image matrix:(NSArray *)m { + CIImage *ci = [CIImage imageWithCGImage:image.CGImage]; + CIFilter *f = [CIFilter filterWithName:@"CIColorMatrix"]; + [f setValue:ci forKey:kCIInputImageKey]; + [f setValue:[CIVector vectorWithX:[m[0] floatValue] Y:[m[1] floatValue] Z:[m[2] floatValue] W:[m[3] floatValue]] forKey:@"inputRVector"]; + [f setValue:[CIVector vectorWithX:[m[5] floatValue] Y:[m[6] floatValue] Z:[m[7] floatValue] W:[m[8] floatValue]] forKey:@"inputGVector"]; + [f setValue:[CIVector vectorWithX:[m[10] floatValue] Y:[m[11] floatValue] Z:[m[12] floatValue] W:[m[13] floatValue]] forKey:@"inputBVector"]; + [f setValue:[CIVector vectorWithX:[m[15] floatValue] Y:[m[16] floatValue] Z:[m[17] floatValue] W:[m[18] floatValue]] forKey:@"inputAVector"]; + [f setValue:[CIVector vectorWithX:[m[4] floatValue] Y:[m[9] floatValue] Z:[m[14] floatValue] W:[m[19] floatValue]] forKey:@"inputBiasVector"]; + CGImageRef cg = [[CIContext context] createCGImage:f.outputImage fromRect:f.outputImage.extent]; + UIImage *result = [UIImage imageWithCGImage:cg]; + CGImageRelease(cg); + return result; +} + RCT_EXPORT_METHOD(generate:(NSArray *)imagePaths fileName:(NSString *)fileName + matrix:(NSArray *)matrix resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -31,8 +48,9 @@ @implementation PdfGenerator 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; + UIImage *raw = [UIImage imageWithContentsOfFile:path]; + if (!raw) continue; + UIImage *image = [self applyMatrix:raw matrix:matrix]; [ctx beginPage]; diff --git a/jest.config.js b/jest.config.js index ed98356..8f491fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,6 @@ module.exports = { ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], transformIgnorePatterns: [ - 'node_modules/(?!(react-native|@react-native|react-native-document-scanner-plugin|react-native-fs|react-native-html-to-pdf|react-native-share)/)', + 'node_modules/(?!(react-native|@react-native|react-native-document-scanner-plugin|react-native-fs|react-native-html-to-pdf|react-native-share|react-native-color-matrix-image-filters|rn-color-matrices|concat-color-matrices)/)', ], }; diff --git a/package-lock.json b/package-lock.json index 057551f..dc3e272 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "react": "18.2.0", "react-native": "0.73.0", + "react-native-color-matrix-image-filters": "^8.0.2", "react-native-document-scanner-plugin": "^2.0.4", "react-native-fs": "^2.20.0", "react-native-share": "^12.2.6" @@ -5840,6 +5841,12 @@ "dev": true, "license": "MIT" }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6018,6 +6025,15 @@ "node": ">= 0.6" } }, + "node_modules/concat-color-matrices": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/concat-color-matrices/-/concat-color-matrices-1.0.0.tgz", + "integrity": "sha512-K0IMtl4m9LcHg4vVFb40Txmie/GwCJBkquGSn6wtA9yIcszzFZgGc6co4sSnjFdqeJAv+q2LrCHMUSb2GHAChA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -12385,6 +12401,21 @@ "react": "18.2.0" } }, + "node_modules/react-native-color-matrix-image-filters": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/react-native-color-matrix-image-filters/-/react-native-color-matrix-image-filters-8.0.2.tgz", + "integrity": "sha512-RJoNJt5Mhi1ztsPdKB3kNDHMSbarrWdNkcY2gPb3G7N9Kx6TKq00hl/X90PWRk9p2lqgiD0YznD8NlxDgqgLPg==", + "license": "MIT", + "dependencies": { + "concat-color-matrices": "^1.0.0", + "rn-color-matrices": "^4.1.0", + "ts-tiny-invariant": "^2.0.5" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-document-scanner-plugin": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-native-document-scanner-plugin/-/react-native-document-scanner-plugin-2.0.4.tgz", @@ -12789,6 +12820,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rn-color-matrices": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rn-color-matrices/-/rn-color-matrices-4.1.0.tgz", + "integrity": "sha512-a8++z3Zi4GhA0oWwKS3etdrVuVQ2q/ByTMh6lMxo+vaSv3SRSSg5SzpZk65GS0NCAqMhFT8WSvgSgNhO/F+xag==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13853,6 +13896,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/ts-tiny-invariant": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/ts-tiny-invariant/-/ts-tiny-invariant-2.0.5.tgz", + "integrity": "sha512-NGQzWRLGLMjOUTpsxMSRj63fuFBC8HV8L4NUxzDVgU4MFeOt3qFI2534M5L+ch22x53oDEwPaRBPXgAfRjcXHA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index eef4f72..b1b6884 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "react": "18.2.0", "react-native": "0.73.0", + "react-native-color-matrix-image-filters": "^8.0.2", "react-native-document-scanner-plugin": "^2.0.4", "react-native-fs": "^2.20.0", "react-native-share": "^12.2.6" diff --git a/src/__tests__/generatePdf.test.ts b/src/__tests__/generatePdf.test.ts index 9a29d10..814536e 100644 --- a/src/__tests__/generatePdf.test.ts +++ b/src/__tests__/generatePdf.test.ts @@ -4,6 +4,11 @@ jest.mock('react-native', () => ({ generate: jest.fn(), }, }, + Platform: {OS: 'android'}, +})); + +jest.mock('../utils/filterMatrices', () => ({ + getFilterMatrix: jest.fn(() => new Array(20).fill(0)), })); import {NativeModules} from 'react-native'; @@ -30,6 +35,7 @@ describe('generatePdf', () => { expect(mockGenerate).toHaveBeenCalledWith( ['file:///mock/scans/scan1.jpg', '/mock/scans/scan2.jpg'], 'test', + expect.any(Array), ); }); diff --git a/src/components/DocumentCard.tsx b/src/components/DocumentCard.tsx index 5218a03..0f02eb9 100644 --- a/src/components/DocumentCard.tsx +++ b/src/components/DocumentCard.tsx @@ -1,7 +1,9 @@ import React from 'react'; import {View, Text, Image, TouchableOpacity, StyleSheet} from 'react-native'; +import {ColorMatrix} from 'react-native-color-matrix-image-filters'; import {ScannedDocument} from '../types'; import {formatDate} from '../utils/imageProcessing'; +import {getFilterMatrix} from '../utils/filterMatrices'; import {useTheme} from '../theme'; interface Props { @@ -20,7 +22,9 @@ export default function DocumentCard({document, onPress}: Props) { activeOpacity={0.8}> {thumb ? ( - + + + ) : ( )} diff --git a/src/hooks/useDocumentStore.ts b/src/hooks/useDocumentStore.ts index e161391..0bad927 100644 --- a/src/hooks/useDocumentStore.ts +++ b/src/hooks/useDocumentStore.ts @@ -1,6 +1,6 @@ import {useState, useCallback, useEffect, useRef} from 'react'; import RNFS from 'react-native-fs'; -import {ScannedDocument, ScannedPage} from '../types'; +import {ScannedDocument, ScannedPage, FilterMode} from '../types'; let idCounter = 0; const uid = () => `${Date.now()}-${++idCounter}`; @@ -42,12 +42,13 @@ export function useDocumentStore() { } }, [documents]); - const createDocument = useCallback((pages: ScannedPage[]): ScannedDocument => { + const createDocument = useCallback((pages: ScannedPage[], filter: FilterMode): ScannedDocument => { const doc: ScannedDocument = { id: uid(), pages, createdAt: new Date(), name: `Scan ${new Date().toLocaleDateString()}`, + filter, }; setDocuments(prev => [doc, ...prev]); return doc; diff --git a/src/screens/ReviewScreen.tsx b/src/screens/ReviewScreen.tsx index b4b8194..846f1a5 100644 --- a/src/screens/ReviewScreen.tsx +++ b/src/screens/ReviewScreen.tsx @@ -10,6 +10,8 @@ import { StatusBar, Alert, } from 'react-native'; +import {ColorMatrix} from 'react-native-color-matrix-image-filters'; +import {getFilterMatrix} from '../utils/filterMatrices'; import {ScannedPage, FilterMode} from '../types'; import FilterPicker from '../components/FilterPicker'; import {useTheme} from '../theme'; @@ -28,14 +30,11 @@ export default function ReviewScreen({pages, onSave, onAddMore, onCancel}: Props const [filter, setFilter] = useState('original'); const [currentIndex, setCurrentIndex] = useState(0); - const imageContainerStyle = () => { - switch (filter) { - case 'grayscale': return styles.filterGrayscale; - case 'blackwhite': return styles.filterBW; - case 'enhanced': return styles.filterEnhanced; - default: return null; - } - }; + const renderImage = (page: ScannedPage) => ( + + + + ); return ( @@ -63,8 +62,8 @@ export default function ReviewScreen({pages, onSave, onAddMore, onCancel}: Props }}> {pages.map(page => ( - - + + {renderImage(page)} ))} @@ -128,9 +127,6 @@ const styles = StyleSheet.create({ }, imageWrapper: {flex: 1, width: '100%', borderRadius: 8, overflow: 'hidden'}, pageImage: {flex: 1, width: '100%'}, - filterGrayscale: {opacity: 0.9}, - filterBW: {opacity: 1}, - filterEnhanced: {opacity: 1}, dots: {flexDirection: 'row', justifyContent: 'center', gap: 6, paddingVertical: 8}, dot: {width: 6, height: 6, borderRadius: 3}, dotActive: {backgroundColor: '#007AFF', width: 18}, diff --git a/src/screens/ViewerScreen.tsx b/src/screens/ViewerScreen.tsx index 09232a9..09ed081 100644 --- a/src/screens/ViewerScreen.tsx +++ b/src/screens/ViewerScreen.tsx @@ -14,8 +14,10 @@ import { TextInput, } from 'react-native'; import Share from 'react-native-share'; +import {ColorMatrix} from 'react-native-color-matrix-image-filters'; import {ScannedDocument} from '../types'; import {generatePdf} from '../utils/generatePdf'; +import {getFilterMatrix} from '../utils/filterMatrices'; import {useTheme} from '../theme'; const {width: SW} = Dimensions.get('window'); @@ -44,7 +46,7 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro const label = pageIndices?.length === 1 ? `${document.name} — page ${pageIndices[0] + 1}` : document.name; - const pdfPath = await generatePdf(pages, label); + const pdfPath = await generatePdf(pages, label, document.filter); await Share.open({ title: label, type: 'application/pdf', @@ -98,11 +100,13 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro }}> {document.pages.map((page, i) => ( - + + + Page {i + 1} ))} diff --git a/src/types/index.ts b/src/types/index.ts index 1ca7adb..b163179 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export interface ScannedDocument { pages: ScannedPage[]; createdAt: Date; name: string; + filter: FilterMode; } export interface ScannedPage { diff --git a/src/utils/filterMatrices.ts b/src/utils/filterMatrices.ts new file mode 100644 index 0000000..34ba711 --- /dev/null +++ b/src/utils/filterMatrices.ts @@ -0,0 +1,15 @@ +import {concatColorMatrices, saturate, contrast, brightness, normal} from 'react-native-color-matrix-image-filters'; +import {FilterMode} from '../types'; + +export function getFilterMatrix(filter: FilterMode): number[] { + switch (filter) { + case 'grayscale': + return saturate(0); + case 'blackwhite': + return concatColorMatrices(saturate(0), contrast(4)); + case 'enhanced': + return concatColorMatrices(contrast(1.3), brightness(1.1)); + default: + return normal(); + } +} diff --git a/src/utils/generatePdf.ts b/src/utils/generatePdf.ts index a9ccb04..160972e 100644 --- a/src/utils/generatePdf.ts +++ b/src/utils/generatePdf.ts @@ -1,10 +1,11 @@ import {NativeModules} from 'react-native'; -import {ScannedPage} from '../types'; +import {ScannedPage, FilterMode} from '../types'; +import {getFilterMatrix} from './filterMatrices'; const {PdfGenerator} = NativeModules; -export async function generatePdf(pages: ScannedPage[], name: string): Promise { +export async function generatePdf(pages: ScannedPage[], name: string, filter: FilterMode = 'original'): Promise { const imagePaths = pages.map(p => p.uri); const fileName = name.replace(/[^a-z0-9_\-]/gi, '_'); - return PdfGenerator.generate(imagePaths, fileName); + return PdfGenerator.generate(imagePaths, fileName, getFilterMatrix(filter)); }