Dark PDF Viewer

Read PDFs in customizable dark mode — web and macOS

Dark PDF Viewer interface

Dark PDF Viewer lets you read any PDF in a fully customizable dark mode. You control inversion, brightness, contrast, and sepia independently — and a "Color Preserve" mode inverts the lightness of a document without destroying the colors of embedded images or charts. It's available as a macOS app on the App Store and as a web app.

Motivation

I read a lot of PDFs — research papers, technical docs, ebooks — and I do most of my reading at night. macOS's built-in Preview has no dark mode for PDF content. Third-party readers either offer a single "invert colors" toggle (which makes photos look terrible) or charge a subscription for basic filter controls.

I wanted something simple: open a PDF, apply a dark theme that looks good, and have it remember my settings. No accounts, no cloud sync, no subscriptions — just local file reading with good filters.

Technical approach

The app is a Next.js web app that renders PDFs with PDF.js (Mozilla's JavaScript PDF renderer). The dark mode is implemented as CSS filters applied to the rendered canvas element — which means the transformation happens on the GPU in the browser's compositor, and preview is instant regardless of document size.

The filter pipeline has four independent controls:

  • Inversion — 0–100%, flips lightness
  • Brightness — 0–300%, adjusts overall luminance
  • Contrast — 0–300%, adjusts dynamic range
  • Sepia — 0–100%, warms the tone for comfortable reading

The Color Preserve mode adds a hue-rotate(180deg) after inversion. Inverting colors rotates hues by 180 degrees as a side effect — so rotating them back restores the original hues while keeping the inverted lightness. The result: dark background, light text, and images/charts that still look correct.

Web and macOS

The core of the app is web technology — it runs in any modern browser. The macOS App Store version wraps the same web app for native distribution, which means I maintain one codebase for both platforms. This was a deliberate architectural choice: PDF.js is battle-tested across every browser, the CSS filter approach works everywhere compositing is hardware-accelerated, and I avoid platform-specific PDF rendering code entirely.

If I'd gone the native route with PDFKit, I would have gained some native polish but lost the web version entirely and introduced a second rendering engine to maintain. For a document viewer focused on visual filters rather than annotation or editing, the web stack was the right call.

Interesting engineering problems

Export with filters baked in

CSS filters are great for live preview but they don't transfer when you export — you can't save a "dark mode PDF" by just screenshotting the canvas. For export, I render each page at 2x resolution to a temporary canvas, read the ImageData, and apply the inversion/brightness/contrast/sepia transforms pixel by pixel in JavaScript. Then I assemble the pages into a new PDF using jsPDF. It's slow for long documents, but it produces a real PDF with the dark mode permanently applied.

OCR integration

Some PDFs are scanned images with no selectable text. I integrated tesseract.js to run optical character recognition directly on the rendered canvas. It runs client-side — no server, no API keys — and surfaces the extracted text in a copy-friendly overlay. Progress is shown in real time since Tesseract reports recognition progress incrementally.

Session persistence and lazy rendering

The app remembers where you were in each document — page number, zoom level, and filter settings — keyed by a hash of the PDF content. When you reopen a file, you're right back where you left off. In scroll mode, pages are rendered lazily using an IntersectionObserver with a 200px preload margin, so memory usage stays bounded even for thousand-page documents.

Outcome

Dark PDF Viewer is on the Mac App Store and the web version is live. The combination of instant CSS-filter preview for reading and pixel-level processing for export gives the best of both worlds — you never wait for filters during normal use, and you get a permanent result when you need one.

Together with Image Shift, these two apps demonstrate the range I work across: one is a React Native mobile app doing GPU pixel manipulation; the other is a Next.js web app solving a reading ergonomics problem. Different platforms, different problems, shared interest in making images behave the way I want them to.