Cross-Platform PDF Generation with Rust, Tauri, and Python

Rust is powerful, it can run on any platform and interoperate with other languages, like Tauri for cross-platform apps or in Python.

For a project I'm on I needed a way to generate PDFs locally and offline on a mobile phone, and to generate them in a Python web app. To solve this, I set out to create a library that works with both Tauri and Python.

The PDF Lib

The first step is to make a crate that will generate our PDFs. I'm using the printpdf crate under the hood for creating the PDFs. The goal is to provide a simple API, passing a Vec<PdfBlock> to generate the document with. PdfBlock is an enum that can be a TextSection or an Image. Each can be positioned on the document, font size can be set, etc. You can find the repo here.

Use in Tauri

Using it in Tauri is like using any crate in Rust. First, we'll add it to our Cargo.toml

pdf-ing = { git = "https://github.com/ChristianPavilonis/pdf-ing" }

Then we'll create a function to use our crate with our application-specific data.

pub fn gen_pdf<P: AsRef<Path>>(
my_data: MyStruct,
my_image: P,
out: P,
) -> Result<()> {
let doc = Doc::new(215.9, 279.4);
let blocks = vec![
Image {
path: my_image,
dpi: 75.0,
pos: (200.0, 14.0),
},
TextSection {
nodes: vec![
TextNode {
content: my_data.my_property,
font_size: 30.0,
line_height: 18.0,
},
// more stuff
 
pdf_ing::generate_pdf(doc, blocks, &out)?;
 
Ok(())
}

Then, I'll create a command that will delegate to this function.

#[tauri::command]
pub async fn make_pdf(app: AppHandle) -> Result<(), String> {
let path_resolver = app.path();
let my_data = // get/build the data or maybe pass it from js
 
// get Documents directory then append the filename.
let mut output = path_resolver.document_dir().unwrap();
path.push("my_pdf.pdf");
 
// resolve image from application directory
let my_image = path_resolver
.resolve("my_image.jpg", BaseDirectory::AppData)
.map_err(|e| e.to_string())?;
 
gen_pdf(my_data, &my_image, &output)
.map_err(|e| e.to_string())?;
 
Ok(())
}

The pyo3 Lib

To make a library that can be used in python we'll have to familiarize ourselves with a few tools.

  • pyo3 is a Rust crate that makes it possible to call our Rust code from Python.
  • maturin is a build system that will help us build and publish whl files that we can later install and use in our Python code.
  • uv works pretty well for managing Python versions and virtual environments. I'll be using it for my examples.

To start a new pyo3 project, run Maturin's new command and pick pyo3.

uvx maturin new pydfing
cd pydfing
uv venv # to init python virtual env

pyo3 lets us create types that can be converted from Python objects using a derive macro, and lets us expose functions to a Python module using the #[pymodule] macro.

First, let's add our crate to Cargo.toml. I'll use my Git URL.

pdf-ing = { git = "https://github.com/ChristianPavilonis/pdf-ing" }

Then we can use pyo3 to wrap the crate.
Here's what my code looks like for wrapping my crate.

use std::{
path::{Path, PathBuf},
str::FromStr,
};
 
use pdf_ing::*;
use pyo3::{exceptions::{PyRuntimeError, PyValueError}, prelude::*};
 
#[derive(FromPyObject, Clone)]
pub enum PyPdfBlock {
Image {
path: String,
dpi: f32,
pos: (f32, f32),
},
TextSection {
nodes: Vec<PyTextNode>,
pos: (f32, f32),
},
}
 
impl From<PyPdfBlock> for PdfBlock<String> {
fn from(val: PyPdfBlock) -> Self {
match val {
PyPdfBlock::Image { path, dpi, pos } => PdfBlock::Image { path, dpi, pos },
PyPdfBlock::TextSection { nodes, pos } => PdfBlock::TextSection {
nodes: nodes.iter().map(|n| TextNode {
content: n.content.clone(),
font_size: n.font_size,
line_height: n.line_height,
}).collect(),
pos,
},
}
}
}
 
#[derive(FromPyObject, Clone)]
pub struct PyTextNode {
pub content: String,
pub font_size: f32,
pub line_height: f32,
}
 
#[pyfunction]
pub fn gen_pdf(path: &str, width: f32, height: f32, blocks: Vec<PyPdfBlock>) -> PyResult<()> {
let doc = Doc::new(width, height);
let blocks: Vec<PdfBlock<String>> = blocks.iter().map(|b| PdfBlock::from(b.clone())).collect();
 
generate_pdf(doc, blocks, path).map_err(|e| PyRuntimeError::new_err(e.to_string()))?;
 
Ok(())
}
 
/// A Python module implemented in Rust.
#[pymodule]
fn pydfing(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(gen_pdf, m)?)?;
Ok(())
}

You should be able to build now using Maturin.

uvx maturin build
# install using pip
uv pip install target/wheels/<whl-file>

Now, we should be able to call our Rust code from Python.

Use in Python

Now, to use in Python, we just have to import the module and call our function. I also added some classes to create the blocks.

import pydfing;
 
class ImageBlock:
def __init__(self, path, dpi, pos):
self.path = path
self.dpi = dpi
self.pos = pos
 
class TextNode:
def __init__(self, content, font_size, line_height):
self.content = content
self.font_size = font_size
self.line_height = line_height
 
class TextBlock:
def __init__(self, nodes, pos):
self.nodes = nodes
self.pos = pos
 
# Artifacts cast with placeholder content
blocks = [
ImageBlock(path='myimage.jpg', dpi=75.0, pos=(200.0, 14.0)),
TextBlock(nodes=[
TextNode(content="Hello, world!", font_size=30.0, line_height=18.0),
TextNode(content="This is a PDF generated in Rust in Python. Crazy!", font_size=14.0, line_height=18.0),
], pos=(20.0, 24.0)),
]
 
pydfing.gen_pdf(
path="output.pdf",
width=215.9,
height=279.4,
blocks=blocks
)

If we run using uv:

uv run test.py

We should now have a PDF file called output.pdf.

Conclusion

The portability of Rust is one of its biggest advantages. Not only can we use it across devices with Tauri, but across languages also.