repos
/ status-rust master

status-rust

mirror archived upstream

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

4.6 KB · 151 lines · Rust Raw History
  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}