Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
18 changes: 16 additions & 2 deletions android/app/src/main/java/com/lensapp/PdfGeneratorModule.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,17 +21,26 @@ 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")
val doc = PdfDocument()

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
Expand Down
22 changes: 20 additions & 2 deletions ios/LensApp/PdfGenerator.m
Original file line number Diff line number Diff line change
@@ -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)
{
Expand All @@ -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];

Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 49 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/generatePdf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +35,7 @@ describe('generatePdf', () => {
expect(mockGenerate).toHaveBeenCalledWith(
['file:///mock/scans/scan1.jpg', '/mock/scans/scan2.jpg'],
'test',
expect.any(Array),
);
});

Expand Down
6 changes: 5 additions & 1 deletion src/components/DocumentCard.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,7 +22,9 @@ export default function DocumentCard({document, onPress}: Props) {
activeOpacity={0.8}>
<View style={styles.thumbContainer}>
{thumb ? (
<Image source={{uri: thumb}} style={styles.thumb} resizeMode="cover" />
<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}]} />
)}
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/useDocumentStore.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 9 additions & 13 deletions src/screens/ReviewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}]}>
Expand Down Expand Up @@ -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>
))}
Expand Down Expand Up @@ -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},
Expand Down
16 changes: 10 additions & 6 deletions src/screens/ViewerScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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}>
<Image
source={{uri: page.uri}}
style={styles.pageImage}
resizeMode="contain"
/>
<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>
))}
Expand Down
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface ScannedDocument {
pages: ScannedPage[];
createdAt: Date;
name: string;
filter: FilterMode;
}

export interface ScannedPage {
Expand Down
15 changes: 15 additions & 0 deletions src/utils/filterMatrices.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
7 changes: 4 additions & 3 deletions src/utils/generatePdf.ts
Original file line number Diff line number Diff line change
@@ -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));
}