# Plan: PDF Export of Preview Mode
## Context
Users want to export the current document's preview rendering to a PDF file. The app currently has no export functionality. The `ParsedDoc` AST (Block/Inline tree) is fully egui-independent, making a direct AST→PDF approach the cleanest path — no HTML intermediate, no external binaries, no headless browser.
The chosen crate is **`printpdf`** (the most widely used and maintained Rust PDF crate). It gives full control over pages, fonts, text placement, colors, and rectangles, at the cost of manual text-flow logic (line wrapping + pagination), which we implement ourselves.
---
## Files to Modify / Create
- `Cargo.toml` — add `printpdf`
- `src/pdf_export.rs` — new file: the entire PDF renderer
- `src/main.rs` — declare the new module
- `src/app.rs` — add "Export PDF" button to toolbar, wire up dialog + export call
---
## Step 1 — Add Dependency (`Cargo.toml`)
```toml
printpdf = "0.8"
```
---
## Step 2 — New module `src/pdf_export.rs`
### 2a. Public entry point
```rust
pub fn export_pdf(doc: &ParsedDoc, dest: &std::path::Path) -> Result<(), Box<dyn std::error::Error>>
```
Takes the parsed doc and a destination path (chosen via rfd save dialog), writes a `.pdf` file.
### 2b. Internal renderer struct
```rust
struct PdfRenderer {
doc: PdfDocumentReference,
page_idx: PdfPageIndex,
layer: PdfLayerReference,
// Font handles
font_regular: IndirectFontRef,
font_bold: IndirectFontRef,
font_italic: IndirectFontRef,
font_mono: IndirectFontRef,
// Layout state
x: Mm, // left margin (fixed)
y: Mm, // current Y cursor (decreases top→bottom)
page_w: Mm,
page_h: Mm,
margin: Mm,
}
```
### 2c. Fonts
Use fonts already bundled under `assets/fonts/` (used by the egui preview). If a font file
is missing at runtime, fall back to printpdf's built-in Helvetica / Courier.
### 2d. Page flow logic
```rust
fn ensure_space(&mut self, needed: Mm) {
if self.y - needed < self.margin {
self.new_page();
}
}
fn new_page(&mut self) {
let (page, layer) = self.doc.add_page(self.page_w, self.page_h, "Layer 1");
self.page_idx = page;
self.layer = self.doc.get_page(page).get_layer(layer);
self.y = self.page_h - self.margin;
}
```
### 2e. Block rendering
| `Heading(level, inlines)` | font size 24/20/16/14 by level, bold, extra vertical space above |
| `Paragraph(inlines)` | word-wrapped at body size (11pt), standard line height |
| `CodeBlock(lang, code)` | light-gray filled rect, monospace font, preserve newlines |
| `BlockQuote(inlines)` | indented, italic, left gray bar via `add_line` |
| `List(ordered, items)` | "•" or "n." prefix, indented, recurse for nested items |
| `Table(headers, rows)` | grid with horizontal lines, columns distributed evenly |
| `Rule` | thin horizontal line across content width |
### 2f. Inline text rendering
Collect consecutive inlines into styled runs for word-wrapping:
| `Text` | regular font |
| `Bold` | bold font |
| `Italic` | italic font |
| `BoldItalic` | bold-italic font |
| `Code` | monospace, small gray box behind text |
| `Link` | blue color, regular font (text only; no PDF hyperlink annotation in v1) |
| `Image` | load via `image` crate (already a dep), embed as XObject; skip with placeholder if path unresolvable |
Word-wrap: measure character widths per font/size, break lines at content width.
---
## Step 3 — Toolbar button (`app.rs`)
### 3a. New field on App struct
```rust
pending_export_pdf: bool, // initialized to false
```
### 3b. Button in toolbar
Inside `TopBottomPanel::top("toolbar")`:
```rust
if ui.button("Export PDF")
.on_hover_text("Export current document as PDF")
.clicked()
{
self.pending_export_pdf = true;
}
```
Only show the button when an active tab with a parsed doc exists.
### 3c. Handle export outside the UI closure
```rust
if self.pending_export_pdf {
self.pending_export_pdf = false;
if let Some(idx) = self.active_tab {
if let Some(doc) = &self.tabs[idx].parsed_doc {
let default_name = self.tabs[idx].path
.file_stem().unwrap_or_default()
.to_string_lossy().to_string() + ".pdf";
if let Some(dest) = rfd::FileDialog::new()
.set_file_name(&default_name)
.add_filter("PDF", &["pdf"])
.save_file()
{
if let Err(e) = crate::pdf_export::export_pdf(doc, &dest) {
eprintln!("PDF export failed: {e}");
}
}
}
}
}
```
`rfd` is already a dependency — no new crate needed for the dialog.
---
## Scope & Limitations (v1)
- No syntax highlight colors in code blocks (monospace on gray bg only)
- Tables: fixed-width columns, no cell text wrapping
- Links: blue text only, no clickable PDF annotations
- No page header/footer
- Images skipped with placeholder if path can't be resolved
All of the above can be improved in follow-up iterations.
---
## Verification
1. Open `test.md` in the app
2. Click "Export PDF" in the toolbar
3. Save dialog appears with `test.pdf` as default filename
4. Open exported PDF — check: headings larger/bold, code blocks gray bg, lists bulleted, rules visible, paragraph text wraps across pages
5. Test pagination with a long document
6. Test with a document containing images