Single-binary self-hosted uptime monitoring and status pages on Rust axum: HTTP probes, Lighthouse audits, SEO crawler, and PDF reports.
axumdockerrustself-hostedsqlitestatus-pageuptime-monitoringvite
1//! PDF generation via the embedded Typst compiler. Mirrors blog/analytics:
2//! the `.typ` template lives in `templates/properties/property_report.typ`
3//! and is rendered through minijinja first (using `typst_md` / `typst_str`
4//! filters to escape user data into Typst-safe markup), then compiled here.
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use chrono::{Datelike, Local};
9use typst::{
10 diag::{FileError, FileResult, SourceDiagnostic},
11 foundations::{Bytes, Datetime},
12 layout::PagedDocument,
13 syntax::{FileId, Source, VirtualPath},
14 text::{Font, FontBook},
15 utils::LazyHash,
16 Library, LibraryExt, World,
17};
18use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};
19
20/// Pre-built renderer state. Fonts and the standard library are loaded once
21/// at startup and shared across renders.
22pub struct PdfRenderer {
23 library: Arc<LazyHash<Library>>,
24 book: Arc<LazyHash<FontBook>>,
25 fonts: Arc<Vec<FontSlot>>,
26 root: PathBuf,
27}
28
29impl PdfRenderer {
30 pub fn new(root: PathBuf) -> Self {
31 let Fonts { book, fonts } =
32 FontSearcher::new().include_system_fonts(true).search();
33 Self {
34 library: Arc::new(LazyHash::new(Library::default())),
35 book: Arc::new(LazyHash::new(book)),
36 fonts: Arc::new(fonts),
37 root,
38 }
39 }
40
41 /// Compile a Typst source string into PDF bytes. Designed to be called
42 /// from `tokio::task::spawn_blocking` since Typst compilation is CPU-bound
43 /// and synchronous.
44 pub fn render(&self, source: String) -> anyhow::Result<Vec<u8>> {
45 let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
46 let main = Source::new(main_id, source);
47 let world = PdfWorld {
48 library: self.library.clone(),
49 book: self.book.clone(),
50 fonts: self.fonts.clone(),
51 root: self.root.clone(),
52 main,
53 };
54 let warned = typst::compile::<PagedDocument>(&world);
55 let document = warned
56 .output
57 .map_err(|errs| format_diagnostics("compile", &errs))?;
58 let bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
59 .map_err(|errs| format_diagnostics("pdf export", &errs))?;
60 Ok(bytes)
61 }
62}
63
64fn format_diagnostics(stage: &str, errs: &[SourceDiagnostic]) -> anyhow::Error {
65 let mut s = String::new();
66 for e in errs {
67 if !s.is_empty() {
68 s.push('\n');
69 }
70 s.push_str(&e.message);
71 for h in &e.hints {
72 s.push_str("\n hint: ");
73 s.push_str(h);
74 }
75 }
76 anyhow::anyhow!("typst {stage}: {s}")
77}
78
79struct PdfWorld {
80 library: Arc<LazyHash<Library>>,
81 book: Arc<LazyHash<FontBook>>,
82 fonts: Arc<Vec<FontSlot>>,
83 root: PathBuf,
84 main: Source,
85}
86
87impl World for PdfWorld {
88 fn library(&self) -> &LazyHash<Library> {
89 &self.library
90 }
91 fn book(&self) -> &LazyHash<FontBook> {
92 &self.book
93 }
94 fn main(&self) -> FileId {
95 self.main.id()
96 }
97 fn source(&self, id: FileId) -> FileResult<Source> {
98 if id == self.main.id() {
99 return Ok(self.main.clone());
100 }
101 let path = self.resolve(id)?;
102 let text =
103 std::fs::read_to_string(&path).map_err(|err| FileError::from_io(err, &path))?;
104 Ok(Source::new(id, text))
105 }
106 fn file(&self, id: FileId) -> FileResult<Bytes> {
107 let path = self.resolve(id)?;
108 let bytes = std::fs::read(&path).map_err(|err| FileError::from_io(err, &path))?;
109 Ok(Bytes::new(bytes))
110 }
111 fn font(&self, index: usize) -> Option<Font> {
112 self.fonts.get(index)?.get()
113 }
114 fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
115 let now = Local::now();
116 Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)
117 }
118}
119
120impl PdfWorld {
121 fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
122 if id.package().is_some() {
123 return Err(FileError::Other(Some(
124 "remote packages not supported".into(),
125 )));
126 }
127 id.vpath()
128 .resolve(&self.root)
129 .ok_or(FileError::AccessDenied)
130 .and_then(|p| {
131 if path_within(&p, &self.root) {
132 Ok(p)
133 } else {
134 Err(FileError::AccessDenied)
135 }
136 })
137 }
138}
139
140fn path_within(path: &Path, root: &Path) -> bool {
141 let canon = match path.canonicalize() {
142 Ok(p) => p,
143 Err(_) => return false,
144 };
145 let canon_root = match root.canonicalize() {
146 Ok(p) => p,
147 Err(_) => return false,
148 };
149 canon.starts_with(canon_root)
150}