filter at pdf generation and review screen (#11)

* filter at pdf generation

* full filter support via external package

* added to jest ignore

* fix intry

* fix filter size

* brightness weirdness fix

* fix preview screen colors
This commit is contained in:
2026-04-23 03:00:12 +05:30
committed by GitHub
parent a85eb65134
commit fd0e5daf20
14 changed files with 142 additions and 32 deletions

View File

@@ -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');

View File

@@ -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

View File

@@ -1,12 +1,29 @@
#import "PdfGenerator.h"
#import <UIKit/UIKit.h>
#import <CoreImage/CoreImage.h>
@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 @@ RCT_EXPORT_METHOD(generate:(NSArray *)imagePaths
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];

View File

@@ -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)/)',
],
};

49
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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),
);
});

View File

@@ -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}>
<View style={styles.thumbContainer}>
{thumb ? (
<ColorMatrix matrix={getFilterMatrix(document.filter)} style={styles.thumb}>
<Image source={{uri: thumb}} style={styles.thumb} resizeMode="cover" />
</ColorMatrix>
) : (
<View style={[styles.thumb, {backgroundColor: t.border}]} />
)}

View File

@@ -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;

View File

@@ -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<FilterMode>('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) => (
<ColorMatrix matrix={getFilterMatrix(filter)} style={styles.pageImage}>
<Image source={{uri: page.uri}} style={styles.pageImage} resizeMode="contain" />
</ColorMatrix>
);
return (
<View style={[styles.container, {backgroundColor: t.bg}]}>
@@ -63,8 +62,8 @@ export default function ReviewScreen({pages, onSave, onAddMore, onCancel}: Props
}}>
{pages.map(page => (
<View key={page.id} style={styles.pageContainer}>
<View style={[styles.imageWrapper, imageContainerStyle()]}>
<Image source={{uri: page.uri}} style={styles.pageImage} resizeMode="contain" />
<View style={styles.imageWrapper}>
{renderImage(page)}
</View>
</View>
))}
@@ -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},

View File

@@ -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) => (
<View key={page.id} style={styles.pageContainer}>
<ColorMatrix matrix={getFilterMatrix(document.filter)} style={styles.pageImage}>
<Image
source={{uri: page.uri}}
style={styles.pageImage}
resizeMode="contain"
/>
</ColorMatrix>
<Text style={[styles.pageNum, {color: t.textSecondary}]}>Page {i + 1}</Text>
</View>
))}

View File

@@ -3,6 +3,7 @@ export interface ScannedDocument {
pages: ScannedPage[];
createdAt: Date;
name: string;
filter: FilterMode;
}
export interface ScannedPage {

View File

@@ -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();
}
}

View File

@@ -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<string> {
export async function generatePdf(pages: ScannedPage[], name: string, filter: FilterMode = 'original'): Promise<string> {
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));
}