Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use chrono::{Datelike, Local};
5use typst::{
6 diag::{FileError, FileResult, SourceDiagnostic},
7 foundations::{Bytes, Datetime},
8 layout::PagedDocument,
9 syntax::{FileId, Source, VirtualPath},
10 text::{Font, FontBook},
11 utils::LazyHash,
12 Library, LibraryExt, World,
13};
14use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};
15
16/// Pre-built renderer state. Fonts and the standard library are loaded once at
17/// startup and shared across renders.
18pub struct PdfRenderer {
19 library: Arc<LazyHash<Library>>,
20 book: Arc<LazyHash<FontBook>>,
21 fonts: Arc<Vec<FontSlot>>,
22 root: PathBuf,
23}
24
25impl PdfRenderer {
26 /// Discover system + embedded fonts and build the renderer. `root` is the
27 /// project root that absolute paths in the Typst source resolve against
28 /// (e.g. `image("/templates/foo.svg")` -> `<root>/templates/foo.svg`).
29 pub fn new(root: PathBuf) -> Self {
30 let Fonts { book, fonts } = FontSearcher::new()
31 .include_system_fonts(true)
32 .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 `source` (Typst markup) into a PDF.
42 pub fn render(&self, source: String) -> anyhow::Result<Vec<u8>> {
43 let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
44 let main = Source::new(main_id, source);
45 let world = PdfWorld {
46 library: self.library.clone(),
47 book: self.book.clone(),
48 fonts: self.fonts.clone(),
49 root: self.root.clone(),
50 main,
51 };
52 let warned = typst::compile::<PagedDocument>(&world);
53 let document = warned
54 .output
55 .map_err(|errs| format_diagnostics("compile", &errs))?;
56 let bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
57 .map_err(|errs| format_diagnostics("pdf export", &errs))?;
58 Ok(bytes)
59 }
60}
61
62fn format_diagnostics(stage: &str, errs: &[SourceDiagnostic]) -> anyhow::Error {
63 let mut s = String::new();
64 for e in errs {
65 if !s.is_empty() {
66 s.push('\n');
67 }
68 s.push_str(&e.message);
69 for h in &e.hints {
70 s.push_str("\n hint: ");
71 s.push_str(h);
72 }
73 }
74 anyhow::anyhow!("typst {stage}: {s}")
75}
76
77struct PdfWorld {
78 library: Arc<LazyHash<Library>>,
79 book: Arc<LazyHash<FontBook>>,
80 fonts: Arc<Vec<FontSlot>>,
81 root: PathBuf,
82 main: Source,
83}
84
85impl World for PdfWorld {
86 fn library(&self) -> &LazyHash<Library> {
87 &self.library
88 }
89 fn book(&self) -> &LazyHash<FontBook> {
90 &self.book
91 }
92 fn main(&self) -> FileId {
93 self.main.id()
94 }
95 fn source(&self, id: FileId) -> FileResult<Source> {
96 if id == self.main.id() {
97 return Ok(self.main.clone());
98 }
99 let path = self.resolve(id)?;
100 let text =
101 std::fs::read_to_string(&path).map_err(|err| FileError::from_io(err, &path))?;
102 Ok(Source::new(id, text))
103 }
104 fn file(&self, id: FileId) -> FileResult<Bytes> {
105 let path = self.resolve(id)?;
106 let bytes = std::fs::read(&path).map_err(|err| FileError::from_io(err, &path))?;
107 Ok(Bytes::new(bytes))
108 }
109 fn font(&self, index: usize) -> Option<Font> {
110 self.fonts.get(index)?.get()
111 }
112 fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
113 let now = Local::now();
114 Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)
115 }
116}
117
118impl PdfWorld {
119 fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
120 if id.package().is_some() {
121 return Err(FileError::Other(Some(
122 "remote packages not supported".into(),
123 )));
124 }
125 id.vpath()
126 .resolve(&self.root)
127 .ok_or(FileError::AccessDenied)
128 .and_then(|p| {
129 if path_within(&p, &self.root) {
130 Ok(p)
131 } else {
132 Err(FileError::AccessDenied)
133 }
134 })
135 }
136}
137
138fn path_within(path: &Path, root: &Path) -> bool {
139 let canon = match path.canonicalize() {
140 Ok(p) => p,
141 Err(_) => return false,
142 };
143 let canon_root = match root.canonicalize() {
144 Ok(p) => p,
145 Err(_) => return false,
146 };
147 canon.starts_with(canon_root)
148}