Skip to content

Commit d928d86

Browse files
alanbldclaude
andcommitted
feat(pdf): Implement PDF Engine via Typst (Phase 25)
Add new utf8dok-pdf crate for PDF generation using Typst as the typesetting backend: - Transpiler: Converts utf8dok AST to Typst markup - Headings, paragraphs, lists, code blocks, tables - Inline formatting (bold, italic, monospace, etc.) - Links, images, quotes, admonitions - Template support via #import - Compiler: Compiles Typst markup to PDF bytes - Uses typst-as-lib for embedded compilation - Custom font support via compile_with_fonts() - CLI Integration: - New --format pdf option for render command - Template support with --template flag - Data includes support with --data-dir 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e6f95fb commit d928d86

8 files changed

Lines changed: 688 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
"crates/utf8dok-wasm",
88
"crates/utf8dok-ooxml",
99
"crates/utf8dok-pptx",
10-
"crates/utf8dok-diagrams", "crates/utf8dok-validate", "crates/utf8dok-plugins", "crates/utf8dok-lsp", "crates/utf8dok-data",
10+
"crates/utf8dok-diagrams", "crates/utf8dok-validate", "crates/utf8dok-plugins", "crates/utf8dok-lsp", "crates/utf8dok-data", "crates/utf8dok-pdf",
1111
]
1212

1313
[workspace.package]

crates/utf8dok-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ utf8dok-validate = { path = "../utf8dok-validate" }
2525
utf8dok-plugins = { path = "../utf8dok-plugins" }
2626
utf8dok-lsp = { path = "../utf8dok-lsp" }
2727
utf8dok-pptx = { path = "../utf8dok-pptx" }
28+
utf8dok-pdf = { path = "../utf8dok-pdf" }
2829
clap.workspace = true
2930
anyhow.workspace = true
3031
serde_json = "1.0"

crates/utf8dok-cli/src/app.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ pub enum RenderFormat {
6868
Docx,
6969
/// PowerPoint presentation (.pptx)
7070
Pptx,
71+
/// Portable Document Format (.pdf) via Typst
72+
Pdf,
7173
}
7274

7375
#[derive(Parser)]
@@ -723,6 +725,7 @@ pub fn render_command(
723725
match format {
724726
RenderFormat::Docx => render_docx(input, output, template, cover, data_dir),
725727
RenderFormat::Pptx => render_pptx(input, output, template, data_dir),
728+
RenderFormat::Pdf => render_pdf(input, output, template, data_dir),
726729
}
727730
}
728731

@@ -923,6 +926,72 @@ fn render_pptx(
923926
Ok(())
924927
}
925928

929+
/// Render AsciiDoc to PDF via Typst
930+
fn render_pdf(
931+
input: &std::path::Path,
932+
output: Option<&std::path::Path>,
933+
template: Option<&std::path::Path>,
934+
data_dir: Option<&std::path::Path>,
935+
) -> Result<()> {
936+
println!(" Format: PDF (via Typst)");
937+
938+
// Determine output path (default: input with .pdf extension)
939+
let output_path = match output {
940+
Some(p) => p.to_path_buf(),
941+
None => input.with_extension("pdf"),
942+
};
943+
944+
// Step 1: Read input AsciiDoc file
945+
println!(" Reading: {}", input.display());
946+
let source_content = fs::read_to_string(input)
947+
.with_context(|| format!("Failed to read input file: {}", input.display()))?;
948+
949+
// Step 2: Parse AsciiDoc to AST (with optional data includes)
950+
println!(" Parsing AsciiDoc...");
951+
let ast = if let Some(base_path) = data_dir {
952+
println!(" Data includes enabled: {}", base_path.display());
953+
let config = ParserConfig::with_data_includes(base_path.to_string_lossy());
954+
parse_with_config(&source_content, config).context("Failed to parse AsciiDoc content")?
955+
} else {
956+
parse(&source_content).context("Failed to parse AsciiDoc content")?
957+
};
958+
println!(" {} blocks parsed", ast.blocks.len());
959+
960+
// Step 3: Transpile AST to Typst markup
961+
println!(" Transpiling to Typst...");
962+
let typst_markup = if let Some(template_path) = template {
963+
if template_path.exists() {
964+
println!(" Using template: {}", template_path.display());
965+
utf8dok_pdf::Transpiler::transpile_with_template(
966+
&ast,
967+
&template_path.display().to_string(),
968+
)
969+
} else {
970+
eprintln!(" Warning: Template not found: {}", template_path.display());
971+
utf8dok_pdf::Transpiler::transpile(&ast)
972+
}
973+
} else {
974+
utf8dok_pdf::Transpiler::transpile(&ast)
975+
};
976+
977+
// Step 4: Compile Typst to PDF
978+
println!(" Compiling PDF...");
979+
let pdf_bytes = utf8dok_pdf::Compiler::compile(&typst_markup)
980+
.with_context(|| "Failed to compile Typst to PDF")?;
981+
982+
// Step 5: Write output
983+
println!(" Writing: {}", output_path.display());
984+
fs::write(&output_path, &pdf_bytes)
985+
.with_context(|| format!("Failed to write output file: {}", output_path.display()))?;
986+
987+
println!();
988+
println!("Render complete!");
989+
println!(" Output: {}", output_path.display());
990+
println!(" Size: {} bytes", pdf_bytes.len());
991+
992+
Ok(())
993+
}
994+
926995
/// Execute the check command
927996
pub fn check_command(
928997
input: &std::path::Path,

crates/utf8dok-pdf/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "utf8dok-pdf"
3+
description = "PDF generation for utf8dok via Typst"
4+
version.workspace = true
5+
authors.workspace = true
6+
edition.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
keywords = ["pdf", "typst", "document", "generation"]
10+
categories = ["text-processing", "rendering"]
11+
12+
[dependencies]
13+
utf8dok-ast = { path = "../utf8dok-ast" }
14+
typst-as-lib = "0.15"
15+
typst-pdf = "0.14"
16+
thiserror.workspace = true
17+
anyhow.workspace = true
18+
19+
[dev-dependencies]
20+
tempfile = "3"

crates/utf8dok-pdf/src/compiler.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//! Typst to PDF compiler
2+
//!
3+
//! Compiles Typst markup to PDF bytes using typst-as-lib.
4+
5+
use crate::error::{PdfError, Result};
6+
use typst_as_lib::TypstEngine;
7+
8+
/// Compiler for converting Typst markup to PDF
9+
pub struct Compiler;
10+
11+
impl Compiler {
12+
/// Compile Typst markup to PDF bytes
13+
///
14+
/// # Arguments
15+
/// * `markup` - Typst markup string
16+
///
17+
/// # Returns
18+
/// PDF bytes on success
19+
pub fn compile(markup: &str) -> Result<Vec<u8>> {
20+
Self::compile_with_fonts(markup, &[])
21+
}
22+
23+
/// Compile with custom fonts
24+
///
25+
/// # Arguments
26+
/// * `markup` - Typst markup string
27+
/// * `font_paths` - Paths to font files to include
28+
///
29+
/// # Returns
30+
/// PDF bytes on success
31+
pub fn compile_with_fonts(markup: &str, font_paths: &[&str]) -> Result<Vec<u8>> {
32+
// Build the Typst engine with the markup as main file
33+
let mut builder = TypstEngine::builder().main_file(markup.to_string());
34+
35+
// Add fonts if provided
36+
for font_path in font_paths {
37+
let font_bytes = std::fs::read(font_path).map_err(|e| {
38+
PdfError::Font(format!("Failed to read font {}: {}", font_path, e))
39+
})?;
40+
builder = builder.fonts([font_bytes]);
41+
}
42+
43+
let engine = builder.build();
44+
45+
// Compile the document
46+
let compiled = engine.compile();
47+
48+
// compiled is Warned<Result<Document, Error>>
49+
// - compiled.output is the Result
50+
// - compiled.warnings contains any warnings
51+
let document = compiled
52+
.output
53+
.map_err(|e| PdfError::Compilation(format!("{:?}", e)))?;
54+
55+
// Generate PDF
56+
let options = typst_pdf::PdfOptions::default();
57+
let pdf_bytes = typst_pdf::pdf(&document, &options)
58+
.map_err(|e| PdfError::Compilation(format!("PDF generation failed: {:?}", e)))?;
59+
60+
Ok(pdf_bytes.into())
61+
}
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
68+
#[test]
69+
fn test_compile_simple() {
70+
let markup = "= Hello World\n\nThis is a test document.";
71+
let result = Compiler::compile(markup);
72+
73+
// Should compile successfully
74+
assert!(result.is_ok(), "Compilation failed: {:?}", result.err());
75+
76+
let pdf = result.unwrap();
77+
// PDF files start with %PDF
78+
assert!(
79+
pdf.starts_with(b"%PDF"),
80+
"Output doesn't start with PDF header"
81+
);
82+
}
83+
84+
#[test]
85+
fn test_compile_with_formatting() {
86+
let markup = r#"
87+
= Document Title
88+
89+
== Section One
90+
91+
This is *bold* and _italic_ text.
92+
93+
- Item one
94+
- Item two
95+
- Item three
96+
97+
```rust
98+
fn main() {
99+
println!("Hello!");
100+
}
101+
```
102+
"#;
103+
let result = Compiler::compile(markup);
104+
assert!(result.is_ok(), "Compilation failed: {:?}", result.err());
105+
}
106+
107+
#[test]
108+
fn test_compile_invalid_syntax() {
109+
// This should still compile - Typst is quite forgiving
110+
let markup = "#invalid_function_that_doesnt_exist()";
111+
let result = Compiler::compile(markup);
112+
// May fail or succeed depending on Typst version
113+
// The important thing is it doesn't panic
114+
let _ = result;
115+
}
116+
}

crates/utf8dok-pdf/src/error.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//! Error types for PDF generation
2+
3+
use thiserror::Error;
4+
5+
/// Result type for PDF operations
6+
pub type Result<T> = std::result::Result<T, PdfError>;
7+
8+
/// Errors that can occur during PDF generation
9+
#[derive(Error, Debug)]
10+
pub enum PdfError {
11+
/// Typst compilation error
12+
#[error("Typst compilation failed: {0}")]
13+
Compilation(String),
14+
15+
/// Font loading error
16+
#[error("Font error: {0}")]
17+
Font(String),
18+
19+
/// Template not found
20+
#[error("Template not found: {0}")]
21+
TemplateNotFound(String),
22+
23+
/// IO error
24+
#[error("IO error: {0}")]
25+
Io(#[from] std::io::Error),
26+
27+
/// Generic error
28+
#[error("{0}")]
29+
Other(String),
30+
}

crates/utf8dok-pdf/src/lib.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! utf8dok-pdf - PDF generation via Typst
2+
//!
3+
//! This crate provides PDF generation for utf8dok documents using Typst
4+
//! as the typesetting backend.
5+
//!
6+
//! # Architecture
7+
//!
8+
//! The PDF generation pipeline consists of two stages:
9+
//!
10+
//! 1. **Transpiler** - Converts `utf8dok_ast::Document` to Typst markup
11+
//! 2. **Compiler** - Compiles Typst markup to PDF bytes
12+
//!
13+
//! # Example
14+
//!
15+
//! ```ignore
16+
//! use utf8dok_ast::Document;
17+
//! use utf8dok_pdf::{Transpiler, Compiler};
18+
//!
19+
//! let doc = Document::new();
20+
//! let typst_markup = Transpiler::transpile(&doc);
21+
//! let pdf_bytes = Compiler::compile(&typst_markup)?;
22+
//! ```
23+
24+
mod compiler;
25+
mod error;
26+
mod transpiler;
27+
28+
pub use compiler::Compiler;
29+
pub use error::{PdfError, Result};
30+
pub use transpiler::Transpiler;
31+
32+
/// Convenience function to render a document to PDF
33+
///
34+
/// # Arguments
35+
/// * `doc` - The AST document to render
36+
///
37+
/// # Returns
38+
/// PDF bytes on success
39+
pub fn render_pdf(doc: &utf8dok_ast::Document) -> Result<Vec<u8>> {
40+
let typst_markup = Transpiler::transpile(doc);
41+
Compiler::compile(&typst_markup)
42+
}
43+
44+
/// Render with a custom template
45+
///
46+
/// # Arguments
47+
/// * `doc` - The AST document to render
48+
/// * `template_path` - Path to a .typ template file
49+
///
50+
/// # Returns
51+
/// PDF bytes on success
52+
pub fn render_pdf_with_template(
53+
doc: &utf8dok_ast::Document,
54+
template_path: &str,
55+
) -> Result<Vec<u8>> {
56+
let typst_markup = Transpiler::transpile_with_template(doc, template_path);
57+
Compiler::compile(&typst_markup)
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
64+
#[test]
65+
fn test_module_structure() {
66+
// Verify exports are accessible
67+
let _ = Transpiler::transpile;
68+
let _ = Compiler::compile;
69+
let _ = render_pdf;
70+
}
71+
}

0 commit comments

Comments
 (0)