Skip to content
Select theme

Exporting to HTML and PDF

Step 11 of 13. Step 10 gave rustnote its native chrome — tray, menus, shortcuts. The notes themselves are still plain Markdown sitting in SQLite, invisible to anyone not running the app. This step gives them a way out: the Rust core renders a note’s Markdown into HTML with pulldown-cmark, the front end either writes that HTML to a standalone file or drops it into a print-only part of the page, and the webview’s print dialog turns it into a PDF. It starts from tauri-ch09, the tag step 10 left behind.

One new dependency in src-tauri/Cargo.toml:

src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
tauri-plugin-global-shortcut = "2"
pulldown-cmark = "0.13"

pulldown-cmark is the CommonMark parser most of the Rust ecosystem builds on. The rendering function is four lines in src-tauri/src/lib.rs:

src-tauri/src/lib.rs
use pulldown_cmark::{html, Options, Parser};
fn render_markdown(source: &str) -> String {
let mut html = String::new();
html::push_html(&mut html, Parser::new_ext(source, Options::all()));
html
}

Parser::new_ext takes the extension flags; Options::all() turns on tables, footnotes, task lists, and strikethrough. That last one is visible in the output — without it, ~~sleeping bags~~ would render as literal tildes. html::push_html walks the parser’s event stream and appends HTML into the string you hand it, which is why the function owns a mut buffer instead of returning the call directly.

You can run this function with no Tauri involved at all, and it is worth doing once so you know what it produces:

use pulldown_cmark::{html, Options, Parser};
fn render_markdown(source: &str) -> String {
let mut html = String::new();
html::push_html(&mut html, Parser::new_ext(source, Options::all()));
html
}
fn main() {
let note = "## Packing\n\nTake the **9 am** ferry and the `camp stove`.\n\n- tent\n- stove\n- ~~sleeping bags~~ borrowed\n";
println!("{}", render_markdown(note));
}
<h2>Packing</h2>
<p>Take the <strong>9 am</strong> ferry and the <code>camp stove</code>.</p>
<ul>
<li>tent</li>
<li>stove</li>
<li><del>sleeping bags</del> borrowed</li>
</ul>

That is the whole trick: Markdown in, escaped and structured HTML out. Everything the renderer emits — the <strong>, the <code>, the <del> — went through pulldown-cmark’s escaper, so note text containing < or & cannot produce broken markup.

Saving a file and printing need the same raw material: the note’s title and its rendered body. So there is one command, not two:

src-tauri/src/lib.rs
#[derive(Serialize)]
pub struct ExportedNote {
title: String,
html: String,
}
#[tauri::command]
async fn export_note(
state: State<'_, Mutex<Connection>>,
id: i64,
) -> Result<ExportedNote, String> {
let conn = state.lock().map_err(|e| e.to_string())?;
let (title, body): (String, String) = conn
.query_row(
"SELECT title, body FROM notes WHERE id = ?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|_| format!("no note #{id}"))?;
Ok(ExportedNote {
title,
html: render_markdown(&body),
})
}

A missing id fails with no note #<id> — the same shape update_note used in step 7. Register the new commands in generate_handler! (there is a second one coming below):

src-tauri/src/lib.rs
.invoke_handler(tauri::generate_handler![
add, divide, stats, create_note, list_notes, update_note, search_notes, export_note,
print_main
])

The front end wraps the fragment into a standalone document and writes it with the fs plugin you already have from step 9. One permission is missing — reading was granted then, writing was not. In src-tauri/capabilities/default.json:

src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
{
"identifier": "fs:scope",
"allow": [{ "path": "$HOME/**" }]
}
]
}

The scope block does not need to grow. Step 9 found that the dialog plugin extends the fs scope at runtime with the path the user picked, and save() does the same — the static $HOME/** scope from step 9 remains the fallback for paths the app writes without a dialog. The asymmetry from that step still holds: the dialog path extends its own scope, drag-and-drop does not.

The export button:

async function exportHtml() {
if (editingId === null) {
setSaved("open a note first");
return;
}
try {
const note = await invoke<{ title: string; html: string }>("export_note", {
id: editingId,
});
const path = await save({ defaultPath: `${note.title}.html` });
if (path === null) return;
await writeTextFile(
path,
`<!doctype html>\n<meta charset="utf-8">\n<title>${note.title}</title>\n<h1>${note.title}</h1>\n${note.html}\n`
);
setSaved(`exported ${path}`);
} catch (e) {
setSaved(`Error: ${e}`);
}
}

The template literal interpolates the title raw. That is fine here — the title is your own note’s title in your own file — but notice the body does not need the same caveat: pulldown-cmark already escaped everything in it. save() resolves to null when the user cancels, which the early return handles. The imports join the ones from step 9:

import { open, save } from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";

The obvious place to look for a print function is the JavaScript API, and it is not there — as of @tauri-apps/api 2.12 there is no print() on the webview object. The method exists on the Rust side: Webview::print hands the request to the platform webview, which on macOS opens the native print dialog. So printing is one small command:

src-tauri/src/lib.rs
#[tauri::command]
fn print_main(app: AppHandle) -> Result<(), String> {
let window = app
.get_webview_window("main")
.ok_or_else(|| "no main window".to_string())?;
window.print().map_err(|e| e.to_string())
}

No capability line is needed for this one — the call never crosses the IPC permission gate; it is Rust talking to the window it already owns, the same rule step 10’s tray code ran under.

The front end puts the rendered note into a div that is invisible on screen and visible only to the printer:

function printNote() {
if (editingId === null) {
setSaved("open a note first");
return;
}
invoke<{ title: string; html: string }>("export_note", { id: editingId })
.then((note) => setPrintHtml(note.html))
.catch((e) => setSaved(`Error: ${e}`));
}
useEffect(() => {
if (printHtml === "") return;
invoke("print_main").catch((e) => setSaved(`Error: ${e}`));
}, [printHtml]);

printNote stores the HTML; the effect fires print_main once React has actually rendered it into the DOM — printing before that would snapshot an empty page. The state declaration joins the others: const [printHtml, setPrintHtml] = useState("");

The container sits at the end of the JSX, after the notes list:

<div className="print-root" dangerouslySetInnerHTML={{ __html: printHtml }} />

and the CSS decides who sees it. Append to src/App.css:

.print-root {
display: none;
}
@media print {
.container > *:not(.print-root) {
display: none;
}
.print-root {
display: block;
}
}

On screen the div is display: none and dangerouslySetInnerHTML is inert — the HTML came from your own renderer, not from an untrusted source. When the print dialog snapshots the page, the media query hides every direct child of the app container except the note. That is the PDF story: the print dialog on macOS has a Save as PDF button, and what it saves is the note and nothing else.

The buttons join the bottom row, appearing once a note is open:

<div className="row">
<button onClick={importFile}>Import .md</button>
{editingId !== null && <button onClick={exportHtml}>Export HTML</button>}
{editingId !== null && <button onClick={printNote}>Print</button>}
<span>or drag a .md file into the window</span>
</div>

Exporting needs the save dialog, and printing needs the print dialog — both want a hand on the mouse. Everything short of that can be driven from throwaway commands, the way steps 9 and 10 drove theirs. Add three commands to src-tauri/src/lib.rs and register them alongside the others:

src-tauri/src/lib.rs
#[tauri::command]
fn probe_log(line: String) {
eprintln!("{}", line);
}
#[tauri::command]
fn probe_seed(state: State<'_, Mutex<Connection>>) -> Result<i64, String> {
let conn = state.lock().map_err(|e| e.to_string())?;
let body = "## Packing\n\nTake the **9 am** ferry and the `camp stove`.\n\n- tent\n- stove\n- ~~sleeping bags~~ borrowed\n";
conn.execute(
"INSERT INTO notes (title, body, created_at) VALUES ('Packing list', ?1, 0)",
rusqlite::params![body],
)
.map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
fn probe_receipt(matches: bool, head: String) {
eprintln!("PROBE roundtrip matches={} html head: {}", matches, head);
let _ = std::fs::remove_file("/Users/rango/rustnote-export-test.html");
}

and one throwaway effect in src/App.tsx that seeds a note, runs the export path, round-trips the file through the fs plugin, and fires the print path three seconds after mount:

useEffect(() => {
const timer = setTimeout(async () => {
const wrap = (note: { title: string; html: string }) =>
`<!doctype html>\n<meta charset="utf-8">\n<title>${note.title}</title>\n<h1>${note.title}</h1>\n${note.html}\n`;
const log = (line: string) => invoke("probe_log", { line }).catch(() => {});
try {
const id = await invoke<number>("probe_seed");
const note = await invoke<{ title: string; html: string }>("export_note", { id });
await log(`PROBE export_note title="${note.title}" bytes=${note.html.length}`);
const file = "/Users/rango/rustnote-export-test.html";
await writeTextFile(file, wrap(note));
const back = await readTextFile(file);
await invoke("probe_receipt", { matches: back === wrap(note), head: note.html.slice(0, 58) });
setPrintHtml(note.html);
setTimeout(() => {
invoke("print_main")
.then(() => log("PROBE print ok"))
.catch((e) => log(`PROBE print err: ${e}`));
}, 500);
} catch (e) {
await log(`PROBE err: ${e}`);
}
}, 3000);
return () => clearTimeout(timer);
}, []);

Run npm run tauri dev:

PROBE export_note title="Packing list" bytes=177
PROBE roundtrip matches=true html head: <h2>Packing</h2>
<p>Take the <strong>9 am</strong> ferry a
PROBE print ok

Line by line: export_note pulled the seeded note from SQLite and rendered its body into 177 bytes of HTML — the same bytes the standalone function printed above. The receipt spans two lines because the 58-character head slice contains the note’s first newline. matches=true means the file written by writeTextFile read back byte-identical, so the write permission, the scope, and the wrap template all behaved. The last line is print_main resolving — the platform webview accepted the print request and opened the print dialog. With the probe in place the dialog opens twice, once from the effect your real code uses and once from the probe’s own call half a second later. Delete all three commands and the effect afterwards; they are scaffolding. What no probe can show is the part that needs eyes — the dialog’s Save-as-PDF turning the note into a file — and that is one manual click away.

The step is tagged tauri-ch10 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/tauri-desktop-app
git checkout tauri-ch10
npm install
npm run tauri dev

If you have been following along, your tree is current: this step touched src-tauri/Cargo.toml, src-tauri/src/lib.rs, src-tauri/capabilities/default.json, src/App.tsx, and src/App.css.

Step 12 turns the dev setup into something you can hand out — cargo tauri build producing signed bundles for macOS, Windows, and Linux — and step 13 is the one that arranges everything into the three-pane window. The track overview keeps the full list current as articles land.