From 78347667149b8f5987dd12a31ad157d88a455648 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Sun, 3 Dec 2023 16:23:15 -0800 Subject: [PATCH] Allow rendering typst/latex formulas --- src/builder.rs | 29 ++++++++- src/custom.rs | 19 ++++++ src/export.rs | 14 ++-- src/lib.rs | 2 + src/main.rs | 10 +-- src/markdown/code.rs | 13 +++- src/markdown/elements.rs | 11 ++++ src/presenter.rs | 10 +-- src/render/highlighting.rs | 1 + src/render/media.rs | 22 ++++--- src/resource.rs | 4 +- src/style.rs | 7 ++ src/theme.rs | 17 +++++ src/typst.rs | 128 +++++++++++++++++++++++++++++++++++++ 14 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 src/typst.rs diff --git a/src/builder.rs b/src/builder.rs index a015416d..969a25db 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -21,6 +21,7 @@ use crate::{ Alignment, AuthorPositioning, ElementType, FooterStyle, LoadThemeError, Margin, PresentationTheme, PresentationThemeSet, }, + typst::{TypstRender, TypstRenderError}, }; use itertools::Itertools; use serde::Deserialize; @@ -59,6 +60,7 @@ pub(crate) struct PresentationBuilder<'a> { highlighter: CodeHighlighter, theme: Cow<'a, PresentationTheme>, resources: &'a mut Resources, + typst: &'a mut TypstRender, slide_state: SlideState, footer_context: Rc>, themes: &'a Themes, @@ -71,6 +73,7 @@ impl<'a> PresentationBuilder<'a> { default_highlighter: CodeHighlighter, default_theme: &'a PresentationTheme, resources: &'a mut Resources, + typst: &'a mut TypstRender, themes: &'a Themes, options: PresentationBuilderOptions, ) -> Self { @@ -82,6 +85,7 @@ impl<'a> PresentationBuilder<'a> { highlighter: default_highlighter, theme: Cow::Borrowed(default_theme), resources, + typst, slide_state: Default::default(), footer_context: Default::default(), themes, @@ -154,7 +158,7 @@ impl<'a> PresentationBuilder<'a> { MarkdownElement::Heading { level, text } => self.push_heading(level, text), MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?, MarkdownElement::List(elements) => self.push_list(elements), - MarkdownElement::Code(code) => self.push_code(code), + MarkdownElement::Code(code) => self.push_code(code)?, MarkdownElement::Table(table) => self.push_table(table), MarkdownElement::ThematicBreak => self.push_separator(), MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, @@ -486,7 +490,10 @@ impl<'a> PresentationBuilder<'a> { self.chunk_operations.push(RenderOperation::RenderLineBreak); } - fn push_code(&mut self, code: Code) { + fn push_code(&mut self, code: Code) -> Result<(), BuildError> { + if code.attributes.auto_render { + return self.push_rendered_code(code); + } let (lines, context) = self.highlight_lines(&code); for line in lines { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line))); @@ -498,6 +505,18 @@ impl<'a> PresentationBuilder<'a> { if code.attributes.execute { self.push_code_execution(code); } + Ok(()) + } + + fn push_rendered_code(&mut self, code: Code) -> Result<(), BuildError> { + let image = match code.language { + CodeLanguage::Typst => self.typst.render_typst(&code.contents, &self.theme.typst)?, + CodeLanguage::Latex => self.typst.render_latex(&code.contents, &self.theme.typst)?, + _ => panic!("language {:?} should not be renderable", code.language), + }; + let operation = RenderOperation::RenderImage(image); + self.chunk_operations.push(operation); + Ok(()) } fn highlight_lines(&self, code: &Code) -> (Vec, Rc>) { @@ -919,6 +938,9 @@ pub enum BuildError { #[error("error parsing command at line {line}: {error}")] CommandParse { line: usize, error: CommandParseError }, + + #[error("typst render failed: {0}")] + TypstRender(#[from] TypstRenderError), } #[derive(Debug, Clone, PartialEq, Deserialize)] @@ -1165,9 +1187,10 @@ mod test { let highlighter = CodeHighlighter::default(); let theme = PresentationTheme::default(); let mut resources = Resources::new("/tmp"); + let mut typst = TypstRender::default(); let options = PresentationBuilderOptions::default(); let themes = Themes::default(); - let builder = PresentationBuilder::new(highlighter, &theme, &mut resources, &themes, options); + let builder = PresentationBuilder::new(highlighter, &theme, &mut resources, &mut typst, &themes, options); builder.build(elements) } diff --git a/src/custom.rs b/src/custom.rs index 1d22e882..e29ce9a2 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -5,6 +5,9 @@ use std::{fs, io, path::Path}; pub struct Config { #[serde(default)] pub defaults: DefaultsConfig, + + #[serde(default)] + pub typst: TypstConfig, } impl Config { @@ -33,3 +36,19 @@ pub enum ConfigLoadError { pub struct DefaultsConfig { pub theme: Option, } + +#[derive(Clone, Debug, Deserialize)] +pub struct TypstConfig { + #[serde(default = "default_typst_ppi")] + pub ppi: u32, +} + +impl Default for TypstConfig { + fn default() -> Self { + Self { ppi: default_typst_ppi() } + } +} + +fn default_typst_ppi() -> u32 { + 300 +} diff --git a/src/export.rs b/src/export.rs index 3f0266d6..5db47fab 100644 --- a/src/export.rs +++ b/src/export.rs @@ -2,6 +2,7 @@ use crate::{ builder::{BuildError, PresentationBuilder, PresentationBuilderOptions, Themes}, markdown::{elements::MarkdownElement, parse::ParseError}, presentation::{Presentation, RenderOperation}, + typst::TypstRender, CodeHighlighter, MarkdownParser, PresentationTheme, Resources, }; use serde::Serialize; @@ -18,8 +19,8 @@ const COMMAND: &str = "presenterm-export"; pub struct Exporter<'a> { parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, - default_highlighter: CodeHighlighter, resources: Resources, + typst: TypstRender, themes: Themes, } @@ -28,11 +29,11 @@ impl<'a> Exporter<'a> { pub fn new( parser: MarkdownParser<'a>, default_theme: &'a PresentationTheme, - default_highlighter: CodeHighlighter, resources: Resources, + typst: TypstRender, themes: Themes, ) -> Self { - Self { parser, default_theme, default_highlighter, resources, themes } + Self { parser, default_theme, resources, typst, themes } } /// Export the given presentation into PDF. @@ -59,9 +60,10 @@ impl<'a> Exporter<'a> { let images = Self::build_image_metadata(&elements, base_path); let options = PresentationBuilderOptions { allow_mutations: false }; let presentation = PresentationBuilder::new( - self.default_highlighter.clone(), + CodeHighlighter::default(), self.default_theme, &mut self.resources, + &mut self.typst, &self.themes, options, ) @@ -200,10 +202,10 @@ mod test { let arena = Arena::new(); let parser = MarkdownParser::new(&arena); let theme = PresentationThemeSet::default().load_by_name("dark").unwrap(); - let highlighter = CodeHighlighter::default(); let resources = Resources::new("examples"); + let typst = TypstRender::default(); let themes = Themes::default(); - let mut exporter = Exporter::new(parser, &theme, highlighter, resources, themes); + let mut exporter = Exporter::new(parser, &theme, resources, typst, themes); exporter.extract_metadata(content, Path::new(path)).expect("metadata extraction failed") } diff --git a/src/lib.rs b/src/lib.rs index 810cae2c..7c941a32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub(crate) mod render; pub(crate) mod resource; pub(crate) mod style; pub(crate) mod theme; +pub(crate) mod typst; pub use crate::{ builder::Themes, @@ -26,4 +27,5 @@ pub use crate::{ render::highlighting::{CodeHighlighter, HighlightThemeSet}, resource::Resources, theme::{LoadThemeError, PresentationTheme, PresentationThemeSet}, + typst::TypstRender, }; diff --git a/src/main.rs b/src/main.rs index 9f9271ac..b52eac8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ use clap::{error::ErrorKind, CommandFactory, Parser}; use comrak::Arena; use presenterm::{ - CodeHighlighter, CommandSource, Config, Exporter, HighlightThemeSet, LoadThemeError, MarkdownParser, PresentMode, - PresentationThemeSet, Presenter, Resources, Themes, + CommandSource, Config, Exporter, HighlightThemeSet, LoadThemeError, MarkdownParser, PresentMode, + PresentationThemeSet, Presenter, Resources, Themes, TypstRender, }; use std::{ env, @@ -108,7 +108,6 @@ fn run(cli: Cli) -> Result<(), Box> { }; let arena = Arena::new(); let parser = MarkdownParser::new(&arena); - let default_highlighter = CodeHighlighter::default(); if cli.acknowledgements { display_acknowledgements(); return Ok(()); @@ -116,8 +115,9 @@ fn run(cli: Cli) -> Result<(), Box> { let path = cli.path.expect("no path"); let resources_path = path.parent().unwrap_or(Path::new("/")); let resources = Resources::new(resources_path); + let typst = TypstRender::new(config.typst.ppi); if cli.export_pdf || cli.generate_pdf_metadata { - let mut exporter = Exporter::new(parser, &default_theme, default_highlighter, resources, themes); + let mut exporter = Exporter::new(parser, &default_theme, resources, typst, themes); if cli.export_pdf { exporter.export_pdf(&path)?; } else { @@ -126,7 +126,7 @@ fn run(cli: Cli) -> Result<(), Box> { } } else { let commands = CommandSource::new(&path); - let presenter = Presenter::new(&default_theme, default_highlighter, commands, parser, resources, themes, mode); + let presenter = Presenter::new(&default_theme, commands, parser, resources, typst, themes, mode); presenter.present(&path)?; } Ok(()) diff --git a/src/markdown/code.rs b/src/markdown/code.rs index 7ca8696f..dbee57de 100644 --- a/src/markdown/code.rs +++ b/src/markdown/code.rs @@ -17,7 +17,10 @@ impl CodeBlockParser { let (language, input) = Self::parse_language(input); let attributes = Self::parse_attributes(input)?; if attributes.execute && !language.supports_execution() { - return Err(CodeBlockParseError::ExecutionNotSupported(language)); + return Err(CodeBlockParseError::UnsupportedAttribute(language, "execution")); + } + if attributes.auto_render && !language.supports_auto_render() { + return Err(CodeBlockParseError::UnsupportedAttribute(language, "rendering")); } Ok((language, attributes)) } @@ -69,6 +72,7 @@ impl CodeBlockParser { "swift" => Swift, "terraform" => Terraform, "typescript" | "ts" => TypeScript, + "typst" => Typst, "xml" => Xml, "yaml" => Yaml, "vue" => Vue, @@ -90,6 +94,7 @@ impl CodeBlockParser { match attribute { Attribute::LineNumbers => attributes.line_numbers = true, Attribute::Exec => attributes.execute = true, + Attribute::AutoRender => attributes.auto_render = true, Attribute::HighlightedLines(lines) => attributes.highlight_groups = lines, }; processed_attributes.push(discriminant); @@ -109,6 +114,7 @@ impl CodeBlockParser { let attribute = match token { "line_numbers" => Attribute::LineNumbers, "exec" => Attribute::Exec, + "render" => Attribute::AutoRender, _ => return Err(CodeBlockParseError::InvalidToken(Self::next_identifier(input).into())), }; (Some(attribute), &input[token.len() + 1..]) @@ -197,14 +203,15 @@ pub(crate) enum CodeBlockParseError { #[error("duplicate attribute: {0}")] DuplicateAttribute(&'static str), - #[error("language {0:?} does not support execution")] - ExecutionNotSupported(CodeLanguage), + #[error("language {0:?} does not support {1}")] + UnsupportedAttribute(CodeLanguage, &'static str), } #[derive(EnumDiscriminants)] enum Attribute { LineNumbers, Exec, + AutoRender, HighlightedLines(Vec), } diff --git a/src/markdown/elements.rs b/src/markdown/elements.rs index 091394da..655249b8 100644 --- a/src/markdown/elements.rs +++ b/src/markdown/elements.rs @@ -235,6 +235,7 @@ pub(crate) enum CodeLanguage { Svelte, Terraform, TypeScript, + Typst, Unknown, Xml, Yaml, @@ -246,6 +247,10 @@ impl CodeLanguage { pub(crate) fn supports_execution(&self) -> bool { matches!(self, Self::Shell(_)) } + + pub(crate) fn supports_auto_render(&self) -> bool { + matches!(self, Self::Latex | Self::Typst) + } } /// Attributes for code blocks. @@ -254,6 +259,12 @@ pub(crate) struct CodeAttributes { /// Whether the code block is marked as executable. pub(crate) execute: bool, + /// Whether a code block is marked to be auto rendered. + /// + /// An auto rendered piece of code is transformed during parsing, leading to some visual + /// representation of it being shown rather than the original code. + pub(crate) auto_render: bool, + /// Whether the code block should show line numbers. pub(crate) line_numbers: bool, diff --git a/src/presenter.rs b/src/presenter.rs index b725b8de..71fd9d1e 100644 --- a/src/presenter.rs +++ b/src/presenter.rs @@ -10,6 +10,7 @@ use crate::{ }, resource::Resources, theme::PresentationTheme, + typst::TypstRender, }; use std::{ collections::HashSet, @@ -24,10 +25,10 @@ use std::{ /// This type puts everything else together. pub struct Presenter<'a> { default_theme: &'a PresentationTheme, - default_highlighter: CodeHighlighter, commands: CommandSource, parser: MarkdownParser<'a>, resources: Resources, + typst: TypstRender, mode: PresentMode, state: PresenterState, slides_with_pending_widgets: HashSet, @@ -38,19 +39,19 @@ impl<'a> Presenter<'a> { /// Construct a new presenter. pub fn new( default_theme: &'a PresentationTheme, - default_highlighter: CodeHighlighter, commands: CommandSource, parser: MarkdownParser<'a>, resources: Resources, + typst: TypstRender, themes: Themes, mode: PresentMode, ) -> Self { Self { default_theme, - default_highlighter, commands, parser, resources, + typst, mode, state: PresenterState::Empty, slides_with_pending_widgets: HashSet::new(), @@ -187,9 +188,10 @@ impl<'a> Presenter<'a> { options.allow_mutations = false; } let presentation = PresentationBuilder::new( - self.default_highlighter.clone(), + CodeHighlighter::default(), self.default_theme, &mut self.resources, + &mut self.typst, &self.themes, options, ) diff --git a/src/render/highlighting.rs b/src/render/highlighting.rs index 5653e5aa..2e3faf10 100644 --- a/src/render/highlighting.rs +++ b/src/render/highlighting.rs @@ -151,6 +151,7 @@ impl CodeHighlighter { Svelte => "svelte", Terraform => "tf", TypeScript => "ts", + Typst => "txt", // default to plain text so we get the same look&feel Unknown => "txt", Vue => "vue", diff --git a/src/render/media.rs b/src/render/media.rs index 22a786be..78a317bf 100644 --- a/src/render/media.rs +++ b/src/render/media.rs @@ -11,7 +11,7 @@ use super::properties::CursorPosition; #[derive(Clone, PartialEq)] pub(crate) struct Image { contents: Rc, - path: PathBuf, + source: ImageSource, } impl Debug for Image { @@ -22,13 +22,19 @@ impl Debug for Image { impl Image { /// Construct a new image from a byte sequence. - pub(crate) fn new(contents: &[u8], path: PathBuf) -> Result { + pub(crate) fn new(contents: &[u8], source: ImageSource) -> Result { let contents = image::load_from_memory(contents)?; let contents = Rc::new(contents); - Ok(Self { contents, path }) + Ok(Self { contents, source }) } } +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ImageSource { + Filesystem(PathBuf), + Generated, +} + /// A media render. pub(crate) struct MediaRender { mode: TerminalMode, @@ -52,7 +58,7 @@ impl MediaRender { if !dimensions.has_pixels { return Err(RenderImageError::NoWindowSize); } - let image_path = &image.path; + let source = &image.source; let image = &image.contents; // Compute the image's width in columns by translating pixels -> columns. @@ -70,7 +76,7 @@ impl MediaRender { // Because we only use the width to draw, here we scale the width based on how much we // need to shrink the height. let shrink_ratio = available_height as f64 / height_in_rows as f64; - width_in_columns = (width_in_columns as f64 * shrink_ratio) as u32; + width_in_columns = (width_in_columns as f64 * shrink_ratio).ceil() as u32; } // Don't go too far wide. let width_in_columns = width_in_columns.min(column_margin); @@ -89,9 +95,9 @@ impl MediaRender { // // This switch is because otherwise `viuer::print_from_file` for kitty/ascii blocks will // re-read the image every time. - match self.mode { - TerminalMode::Iterm2 => viuer::print_from_file(image_path, &config)?, - TerminalMode::Other => viuer::print(image, &config)?, + match (&self.mode, source) { + (TerminalMode::Iterm2, ImageSource::Filesystem(image_path)) => viuer::print_from_file(image_path, &config)?, + (TerminalMode::Other, _) | (_, ImageSource::Generated) => viuer::print(image, &config)?, }; Ok(()) } diff --git a/src/resource.rs b/src/resource.rs index a96d625b..a5eeec7c 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -1,5 +1,5 @@ use crate::{ - render::media::{Image, InvalidImage}, + render::media::{Image, ImageSource, InvalidImage}, theme::{LoadThemeError, PresentationTheme}, }; use std::{ @@ -34,7 +34,7 @@ impl Resources { } let contents = fs::read(&path).map_err(|e| LoadImageError::Io(path.clone(), e))?; - let image = Image::new(&contents, path.clone())?; + let image = Image::new(&contents, ImageSource::Filesystem(path.clone()))?; self.images.insert(path, image.clone()); Ok(image) } diff --git a/src/style.rs b/src/style.rs index 031cd43d..fc670a25 100644 --- a/src/style.rs +++ b/src/style.rs @@ -125,6 +125,13 @@ impl Color { pub(crate) fn new(r: u8, g: u8, b: u8) -> Self { Self(crossterm::style::Color::Rgb { r, g, b }) } + + pub(crate) fn as_rgb(&self) -> Option<(u8, u8, u8)> { + match self.0 { + crossterm::style::Color::Rgb { r, g, b } => Some((r, g, b)), + _ => None, + } + } } impl FromStr for Color { diff --git a/src/theme.rs b/src/theme.rs index 8645d34e..edf01390 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -95,6 +95,10 @@ pub struct PresentationTheme { /// The style of the presentation footer. #[serde(default)] pub(crate) footer: Option, + + /// The style for typst auto-rendered code blocks. + #[serde(default)] + pub(crate) typst: TypstStyle, } impl PresentationTheme { @@ -465,6 +469,19 @@ pub(crate) enum AuthorPositioning { PageBottom, } +/// Where to position the author's name in the intro slide. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct TypstStyle { + /// The horizontal margin on the generated images. + pub(crate) horizontal_margin: Option, + + /// The vertical margin on the generated images. + pub(crate) vertical_margin: Option, + + /// The colors to be used. + pub(crate) colors: Colors, +} + /// An error loading a presentation theme. #[derive(thiserror::Error, Debug)] pub enum LoadThemeError { diff --git a/src/typst.rs b/src/typst.rs new file mode 100644 index 00000000..2d825b9d --- /dev/null +++ b/src/typst.rs @@ -0,0 +1,128 @@ +use crate::{ + render::media::{Image, ImageSource, InvalidImage}, + style::Color, + theme::TypstStyle, +}; +use std::{ + fs, + io::{self, Write}, + path::Path, + process::{Command, Output, Stdio}, +}; +use tempfile::tempdir; + +const DEFAULT_PPI: u32 = 300; +const DEFAULT_HORIZONTAL_MARGIN: u16 = 5; +const DEFAULT_VERTICAL_MARGIN: u16 = 7; + +pub struct TypstRender { + ppi: String, +} + +impl TypstRender { + pub fn new(ppi: u32) -> Self { + Self { ppi: ppi.to_string() } + } + + pub(crate) fn render_typst(&self, input: &str, style: &TypstStyle) -> Result { + let workdir = tempdir()?; + let mut typst_input = Self::generate_page_header(style)?; + typst_input.push_str(input); + + let input_path = workdir.path().join("input.typst"); + fs::write(&input_path, &typst_input)?; + self.render_to_image(workdir.path(), &input_path) + } + + pub(crate) fn render_latex(&self, input: &str, style: &TypstStyle) -> Result { + let mut child = Command::new("pandoc") + .args(["--from", "latex", "--to", "typst"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| TypstRenderError::CommandRun("pandoc", e.to_string()))?; + + child.stdin.take().expect("no stdin").write_all(input.as_bytes())?; + let output = child.wait_with_output().map_err(|e| TypstRenderError::CommandRun("pandoc", e.to_string()))?; + Self::validate_output(&output, "pandoc")?; + + let input = String::from_utf8_lossy(&output.stdout); + self.render_typst(&input, style) + } + + fn render_to_image(&self, base_path: &Path, path: &Path) -> Result { + let output_path = base_path.join("output.png"); + let output = Command::new("typst") + .args([ + "compile", + "--format", + "png", + "--ppi", + &self.ppi, + &path.to_string_lossy(), + &output_path.to_string_lossy(), + ]) + .stderr(Stdio::piped()) + .output() + .map_err(|e| TypstRenderError::CommandRun("typst", e.to_string()))?; + Self::validate_output(&output, "typst")?; + + let png_contents = fs::read(&output_path)?; + let image = Image::new(&png_contents, ImageSource::Generated)?; + Ok(image) + } + + fn validate_output(output: &Output, name: &'static str) -> Result<(), TypstRenderError> { + if output.status.success() { + Ok(()) + } else { + let error = String::from_utf8_lossy(&output.stderr); + let error = error.lines().take(10).collect(); + Err(TypstRenderError::CommandRun(name, error)) + } + } + + fn generate_page_header(style: &TypstStyle) -> Result { + let x_margin = style.horizontal_margin.unwrap_or(DEFAULT_HORIZONTAL_MARGIN); + let y_margin = style.vertical_margin.unwrap_or(DEFAULT_VERTICAL_MARGIN); + let background = + style.colors.background.as_ref().map(Self::as_typst_color).unwrap_or_else(|| Ok(String::from("none")))?; + let mut header = format!( + "#set page(width: auto, height: auto, margin: (x: {x_margin}pt, y: {y_margin}pt), fill: {background})\n" + ); + if let Some(color) = &style.colors.foreground { + let color = Self::as_typst_color(color)?; + header.push_str(&format!("#set text(fill: {color})\n")); + } + Ok(header) + } + + fn as_typst_color(color: &Color) -> Result { + match color.as_rgb() { + Some((r, g, b)) => Ok(format!("rgb(\"#{r:02x}{g:02x}{b:02x}\")")), + None => Err(TypstRenderError::UnsupportedColor(color.to_string())), + } + } +} + +impl Default for TypstRender { + fn default() -> Self { + Self::new(DEFAULT_PPI) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum TypstRenderError { + #[error("io: {0}")] + Io(#[from] io::Error), + + #[error("invalid output image: {0}")] + InvalidImage(#[from] InvalidImage), + + #[error("running command '{0}': {1}")] + CommandRun(&'static str, String), + + #[error("unsupported color '{0}', only RGB is supported")] + UnsupportedColor(String), +}