use super::{code::CodeLine, execution::SnippetExecutionDisabledOperation, modals::KeyBindingsModalBuilder}; use crate::{ custom::{KeyBindingsConfig, OptionsConfig}, execute::SnippetExecutor, markdown::{ elements::{ Highlight, HighlightGroup, ListItem, ListItemType, MarkdownElement, ParagraphElement, Percent, PercentParseError, Snippet, SnippetLanguage, SourcePosition, Table, TableRow, Text, TextBlock, }, text::WeightedTextBlock, }, media::{image::Image, printer::RegisterImageError, register::ImageRegistry}, presentation::{ BlockLine, BlockLineText, ChunkMutator, ImageProperties, ImageSize, MarginProperties, Modals, Presentation, PresentationMetadata, PresentationState, PresentationThemeMetadata, RenderOperation, Slide, SlideBuilder, SlideChunk, }, processing::{ code::{CodePreparer, HighlightContext, HighlightMutator, HighlightedLine}, execution::RunSnippetOperation, footer::{FooterContext, FooterGenerator}, modals::IndexBuilder, separator::RenderSeparator, }, render::highlighting::{CodeHighlighter, HighlightThemeSet}, resource::{LoadImageError, Resources}, style::{Color, Colors, TextStyle}, theme::{ Alignment, AuthorPositioning, ElementType, LoadThemeError, Margin, PresentationTheme, PresentationThemeSet, }, third_party::{ThirdPartyRender, ThirdPartyRenderError, ThirdPartyRenderRequest}, }; use image::DynamicImage; use serde::Deserialize; use std::{borrow::Cow, cell::RefCell, fmt::Display, iter, mem, path::PathBuf, rc::Rc, str::FromStr}; use unicode_width::UnicodeWidthStr; // TODO: move to a theme config. static DEFAULT_BOTTOM_SLIDE_MARGIN: u16 = 3; pub(crate) static DEFAULT_IMAGE_Z_INDEX: i32 = -2; #[derive(Default)] pub struct Themes { pub presentation: PresentationThemeSet, pub highlight: HighlightThemeSet, } #[derive(Clone, Debug)] pub struct PresentationBuilderOptions { pub allow_mutations: bool, pub implicit_slide_ends: bool, pub command_prefix: String, pub image_attribute_prefix: String, pub incremental_lists: bool, pub force_default_theme: bool, pub end_slide_shorthand: bool, pub print_modal_background: bool, pub strict_front_matter_parsing: bool, pub enable_snippet_execution: bool, } impl PresentationBuilderOptions { fn merge(&mut self, options: OptionsConfig) { self.implicit_slide_ends = options.implicit_slide_ends.unwrap_or(self.implicit_slide_ends); self.incremental_lists = options.incremental_lists.unwrap_or(self.incremental_lists); self.end_slide_shorthand = options.end_slide_shorthand.unwrap_or(self.end_slide_shorthand); self.strict_front_matter_parsing = options.strict_front_matter_parsing.unwrap_or(self.strict_front_matter_parsing); if let Some(prefix) = options.command_prefix { self.command_prefix = prefix; } if let Some(prefix) = options.image_attributes_prefix { self.image_attribute_prefix = prefix; } } } impl Default for PresentationBuilderOptions { fn default() -> Self { Self { allow_mutations: true, implicit_slide_ends: false, command_prefix: String::default(), image_attribute_prefix: "image:".to_string(), incremental_lists: false, force_default_theme: false, end_slide_shorthand: false, print_modal_background: false, strict_front_matter_parsing: true, enable_snippet_execution: false, } } } /// Builds a presentation. /// /// This type transforms [MarkdownElement]s and turns them into a presentation, which is made up of /// render operations. pub(crate) struct PresentationBuilder<'a> { slide_chunks: Vec, chunk_operations: Vec, chunk_mutators: Vec>, slides: Vec, highlighter: CodeHighlighter, code_executor: Rc, theme: Cow<'a, PresentationTheme>, resources: &'a mut Resources, third_party: &'a mut ThirdPartyRender, slide_state: SlideState, presentation_state: PresentationState, footer_context: Rc>, themes: &'a Themes, index_builder: IndexBuilder, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, options: PresentationBuilderOptions, } impl<'a> PresentationBuilder<'a> { /// Construct a new builder. #[allow(clippy::too_many_arguments)] pub(crate) fn new( default_theme: &'a PresentationTheme, resources: &'a mut Resources, third_party: &'a mut ThirdPartyRender, code_executor: Rc, themes: &'a Themes, image_registry: ImageRegistry, bindings_config: KeyBindingsConfig, options: PresentationBuilderOptions, ) -> Self { Self { slide_chunks: Vec::new(), chunk_operations: Vec::new(), chunk_mutators: Vec::new(), slides: Vec::new(), highlighter: CodeHighlighter::default(), code_executor, theme: Cow::Borrowed(default_theme), resources, third_party, slide_state: Default::default(), presentation_state: Default::default(), footer_context: Default::default(), themes, index_builder: Default::default(), image_registry, bindings_config, options, } } /// Build a presentation. pub(crate) fn build(mut self, elements: Vec) -> Result { let mut skip_first = false; if let Some(MarkdownElement::FrontMatter(contents)) = elements.first() { self.process_front_matter(contents)?; skip_first = true; } let mut elements = elements.into_iter(); if skip_first { elements.next(); } self.set_code_theme()?; if self.chunk_operations.is_empty() { self.push_slide_prelude(); } for element in elements { self.slide_state.ignore_element_line_break = false; self.process_element(element)?; self.validate_last_operation()?; if !self.slide_state.ignore_element_line_break { self.push_line_break(); } } if !self.chunk_operations.is_empty() || !self.slide_chunks.is_empty() { self.terminate_slide(); } self.footer_context.borrow_mut().total_slides = self.slides.len(); let mut bindings_modal_builder = KeyBindingsModalBuilder::default(); if self.options.print_modal_background { let background = self.build_modal_background()?; self.index_builder.set_background(background.clone()); bindings_modal_builder.set_background(background); }; let slide_index = self.index_builder.build(&self.theme, self.presentation_state.clone()); let bindings = bindings_modal_builder.build(&self.theme, &self.bindings_config); let modals = Modals { slide_index, bindings }; let presentation = Presentation::new(self.slides, modals, self.presentation_state); Ok(presentation) } fn build_modal_background(&self) -> Result { let color = self .theme .modals .colors .background .as_ref() .or(self.theme.default_style.colors.background.as_ref()) .and_then(Color::as_rgb); // If we don't have an rgb color (or we don't have a color at all), we default to a dark // background. let rgba = match color { Some((r, g, b)) => [r, g, b, 255], None => [0, 0, 0, 255], }; let mut image = DynamicImage::new_rgba8(1, 1); image.as_mut_rgba8().unwrap().get_pixel_mut(0, 0).0 = rgba; let image = self.image_registry.register_image(image)?; Ok(image) } fn validate_last_operation(&mut self) -> Result<(), BuildError> { if !self.slide_state.needs_enter_column { return Ok(()); } let Some(last) = self.chunk_operations.last() else { return Ok(()); }; if matches!(last, RenderOperation::InitColumnLayout { .. }) { return Ok(()); } self.slide_state.needs_enter_column = false; let last_valid = matches!(last, RenderOperation::EnterColumn { .. } | RenderOperation::ExitLayout); if last_valid { Ok(()) } else { Err(BuildError::NotInsideColumn) } } fn push_slide_prelude(&mut self) { let colors = self.theme.default_style.colors.clone(); self.chunk_operations.extend([ RenderOperation::SetColors(colors), RenderOperation::ClearScreen, RenderOperation::ApplyMargin(MarginProperties { horizontal_margin: self.theme.default_style.margin.clone().unwrap_or_default(), bottom_slide_margin: DEFAULT_BOTTOM_SLIDE_MARGIN, }), ]); self.push_line_break(); } fn process_element(&mut self, element: MarkdownElement) -> Result<(), BuildError> { let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. }); match element { // This one is processed before everything else as it affects how the rest of the // elements is rendered. MarkdownElement::FrontMatter(_) => self.slide_state.ignore_element_line_break = true, MarkdownElement::SetexHeading { text } => self.push_slide_title(text), MarkdownElement::Heading { level, text } => self.push_heading(level, text), MarkdownElement::Paragraph(elements) => self.push_paragraph(elements)?, MarkdownElement::List(elements) => self.push_list(elements), MarkdownElement::Snippet(code) => self.push_code(code)?, MarkdownElement::Table(table) => self.push_table(table), MarkdownElement::ThematicBreak => self.process_thematic_break(), MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, MarkdownElement::BlockQuote(lines) => self.push_block_quote(lines), MarkdownElement::Image { path, title, source_position } => { self.push_image_from_path(path, title, source_position)? } }; if should_clear_last { self.slide_state.last_element = LastElement::Other; } Ok(()) } fn process_front_matter(&mut self, contents: &str) -> Result<(), BuildError> { let metadata = match self.options.strict_front_matter_parsing { true => serde_yaml::from_str::(contents).map(PresentationMetadata::from), false => serde_yaml::from_str::(contents), }; let mut metadata = metadata.map_err(|e| BuildError::InvalidMetadata(e.to_string()))?; if metadata.author.is_some() && !metadata.authors.is_empty() { return Err(BuildError::InvalidMetadata("cannot have both 'author' and 'authors'".into())); } if let Some(options) = metadata.options.take() { self.options.merge(options); } self.footer_context.borrow_mut().author = metadata.author.clone().unwrap_or_default(); self.set_theme(&metadata.theme)?; if metadata.title.is_some() || metadata.sub_title.is_some() || metadata.author.is_some() || !metadata.authors.is_empty() { self.push_slide_prelude(); self.push_intro_slide(metadata); } Ok(()) } fn set_theme(&mut self, metadata: &PresentationThemeMetadata) -> Result<(), BuildError> { if metadata.name.is_some() && metadata.path.is_some() { return Err(BuildError::InvalidMetadata("cannot have both theme path and theme name".into())); } // Only override the theme if we're not forced to use the defaul theme if we're not forced // to use the default one. if !self.options.force_default_theme { if let Some(theme_name) = &metadata.name { let theme = self .themes .presentation .load_by_name(theme_name) .ok_or_else(|| BuildError::InvalidMetadata(format!("theme '{theme_name}' does not exist")))?; self.theme = Cow::Owned(theme); } if let Some(theme_path) = &metadata.path { let theme = self.resources.theme(theme_path)?; self.theme = Cow::Owned(theme); } } if let Some(overrides) = &metadata.overrides { if overrides.extends.is_some() { return Err(BuildError::InvalidMetadata("theme overrides can't use 'extends'".into())); } // This shouldn't fail as the models are already correct. let theme = merge_struct::merge(self.theme.as_ref(), overrides) .map_err(|e| BuildError::InvalidMetadata(format!("invalid theme: {e}")))?; self.theme = Cow::Owned(theme); } Ok(()) } fn set_code_theme(&mut self) -> Result<(), BuildError> { if let Some(theme) = &self.theme.code.theme_name { let highlighter = self.themes.highlight.load_by_name(theme).ok_or_else(|| BuildError::InvalidCodeTheme(theme.clone()))?; self.highlighter = highlighter; } Ok(()) } fn push_intro_slide(&mut self, metadata: PresentationMetadata) { let styles = &self.theme.intro_slide; let title = Text::new( metadata.title.unwrap_or_default().clone(), TextStyle::default().bold().colors(styles.title.colors.clone()), ); let sub_title = metadata .sub_title .as_ref() .map(|text| Text::new(text.clone(), TextStyle::default().colors(styles.subtitle.colors.clone()))); let authors: Vec<_> = metadata .author .into_iter() .chain(metadata.authors) .map(|author| Text::new(author, TextStyle::default().colors(styles.author.colors.clone()))) .collect(); if styles.footer == Some(false) { self.slide_state.ignore_footer = true; } self.chunk_operations.push(RenderOperation::JumpToVerticalCenter); self.push_text(TextBlock::from(title), ElementType::PresentationTitle); self.push_line_break(); if let Some(text) = sub_title { self.push_text(TextBlock::from(text), ElementType::PresentationSubTitle); self.push_line_break(); } if !authors.is_empty() { match self.theme.intro_slide.author.positioning { AuthorPositioning::BelowTitle => { self.push_line_break(); self.push_line_break(); self.push_line_break(); } AuthorPositioning::PageBottom => { self.chunk_operations.push(RenderOperation::JumpToBottomRow { index: authors.len() as u16 - 1 }); } }; for author in authors { self.push_text(TextBlock::from(author), ElementType::PresentationAuthor); self.push_line_break(); } } self.slide_state.title = Some(TextBlock::from("[Introduction]")); self.terminate_slide(); } fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> Result<(), BuildError> { let comment = comment.trim(); if self.should_ignore_comment(comment) { return Ok(()); } let comment = comment.trim_start_matches(&self.options.command_prefix); let comment = match comment.parse::() { Ok(comment) => comment, Err(error) => return Err(BuildError::CommandParse { line: source_position.start.line + 1, error }), }; match comment { CommentCommand::Pause => self.process_pause(), CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::NewLine => self.push_line_break(), CommentCommand::NewLines(count) => { for _ in 0..count { self.push_line_break(); } } CommentCommand::JumpToMiddle => self.chunk_operations.push(RenderOperation::JumpToVerticalCenter), CommentCommand::InitColumnLayout(columns) => { Self::validate_column_layout(&columns)?; self.slide_state.layout = LayoutState::InLayout { columns_count: columns.len() }; self.chunk_operations.push(RenderOperation::InitColumnLayout { columns }); self.slide_state.needs_enter_column = true; } CommentCommand::ResetLayout => { self.slide_state.layout = LayoutState::Default; self.chunk_operations.extend([RenderOperation::ExitLayout, RenderOperation::RenderLineBreak]); } CommentCommand::Column(column) => { let (current_column, columns_count) = match self.slide_state.layout { LayoutState::InColumn { column, columns_count } => (Some(column), columns_count), LayoutState::InLayout { columns_count } => (None, columns_count), LayoutState::Default => return Err(BuildError::NoLayout), }; if current_column == Some(column) { return Err(BuildError::AlreadyInColumn); } else if column >= columns_count { return Err(BuildError::ColumnIndexTooLarge); } self.slide_state.layout = LayoutState::InColumn { column, columns_count }; self.chunk_operations.push(RenderOperation::EnterColumn { column }); } CommentCommand::IncrementalLists(value) => { self.slide_state.incremental_lists = Some(value); } CommentCommand::NoFooter => { self.slide_state.ignore_footer = true; } }; // Don't push line breaks for any comments. self.slide_state.ignore_element_line_break = true; Ok(()) } fn should_ignore_comment(&self, comment: &str) -> bool { if comment.contains('\n') || !comment.starts_with(&self.options.command_prefix) { // Ignore any multi line comment; those are assumed to be user comments // Ignore any line that doesn't start with the selected prefix. true } else { // Ignore vim-like code folding tags let comment = comment.trim(); comment == "{{{" || comment == "}}}" } } fn validate_column_layout(columns: &[u8]) -> Result<(), BuildError> { if columns.is_empty() { Err(BuildError::InvalidLayout("need at least one column")) } else if columns.iter().any(|column| column == &0) { Err(BuildError::InvalidLayout("can't have zero sized columns")) } else { Ok(()) } } fn process_pause(&mut self) { self.slide_state.last_chunk_ended_in_list = matches!(self.slide_state.last_element, LastElement::List { .. }); let chunk_operations = mem::take(&mut self.chunk_operations); let mutators = mem::take(&mut self.chunk_mutators); self.slide_chunks.push(SlideChunk::new(chunk_operations, mutators)); } fn push_slide_title(&mut self, mut text: TextBlock) { if self.options.implicit_slide_ends && !matches!(self.slide_state.last_element, LastElement::None) { self.terminate_slide(); } if self.slide_state.title.is_none() { self.slide_state.title = Some(text.clone()); } let style = self.theme.slide_title.clone(); let mut text_style = TextStyle::default().colors(style.colors.clone()); if style.bold.unwrap_or_default() { text_style = text_style.bold(); } if style.italics.unwrap_or_default() { text_style = text_style.italics(); } if style.underlined.unwrap_or_default() { text_style = text_style.underlined(); } text.apply_style(&text_style); for _ in 0..style.padding_top.unwrap_or(0) { self.push_line_break(); } self.push_text(text, ElementType::SlideTitle); self.push_line_break(); for _ in 0..style.padding_bottom.unwrap_or(0) { self.push_line_break(); } if style.separator { self.chunk_operations.push(RenderSeparator::default().into()); } self.push_line_break(); self.slide_state.ignore_element_line_break = true; } fn push_heading(&mut self, level: u8, mut text: TextBlock) { let (element_type, style) = match level { 1 => (ElementType::Heading1, &self.theme.headings.h1), 2 => (ElementType::Heading2, &self.theme.headings.h2), 3 => (ElementType::Heading3, &self.theme.headings.h3), 4 => (ElementType::Heading4, &self.theme.headings.h4), 5 => (ElementType::Heading5, &self.theme.headings.h5), 6 => (ElementType::Heading6, &self.theme.headings.h6), other => panic!("unexpected heading level {other}"), }; if let Some(prefix) = &style.prefix { let mut prefix = prefix.clone(); prefix.push(' '); text.0.insert(0, Text::from(prefix)); } let text_style = TextStyle::default().bold().colors(style.colors.clone()); text.apply_style(&text_style); self.push_text(text, element_type); self.push_line_break(); } fn push_paragraph(&mut self, elements: Vec) -> Result<(), BuildError> { for element in elements { match element { ParagraphElement::Text(text) => { self.push_text(text, ElementType::Paragraph); self.push_line_break(); } ParagraphElement::LineBreak => { // Line breaks are already pushed after every text chunk. } }; } Ok(()) } fn process_thematic_break(&mut self) { if self.options.end_slide_shorthand { self.terminate_slide(); self.slide_state.ignore_element_line_break = true; } else { self.chunk_operations.extend([RenderSeparator::default().into(), RenderOperation::RenderLineBreak]); } } fn push_image_from_path( &mut self, path: PathBuf, title: String, source_position: SourcePosition, ) -> Result<(), BuildError> { let image = self.resources.image(&path).map_err(|e| BuildError::LoadImage(path, e))?; self.push_image(image, title, source_position) } fn push_image(&mut self, image: Image, title: String, source_position: SourcePosition) -> Result<(), BuildError> { let attributes = Self::parse_image_attributes(&title, &self.options.image_attribute_prefix, source_position)?; let size = match attributes.width { Some(percent) => ImageSize::WidthScaled { ratio: percent.as_ratio() }, None => ImageSize::ShrinkIfNeeded, }; let properties = ImageProperties { z_index: DEFAULT_IMAGE_Z_INDEX, size, restore_cursor: false, background_color: self.theme.default_style.colors.background, }; self.chunk_operations.extend([ RenderOperation::RenderImage(image, properties), RenderOperation::SetColors(self.theme.default_style.colors.clone()), ]); Ok(()) } fn push_list(&mut self, list: Vec) { let last_chunk_operation = self.slide_chunks.last().and_then(|chunk| chunk.iter_operations().last()); // If the last chunk ended in a list, pop the newline so we get them all next to each // other. if matches!(last_chunk_operation, Some(RenderOperation::RenderLineBreak)) && self.slide_state.last_chunk_ended_in_list && self.chunk_operations.is_empty() { self.slide_chunks.last_mut().unwrap().pop_last(); } // If this chunk just starts (because there was a pause), pick up from the last index. let start_index = match self.slide_state.last_element { LastElement::List { last_index } if self.chunk_operations.is_empty() => last_index + 1, _ => 0, }; let incremental_lists = self.slide_state.incremental_lists.unwrap_or(self.options.incremental_lists); let iter = ListIterator::new(list, start_index); for (index, item) in iter.enumerate() { if index > 0 && incremental_lists { self.process_pause(); } self.push_list_item(item.index, item.item); } } fn push_list_item(&mut self, index: usize, item: ListItem) { let padding_length = (item.depth as usize + 1) * 3; let mut prefix: String = " ".repeat(padding_length); match item.item_type { ListItemType::Unordered => { let delimiter = match item.depth { 0 => '•', 1 => '◦', _ => '▪', }; prefix.push(delimiter); } ListItemType::OrderedParens => { prefix.push_str(&(index + 1).to_string()); prefix.push_str(") "); } ListItemType::OrderedPeriod => { prefix.push_str(&(index + 1).to_string()); prefix.push_str(". "); } }; let prefix_length = prefix.len() as u16; self.push_text(prefix.into(), ElementType::List); let text = item.contents; self.push_aligned_text(text, Alignment::Left { margin: Margin::Fixed(prefix_length) }); self.push_line_break(); if item.depth == 0 { self.slide_state.last_element = LastElement::List { last_index: index }; } } fn push_block_quote(&mut self, lines: Vec) { let prefix = self.theme.block_quote.prefix.clone().unwrap_or_default(); let block_length = lines.iter().map(|line| line.width() + prefix.width()).max().unwrap_or(0) as u16; let prefix_color = self.theme.block_quote.colors.prefix.or(self.theme.block_quote.colors.base.foreground); let prefix = Text::new( prefix, TextStyle::default() .colors(Colors { foreground: prefix_color, background: self.theme.block_quote.colors.base.background }), ); let alignment = self.theme.alignment(&ElementType::BlockQuote).clone(); let style = TextStyle::default().colors(self.theme.block_quote.colors.base.clone()); for line in lines { let line = TextBlock(vec![prefix.clone(), Text::new(line, style.clone())]); self.chunk_operations.extend([ // Print a preformatted empty block so we fill in the line with properly colored // spaces. RenderOperation::SetColors(self.theme.block_quote.colors.base.clone()), RenderOperation::RenderBlockLine(BlockLine { text: BlockLineText::Preformatted("".into()), unformatted_length: 0, block_length, alignment: alignment.clone(), }), // Now render our prefix + entire line RenderOperation::RenderText { line: line.into(), alignment: alignment.clone() }, ]); self.push_line_break(); } self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.colors.clone())); } fn push_text(&mut self, text: TextBlock, element_type: ElementType) { let alignment = self.theme.alignment(&element_type); self.push_aligned_text(text, alignment); } fn push_aligned_text(&mut self, mut block: TextBlock, alignment: Alignment) { for chunk in &mut block.0 { if chunk.style.is_code() { chunk.style.colors = self.theme.inline_code.colors.clone(); } } if !block.0.is_empty() { self.chunk_operations.push(RenderOperation::RenderText { line: WeightedTextBlock::from(block), alignment: alignment.clone(), }); } } fn push_line_break(&mut self) { self.chunk_operations.push(RenderOperation::RenderLineBreak); } fn push_code(&mut self, code: Snippet) -> Result<(), BuildError> { if code.attributes.auto_render { return self.push_rendered_code(code); } let lines = CodePreparer::new(&self.theme).prepare(&code); let block_length = lines.iter().map(|line| line.width()).max().unwrap_or(0); let (lines, context) = self.highlight_lines(&code, lines, block_length); for line in lines { self.chunk_operations.push(RenderOperation::RenderDynamic(Rc::new(line))); } self.chunk_operations.push(RenderOperation::SetColors(self.theme.default_style.colors.clone())); if self.options.allow_mutations && context.borrow().groups.len() > 1 { self.chunk_mutators.push(Box::new(HighlightMutator::new(context))); } if code.attributes.execute { if self.options.enable_snippet_execution { self.push_code_execution(code, block_length)?; } else { let operation = SnippetExecutionDisabledOperation::new( self.theme.execution_output.status.failure.clone(), self.theme.code.alignment.clone().unwrap_or_default(), ); self.chunk_operations.push(RenderOperation::RenderAsync(Rc::new(operation))) } } Ok(()) } fn push_rendered_code(&mut self, code: Snippet) -> Result<(), BuildError> { let Snippet { contents, language, attributes } = code; let error_holder = self.presentation_state.async_error_holder(); let request = match language { SnippetLanguage::Typst => ThirdPartyRenderRequest::Typst(contents, self.theme.typst.clone()), SnippetLanguage::Latex => ThirdPartyRenderRequest::Latex(contents, self.theme.typst.clone()), SnippetLanguage::Mermaid => ThirdPartyRenderRequest::Mermaid(contents, self.theme.mermaid.clone()), _ => panic!("language {language:?} should not be renderable"), }; let operation = self.third_party.render(request, &self.theme, error_holder, self.slides.len() + 1, attributes.width)?; self.chunk_operations.push(operation); Ok(()) } fn highlight_lines( &self, code: &Snippet, lines: Vec, block_length: usize, ) -> (Vec, Rc>) { let mut code_highlighter = self.highlighter.language_highlighter(&code.language); let dim_style = { let mut highlighter = self.highlighter.language_highlighter(&SnippetLanguage::Rust); highlighter.style_line("//").next().expect("no styles").style }; let groups = match self.options.allow_mutations { true => code.attributes.highlight_groups.clone(), false => vec![HighlightGroup::new(vec![Highlight::All])], }; let context = Rc::new(RefCell::new(HighlightContext { groups, current: 0, block_length, alignment: self.theme.alignment(&ElementType::Code), })); let mut output = Vec::new(); let block_style = &self.theme.code; for line in lines.into_iter() { let highlighted = line.highlight(&dim_style, &mut code_highlighter, block_style); let not_highlighted = line.dim(&dim_style, block_style); let width = line.width(); let line_number = line.line_number; let context = context.clone(); output.push(HighlightedLine { highlighted, not_highlighted, line_number, width, context }); } (output, context) } fn push_code_execution(&mut self, code: Snippet, block_length: usize) -> Result<(), BuildError> { if !self.code_executor.is_execution_supported(&code.language) { return Err(BuildError::UnsupportedExecution(code.language)); } let operation = RunSnippetOperation::new(code, self.code_executor.clone(), &self.theme, block_length as u16); let operation = RenderOperation::RenderAsync(Rc::new(operation)); self.chunk_operations.push(operation); Ok(()) } fn terminate_slide(&mut self) { let footer = self.generate_footer(); let operations = mem::take(&mut self.chunk_operations); let mutators = mem::take(&mut self.chunk_mutators); self.slide_chunks.push(SlideChunk::new(operations, mutators)); let chunks = mem::take(&mut self.slide_chunks); let slide = SlideBuilder::default().chunks(chunks).footer(footer).build(); self.index_builder.add_title(self.slide_state.title.take().unwrap_or_else(|| Text::from("").into())); self.slides.push(slide); self.push_slide_prelude(); self.slide_state = Default::default(); self.slide_state.last_element = LastElement::None; } fn generate_footer(&mut self) -> Vec { if self.slide_state.ignore_footer { return Vec::new(); } let generator = FooterGenerator { style: self.theme.footer.clone().unwrap_or_default(), current_slide: self.slides.len(), context: self.footer_context.clone(), }; vec![ // Exit any layout we're in so this gets rendered on a default screen size. RenderOperation::ExitLayout, // Pop the slide margin so we're at the terminal rect. RenderOperation::PopMargin, RenderOperation::RenderDynamic(Rc::new(generator)), ] } fn push_table(&mut self, table: Table) { let widths: Vec<_> = (0..table.columns()) .map(|column| table.iter_column(column).map(|text| text.width()).max().unwrap_or(0)) .collect(); let flattened_header = Self::prepare_table_row(table.header, &widths); self.push_text(flattened_header, ElementType::Table); self.push_line_break(); let mut separator = TextBlock(Vec::new()); for (index, width) in widths.iter().enumerate() { let mut contents = String::new(); let mut margin = 1; if index > 0 { contents.push('┼'); // Append an extra dash to have 1 column margin on both sides if index < widths.len() - 1 { margin += 1; } } contents.extend(iter::repeat("─").take(*width + margin)); separator.0.push(Text::from(contents)); } self.push_text(separator, ElementType::Table); self.push_line_break(); for row in table.rows { let flattened_row = Self::prepare_table_row(row, &widths); self.push_text(flattened_row, ElementType::Table); self.push_line_break(); } } fn prepare_table_row(row: TableRow, widths: &[usize]) -> TextBlock { let mut flattened_row = TextBlock(Vec::new()); for (column, text) in row.0.into_iter().enumerate() { if column > 0 { flattened_row.0.push(Text::from(" │ ")); } let text_length = text.width(); flattened_row.0.extend(text.0.into_iter()); let cell_width = widths[column]; if text_length < cell_width { let padding = " ".repeat(cell_width - text_length); flattened_row.0.push(Text::from(padding)); } } flattened_row } fn parse_image_attributes( input: &str, attribute_prefix: &str, source_position: SourcePosition, ) -> Result { let mut attributes = ImageAttributes::default(); for attribute in input.split(',') { let Some((prefix, suffix)) = attribute.split_once(attribute_prefix) else { continue }; if !prefix.is_empty() || (attribute_prefix.is_empty() && suffix.is_empty()) { continue; } Self::parse_image_attribute(suffix, &mut attributes) .map_err(|e| BuildError::ImageAttributeParse { line: source_position.start.line + 1, error: e })?; } Ok(attributes) } fn parse_image_attribute(input: &str, attributes: &mut ImageAttributes) -> Result<(), ImageAttributeError> { let Some((key, value)) = input.split_once(':') else { return Err(ImageAttributeError::AttributeMissing); }; match key { "width" | "w" => { let width = value.parse().map_err(ImageAttributeError::InvalidWidth)?; attributes.width = Some(width); Ok(()) } _ => Err(ImageAttributeError::UnknownAttribute(key.to_string())), } } } #[derive(Debug, Default)] struct SlideState { ignore_element_line_break: bool, ignore_footer: bool, needs_enter_column: bool, last_chunk_ended_in_list: bool, last_element: LastElement, incremental_lists: Option, layout: LayoutState, title: Option, } #[derive(Debug, Default)] enum LayoutState { #[default] Default, InLayout { columns_count: usize, }, InColumn { column: usize, columns_count: usize, }, } #[derive(Debug, Default)] enum LastElement { #[default] None, List { last_index: usize, }, Other, } /// An error when building a presentation. #[derive(thiserror::Error, Debug)] pub enum BuildError { #[error("failed to load image '{0}': {1}")] LoadImage(PathBuf, LoadImageError), #[error("failed to register image: {0}")] RegisterImage(#[from] RegisterImageError), #[error("invalid presentation metadata: {0}")] InvalidMetadata(String), #[error("invalid theme: {0}")] InvalidTheme(#[from] LoadThemeError), #[error("invalid code highlighter theme: '{0}'")] InvalidCodeTheme(String), #[error("invalid layout: {0}")] InvalidLayout(&'static str), #[error("can't enter layout: no layout defined")] NoLayout, #[error("can't enter layout column: already in it")] AlreadyInColumn, #[error("can't enter layout column: column index too large")] ColumnIndexTooLarge, #[error("need to enter layout column explicitly using `column` command")] NotInsideColumn, #[error("error parsing command at line {line}: {error}")] CommandParse { line: usize, error: CommandParseError }, #[error("error parsing image attribute at line {line}: {error}")] ImageAttributeParse { line: usize, error: ImageAttributeError }, #[error("third party render failed: {0}")] ThirdPartyRender(#[from] ThirdPartyRenderError), #[error("language {0:?} does not support execution")] UnsupportedExecution(SnippetLanguage), } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "snake_case")] enum CommentCommand { Pause, EndSlide, #[serde(alias = "newline")] NewLine, #[serde(alias = "newlines")] NewLines(u32), #[serde(rename = "column_layout")] InitColumnLayout(Vec), Column(usize), ResetLayout, JumpToMiddle, IncrementalLists(bool), NoFooter, } impl FromStr for CommentCommand { type Err = CommandParseError; fn from_str(s: &str) -> Result { #[derive(Deserialize)] struct CommandWrapper(#[serde(with = "serde_yaml::with::singleton_map")] CommentCommand); let wrapper = serde_yaml::from_str::(s)?; Ok(wrapper.0) } } #[derive(thiserror::Error, Debug)] pub struct CommandParseError(#[from] serde_yaml::Error); impl Display for CommandParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let inner = self.0.to_string(); // Remove the trailing "at line X, ..." that comes from serde_yaml. This otherwise claims // we're always in line 1 because the yaml is parsed in isolation out of the HTML comment. let inner = inner.split(" at line").next().unwrap(); write!(f, "{inner}") } } struct ListIterator { remaining: I, next_index: usize, current_depth: u8, saved_indexes: Vec, } impl ListIterator { fn new(remaining: T, next_index: usize) -> Self where I: Iterator, T: IntoIterator, { Self { remaining: remaining.into_iter(), next_index, current_depth: 0, saved_indexes: Vec::new() } } } impl Iterator for ListIterator where I: Iterator, { type Item = IndexedListItem; fn next(&mut self) -> Option { let head = self.remaining.next()?; if head.depth != self.current_depth { if head.depth > self.current_depth { // If we're going deeper, save the next index so we can continue later on and start // from 0. self.saved_indexes.push(self.next_index); self.next_index = 0; } else { // if we're getting out, recover the index we had previously saved. for _ in head.depth..self.current_depth { self.next_index = self.saved_indexes.pop().unwrap_or(0); } } self.current_depth = head.depth; } let index = self.next_index; self.next_index += 1; Some(IndexedListItem { index, item: head }) } } #[derive(Debug)] struct IndexedListItem { index: usize, item: ListItem, } #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] struct StrictPresentationMetadata { #[serde(default)] title: Option, #[serde(default)] sub_title: Option, #[serde(default)] author: Option, #[serde(default)] authors: Vec, #[serde(default)] theme: PresentationThemeMetadata, #[serde(default)] options: Option, } impl From for PresentationMetadata { fn from(strict: StrictPresentationMetadata) -> Self { let StrictPresentationMetadata { title, sub_title, author, authors, theme, options } = strict; Self { title, sub_title, author, authors, theme, options } } } #[derive(thiserror::Error, Debug)] pub enum ImageAttributeError { #[error("invalid width: {0}")] InvalidWidth(PercentParseError), #[error("no attribute given")] AttributeMissing, #[error("unknown attribute: '{0}'")] UnknownAttribute(String), } #[derive(Clone, Debug, Default, PartialEq)] struct ImageAttributes { width: Option, } #[cfg(test)] mod test { use super::*; use crate::markdown::elements::SnippetAttributes; use rstest::rstest; fn build_presentation(elements: Vec) -> Presentation { try_build_presentation(elements).expect("build failed") } fn build_presentation_with_options( elements: Vec, options: PresentationBuilderOptions, ) -> Presentation { try_build_presentation_with_options(elements, options).expect("build failed") } fn try_build_presentation(elements: Vec) -> Result { try_build_presentation_with_options(elements, Default::default()) } fn try_build_presentation_with_options( elements: Vec, options: PresentationBuilderOptions, ) -> Result { let theme = PresentationTheme::default(); let mut resources = Resources::new("/tmp", Default::default()); let mut third_party = ThirdPartyRender::default(); let code_executor = Rc::new(SnippetExecutor::default()); let themes = Themes::default(); let bindings = KeyBindingsConfig::default(); let builder = PresentationBuilder::new( &theme, &mut resources, &mut third_party, code_executor, &themes, Default::default(), bindings, options, ); builder.build(elements) } fn build_pause() -> MarkdownElement { MarkdownElement::Comment { comment: "pause".into(), source_position: Default::default() } } fn build_end_slide() -> MarkdownElement { MarkdownElement::Comment { comment: "end_slide".into(), source_position: Default::default() } } fn build_column_layout(width: u8) -> MarkdownElement { MarkdownElement::Comment { comment: format!("column_layout: [{width}]"), source_position: Default::default() } } fn build_column(column: u8) -> MarkdownElement { MarkdownElement::Comment { comment: format!("column: {column}"), source_position: Default::default() } } fn is_visible(operation: &RenderOperation) -> bool { use RenderOperation::*; match operation { ClearScreen | SetColors(_) | JumpToVerticalCenter | JumpToRow { .. } | JumpToBottomRow { .. } | InitColumnLayout { .. } | EnterColumn { .. } | ExitLayout { .. } | ApplyMargin(_) | PopMargin => false, RenderText { .. } | RenderLineBreak | RenderImage(_, _) | RenderBlockLine(_) | RenderDynamic(_) | RenderAsync(_) => true, } } fn extract_text_lines(operations: &[RenderOperation]) -> Vec { let mut output = Vec::new(); let mut current_line = String::new(); for operation in operations { match operation { RenderOperation::RenderText { line, .. } => { let texts: Vec<_> = line.iter_texts().map(|text| text.text().content.clone()).collect(); current_line.push_str(&texts.join("")); } RenderOperation::RenderLineBreak if !current_line.is_empty() => { output.push(mem::take(&mut current_line)); } _ => (), }; } if !current_line.is_empty() { output.push(current_line); } output } fn extract_slide_text_lines(slide: Slide) -> Vec { let operations: Vec<_> = slide.into_operations().into_iter().filter(is_visible).collect(); extract_text_lines(&operations) } #[test] fn prelude_appears_once() { let elements = vec![ MarkdownElement::FrontMatter("author: bob".to_string()), MarkdownElement::Heading { text: TextBlock::from("hello"), level: 1 }, build_end_slide(), MarkdownElement::Heading { text: TextBlock::from("bye"), level: 1 }, ]; let presentation = build_presentation(elements); for (index, slide) in presentation.iter_slides().enumerate() { let clear_screen_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::ClearScreen)).count(); let set_colors_count = slide.iter_visible_operations().filter(|op| matches!(op, RenderOperation::SetColors(_))).count(); assert_eq!(clear_screen_count, 1, "{clear_screen_count} clear screens in slide {index}"); assert_eq!(set_colors_count, 1, "{set_colors_count} clear screens in slide {index}"); } } #[test] fn slides_start_with_one_newline() { let elements = vec![ MarkdownElement::FrontMatter("author: bob".to_string()), MarkdownElement::Heading { text: TextBlock::from("hello"), level: 1 }, build_end_slide(), MarkdownElement::Heading { text: TextBlock::from("bye"), level: 1 }, ]; let presentation = build_presentation(elements); assert_eq!(presentation.iter_slides().count(), 3); // Don't process the intro slide as it's special let slides = presentation.into_slides().into_iter().skip(1); for slide in slides { let mut ops = slide.into_operations().into_iter().filter(is_visible); // We should start with a newline assert!(matches!(ops.next(), Some(RenderOperation::RenderLineBreak))); // And the second one should _not_ be a newline assert!(!matches!(ops.next(), Some(RenderOperation::RenderLineBreak))); } } #[test] fn table() { let elements = vec![MarkdownElement::Table(Table { header: TableRow(vec![TextBlock::from("key"), TextBlock::from("value"), TextBlock::from("other")]), rows: vec![TableRow(vec![TextBlock::from("potato"), TextBlock::from("bar"), TextBlock::from("yes")])], })]; let slides = build_presentation(elements).into_slides(); let lines = extract_slide_text_lines(slides.into_iter().next().unwrap()); let expected_lines = &["key │ value │ other", "───────┼───────┼──────", "potato │ bar │ yes "]; assert_eq!(lines, expected_lines); } #[test] fn layout_without_init() { let elements = vec![build_column(0)]; let result = try_build_presentation(elements); assert!(result.is_err()); } #[test] fn already_in_column() { let elements = vec![ MarkdownElement::Comment { comment: "column_layout: [1]".into(), source_position: Default::default() }, MarkdownElement::Comment { comment: "column: 0".into(), source_position: Default::default() }, MarkdownElement::Comment { comment: "column: 0".into(), source_position: Default::default() }, ]; let result = try_build_presentation(elements); assert!(result.is_err()); } #[test] fn column_index_overflow() { let elements = vec![ MarkdownElement::Comment { comment: "column_layout: [1]".into(), source_position: Default::default() }, MarkdownElement::Comment { comment: "column: 1".into(), source_position: Default::default() }, ]; let result = try_build_presentation(elements); assert!(result.is_err()); } #[rstest] #[case::empty("column_layout: []")] #[case::zero("column_layout: [0]")] #[case::one_is_zero("column_layout: [1, 0]")] fn invalid_layouts(#[case] definition: &str) { let elements = vec![MarkdownElement::Comment { comment: definition.into(), source_position: Default::default() }]; let result = try_build_presentation(elements); assert!(result.is_err()); } #[test] fn operation_without_enter_column() { let elements = vec![ MarkdownElement::Comment { comment: "column_layout: [1]".into(), source_position: Default::default() }, MarkdownElement::ThematicBreak, ]; let result = try_build_presentation(elements); assert!(result.is_err()); } #[rstest] #[case::pause("pause", CommentCommand::Pause)] #[case::pause(" pause ", CommentCommand::Pause)] #[case::end_slide("end_slide", CommentCommand::EndSlide)] #[case::column_layout("column_layout: [1, 2]", CommentCommand::InitColumnLayout(vec![1, 2]))] #[case::column("column: 1", CommentCommand::Column(1))] #[case::reset_layout("reset_layout", CommentCommand::ResetLayout)] #[case::incremental_lists("incremental_lists: true", CommentCommand::IncrementalLists(true))] #[case::incremental_lists("new_lines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("newlines: 2", CommentCommand::NewLines(2))] #[case::incremental_lists("new_line", CommentCommand::NewLine)] #[case::incremental_lists("newline", CommentCommand::NewLine)] fn command_formatting(#[case] input: &str, #[case] expected: CommentCommand) { let parsed: CommentCommand = input.parse().expect("deserialization failed"); assert_eq!(parsed, expected); } #[test] fn end_slide_inside_layout() { let elements = vec![build_column_layout(1), build_end_slide()]; let presentation = build_presentation(elements); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn end_slide_inside_column() { let elements = vec![build_column_layout(1), build_column(0), build_end_slide()]; let presentation = build_presentation(elements); assert_eq!(presentation.iter_slides().count(), 2); } #[test] fn pause_inside_layout() { let elements = vec![build_column_layout(1), build_pause(), build_column(0)]; let presentation = build_presentation(elements); assert_eq!(presentation.iter_slides().count(), 1); } #[test] fn iterate_list() { let iter = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "00".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "01".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "02".into(), item_type: ListItemType::Unordered }, ListItem { depth: 2, contents: "001".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "2".into(), item_type: ListItemType::Unordered }, ], 0, ); let expected_indexes = [0, 1, 0, 1, 2, 0, 2]; let indexes: Vec<_> = iter.map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn iterate_list_starting_from_other() { let list = ListIterator::new( vec![ ListItem { depth: 0, contents: "0".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "1".into(), item_type: ListItemType::Unordered }, ], 3, ); let expected_indexes = [3, 4]; let indexes: Vec<_> = list.into_iter().map(|item| item.index).collect(); assert_eq!(indexes, expected_indexes); } #[test] fn ordered_list_with_pauses() { let elements = vec![ MarkdownElement::List(vec![ ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::OrderedPeriod }, ListItem { depth: 1, contents: "one_one".into(), item_type: ListItemType::OrderedPeriod }, ListItem { depth: 1, contents: "one_two".into(), item_type: ListItemType::OrderedPeriod }, ]), build_pause(), MarkdownElement::List(vec![ListItem { depth: 0, contents: "two".into(), item_type: ListItemType::OrderedPeriod, }]), ]; let slides = build_presentation(elements).into_slides(); let lines = extract_slide_text_lines(slides.into_iter().next().unwrap()); let expected_lines = &[" 1. one", " 1. one_one", " 2. one_two", " 2. two"]; assert_eq!(lines, expected_lines); } #[test] fn automatic_pauses() { let elements = vec![ MarkdownElement::Comment { comment: "incremental_lists: true".into(), source_position: Default::default() }, MarkdownElement::List(vec![ ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered }, ListItem { depth: 0, contents: "three".into(), item_type: ListItemType::Unordered }, ]), ]; let slides = build_presentation(elements).into_slides(); assert_eq!(slides[0].iter_chunks().count(), 3); } #[test] fn pause_after_list() { let elements = vec![ MarkdownElement::List(vec![ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::OrderedPeriod, }]), build_pause(), MarkdownElement::Heading { level: 1, text: "hi".into() }, MarkdownElement::List(vec![ListItem { depth: 0, contents: "two".into(), item_type: ListItemType::OrderedPeriod, }]), ]; let slides = build_presentation(elements).into_slides(); let first_chunk = &slides[0]; let operations = first_chunk.iter_visible_operations().collect::>(); // This is pretty easy to break, refactor soon let last_operation = &operations[operations.len() - 4]; assert!(matches!(last_operation, RenderOperation::RenderLineBreak), "last operation is {last_operation:?}"); } #[test] fn implicit_slide_ends() { let elements = vec![ // first slide MarkdownElement::SetexHeading { text: "hi".into() }, // second MarkdownElement::SetexHeading { text: "hi".into() }, MarkdownElement::Heading { level: 1, text: "hi".into() }, // explicitly ends MarkdownElement::Comment { comment: "end_slide".into(), source_position: Default::default() }, // third starts MarkdownElement::SetexHeading { text: "hi".into() }, ]; let options = PresentationBuilderOptions { implicit_slide_ends: true, ..Default::default() }; let slides = build_presentation_with_options(elements, options).into_slides(); assert_eq!(slides.len(), 3); } #[test] fn implicit_slide_ends_with_front_matter() { let elements = vec![ MarkdownElement::FrontMatter("theme:\n name: light".into()), MarkdownElement::SetexHeading { text: "hi".into() }, ]; let options = PresentationBuilderOptions { implicit_slide_ends: true, ..Default::default() }; let slides = build_presentation_with_options(elements, options).into_slides(); assert_eq!(slides.len(), 1); } #[rstest] #[case::multiline("hello\nworld")] #[case::many_open_braces("{{{")] #[case::many_close_braces("}}}")] fn ignore_comments(#[case] comment: &str) { let element = MarkdownElement::Comment { comment: comment.into(), source_position: Default::default() }; build_presentation(vec![element]); } #[rstest] #[case::command_with_prefix("cmd:end_slide", true)] #[case::non_command_with_prefix("cmd:bogus", false)] #[case::non_prefixed("random", true)] fn comment_prefix(#[case] comment: &str, #[case] should_work: bool) { let options = PresentationBuilderOptions { command_prefix: "cmd:".into(), ..Default::default() }; let element = MarkdownElement::Comment { comment: comment.into(), source_position: Default::default() }; let result = try_build_presentation_with_options(vec![element], options); assert_eq!(result.is_ok(), should_work, "{result:?}"); } #[test] fn extra_fields_in_metadata() { let element = MarkdownElement::FrontMatter("nope: 42".into()); let result = try_build_presentation(vec![element]); assert!(result.is_err()); } #[test] fn end_slide_shorthand() { let options = PresentationBuilderOptions { end_slide_shorthand: true, ..Default::default() }; let elements = vec![ MarkdownElement::Paragraph(vec![]), MarkdownElement::ThematicBreak, MarkdownElement::Paragraph(vec![ParagraphElement::Text("hi".into())]), ]; let presentation = build_presentation_with_options(elements, options); assert_eq!(presentation.iter_slides().count(), 2); let second = presentation.iter_slides().nth(1).unwrap(); let before_text = second.iter_visible_operations().take_while(|e| !matches!(e, RenderOperation::RenderText { .. })); let break_count = before_text.filter(|e| matches!(e, RenderOperation::RenderLineBreak)).count(); assert_eq!(break_count, 1); } #[test] fn parse_front_matter_strict() { let options = PresentationBuilderOptions { strict_front_matter_parsing: false, ..Default::default() }; let elements = vec![MarkdownElement::FrontMatter("potato: yes".into())]; let result = try_build_presentation_with_options(elements, options); assert!(result.is_ok()); } #[rstest] #[case::enabled(true)] #[case::disabled(false)] fn snippet_execution(#[case] enabled: bool) { let element = MarkdownElement::Snippet(Snippet { contents: "".into(), language: SnippetLanguage::Rust, attributes: SnippetAttributes { execute: true, ..Default::default() }, }); let options = PresentationBuilderOptions { enable_snippet_execution: enabled, ..Default::default() }; let presentation = build_presentation_with_options(vec![element], options); let slide = presentation.iter_slides().next().unwrap(); let mut found_render_block = false; let mut found_cant_render_block = false; for operation in slide.iter_visible_operations() { match operation { RenderOperation::RenderAsync(operation) => { let operation = format!("{operation:?}"); if operation.contains("RunSnippetOperation") { assert!(enabled); found_render_block = true; } else if operation.contains("SnippetExecutionDisabledOperation") { assert!(!enabled); found_cant_render_block = true; } } _ => (), }; } if found_render_block { assert!(enabled, "snippet execution block found but not enabled"); } else { assert!(!enabled, "snippet execution enabled but not found"); } if found_cant_render_block { assert!(!enabled, "can't execute snippet operation found but enabled"); } else { assert!(enabled, "can't execute snippet operation not found"); } } #[rstest] #[case::width("image:width:50%", Some(50))] #[case::w("image:w:50%", Some(50))] #[case::nothing("", None)] #[case::no_prefix("width", None)] fn image_attributes(#[case] input: &str, #[case] expectation: Option) { let attributes = PresentationBuilder::parse_image_attributes(&input, "image:", Default::default()).expect("failed to parse"); assert_eq!(attributes.width, expectation.map(Percent)); } #[rstest] #[case::width("width:50%", Some(50))] #[case::empty("", None)] fn image_attributes_empty_prefix(#[case] input: &str, #[case] expectation: Option) { let attributes = PresentationBuilder::parse_image_attributes(input, "", Default::default()).expect("failed to parse"); assert_eq!(attributes.width, expectation.map(Percent)); } }