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:
4
App.tsx
4
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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
49
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}]} />
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ScannedDocument {
|
||||
pages: ScannedPage[];
|
||||
createdAt: Date;
|
||||
name: string;
|
||||
filter: FilterMode;
|
||||
}
|
||||
|
||||
export interface ScannedPage {
|
||||
|
||||
15
src/utils/filterMatrices.ts
Normal file
15
src/utils/filterMatrices.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user