pdfsmith
A fully customizable Rust library for generating PDFs from Markdown, structured JSON, or raw HTML.
Everything is under the user's control — CSS styling, page size, margins, orientation, headers, footers, Markdown extensions, and Chrome rendering options. Supply your own CSS or use the clean built-in default. Need a header? Configure it. Don't need one? Skip it. Every decision is yours.
Table of Contents
- mdoc_pdf
- Table of Contents
- Features
- Prerequisites
- Installation
- Quick Start
- Input Sources
- JSON Structure Reference
- Customization Guide
- Full Configuration Reference
- Examples
- Running the Examples
- Error Handling
- Architecture
- Troubleshooting
- License
Features
| Feature | Description |
|---|---|
| Markdown → PDF | Convert any Markdown string or file to a styled PDF |
| JSON → PDF | Define document content with typed JSON blocks (headings, paragraphs, tables, code, lists, etc.) |
| HTML → PDF | Pass your own pre-built HTML directly |
| Custom CSS | Replace the default stylesheet entirely, or append extra rules |
| Paper Sizes | A4, Letter, Legal, or any custom width × height |
| Page Margins | Set top, bottom, left, right margins independently (in inches) |
| Orientation | Portrait or landscape |
| Headers | Simple left/center/right text, or full custom HTML |
| Footers | Simple left/center/right text with auto page numbers, or full custom HTML |
| Heading Numbers | Automatic hierarchical numbering (1, 1.1, 1.1.1) via JSON paraSequence or CSS counters |
| Markdown Extensions | Tables, footnotes, strikethrough, task lists, autolinks, superscript, description lists — all toggleable |
| File Input | Read .md or .json files directly from disk |
| Builder Pattern | Fluent, chainable API — configure only what you need |
Prerequisites
Install Chromium / Chrome
pdfsmith uses headless Chrome/Chromium under the hood to render HTML to PDF. You must have Chrome or Chromium installed on any machine that generates PDFs.
Ubuntu / Debian
# Option A: via snap (recommended)
# Option B: via apt
Fedora / RHEL
macOS
# If you have Google Chrome installed, it works automatically.
# Otherwise:
Windows
Install Google Chrome or Chromium. The headless_chrome crate will auto-detect it.
Docker
If your app runs in a container, add Chromium to your Dockerfile:
RUN apt-get update && apt-get install -y chromium-browser --no-install-recommends && rm -rf /var/lib/apt/lists/*
Why not auto-install? A library crate should never silently install system software. Chromium is a ~200 MB browser — the user should install it explicitly and control which version is on their system.
Verify the Installation
# or
# or
If any of these prints a version number, you're ready.
Installation
Add pdfsmith to your Cargo.toml:
[]
= "0.1.0"
Or via the command line:
Optional: Enable Logging
pdfsmith uses the log crate. To see progress messages, add a logger like env_logger:
[]
= "0.11"
Then initialise it in your code:
Run with RUST_LOG=info to see output:
RUST_LOG=info
Quick Start
use PdfBuilder;
That's it. Three lines of code, zero configuration. The default CSS, A4 paper, 0.75-inch margins, and no header/footer are applied automatically.
Input Sources
From Markdown String
use PdfBuilder;
let pdf = new
.from_markdown
.unwrap;
All standard Markdown is supported: headings, bold, italic, links, images, code blocks, tables, task lists, blockquotes, horizontal rules, footnotes, strikethrough, and more.
From Markdown File
use PdfBuilder;
let pdf = new
.from_markdown_file
.unwrap;
From JSON
use PdfBuilder;
let json = json!;
let pdf = new
.from_json
.unwrap;
JSON is first converted to Markdown, then rendered through the same pipeline — so all CSS and config applies equally.
From JSON File
use PdfBuilder;
let pdf = new
.from_json_file
.unwrap;
From Raw HTML
use PdfBuilder;
let html = r#"
<!DOCTYPE html>
<html>
<head><style>body { font-family: Arial; }</style></head>
<body><h1>Hello</h1><p>From raw HTML.</p></body>
</html>
"#;
let pdf = new
.from_html
.unwrap;
When using from_html, the HTML is sent to Chrome as-is — no CSS injection, no Markdown conversion. Header/footer and page options still apply.
JSON Structure Reference
The JSON input is an array of content blocks. Each block has a "type" field that determines how it's rendered.
Two top-level formats are accepted:
// Format A: bare array
[
{ "type": "heading", "paraSequence": "1", "text": "Title" },
{ "type": "paragraph", "text": "Body." },
{ "type": "heading", "paraSequence": "1.1", "text": "Sub-section" },
{ "type": "paragraph", "text": "More body." }
]
// Format B: object with "content" key
{
"content": [
{ "type": "heading", "paraSequence": "1", "text": "Title" },
{ "type": "paragraph", "text": "Body." }
]
}
Block Types
heading
| Field | Type | Required | Description |
|---|---|---|---|
paraSequence |
string | No | Hierarchical section number (e.g. "1", "1.2", "1.2.1", "A.1"). The heading level (h1–h6) is derived from the depth (number of dot-separated parts). The sequence is prefixed to the heading text automatically. |
text |
string | Yes | Heading text (Markdown allowed) |
level |
number (1–6) | No | Explicit heading level — overrides the level derived from paraSequence. If neither paraSequence nor level is given, defaults to h1. |
paragraph (alias: text)
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Yes | Paragraph content (Markdown allowed) |
code
| Field | Type | Required | Description |
|---|---|---|---|
language |
string | No (default: "") |
Syntax highlighting hint |
text |
string | Yes | Code content |
list
| Field | Type | Required | Description |
|---|---|---|---|
ordered |
boolean | No (default: false) |
true for numbered list, false for bullets |
items |
array of strings | Yes | List items (Markdown allowed in each item) |
quote (alias: blockquote)
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Yes | Quote content (Markdown allowed) |
table
| Field | Type | Required | Description |
|---|---|---|---|
headers |
array of strings | Yes | Column headers |
rows |
array of arrays | No | Data rows (each row is an array of strings) |
image (alias: img)
| Field | Type | Required | Description |
|---|---|---|---|
src |
string | Yes | Image URL or path |
alt |
string | No (default: "") |
Alt text |
divider (alias: hr)
Renders a horizontal rule (---). No fields needed.
html (alias: raw)
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Yes | Raw HTML passed through as-is |
Customization Guide
Every aspect of the PDF is configurable through PdfBuilder methods or the PdfConfig struct directly.
CSS Styling
Option 1: Use the built-in default
Do nothing — a clean, neutral, print-friendly stylesheet is applied automatically.
let pdf = new
.from_markdown
.unwrap;
Option 2: Replace the default CSS entirely
let pdf = new
.custom_css
.from_markdown
.unwrap;
When custom_css is set, the built-in stylesheet is completely replaced. You are in full control.
Option 3: Append extra CSS to the default
let pdf = new
.extra_css
.from_markdown
.unwrap;
extra_css is added after the default stylesheet, so your rules override specific properties while keeping everything else intact.
Option 4: Load CSS from a file
let css = read_to_string.unwrap;
let pdf = new
.custom_css
.from_markdown
.unwrap;
Access the default CSS
The built-in stylesheet is exported as a constant if you want to inspect or extend it programmatically:
use DEFAULT_CSS;
println!;
Paper Size
use ;
// Preset sizes
new.paper_size; // 8.27 × 11.69 in (default)
new.paper_size; // 8.5 × 11 in
new.paper_size; // 8.5 × 14 in
// Custom size (width × height in inches)
new.paper_size; // e.g. trade paperback
Page Margins
Margins are specified in inches:
use ;
let pdf = new
.margins
.from_markdown
.unwrap;
Default margins are 0.75 inches on all sides.
Orientation
let pdf = new
.landscape // landscape
.from_markdown
.unwrap;
Default is false (portrait).
Headers
Headers appear on every page. Three approaches:
Simple left / center / right
use ;
let pdf = new
.display_header_footer
.header
.from_markdown
.unwrap;
Full custom HTML
use ;
let pdf = new
.display_header_footer
.header
.from_markdown
.unwrap;
No header (default)
let pdf = new
.from_markdown // display_header_footer defaults to false
.unwrap;
Footers
Footers work exactly like headers.
Default footer (when enabled)
When display_header_footer(true) is set and no footer fields are configured, a simple centred page number is shown: Page 1 / 5.
let pdf = new
.display_header_footer
.from_markdown
.unwrap;
Custom left / center / right
use ;
let pdf = new
.display_header_footer
.footer
.from_markdown
.unwrap;
Full custom HTML footer
use ;
let pdf = new
.display_header_footer
.footer
.from_markdown
.unwrap;
Chrome Page Number Placeholders
Inside any header/footer text or custom_html, use these Chrome built-in placeholders:
| Placeholder | Renders as |
|---|---|
<span class="pageNumber"></span> |
Current page number |
<span class="totalPages"></span> |
Total page count |
<span class="title"></span> |
Document title |
<span class="url"></span> |
Page URL |
<span class="date"></span> |
Current date |
Markdown Extensions
All extensions are enabled by default. Toggle them individually:
use ;
let pdf = new
.markdown_options
.from_markdown
.unwrap;
To disable a specific extension:
let pdf = new
.markdown_options
.from_markdown
.unwrap;
Heading Numbers
Automatic hierarchical heading numbers like 1, 1.1, 1.1.1, 2, 2.1.
There are two approaches — use whichever fits your workflow:
Approach 1: JSON auto_number (JSON input only)
Set "auto_number": true in the top-level JSON object. Numbers are generated
automatically from the heading level fields:
use PdfBuilder;
let json = json!;
let pdf = new
.from_json
.unwrap;
This produces headings:
1 Introduction
1.1 Background
1.2 Scope
1.2.1 Details
2 Conclusion
Manual number field
Any heading block can carry a "number" field for manual control. This
takes priority over auto-numbering:
Approach 2: CSS-based heading_numbers (works with ALL input types)
Enable .heading_numbers(true) on the builder. This injects CSS counter
rules that automatically number every heading — works for Markdown, JSON,
and raw HTML:
use PdfBuilder;
let pdf = new
.heading_numbers
.from_markdown
.unwrap;
Rendered headings look like:
1. Intro
1.1 Part A
1.2 Part B
1.2.1 Detail
2. Summary
The CSS approach has no dependency on JSON — it works everywhere.
Which should I use?
- Use
auto_numberin JSON when you want the numbers baked into the Markdown text (good for exports, copy-paste).- Use
heading_numbers(true)on the builder when you want visual numbering via CSS (works with any input, numbers don’t appear in the underlying text).
Chrome Options
Fine-tune the headless Chrome rendering:
let pdf = new
.chrome_window_size // browser viewport (default: 1280×900)
.page_load_wait_secs // wait for images/JS (default: 2)
.print_background // render background colours (default: true)
.from_markdown
.unwrap;
Full Configuration Reference
You can also set everything at once via the PdfConfig struct:
use ;
let config = PdfConfig ;
let pdf = with_config
.from_markdown
.unwrap;
PdfConfig Fields
| Field | Type | Default | Description |
|---|---|---|---|
title |
String |
"" |
Document title (used in <title> tag and default header) |
custom_css |
Option<String> |
None |
Replaces the entire default stylesheet |
extra_css |
Option<String> |
None |
Appended after the base stylesheet |
paper_size |
PaperSize |
A4 |
Paper dimensions |
margins |
PageMargins |
0.75 all |
Page margins in inches |
landscape |
bool |
false |
Landscape orientation |
display_header_footer |
bool |
false |
Show header and footer |
header |
HeaderConfig |
empty | Header configuration |
footer |
FooterConfig |
empty | Footer configuration |
print_background |
bool |
true |
Print background colours/images |
markdown_options |
MarkdownOptions |
all true |
Markdown extension toggles |
heading_numbers |
bool |
false |
CSS-based hierarchical heading numbers |
chrome_window_size |
(u32, u32) |
(1280, 900) |
Headless Chrome viewport |
page_load_wait_secs |
u64 |
5 |
Seconds to wait after page load ( Usually helpfull to load images perfectly) |
Examples
The repository includes eight runnable examples in the examples/ directory, each producing a 2+ page PDF.
Example 1 — Minimal Markdown
Markdown string → PDF with heading_numbers(true). Default CSS, no header/footer. Covers tables, code blocks, lists, blockquotes.
Example 2 — JSON with paraSequence
Structured JSON content blocks with "paraSequence" on every heading for hierarchical numbering. The heading level (h1–h6) is derived automatically from the sequence depth.
// examples/from_json.rs (abbreviated)
let json = json!;
let pdf = new
.title
.from_json
.expect;
Output headings:
1 Project Status Report (depth 1 → h1)
1.1 Timeline (depth 2 → h2)
1.2 Key Highlights (depth 2 → h2)
1.2.1 Performance Details (depth 3 → h3)
2 Architecture (depth 1 → h1)
Example 3 — Custom CSS
Replaces the entire default stylesheet. Indented sub-headings (h2 → 20px, h3 → 40px, h4 → 60px), per-level colours (blue → green → gold), dark code blocks, heading_numbers(true) on top.
Example 4 — Full Document
Every option configured: title, Letter landscape paper, header, footer, heading_numbers(true), extra CSS for colours. Quarterly business review theme.
Example 5 — Corporate Report
A4 report with default CSS as the base, small tweaks via extra_css (indented headings, corporate colours). Header shows company name + report title, footer shows "INTERNAL" + page numbers.
Example 6 — Images
Markdown document with remote images (Wikimedia Commons). Uses page_load_wait_secs(5) to allow time for image loading. Header/footer, heading numbers, extra CSS for image styling.
Example 7 — Newsletter
Vibrant editorial style with full custom_css: gradient heading backgrounds, purple/green/gold colours, styled blockquotes. No heading numbers — clean magazine look. Header + footer.
Example 8 — Technical Doc (JSON)
Complete API reference built entirely from JSON with paraSequence on every heading. Uses a build_content() helper to construct large JSON arrays. Header/footer, indented headings via extra_css.
Running the Examples
# Clone the repository
# Run any example
# With logging enabled
RUST_LOG=info
Error Handling
All generation methods return pdfsmith::Result<Vec<u8>>, which is Result<Vec<u8>, MdocError>.
use ;
match new.from_markdown
Error Variants
| Variant | When it occurs |
|---|---|
MdocError::Chrome(String) |
Chrome/Chromium not found, failed to launch, navigation timeout, print failure |
MdocError::Io(std::io::Error) |
File read/write errors |
MdocError::Json(String) |
Invalid JSON structure (not an array or missing "content" key) |
MdocError::ImageDownload { url, reason } |
Remote image fetch failed |
MdocError::Other(String) |
Catch-all for uncategorised errors |
Architecture
pdfsmith/
├── src/
│ ├── lib.rs # Public API — PdfBuilder and re-exports
│ ├── config.rs # PdfConfig, PaperSize, PageMargins, HeaderConfig, FooterConfig, MarkdownOptions
│ ├── css.rs # DEFAULT_CSS constant
│ ├── error.rs # MdocError enum and Result type alias
│ ├── parser/
│ │ ├── mod.rs # Re-exports
│ │ ├── markdown.rs # Markdown → HTML body (via comrak)
│ │ └── json.rs # JSON content blocks → Markdown
│ └── renderer/
│ ├── mod.rs # Re-exports
│ ├── html.rs # Wraps body HTML in full document with CSS
│ ├── template.rs # Header/footer template builders
│ └── chrome.rs # Headless Chrome PDF rendering
├── examples/
│ ├── from_markdown.rs
│ ├── from_json.rs
│ ├── custom_css.rs
│ ├── full_document.rs
│ ├── report_style.rs
│ ├── with_images.rs
│ ├── newsletter.rs
│ └── technical_doc.rs
├── tests/
│ ├── basic.rs
│ └── json.rs
└── Cargo.toml
Pipeline
Markdown string ─┐
├─→ HTML body ─→ Full HTML doc (+ CSS) ─→ Chrome ─→ PDF bytes
JSON blocks ─────┘ (comrak) (wrap_body_in_html) (headless)
- Input — Markdown text, JSON blocks, or raw HTML.
- Parse — Markdown is converted to HTML by comrak. JSON is first converted to Markdown, then through comrak.
- Wrap — The HTML body is wrapped in a full
<!DOCTYPE html>document with the resolved CSS (default, custom, or default + extra). - Template — Header and footer HTML templates are built from config.
- Render — Headless Chrome loads the HTML, applies print options (paper, margins, header, footer), and outputs PDF bytes.
- Return — Raw
Vec<u8>PDF bytes ready to write to disk or send over HTTP.
Troubleshooting
"Chrome error: Failed to launch Chrome/Chromium"
Chromium is not installed or not in your PATH. See Prerequisites.
PDF is blank
- Check that your Markdown/JSON input is not empty.
- Increase
page_load_wait_secsif your HTML has images that need time to load.
Header/footer not showing
- You must call
.display_header_footer(true)— it defaults tofalse. - Increase
margins.top/margins.bottomto make room for the header/footer.
Custom CSS not applied
- If you use
custom_css, it replaces the default entirely. Make sure your CSS covers everything (body font, headings, tables, code blocks, etc.). - If you only want small tweaks, use
extra_cssinstead.
Docker / CI: "No usable sandbox"
Chrome needs a sandbox. In Docker, run with --no-sandbox:
ENV CHROME_FLAGS="--no-sandbox --disable-setuid-sandbox"
Or use a container image that includes Chromium with proper sandbox config.
License
MIT