Skip to main content

typub_engine/
renderer.rs

1use crate::adapters::{ContentTransform, RenderConfig};
2use crate::content::{Content, ContentFormat};
3use anyhow::{Context, Result};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use typub_adapters_core::OutputFormat;
7use typub_config::Config;
8
9const MATH_TO_STRING_TYP: &str = include_str!("../typst-scripts/math-to-string.typ");
10const CMARKER_CONFIG_TYP: &str = include_str!("../typst-scripts/cmarker-config.typ");
11
12#[derive(Debug)]
13pub struct RenderedOutput {
14    pub format: OutputFormat,
15    pub paths: Vec<PathBuf>,
16    pub html: Option<String>,
17}
18
19impl RenderedOutput {
20    pub fn html(&self) -> anyhow::Result<&str> {
21        self.html
22            .as_deref()
23            .ok_or_else(|| anyhow::anyhow!("No HTML content available"))
24    }
25}
26
27pub struct Renderer<'a> {
28    config: &'a Config,
29    project_root: PathBuf,
30}
31
32impl<'a> Renderer<'a> {
33    pub fn new(config: &'a Config) -> Self {
34        let project_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
35        Self {
36            config,
37            project_root,
38        }
39    }
40
41    pub fn new_with_root(config: &'a Config, project_root: PathBuf) -> Self {
42        Self {
43            config,
44            project_root,
45        }
46    }
47
48    fn project_root(&self) -> &std::path::Path {
49        &self.project_root
50    }
51
52    pub async fn render_for_platform(
53        &self,
54        content: &Content,
55        platform_id: &str,
56        format: OutputFormat,
57        config: &RenderConfig,
58    ) -> Result<RenderedOutput> {
59        let output_dir = self
60            .config
61            .output_dir
62            .join(content.slug())
63            .join(platform_id);
64        std::fs::create_dir_all(&output_dir)?;
65
66        let wrapper_file = self.generate_wrapper(content, &output_dir, format, config)?;
67
68        match format {
69            OutputFormat::Html | OutputFormat::HtmlFragment => {
70                self.compile_html(&wrapper_file, &output_dir, format).await
71            }
72            OutputFormat::Png => self.compile_png(&wrapper_file, &output_dir).await,
73            OutputFormat::Pdf => self.compile_pdf(&wrapper_file, &output_dir).await,
74        }
75    }
76
77    fn generate_wrapper(
78        &self,
79        content: &Content,
80        output_dir: &Path,
81        format: OutputFormat,
82        config: &RenderConfig,
83    ) -> Result<PathBuf> {
84        let wrapper_path = output_dir.join(".wrapper.typ");
85        let content_path = self.get_relative_content_path(content)?;
86
87        let imports = config.imports.join("\n");
88        let content_include = self.build_content_include(content, &content_path, config);
89
90        let html_math_rule: String = match format {
91            OutputFormat::Html | OutputFormat::HtmlFragment => {
92                use typub_core::MathRendering;
93                match config.math_rendering {
94                    MathRendering::Latex => {
95                        format!(
96                            r#"{math_to_string}
97
98#show math.equation: it => {{
99  let src = math-to-string(it.body)
100  if it.block {{
101    html.elem("div", attrs: (class: "typst-svg-block", "data-typst-src": src), html.frame(it))
102  }} else {{
103    html.elem("span", attrs: (class: "typst-svg-inline", "data-typst-src": src), html.frame(it))
104  }}
105}}"#,
106                            math_to_string = MATH_TO_STRING_TYP,
107                        )
108                    }
109                    MathRendering::Svg | MathRendering::Png => r#"#show math.equation: it => {
110  if it.block {
111    html.elem("div", attrs: (class: "typst-svg-block"), html.frame(it))
112  } else {
113    html.elem("span", attrs: (class: "typst-svg-inline"), html.frame(it))
114  }
115}"#
116                    .to_string(),
117                }
118            }
119            _ => String::new(),
120        };
121
122        // For deferred upload strategies, emit <img src="path"> markers instead of embedding images.
123        // This allows the asset pipeline to handle them as LocalPath assets.
124        let html_image_rule: String = match format {
125            OutputFormat::Html | OutputFormat::HtmlFragment if config.image_as_marker => {
126                r#"#show image: it => {
127  let attrs = (:)
128  if it.width != auto {
129    let width-str = repr(it.width)
130    attrs.insert("width", width-str.slice(0, width-str.position("%") + 1))
131  }
132  if it.height != auto {
133    let height-str = repr(it.height)
134    attrs.insert("height", height-str.slice(0, height-str.position("%") + 1))
135  }
136  if it.alt != none {
137    attrs.insert("alt", it.alt)
138  }
139  attrs.insert("src", it.source)
140  return html.elem("img", attrs: attrs)
141}"#
142                .to_string()
143            }
144            _ => String::new(),
145        };
146
147        let wrapper = format!(
148            r#"
149{imports}
150
151{html_math_rule}
152
153{html_image_rule}
154
155{preamble}
156
157{template_before}
158
159{content_include}
160
161{template_after}
162"#,
163            imports = imports,
164            html_math_rule = html_math_rule,
165            preamble = config.preamble,
166            template_before = config.template_before,
167            content_include = content_include,
168            template_after = config.template_after,
169        );
170
171        std::fs::write(&wrapper_path, wrapper)?;
172        Ok(wrapper_path)
173    }
174
175    fn build_content_include(
176        &self,
177        content: &Content,
178        content_path: &str,
179        config: &RenderConfig,
180    ) -> String {
181        use typub_core::MathRendering;
182
183        let content_dir = content
184            .path
185            .strip_prefix(self.project_root())
186            .unwrap_or(&content.path)
187            .to_str()
188            .unwrap_or("")
189            .replace('\\', "/");
190
191        let math_mode = match config.math_rendering {
192            MathRendering::Latex => "latex",
193            MathRendering::Svg | MathRendering::Png => "svg",
194        };
195
196        match &config.content_transform {
197            ContentTransform::Default => match content.source_format {
198                ContentFormat::Typst => {
199                    format!(r#"#include "/{path}""#, path = content_path)
200                }
201                ContentFormat::Markdown => {
202                    format!(
203                        r##"{cmarker_config}
204
205#render-md(
206  "/{path}",
207  content-dir: "{content_dir}",
208  math-mode: "{math_mode}",
209  image-as-marker: {image_as_marker},
210)"##,
211                        cmarker_config = CMARKER_CONFIG_TYP,
212                        path = content_path,
213                        content_dir = content_dir,
214                        math_mode = math_mode,
215                        image_as_marker = config.image_as_marker
216                    )
217                }
218            },
219            ContentTransform::ShowRules(rules) => match content.source_format {
220                ContentFormat::Typst => {
221                    format!(
222                        r#"#{{
223{rules}
224  include "/{path}"
225}}"#,
226                        rules = rules,
227                        path = content_path
228                    )
229                }
230                ContentFormat::Markdown => {
231                    format!(
232                        r##"{cmarker_config}
233
234{rules}
235
236#render-md(
237  "/{path}",
238  content-dir: "{content_dir}",
239  math-mode: "{math_mode}",
240  image-as-marker: {image_as_marker},
241)"##,
242                        cmarker_config = CMARKER_CONFIG_TYP,
243                        path = content_path,
244                        content_dir = content_dir,
245                        math_mode = math_mode,
246                        image_as_marker = config.image_as_marker,
247                        rules = rules
248                    )
249                }
250            },
251            ContentTransform::Custom(template) => template.replace("{path}", content_path),
252        }
253    }
254
255    fn get_relative_content_path(&self, content: &Content) -> Result<String> {
256        let path = content
257            .content_file
258            .strip_prefix(self.project_root())
259            .unwrap_or(&content.content_file);
260        Ok(path.display().to_string().replace('\\', "/"))
261    }
262
263    async fn compile_html(
264        &self,
265        wrapper_file: &Path,
266        output_dir: &Path,
267        format: OutputFormat,
268    ) -> Result<RenderedOutput> {
269        let output_path = output_dir.join("content.html");
270        let root = self.project_root();
271
272        let root_str = root
273            .to_str()
274            .ok_or_else(|| anyhow::anyhow!("project root path is not valid UTF-8"))?;
275        let wrapper_str = wrapper_file
276            .to_str()
277            .ok_or_else(|| anyhow::anyhow!("wrapper file path is not valid UTF-8"))?;
278        let output_str = output_path
279            .to_str()
280            .ok_or_else(|| anyhow::anyhow!("output path is not valid UTF-8"))?;
281
282        let output = Command::new("typst")
283            .args([
284                "compile",
285                "--root",
286                root_str,
287                "--format",
288                "html",
289                "--features",
290                "html",
291                wrapper_str,
292                output_str,
293            ])
294            .output()
295            .context("Failed to execute typst")?;
296
297        if !output.status.success() {
298            let stderr = String::from_utf8_lossy(&output.stderr);
299            anyhow::bail!("typst compile failed: {}", stderr);
300        }
301
302        let html = std::fs::read_to_string(&output_path)?;
303        let html = if format == OutputFormat::HtmlFragment {
304            extract_body_html(&html)
305        } else {
306            html
307        };
308
309        Ok(RenderedOutput {
310            format,
311            paths: vec![output_path],
312            html: Some(html),
313        })
314    }
315
316    async fn compile_png(&self, wrapper_file: &Path, output_dir: &Path) -> Result<RenderedOutput> {
317        // Clear old slide images to handle cases where slide count decreases
318        for entry in std::fs::read_dir(output_dir)? {
319            let entry = entry?;
320            let path = entry.path();
321            if path.extension().is_some_and(|ext| ext == "png") {
322                std::fs::remove_file(&path)?;
323            }
324        }
325
326        let output_pattern = output_dir.join("slide-{n}.png");
327        let root = self.project_root();
328
329        let root_str = root
330            .to_str()
331            .ok_or_else(|| anyhow::anyhow!("project root path is not valid UTF-8"))?;
332        let wrapper_str = wrapper_file
333            .to_str()
334            .ok_or_else(|| anyhow::anyhow!("wrapper file path is not valid UTF-8"))?;
335        let pattern_str = output_pattern
336            .to_str()
337            .ok_or_else(|| anyhow::anyhow!("output pattern path is not valid UTF-8"))?;
338
339        let output = Command::new("typst")
340            .args(["compile", "--root", root_str, wrapper_str, pattern_str])
341            .output()
342            .context("Failed to execute typst")?;
343
344        if !output.status.success() {
345            let stderr = String::from_utf8_lossy(&output.stderr);
346            anyhow::bail!("typst compile failed: {}", stderr);
347        }
348
349        let mut paths = Vec::new();
350        for entry in std::fs::read_dir(output_dir)? {
351            let entry = entry?;
352            let path = entry.path();
353            if path.extension().is_some_and(|ext| ext == "png") {
354                paths.push(path);
355            }
356        }
357        paths.sort();
358
359        Ok(RenderedOutput {
360            format: OutputFormat::Png,
361            paths,
362            html: None,
363        })
364    }
365
366    async fn compile_pdf(&self, wrapper_file: &Path, output_dir: &Path) -> Result<RenderedOutput> {
367        let output_path = output_dir.join("content.pdf");
368        let root = self.project_root();
369
370        let root_str = root
371            .to_str()
372            .ok_or_else(|| anyhow::anyhow!("project root path is not valid UTF-8"))?;
373        let wrapper_str = wrapper_file
374            .to_str()
375            .ok_or_else(|| anyhow::anyhow!("wrapper file path is not valid UTF-8"))?;
376        let output_str = output_path
377            .to_str()
378            .ok_or_else(|| anyhow::anyhow!("output path is not valid UTF-8"))?;
379
380        let output = Command::new("typst")
381            .args([
382                "compile",
383                "--root",
384                root_str,
385                "--format",
386                "pdf",
387                wrapper_str,
388                output_str,
389            ])
390            .output()
391            .context("Failed to execute typst")?;
392
393        if !output.status.success() {
394            let stderr = String::from_utf8_lossy(&output.stderr);
395            anyhow::bail!("typst compile failed: {}", stderr);
396        }
397
398        Ok(RenderedOutput {
399            format: OutputFormat::Pdf,
400            paths: vec![output_path],
401            html: None,
402        })
403    }
404}
405
406pub(crate) fn extract_body_html(html: &str) -> String {
407    if let Some(start) = html.find("<body>")
408        && let Some(end) = html.find("</body>")
409    {
410        return html[start + 6..end].trim().to_string();
411    }
412    html.to_string()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::extract_body_html;
418
419    #[test]
420    fn test_extract_body() {
421        let html = r#"<!DOCTYPE html>
422<html>
423<head><title>Test</title></head>
424<body>
425<h1>Hello</h1>
426<p>World</p>
427</body>
428</html>"#;
429        let body = extract_body_html(html);
430        assert!(body.contains("<h1>Hello</h1>"));
431        assert!(!body.contains("DOCTYPE"));
432    }
433}