diff --git a/package-lock.json b/package-lock.json index 677c4e5..93a8e65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,13 @@ "": { "name": "LensApp", "version": "0.0.1", + "hasInstallScript": true, "dependencies": { "react": "18.2.0", "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-image-crop-picker": "^0.51.1", "react-native-share": "^12.2.6" }, @@ -10495,6 +10497,19 @@ } } }, + "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-image-crop-picker": { "version": "0.51.1", "resolved": "https://registry.npmjs.org/react-native-image-crop-picker/-/react-native-image-crop-picker-0.51.1.tgz", diff --git a/package.json b/package.json index 553f6be..af57f57 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "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-image-crop-picker": "^0.51.1", "react-native-share": "^12.2.6" }, diff --git a/src/screens/ViewerScreen.tsx b/src/screens/ViewerScreen.tsx index 70f7e71..54835a9 100644 --- a/src/screens/ViewerScreen.tsx +++ b/src/screens/ViewerScreen.tsx @@ -7,11 +7,13 @@ import { TouchableOpacity, StyleSheet, Dimensions, - Share, StatusBar, Alert, + ActivityIndicator, } from 'react-native'; +import Share from 'react-native-share'; import {ScannedDocument} from '../types'; +import {generatePdf} from '../utils/generatePdf'; const {width: SW} = Dimensions.get('window'); @@ -24,30 +26,33 @@ interface Props { export default function ViewerScreen({document, onBack, onDelete, onRename}: Props) { const [currentIndex, setCurrentIndex] = useState(0); + const [generating, setGenerating] = useState(false); - const shareImage = async () => { - const page = document.pages[currentIndex]; - if (!page) return; + const sharePdf = async (pageIndices?: number[]) => { + setGenerating(true); try { - await Share.share({ - title: document.name, - url: page.uri, - message: `${document.name} — page ${currentIndex + 1}`, - }); - } catch { - // user cancelled - } - }; + const pages = + pageIndices + ? pageIndices.map(i => document.pages[i]).filter(Boolean) + : document.pages; - const shareAll = async () => { - try { - await Share.share({ - title: document.name, - message: `${document.name}: ${document.pages.length} pages`, - url: document.pages[0]?.uri, + const label = + pageIndices?.length === 1 + ? `${document.name} — page ${pageIndices[0] + 1}` + : document.name; + + const pdfPath = await generatePdf(pages, label); + + await Share.open({ + title: label, + type: 'application/pdf', + url: `file://${pdfPath}`, + failOnCancel: false, }); - } catch { - // user cancelled + } catch (err: any) { + Alert.alert('Share failed', err?.message ?? 'Could not generate PDF.'); + } finally { + setGenerating(false); } }; @@ -72,8 +77,11 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro {currentIndex + 1} / {document.pages.length} - - Share + sharePdf()} + style={styles.headerBtn} + disabled={generating}> + Share PDF @@ -106,10 +114,20 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro )} - + sharePdf([currentIndex])} + disabled={generating}> ⬆ Share Page + sharePdf()} + disabled={generating}> + 📄 + Share All + onRename(document.name)}> ✎ Rename @@ -119,6 +137,13 @@ export default function ViewerScreen({document, onBack, onDelete, onRename}: Pro Delete + + {generating && ( + + + Generating PDF… + + )} ); } @@ -174,4 +199,12 @@ const styles = StyleSheet.create({ toolIconDanger: {color: '#ff453a'}, toolLabel: {fontSize: 11, color: '#007AFF'}, toolLabelDanger: {color: '#ff453a'}, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.65)', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + loadingText: {color: '#fff', fontSize: 15}, }); diff --git a/src/utils/generatePdf.ts b/src/utils/generatePdf.ts new file mode 100644 index 0000000..c786de4 --- /dev/null +++ b/src/utils/generatePdf.ts @@ -0,0 +1,60 @@ +import {generatePDF as rnGeneratePDF} from 'react-native-html-to-pdf'; +import RNFS from 'react-native-fs'; +import {ScannedPage} from '../types'; + +const PDF_DIR = `${RNFS.DocumentDirectoryPath}/pdfs`; + +async function pageToBase64(uri: string): Promise { + const path = uri.startsWith('file://') ? uri.slice(7) : uri; + return RNFS.readFile(path, 'base64'); +} + +export async function generatePdf( + pages: ScannedPage[], + name: string, +): Promise { + await RNFS.mkdir(PDF_DIR); + + const imageHtml = await Promise.all( + pages.map(async p => { + const b64 = await pageToBase64(p.uri); + return `
`; + }), + ); + + const html = ` + + + + + + + ${imageHtml.join('\n')} + + `; + + const result = await rnGeneratePDF({ + html, + fileName: name.replace(/[^a-z0-9_\-]/gi, '_'), + directory: PDF_DIR, + base64: false, + }); + + if (!result.filePath) { + throw new Error('PDF generation failed — no output path returned'); + } + + return result.filePath; +}