A friend complained about eye strain from reading white-backgrounded PDFs at night. What started as a simple CSS fix turned into building a custom PDF text extraction and rendering system in bare React Native when every existing library failed.

Here's the thing: sometimes the best solutions come from the worst failures. My friend was tired of her eyes burning from reading PDFs late at night. "Just make it dark mode," she said. Simple enough, right? Turns out, making PDFs actually readable in dark mode is way harder than it sounds.
This is the story of how I went from "let me just add some CSS" to building a complete text extraction and rendering system in bare React Native, complete with custom native modules in Kotlin.
Most e-books and PDFs come with bright white backgrounds. At 2 AM, that's basically staring into a flashlight. My friend wanted something like Kindle's reading experience but for her existing PDF collection. The requirements were straightforward:
Seemed reasonable. Turns out, it wasn't.
My first instinct was to keep it simple. Just render the PDF in a WebView and slap some CSS on it to invert colors or change the background.
tsx1// What I tried first - spoiler: doesn't work 2<WebView 3 source={{ uri: pdfPath }} 4 style={{ backgroundColor: "#000" }} 5 injectedJavaScript={` 6 document.body.style.backgroundColor = '#000'; 7 document.body.style.filter = 'invert(1)'; 8 `} 9/>
The problems showed up immediately:
This approach was dead on arrival. CSS can't fix what it can't see, and PDF rendering engines don't play by web rules.
Okay, CSS failed. What about applying visual filters to tone down the brightness? I found a few React Native libraries that let you apply filters to views.
I tried the color inversion approach at the native layer. Here's what that looked like in Kotlin:
kotlin1private fun applyColorInversion(view: View) { 2 val colorMatrix = ColorMatrix() 3 4 // Invert colors: multiply by -1 and add 255 5 colorMatrix.set(floatArrayOf( 6 -1f, 0f, 0f, 0f, 255f, 7 0f, -1f, 0f, 0f, 255f, 8 0f, 0f, -1f, 0f, 255f, 9 0f, 0f, 0f, 1f, 0f 10 )) 11 12 val paint = Paint() 13 paint.colorFilter = ColorMatrixColorFilter(colorMatrix) 14 view.setLayerType(View.LAYER_TYPE_HARDWARE, paint) 15}
This actually worked technically. The white turned dark, the black text turned white. But here's what I didn't account for:
My friend tried it for five minutes and said "this is worse than the white background."
Back to the drawing board.
At this point, I figured someone must have solved this already. I spent days researching and testing PDF rendering libraries for React Native:
react-native-pdf - The most popular option. Great for displaying PDFs, zero support for text extraction or custom rendering. It's essentially a wrapper around native PDF viewers, which means you're stuck with whatever display options they provide.
react-native-pdf-lib - Focused on creating and manipulating PDFs, not extracting text from them. Wrong tool for the job.
rn-pdf-reader-js - Uses PDF.js in a WebView. Performance was terrible on complex PDFs, and the dark mode implementation was the same filter approach that already failed.
react-native-view-pdf - Another viewer library. Again, great for viewing, useless for text extraction.
I tried about six more libraries. None of them could do what I needed: extract the actual text content from a PDF so I could re-render it with full control over the styling.
When the libraries fail, you write native modules. Here's what I needed to build:
For the Android side, I used Apache PDFBox. It's a mature Java library for PDF manipulation, and it has Android compatibility through PDFBox-Android.
First, add the dependency to your android/app/build.gradle:
gradle1dependencies { 2 implementation 'com.tom-roush:pdfbox-android:2.0.27.0' 3}
Then I created a native module that exposes PDF text extraction to React Native:
kotlin1package com.atharv.reverie 2 3import com.facebook.react.bridge.* 4import com.tom_roush.pdfbox.android.PDFBoxResourceLoader 5import com.tom_roush.pdfbox.pdmodel.PDDocument 6import com.tom_roush.pdfbox.text.PDFTextStripper 7import java.io.File 8 9class PdfTextExtractorModule(reactContext: ReactApplicationContext) : 10 ReactContextBaseJavaModule(reactContext) { 11 12 override fun getName(): String { 13 return "PdfTextExtractor" 14 } 15 16 @ReactMethod 17 fun extractText(filePath: String, promise: Promise) { 18 try { 19 PDFBoxResourceLoader.init(reactApplicationContext) 20 21 val file = File(filePath) 22 if (!file.exists()) { 23 promise.reject("FILE_NOT_FOUND", "PDF file not found at: $filePath") 24 return 25 } 26 27 val document = PDDocument.load(file) 28 val stripper = PDFTextStripper() 29 30 // These settings are critical for preserving layout 31 stripper.setSortByPosition(true) 32 stripper.setLineSeparator("\n") 33 stripper.setWordSeparator(" ") 34 stripper.setAddMoreFormatting(true) 35 stripper.setIndentThreshold(2.0f) 36 stripper.setDropThreshold(2.5f) 37 stripper.setAverageCharTolerance(0.3f) 38 stripper.setSpacingTolerance(0.5f) 39 40 val pageCount = document.numberOfPages 41 val result = Arguments.createMap() 42 result.putInt("pageCount", pageCount) 43 44 val pages = Arguments.createArray() 45 46 for (i in 1..pageCount) { 47 stripper.startPage = i 48 stripper.endPage = i 49 50 var pageText = stripper.getText(document) 51 52 // Post-process to improve paragraph detection 53 pageText = pageText.replace(Regex("\\n\\s*\\n"), "\n\n") 54 pageText = pageText.replace(Regex("[ \\t]+"), " ") 55 pageText = pageText.replace(Regex(" +\\n"), "\n") 56 57 val pageData = Arguments.createMap() 58 pageData.putInt("pageNumber", i) 59 pageData.putString("text", pageText.trim()) 60 61 pages.pushMap(pageData) 62 } 63 64 result.putArray("pages", pages) 65 document.close() 66 67 promise.resolve(result) 68 69 } catch (e: Exception) { 70 promise.reject("EXTRACTION_ERROR", "Failed to extract text: ${e.message}", e) 71 } 72 } 73}
The key here is configuring the PDFTextStripper properly. Those tolerance and threshold values took a lot of trial and error to get right. Too strict and paragraphs break incorrectly. Too loose and you lose formatting structure.
Register the module in your package:
kotlin1package com.atharv.reverie 2 3import com.facebook.react.ReactPackage 4import com.facebook.react.bridge.NativeModule 5import com.facebook.react.bridge.ReactApplicationContext 6import com.facebook.react.uimanager.ViewManager 7 8class PdfTextExtractorPackage : ReactPackage { 9 override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> { 10 return listOf(PdfTextExtractorModule(reactContext)) 11 } 12 13 override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> { 14 return emptyList() 15 } 16}
And add it to your MainApplication.kt:
kotlin1class MainApplication : Application(), ReactApplication { 2 override val reactHost: ReactHost by lazy { 3 getDefaultReactHost( 4 context = applicationContext, 5 packageList = 6 PackageList(this).packages.apply { 7 add(PdfTextExtractorPackage()) 8 }, 9 ) 10 } 11}
Now that we can extract text, we need to render it properly. The goal was to make it feel like reading an actual book, not just scrolling through plain text.
Here's the core reader component:
tsx1export interface BookReaderProps { 2 filePath: string; 3 bookId: number; 4 currentPage?: number; 5 onPageChanged?: (page: number, totalPages?: number) => void; 6 onLoadComplete?: (totalPages: number) => void; 7 fontSize?: number; 8 lineSpacing?: number; 9 enableDarkMode?: boolean; 10} 11 12export const BookReader: React.FC<BookReaderProps> = memo(({ 13 filePath, 14 bookId, 15 currentPage: _currentPage, 16 onPageChanged, 17 fontSize: propFontSize, 18 lineSpacing: propLineSpacing, 19 enableDarkMode, 20}) => { 21 const systemColorScheme = useColorScheme(); 22 const isDark = enableDarkMode ?? systemColorScheme === 'dark'; 23 24 const [bookContent, setBookContent] = useState<BookContent | null>(null); 25 const [loading, setLoading] = useState(true); 26 const [currentPage, setCurrentPage] = useState(1); 27 const [scrollPosition, setScrollPosition] = useState(0); 28 29 const scrollViewRef = useRef<ScrollView>(null); 30 31 // Theme configuration 32 const theme = { 33 background: isDark ? '#121212' : '#FAFAFA', 34 surface: isDark ? '#1E1E1E' : '#FFFFFF', 35 text: isDark ? '#E8E8E8' : '#1A1A1A', 36 textSecondary: isDark ? '#A0A0A0' : '#666666', 37 }; 38 39 const loadBook = useCallback(async () => { 40 setLoading(true); 41 try { 42 const content = await TextExtractor.extractBook(filePath, bookId); 43 setBookContent(content); 44 onLoadComplete?.(content.pages.length); 45 } catch (err) { 46 console.error('[BookReader] Failed to load book:', err); 47 } finally { 48 setLoading(false); 49 } 50 }, [filePath, bookId]); 51 52 useEffect(() => { 53 loadBook(); 54 }, [loadBook]);
The pagination is handled with horizontal scrolling:
tsx1const handleScroll = useCallback( 2 (event: NativeSyntheticEvent<NativeScrollEvent>) => { 3 const { x } = event.nativeEvent.contentOffset; 4 setScrollPosition(x); 5 6 const pageIndex = Math.round(x / SCREEN_WIDTH); 7 const newPage = Math.max( 8 1, 9 Math.min(pageIndex + 1, bookContent?.pages.length || 1), 10 ); 11 12 if (newPage !== currentPage && bookContent) { 13 setCurrentPage(newPage); 14 onPageChanged?.(newPage, bookContent.pages.length); 15 } 16 }, 17 [currentPage, bookContent], 18);
The actual rendering creates book-like pages:
tsx1 return ( 2 <ScrollView 3 ref={scrollViewRef} 4 horizontal 5 pagingEnabled 6 showsHorizontalScrollIndicator={false} 7 onScroll={handleScroll} 8 scrollEventThrottle={16} 9 decelerationRate="fast" 10 > 11 {bookContent.pages.map(page => ( 12 <View key={page.pageNumber} style={[styles.pageWrapper, { width: SCREEN_WIDTH }]}> 13 <View style={styles.pageInnerWrapper}> 14 <View 15 style={[ 16 styles.bookPage, 17 { 18 backgroundColor: theme.surface, 19 borderColor: theme.border, 20 }, 21 ]} 22 > 23 <Text style={[styles.pageNumber, { color: theme.textSecondary }]}> 24 {page.pageNumber} 25 </Text> 26 27 <ScrollView 28 style={styles.contentWrapper} 29 showsVerticalScrollIndicator={false} 30 bounces={false} 31 > 32 {formatExtractedText(page.text) 33 .split('\n\n') 34 .filter(para => para.trim().length > 0) 35 .map((paragraph, pIndex) => { 36 const isHeading = 37 paragraph.length < 60 && 38 (paragraph.toLowerCase().includes('chapter') || 39 paragraph.match(/^[A-Z\s]+$/) || 40 pIndex === 0); 41 42 return ( 43 <Text 44 key={`p-${page.pageNumber}-${pIndex}`} 45 style={[ 46 isHeading ? styles.chapterHeading : styles.paragraph, 47 { 48 color: theme.text, 49 fontSize: isHeading 50 ? actualFontSize * 1.3 51 : actualFontSize, 52 lineHeight: isHeading 53 ? actualFontSize * 1.5 54 : actualLineHeight, 55 }, 56 ]} 57 > 58 {paragraph} 59 </Text> 60 ); 61 })} 62 </ScrollView> 63 </View> 64 </View> 65 </View> 66 ))} 67 </ScrollView> 68 ); 69});
A few things that made a huge difference in the reading experience:
Typography and Spacing
tsx1const baseFontSize = 14; 2const actualFontSize = baseFontSize * fontSize; // fontSize is user-adjustable 3const actualLineHeight = actualFontSize * lineSpacing; // lineSpacing is user-adjustable
Letting users control font size and line spacing is critical. Everyone reads differently.
Paragraph Detection
The post-processing in the text extraction is crucial:
kotlin1// Remove excessive whitespace while preserving paragraph breaks 2pageText = pageText.replace(Regex("\\n\\s*\\n"), "\n\n") 3pageText = pageText.replace(Regex("[ \\t]+"), " ") 4pageText = pageText.replace(Regex(" +\\n"), "\n")
PDFs don't have semantic paragraph markers. You have to infer them from spacing patterns.
Chapter Heading Detection
tsx1const isHeading = 2 paragraph.length < 60 && 3 (paragraph.toLowerCase().includes("chapter") || 4 paragraph.match(/^[A-Z\s]+$/) || 5 pIndex === 0);
This heuristic isn't perfect, but it catches most chapter headings and section titles. Short paragraphs in all caps or containing "chapter" get styled as headings.
Reading Progress Persistence
tsx1useEffect(() => { 2 return () => { 3 if (bookContent) { 4 saveReadingProgress( 5 bookId.toString(), 6 currentPage, 7 scrollPosition, 8 bookContent.pageCount, 9 "book", 10 ); 11 } 12 }; 13}, [bookId, currentPage, scrollPosition, bookContent]);
When the component unmounts, save where the user was. When they come back, restore that exact position.
Libraries aren't always the answer. I wasted two days trying to force existing libraries to do something they weren't designed for. Sometimes building your own solution is faster.
Native modules aren't scary. I'd avoided writing native code for React Native projects before this. Turns out, for specific use cases like this, native modules are the only way to get real control.
Text extraction is harder than it looks. PDFs store text in weird ways. Getting clean, readable output requires tuning a bunch of parameters and doing post-processing. There's no universal "extract text" button that works perfectly.
Reading experience is about details. The difference between a usable reader and a great one came down to things like proper line spacing, smooth pagination, and persistent reading progress.
Performance matters. Loading and rendering a 300-page book could easily freeze the UI. Using memo, useCallback, and proper state management kept everything smooth.
My friend can now read her PDFs at night without her eyes burning. The dark mode actually works because we're rendering text, not filtering a PDF. She can adjust font size and line spacing to her preference. Progress is saved automatically.
More importantly, I learned that when the existing solutions don't work, building your own isn't as daunting as it seems. You just need to break the problem down:
The code is rough in places. There are edge cases I haven't handled. But it works, and it solved the actual problem.
Sometimes that's all you need.
Related posts based on tags, category, and projects