mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-12 18:54:37 +00:00
1608 lines
63 KiB
Rust
1608 lines
63 KiB
Rust
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<SlideChunk>,
|
|
chunk_operations: Vec<RenderOperation>,
|
|
chunk_mutators: Vec<Box<dyn ChunkMutator>>,
|
|
slides: Vec<Slide>,
|
|
highlighter: CodeHighlighter,
|
|
code_executor: Rc<SnippetExecutor>,
|
|
theme: Cow<'a, PresentationTheme>,
|
|
resources: &'a mut Resources,
|
|
third_party: &'a mut ThirdPartyRender,
|
|
slide_state: SlideState,
|
|
presentation_state: PresentationState,
|
|
footer_context: Rc<RefCell<FooterContext>>,
|
|
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<SnippetExecutor>,
|
|
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<MarkdownElement>) -> Result<Presentation, BuildError> {
|
|
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<Image, RegisterImageError> {
|
|
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::<StrictPresentationMetadata>(contents).map(PresentationMetadata::from),
|
|
false => serde_yaml::from_str::<PresentationMetadata>(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::<CommentCommand>() {
|
|
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<ParagraphElement>) -> 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<ListItem>) {
|
|
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<String>) {
|
|
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<CodeLine>,
|
|
block_length: usize,
|
|
) -> (Vec<HighlightedLine>, Rc<RefCell<HighlightContext>>) {
|
|
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("<no title>").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<RenderOperation> {
|
|
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<ImageAttributes, BuildError> {
|
|
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<bool>,
|
|
layout: LayoutState,
|
|
title: Option<TextBlock>,
|
|
}
|
|
|
|
#[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<u8>),
|
|
Column(usize),
|
|
ResetLayout,
|
|
JumpToMiddle,
|
|
IncrementalLists(bool),
|
|
NoFooter,
|
|
}
|
|
|
|
impl FromStr for CommentCommand {
|
|
type Err = CommandParseError;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
#[derive(Deserialize)]
|
|
struct CommandWrapper(#[serde(with = "serde_yaml::with::singleton_map")] CommentCommand);
|
|
|
|
let wrapper = serde_yaml::from_str::<CommandWrapper>(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<I> {
|
|
remaining: I,
|
|
next_index: usize,
|
|
current_depth: u8,
|
|
saved_indexes: Vec<usize>,
|
|
}
|
|
|
|
impl<I> ListIterator<I> {
|
|
fn new<T>(remaining: T, next_index: usize) -> Self
|
|
where
|
|
I: Iterator<Item = ListItem>,
|
|
T: IntoIterator<IntoIter = I, Item = ListItem>,
|
|
{
|
|
Self { remaining: remaining.into_iter(), next_index, current_depth: 0, saved_indexes: Vec::new() }
|
|
}
|
|
}
|
|
|
|
impl<I> Iterator for ListIterator<I>
|
|
where
|
|
I: Iterator<Item = ListItem>,
|
|
{
|
|
type Item = IndexedListItem;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
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<String>,
|
|
|
|
#[serde(default)]
|
|
sub_title: Option<String>,
|
|
|
|
#[serde(default)]
|
|
author: Option<String>,
|
|
|
|
#[serde(default)]
|
|
authors: Vec<String>,
|
|
|
|
#[serde(default)]
|
|
theme: PresentationThemeMetadata,
|
|
|
|
#[serde(default)]
|
|
options: Option<OptionsConfig>,
|
|
}
|
|
|
|
impl From<StrictPresentationMetadata> 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<Percent>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use crate::markdown::elements::SnippetAttributes;
|
|
use rstest::rstest;
|
|
|
|
fn build_presentation(elements: Vec<MarkdownElement>) -> Presentation {
|
|
try_build_presentation(elements).expect("build failed")
|
|
}
|
|
|
|
fn build_presentation_with_options(
|
|
elements: Vec<MarkdownElement>,
|
|
options: PresentationBuilderOptions,
|
|
) -> Presentation {
|
|
try_build_presentation_with_options(elements, options).expect("build failed")
|
|
}
|
|
|
|
fn try_build_presentation(elements: Vec<MarkdownElement>) -> Result<Presentation, BuildError> {
|
|
try_build_presentation_with_options(elements, Default::default())
|
|
}
|
|
|
|
fn try_build_presentation_with_options(
|
|
elements: Vec<MarkdownElement>,
|
|
options: PresentationBuilderOptions,
|
|
) -> Result<Presentation, BuildError> {
|
|
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<String> {
|
|
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<String> {
|
|
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::<Vec<_>>();
|
|
// 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<u8>) {
|
|
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<u8>) {
|
|
let attributes =
|
|
PresentationBuilder::parse_image_attributes(input, "", Default::default()).expect("failed to parse");
|
|
assert_eq!(attributes.width, expectation.map(Percent));
|
|
}
|
|
}
|