Blog Post

How to Build a Tauri PDF Viewer with PSPDFKit

Illustration: How to Build a Tauri PDF Viewer with PSPDFKit

In this blog post, you’ll learn how to build a Tauri PDF viewer with the PSPDFKit for Web SDK.

Tauri is an open source framework used to build fast and tiny binaries for all major desktop platforms. Programmers can integrate nearly any frontend framework in existence that compiles to HTML, JavaScript, or CSS into Tauri.

Tauri currently supports Rust as its backend language. Since its API can be implemented in different languages, we can soon expect C, C++, Go, Ruby, Python, and even JavaScript backend bindings as users decide to implement them.

Tauri offers what people like most about Electron, including:

  • The ability to create cross-platform desktop applications using HTML, JavaScript, and CSS.

  • Seamless access to underlying APIs from the operating system.

At the same time, it tries to fix many of Electron’s performance and security concerns.

Tauri vs. Electron

The table below, which was compiled with information from the Tauri GitHub page, provides a detailed comparison of Tauri and Electron.

Detail Tauri Electron
Installer Size Linux 3.1 MB 52.1 MB
Memory Consumption Linux 180 MB 462 MB
Launch Time Linux 0.39s 0.80s
Interface Service Provider WRY Chromium
Backend Binding Rust Node.js (ECMAScript)
Underlying Engine Rust V8 (C/C++)
FLOSS Yes No
Multithreading Yes Yes
Bytecode Delivery Yes No
Multiple Windows Yes Yes
Auto Updater Yes Yes
Custom App Icon Yes Yes
Windows Binary Yes Yes
macOS Binary Yes Yes
Linux Binary Yes Yes
Desktop Tray Yes Yes
Sidecar Binaries Yes No

How to Build a Tauri App

As a way to showcase how easy it is to use Tauri for building desktop applications, you’ll build a PDF viewer powered by the PSPDFKit for Web SDK.

Information

This post shows how to build a project from scratch. However, for a complete reference, refer to this GitHub repository, which contains the full example.

Tauri features a comprehensive list of prerequisites, depending on your development platform, which you can find on the corresponding prerequisites page.

After ensuring you have what’s needed — most importantly, having the Rust tooling in place — you’ll move on to bootstrapping your Tauri project.

This is where things will start feeling more familiar for web developers. For this example, you’ll pair Tauri with Vite to build your application. There’s a specific getting started guide with Tauri and Vite that provides a detailed step-by-step explanation of what to do. For the sake of convenience, these steps are listed here:

  1. Run yarn create tauri-app.

  2. Follow the steps prompted by the command-line interface (CLI). This example uses yarn as the package manager and react as the UI framework.

  3. Go to the new directory created by the CLI tool and run yarn.

  4. Once all the dependencies are installed, run yarn tauri dev to make sure everything works. A new window like the following will pop up.

Initial view after bootstrapping a Tauri and Vite application

Now that you have the basic dependencies in place with Tauri and Vite, you’ll add PSPDFKIt for Web to your desktop application.

  1. Run yarn add pspdfkit from the project root.

  2. Now, you’ll wrap all the logic that interacts with the PSPDFKit SDK into its own React component. Create a new PdfViewerComponent.jsx file with the following content:

import { useEffect, useRef } from 'react';

export default function PdfViewerComponent(props) {
	const containerRef = useRef(null);

	useEffect(() => {
		const container = containerRef.current;
		let PSPDFKit;
		(async function () {
			PSPDFKit = await import('pspdfkit');
			await PSPDFKit.load({
				// Container where PSPDFKit should be mounted.
				container,
				// The document to open.
				document: 'example.pdf',
				// Use the public directory URL as a base URL. PSPDFKit will download its library assets from here.
				baseUrl: `${window.location.protocol}//${
					window.location.host
				}/${import.meta.env.PUBLIC_URL ?? ''}`,
			});
		})();

		return () => PSPDFKit && PSPDFKit.unload(container);
	}, [props]);

	return (
		<div
			ref={containerRef}
			style={{ width: '100%', height: '100vh' }}
		/>
	);
}

PSPDFKit for Web requires specifying a document to open. This example hardcodes an "example.pdf" document to load. You can download and use this document if you like, but make sure to copy whichever PDF file you end up using to the public directory of the project and rename it to example.pdf.

  1. Now that you have your viewer component ready, you need to connect it to the main App.jsx entry point component. You can get rid of all the content rendered by the boilerplate and instead render the following:

// src/App.tsx
import './App.css';
import PdfViewerComponent from './PdfViewerComponent';

function App() {
	return (
		<div className="container">
			<div className="PDF-viewer">
				<PdfViewerComponent />
			</div>
		</div>
	);
}

export default App;
  1. Next, you’ll fix your CSS stylesheets to remove all the default content. Modify src/style.css so that it looks like this:

body,
.container {
	margin: 0;
}
  1. Get rid of src/App.css altogether and remove the import statement on App.jsx that referenced it:

- import "./App.css";
  1. Before running your viewer, there’s one last step. PSPDFKit requires some assets to be available to your server for it to lazily fetch them when needed. For this, you’ll use rollup-plugin-copy to make them available as part of Vite’s build process. Run yarn add -D rollup-plugin-copy to install the plugin.

  2. Then, go to vite.config.js and register the new plugin, like this:

// vite.config.js
// Other imports.
import copy from 'rollup-plugin-copy';
// ...

export default defineConfig({
	plugins: [
		react(),
		copy({
			targets: [
				{
					src: 'node_modules/pspdfkit/dist/pspdfkit-lib',
					dest: 'public/',
				},
			],
			hook: 'buildStart',
		}),
	],
	// Additional configuration.
});

Now that all of these steps are complete, you can see the viewer in action by running yarn tauri dev.

Opening and Saving Files

Next, you’ll add the ability to open and save files from the file system.

For this, you’ll first need to access the underlying operating system (OS) resources via your backend (Rust). Go to src-tauri/src/main.rs and add the following:

#![cfg_attr(
  all(not(debug_assertions), target_os = "windows"),
  windows_subsystem = "windows"
)]

use tauri::{CustomMenuItem, Menu, Submenu};

fn main() {
  let context = tauri::generate_context!();
  tauri::Builder::default()
      .menu(
          Menu::new().add_submenu(Submenu::new(
              "File",
              Menu::new()
                  .add_item(CustomMenuItem::new("open", "Open").accelerator("cmdOrControl+O"))
                  .add_item(CustomMenuItem::new("save", "Save").accelerator("cmdOrControl+S"))
                  .add_item(CustomMenuItem::new("close", "Close").accelerator("cmdOrControl+Q")),
          )),
      )
      .on_menu_event(|event| match event.menu_item_id() {
          "save" => {
              let _ = event.window().emit("menu-event", "save-event").unwrap();
              // Success.
          }
          "open" => {
              let _ = event.window().emit("menu-event", "open-event").unwrap();
              // Success.
          }
          "close" => {
              event.window().close().unwrap();
          }
          _ => {}
      })
      .run(context)
      .expect("error while running tauri application");
}

In the code above, you’re registering Open, Save, and Close as menu items, in addition to registering keyboard shortcuts for each of them.

Communication between the backend and frontend will happen via events. Consider the following line:

.on_menu_event(|event| match event.menu_item_id() {
          "save" => {
              let _ = event.window().emit("menu-event", "save-event").unwrap();
              // Success.
          }
      })

On each press of the Save menu item, you’ll emit a “menu-event” with "save-event" as the payload. This is the event that you’ll be listening for from the frontend (React) to trigger the opening/saving logic.

Go to src/App.jsx and add a listener for this event:

// src/App.jsx
import * as React from 'react';
import PdfViewerComponent from './PdfViewerComponent';
import { listen } from '@tauri-apps/api/event';

const STATES = {
	IDLE: 'IDLE',
	OPENING_FILE: 'OPENING_FILE',
	EDITING_FILE: 'EDITING_FILE',
	SAVING_FILE: 'SAVING_FILE',
};

function App() {
	const currentState = React.useRef(STATES.IDLE);

	React.useEffect(() => {
		listen('menu-event', (e) => {
			if (
				e.payload === 'open-event' &&
				[STATES.IDLE, STATES.EDITING_FILE].includes(
					currentState.current,
				)
			) {
				currentState.current = STATES.OPENING_FILE;
			} else if (
				e.payload === 'save-event' &&
				currentState.current === STATES.EDITING_FILE
			) {
				currentState.current = STATES.SAVING_FILE;
			}
		});
	});

	// The rest of the component.
}

This is all familiar JavaScript code! The Tauri API provides you with a listen function that looks similar to regular DOM event listeners. You’re defining a basic state machine to indicate which process of the app interaction you’re currently in, and you’re transitioning between the different states based on the event payload received from the backend.

This looks good, and the menu items will render correctly. However, this isn’t useful without proper logic to open files and write content back to them.

To make opening files work, add the following import statements in App.jsx:

// src/App.jsx
import { open } from '@tauri-apps/api/dialog';
import { readBinaryFile } from '@tauri-apps/api/fs';

Now, add some state to your App component to capture the file selection:

// src/App.jsx
const [fileBuffer, setFileBuffer] = React.useState(null);
const [filePath, setFilePath] = React.useState(null);

Modify your PdfViewerComponent so that instead of having a hardcoded reference to a document, it accepts a document as a prop:

// src/PdfViewerComponent.tsx
- document: "example.pdf",
+ document: props.document,

Excellent! Now, modify the useEffect hook to make src/App.tsx look like this:

React.useEffect(() => {
	const openFile = async () => {
		try {
			const selectedPath = await open({
				multiple: false,
			});
			if (!selectedPath) return;
			const content = await readBinaryFile(selectedPath);
			setFileBuffer(content.buffer);
			setFilePath(selectedPath);
			currentState.current = STATES.EDITING_FILE;
		} catch (err) {
			console.error(err);
			currentState.current = STATES.IDLE;
		}
	};

	listen('menu-event', (e) => {
		if (
			e.payload === 'open-event' &&
			[STATES.IDLE, STATES.EDITING_FILE].includes(
				currentState.current,
			)
		) {
			currentState.current = STATES.OPENING_FILE;
			openFile();
		} else if (
			e.payload === 'save-event' &&
			currentState.current === STATES.EDITING_FILE
		) {
			currentState.current = STATES.SAVING_FILE;
		}
	});
});

Notice that you added an openFile function inside the useEffect hook, which is called when the "open-event" payload is received.

Now, you need to specify a document prop when rendering PdfViewerComponent. This is what your returned JSX file from App.tsx will look like:

// src/App.tsx
return (
	<div className="container">
		<div className="PDF-viewer">
			{fileBuffer ? (
				<PdfViewerComponent document={fileBuffer} />
			) : null}
		</div>
	</div>
);

Try it again and you’ll be able to open a document you pick from your file system using the PSPDFKit for Web SDK.

Exporting Files from PSPDFKit

Now, you’ll add support for saving changes made in the viewer back to the original document.

Modify PdfViewerComponent to invoke a callback prop each time an instance is created:

// src/PdfViewerComponent.tsx

import { useEffect, useRef } from 'react';

export default function PdfViewerComponent(props) {
	const containerRef = useRef(null);

	useEffect(() => {
		const container = containerRef.current;
		let instance, PSPDFKit;
		(async function () {
			PSPDFKit = await import('pspdfkit');
			instance = await PSPDFKit.load({
				// Container where PSPDFKit should be mounted.
				container,
				// The document to open.
				document: props.document,
				// Use the public directory URL as a base URL. PSPDFKit will download its library assets from here.
				baseUrl: `${window.location.protocol}//${
					window.location.host
				}/${import.meta.env.PUBLIC_URL ?? ''}`,
				toolbarItems: PSPDFKit.defaultToolbarItems.filter(
					(item) => item.type !== 'export-pdf',
				),
			});
			props.onInstance(instance);
		})();

		return () => PSPDFKit && PSPDFKit.unload(container);
	}, [props]);

	return (
		<div
			ref={containerRef}
			style={{ width: '100%', height: '100vh' }}
		/>
	);
}

Notice you’re now invoking props.onInstance(instance); after instantiating PSPDFKit for Web.

Back in src/App.jsx, modify your import of Tauri utilities so they look like this:

import { readBinaryFile, writeBinaryFile } from '@tauri-apps/api/fs';

Then, store the current PSPDFKit instance via the useRef hook:

const pspdfkitInstance = React.useRef(null);

Perfect! You can now add a saveFile function, which is similar to openFile and also part of the useEffect hook:

// src/App.jsx
const saveFile = async () => {
	if (!filePath || !pspdfkitInstance.current) {
		currentState.current = STATES.EDITING_FILE;
		return;
	}

	try {
		const buffer = await pspdfkitInstance.current.exportPDF();
		await writeBinaryFile(filePath, buffer);
	} finally {
		currentState.current = STATES.EDITING_FILE;
	}
};

The code above uses the instance.exportPDF API to get an ArrayBuffer with the changes you applied to the document. Then, it uses Tauri’s writeBinaryFile to write the content into the file system.

Now, invoke saveFile when the "save-event" payload is received:

// src/App.jsx

listen('menu-event', (e) => {
	if (
		e.payload === 'open-event' &&
		[STATES.IDLE, STATES.EDITING_FILE].includes(currentState.current)
	) {
		currentState.current = STATES.OPENING_FILE;
		openFile();
	} else if (
		e.payload === 'save-event' &&
		currentState.current === STATES.EDITING_FILE
	) {
		currentState.current = STATES.SAVING_FILE;
		saveFile();
	}
});

PdfViewerComponent expects you to pass an onInstance prop:

// src/App.jsx
const handleInstanceSet = React.useCallback((newInstance) => {
	pspdfkitInstance.current = newInstance;
});

return (
	<div className="App">
		<div className="PDF-viewer">
			{fileBuffer ? (
				<PdfViewerComponent
					document={fileBuffer}
					onInstance={handleInstanceSet}
				/>
			) : null}
		</div>
	</div>
);

Try running the app again. You’ll now be able to both open the changes and save them back into the original app!

The full example shown here is available on GitHub. Feel free to clone it and try it locally.

Conclusion

This post covered the fundamentals of Tauri, how it works in comparison to Electron, how to get started with it, and instructions on how to build a PDF viewer using Tauri and PSPDFKit.

Tauri is still in its early stages, but it already offers a lot of amazing features, with even more on the way — such as support for bundling binaries for all major mobile operating systems.

Tauri has good performance and good prospects. It solves many of Electron’s existing problems and offers a simple and convenient development experience. But there are still some issues that need to be improved, and the learning curve of Rust is high and has a certain cost.

Related Products
Share Post
Free 60-Day Trial Try PSPDFKit in your app today.
Free Trial

Related Articles

Explore more
DESIGN  |  Baseline UI • Web

Part V — Mastering the Baseline UI Theme: An In-Depth Exploration

DESIGN  |  Baseline UI • Web

Part IV — Building Consistency: A Guide to Design Tokens in Baseline UI

DESIGN  |  Baseline UI • Web

Part III — Accessible UI Design: Building Inclusive Digital Experiences