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 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 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}