diff --git a/src/markdown/elements.rs b/src/markdown/elements.rs index 08959ad1..a5b7fa29 100644 --- a/src/markdown/elements.rs +++ b/src/markdown/elements.rs @@ -1,5 +1,8 @@ use super::text_style::{Color, TextStyle, UndefinedPaletteColorError}; -use crate::theme::{ColorPalette, raw::RawColor}; +use crate::{ + markdown::html::block::HtmlBlock, + theme::{ColorPalette, raw::RawColor}, +}; use comrak::nodes::AlertType; use std::{fmt, iter, path::PathBuf, str::FromStr}; use unicode_width::UnicodeWidthStr; @@ -48,8 +51,8 @@ pub(crate) enum MarkdownElement { /// A thematic break. ThematicBreak, - /// An HTML comment. - Comment { comment: String, source_position: SourcePosition }, + /// An HTML block. + HtmlBlock { block: HtmlBlock, source_position: SourcePosition }, /// A block quote containing a list of lines. BlockQuote(Vec>), diff --git a/src/markdown/html/block.rs b/src/markdown/html/block.rs new file mode 100644 index 00000000..98bc0794 --- /dev/null +++ b/src/markdown/html/block.rs @@ -0,0 +1,100 @@ +use tl::Node; + +pub(crate) struct HtmlBlockParser; + +impl HtmlBlockParser { + pub(crate) fn parse(&self, input: &str) -> Result, ParseHtmlBlockError> { + let dom = tl::parse(input, Default::default())?; + let parser = dom.parser(); + let children = dom.children(); + let mut output = Vec::new(); + for child in children { + let child = child.get(parser).expect("faild to get"); + match child { + Node::Tag(tag) => match tag.name().as_bytes() { + b"speaker-note" => { + let contents = child.inner_text(parser); + let text = if let Some(text) = contents.strip_prefix('\n') { text } else { &contents }; + let lines = text.lines().map(|l| l.to_string()).collect(); + output.push(HtmlBlock::SpeakerNotes { lines }); + } + name => return Err(ParseHtmlBlockError::UnsupportedTag(String::from_utf8_lossy(name).to_string())), + }, + Node::Comment(bytes) => { + let start_tag = ""; + let text = bytes.as_utf8_str(); + let text = &text[start_tag.len()..]; + let text = &text[0..text.len() - end_tag.len()]; + output.push(HtmlBlock::Comment(text.to_string())); + } + Node::Raw(_) => (), + }; + } + Ok(output) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum HtmlBlock { + SpeakerNotes { lines: Vec }, + Comment(String), +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ParseHtmlBlockError { + #[error("parsing html failed: {0}")] + ParsingHtml(#[from] tl::ParseError), + + #[error("unsupported HTML tag: {0}")] + UnsupportedTag(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_note() { + let input = r" + +hello + +this is +text + +"; + let mut blocks = HtmlBlockParser.parse(&input).expect("parse failed"); + assert_eq!(blocks.len(), 1); + + let HtmlBlock::SpeakerNotes { lines } = blocks.pop().unwrap() else { + panic!("not a speaker note!"); + }; + let expected_lines = &["hello", "", "this is", "text"]; + assert_eq!(lines, expected_lines); + } + + #[test] + fn comment() { + let input = r" + +"; + let mut blocks = HtmlBlockParser.parse(&input).expect("parse failed"); + assert_eq!(blocks.len(), 1); + + let HtmlBlock::Comment(comment) = blocks.pop().unwrap() else { + panic!("not a comment!"); + }; + assert_eq!(comment, " this is a comment "); + } + + #[test] + fn multiple_blocks() { + let input = r" +hello + +"; + let blocks = HtmlBlockParser.parse(&input).expect("parse failed"); + assert_eq!(blocks.len(), 2); + } +} diff --git a/src/markdown/html.rs b/src/markdown/html/inline.rs similarity index 62% rename from src/markdown/html.rs rename to src/markdown/html/inline.rs index a2aae6db..d2ac92d1 100644 --- a/src/markdown/html.rs +++ b/src/markdown/html/inline.rs @@ -1,48 +1,50 @@ -use super::text_style::{Color, TextStyle}; -use crate::theme::raw::{ParseColorError, RawColor}; +use crate::{ + markdown::text_style::{Color, TextStyle}, + theme::raw::{ParseColorError, RawColor}, +}; use std::{borrow::Cow, str, str::Utf8Error}; use tl::Attributes; -pub(crate) struct HtmlParseOptions { +pub(crate) struct InlineHtmlParseOptions { pub(crate) strict: bool, } -impl Default for HtmlParseOptions { +impl Default for InlineHtmlParseOptions { fn default() -> Self { Self { strict: true } } } #[derive(Default)] -pub(crate) struct HtmlParser { - options: HtmlParseOptions, +pub(crate) struct InlineHtmlParser { + options: InlineHtmlParseOptions, } -impl HtmlParser { - pub(crate) fn parse(self, input: &str) -> Result { +impl InlineHtmlParser { + pub(crate) fn parse(self, input: &str) -> Result { if input.starts_with(" (HtmlTag::Span, TextStyle::default()), - b"sup" => (HtmlTag::Sup, TextStyle::default().superscript()), - _ => return Err(ParseHtmlError::UnsupportedHtml), + b"span" => (HtmlInlineTag::Span, TextStyle::default()), + b"sup" => (HtmlInlineTag::Sup, TextStyle::default().superscript()), + _ => return Err(ParseInlineHtmlError::UnsupportedHtml), }; let style = self.parse_attributes(tag.attributes())?; Ok(HtmlInline::OpenTag { style: style.merged(&base_style), tag: output_tag }) } - fn parse_attributes(&self, attributes: &Attributes) -> Result, ParseHtmlError> { + fn parse_attributes(&self, attributes: &Attributes) -> Result, ParseInlineHtmlError> { let mut style = TextStyle::default(); for (name, value) in attributes.iter() { let value = value.unwrap_or(Cow::Borrowed("")); @@ -54,7 +56,7 @@ impl HtmlParser { } _ => { if self.options.strict { - return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string())); + return Err(ParseInlineHtmlError::UnsupportedTagAttribute(name.to_string())); } } } @@ -62,13 +64,17 @@ impl HtmlParser { Ok(style) } - fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle) -> Result<(), ParseHtmlError> { + fn parse_css_attribute( + &self, + attribute: &str, + style: &mut TextStyle, + ) -> Result<(), ParseInlineHtmlError> { for attribute in attribute.split(';') { let attribute = attribute.trim(); if attribute.is_empty() { continue; } - let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?; + let (key, value) = attribute.split_once(':').ok_or(ParseInlineHtmlError::NoColonInAttribute)?; let key = key.trim(); let value = value.trim(); match key { @@ -76,7 +82,7 @@ impl HtmlParser { "background-color" => style.colors.background = Some(Self::parse_color(value)?), _ => { if self.options.strict { - return Err(ParseHtmlError::UnsupportedCssAttribute(key.into())); + return Err(ParseInlineHtmlError::UnsupportedCssAttribute(key.into())); } } } @@ -84,14 +90,14 @@ impl HtmlParser { Ok(()) } - fn parse_color(input: &str) -> Result { + fn parse_color(input: &str) -> Result { if input.starts_with('#') { let color = input.strip_prefix('#').unwrap().parse()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) } } else { let color = input.parse::()?; if matches!(color, RawColor::Color(Color::Rgb { .. })) { - Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into())) + Err(ParseInlineHtmlError::InvalidColor("missing '#' in rgb color".into())) } else { Ok(color) } @@ -101,18 +107,18 @@ impl HtmlParser { #[derive(Debug, PartialEq)] pub(crate) enum HtmlInline { - OpenTag { style: TextStyle, tag: HtmlTag }, - CloseTag { tag: HtmlTag }, + OpenTag { style: TextStyle, tag: HtmlInlineTag }, + CloseTag { tag: HtmlInlineTag }, } #[derive(Clone, Debug, PartialEq)] -pub(crate) enum HtmlTag { +pub(crate) enum HtmlInlineTag { Span, Sup, } #[derive(Debug, thiserror::Error)] -pub(crate) enum ParseHtmlError { +pub(crate) enum ParseInlineHtmlError { #[error("parsing html failed: {0}")] ParsingHtml(#[from] tl::ParseError), @@ -141,7 +147,7 @@ pub(crate) enum ParseHtmlError { UnsupportedClosingTag(String), } -impl From for ParseHtmlError { +impl From for ParseInlineHtmlError { fn from(e: ParseColorError) -> Self { Self::InvalidColor(e.to_string()) } @@ -154,23 +160,24 @@ mod tests { #[test] fn parse_style() { - let tag = - HtmlParser::default().parse(r#""#).expect("parse failed"); - let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; + let tag = InlineHtmlParser::default() + .parse(r#""#) + .expect("parse failed"); + let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Span } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red)); } #[test] fn parse_sup() { - let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); - let HtmlInline::OpenTag { style, tag: HtmlTag::Sup } = tag else { panic!("not an open tag") }; + let tag = InlineHtmlParser::default().parse(r#""#).expect("parse failed"); + let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Sup } = tag else { panic!("not an open tag") }; assert_eq!(style, TextStyle::default().superscript()); } #[test] fn parse_class() { - let tag = HtmlParser::default().parse(r#""#).expect("parse failed"); - let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") }; + let tag = InlineHtmlParser::default().parse(r#""#).expect("parse failed"); + let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Span } = tag else { panic!("not an open tag") }; assert_eq!( style, TextStyle::default() @@ -180,10 +187,10 @@ mod tests { } #[rstest] - #[case::span("", HtmlTag::Span)] - #[case::sup("", HtmlTag::Sup)] - fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlTag) { - let inline = HtmlParser::default().parse(input).expect("parse failed"); + #[case::span("", HtmlInlineTag::Span)] + #[case::sup("", HtmlInlineTag::Sup)] + fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlInlineTag) { + let inline = InlineHtmlParser::default().parse(input).expect("parse failed"); assert_eq!(inline, HtmlInline::CloseTag { tag }); } @@ -194,14 +201,14 @@ mod tests { #[case::invalid_attribute(" MarkdownParser<'a> { NodeValue::Table(_) => self.parse_table(node)?, NodeValue::CodeBlock(block) => Self::parse_code_block(block, data.sourcepos)?, NodeValue::ThematicBreak => MarkdownElement::ThematicBreak, - NodeValue::HtmlBlock(block) => self.parse_html_block(block, data.sourcepos)?, + NodeValue::HtmlBlock(block) => return self.parse_html_block(block, data.sourcepos), NodeValue::BlockQuote | NodeValue::MultilineBlockQuote(_) => self.parse_block_quote(node)?, NodeValue::Alert(alert) => self.parse_alert(alert, node)?, NodeValue::FootnoteDefinition(definition) => self.parse_footnote_definition(definition, node)?, @@ -129,16 +134,15 @@ impl<'a> MarkdownParser<'a> { Ok(MarkdownElement::FrontMatter(contents.into())) } - fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult { - let block = block.literal.trim(); - let start_tag = ""; - if !block.starts_with(start_tag) || !block.ends_with(end_tag) { - return Err(ParseErrorKind::UnsupportedElement("html block").with_sourcepos(sourcepos)); - } - let block = &block[start_tag.len()..]; - let block = &block[0..block.len() - end_tag.len()]; - Ok(MarkdownElement::Comment { comment: block.into(), source_position: sourcepos.into() }) + fn parse_html_block(&self, block: &NodeHtmlBlock, sourcepos: Sourcepos) -> ParseResult> { + let blocks = HtmlBlockParser + .parse(&block.literal) + .map_err(|e| ParseErrorKind::InvalidHtmlBlock(e).with_sourcepos(sourcepos))?; + let elements = blocks + .into_iter() + .map(|block| MarkdownElement::HtmlBlock { block, source_position: sourcepos.into() }) + .collect(); + Ok(elements) } fn parse_block_quote(&self, node: &'a AstNode<'a>) -> ParseResult { @@ -485,9 +489,9 @@ impl<'a> InlinesParser<'a> { self.process_children(node, style)?; } NodeValue::HtmlInline(html) => { - let html_inline = HtmlParser::default() + let html_inline = InlineHtmlParser::default() .parse(html) - .map_err(|e| ParseErrorKind::InvalidHtml(e).with_sourcepos(data.sourcepos))?; + .map_err(|e| ParseErrorKind::InvalidInlineHtml(e).with_sourcepos(data.sourcepos))?; match html_inline { HtmlInline::OpenTag { style, tag } => return Ok(Some(HtmlStyle::Add(style, tag))), HtmlInline::CloseTag { tag } => return Ok(Some(HtmlStyle::Remove(tag))), @@ -534,8 +538,8 @@ impl<'a> InlinesParser<'a> { } enum HtmlStyle { - Add(TextStyle, HtmlTag), - Remove(HtmlTag), + Add(TextStyle, HtmlInlineTag), + Remove(HtmlInlineTag), } enum Inline { @@ -591,8 +595,11 @@ pub(crate) enum ParseErrorKind { /// We don't support external URLs in images. ExternalImageUrl, - /// Invalid HTML was found. - InvalidHtml(ParseHtmlError), + /// Invalid inline HTML was found. + InvalidInlineHtml(ParseInlineHtmlError), + + /// An invalid HTML block. + InvalidHtmlBlock(ParseHtmlBlockError), /// HTML tag closed without having an open one. NoOpenTag, @@ -613,7 +620,8 @@ impl Display for ParseErrorKind { } Self::ExternalImageUrl => write!(f, "external URLs are not supported in image tags"), Self::UnfencedCodeBlock => write!(f, "only fenced code blocks are supported"), - Self::InvalidHtml(inner) => write!(f, "invalid HTML: {inner}"), + Self::InvalidInlineHtml(inner) => write!(f, "invalid inline HTML: {inner}"), + Self::InvalidHtmlBlock(inner) => write!(f, "invalid HTML block: {inner}"), Self::NoOpenTag => write!(f, "closing tag without an open one"), Self::CloseTagMismatch => write!(f, "closing tag does not match last open one"), Self::Internal(message) => write!(f, "internal error: {message}"), @@ -686,7 +694,7 @@ pub(crate) struct ParseInlinesError(String); #[cfg(test)] mod test { use super::*; - use crate::markdown::text_style::Color; + use crate::markdown::{html::block::HtmlBlock, text_style::Color}; use rstest::rstest; use std::path::Path; @@ -1001,7 +1009,9 @@ let q = 42; ", ); - let MarkdownElement::Comment { comment, .. } = parsed else { panic!("not a comment: {parsed:?}") }; + let MarkdownElement::HtmlBlock { block: HtmlBlock::Comment(comment), .. } = parsed else { + panic!("not a comment: {parsed:?}") + }; assert_eq!(comment, " foo "); } @@ -1122,7 +1132,7 @@ mom ", ); - let MarkdownElement::Comment { source_position, .. } = &parsed[1] else { panic!("not a comment") }; + let MarkdownElement::HtmlBlock { source_position, .. } = &parsed[1] else { panic!("not a comment") }; assert_eq!(source_position.start.line, 6); assert_eq!(source_position.start.column, 1); } @@ -1183,4 +1193,19 @@ this[^1] let MarkdownElement::Footnote(line) = &elements[1] else { panic!("not a footnote") }; assert_eq!(line, &Line(vec![Text::new("1", TextStyle::default().superscript()), Text::from("ref")])); } + + #[test] + fn html_speaker_note() { + let input = r" + +hi +bye + + "; + let parsed = parse_single(input); + let MarkdownElement::HtmlBlock { block: HtmlBlock::SpeakerNotes { lines }, .. } = parsed else { + panic!("not a speaker note"); + }; + assert_eq!(lines, &["hi", "bye"]); + } } diff --git a/src/presentation/builder/error.rs b/src/presentation/builder/error.rs index e14bf117..ce93db9c 100644 --- a/src/presentation/builder/error.rs +++ b/src/presentation/builder/error.rs @@ -5,7 +5,7 @@ use crate::{ parse::ParseError, text_style::{Color, TextStyle, UndefinedPaletteColorError}, }, - presentation::builder::{comment::CommandParseError, images::ImageAttributeError, sources::MarkdownSourceError}, + presentation::builder::{html::CommandParseError, images::ImageAttributeError, sources::MarkdownSourceError}, terminal::{capabilities::TerminalCapabilities, image::printer::RegisterImageError}, theme::{ProcessingThemeError, registry::LoadThemeError}, third_party::ThirdPartyRenderError, diff --git a/src/presentation/builder/comment.rs b/src/presentation/builder/html.rs similarity index 94% rename from src/presentation/builder/comment.rs rename to src/presentation/builder/html.rs index 7ea41e75..a48f94e8 100644 --- a/src/presentation/builder/comment.rs +++ b/src/presentation/builder/html.rs @@ -1,5 +1,8 @@ use crate::{ - markdown::elements::{MarkdownElement, SourcePosition}, + markdown::{ + elements::{MarkdownElement, SourcePosition}, + html::block::HtmlBlock, + }, presentation::builder::{BuildResult, LayoutState, PresentationBuilder, error::InvalidPresentation}, render::operation::{LayoutGrid, RenderOperation}, theme::{Alignment, ElementType}, @@ -8,7 +11,19 @@ use serde::Deserialize; use std::{fmt, num::NonZeroU8, path::PathBuf, str::FromStr}; impl PresentationBuilder<'_, '_> { - pub(crate) fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> BuildResult { + pub(crate) fn process_html_block(&mut self, block: HtmlBlock, source_position: SourcePosition) -> BuildResult { + match block { + HtmlBlock::Comment(comment) => self.process_comment(comment, source_position), + HtmlBlock::SpeakerNotes { lines } => { + if self.options.render_speaker_notes_only { + self.process_speaker_notes(lines); + } + Ok(()) + } + } + } + + fn process_comment(&mut self, comment: String, source_position: SourcePosition) -> BuildResult { let comment = comment.trim(); let trimmed_comment = comment.trim_start_matches(&self.options.command_prefix); let command = match trimmed_comment.parse::() { @@ -125,11 +140,7 @@ impl PresentationBuilder<'_, '_> { fn process_comment_command_speaker_notes_mode(&mut self, comment_command: CommentCommand) { match comment_command { CommentCommand::SpeakerNote(note) => { - for line in note.lines() { - self.push_text(line.into(), ElementType::Paragraph); - self.push_line_break(); - } - self.push_line_break(); + self.process_speaker_notes(note.lines().map(ToString::to_string)); } CommentCommand::EndSlide => self.terminate_slide(), CommentCommand::Pause => self.push_pause(), @@ -138,6 +149,14 @@ impl PresentationBuilder<'_, '_> { } } + fn process_speaker_notes(&mut self, lines: impl IntoIterator) { + for line in lines { + self.push_text(line.into(), ElementType::Paragraph); + self.push_line_break(); + } + self.push_line_break(); + } + 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 @@ -273,7 +292,10 @@ mod tests { use std::{fs, io::BufWriter}; use super::*; - use crate::presentation::builder::{PresentationBuilderOptions, utils::Test}; + use crate::{ + markdown::html::block::HtmlBlock, + presentation::builder::{PresentationBuilderOptions, utils::Test}, + }; use image::{DynamicImage, ImageEncoder, codecs::png::PngEncoder}; use rstest::rstest; use tempfile::tempdir; @@ -313,7 +335,10 @@ mod tests { 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 element = MarkdownElement::HtmlBlock { + block: HtmlBlock::Comment(comment.into()), + source_position: Default::default(), + }; let result = Test::new(vec![element]).options(options).try_build(); assert_eq!(result.is_ok(), should_work, "{result:?}"); } diff --git a/src/presentation/builder/mod.rs b/src/presentation/builder/mod.rs index a37a4dda..5e5b72aa 100644 --- a/src/presentation/builder/mod.rs +++ b/src/presentation/builder/mod.rs @@ -47,12 +47,11 @@ use std::{ }; pub(crate) mod error; - -mod comment; -pub(crate) use comment::CommentCommand; +pub(crate) use html::CommentCommand; mod frontmatter; mod heading; +mod html; mod images; mod list; mod quote; @@ -282,6 +281,7 @@ impl<'a, 'b> PresentationBuilder<'a, 'b> { let slide_index = self.index_builder.build(&self.theme, self.presentation_state.clone()); let modals = Modals { slide_index, bindings }; let presentation = Presentation::new(slides, modals, self.presentation_state); + eprintln!("{presentation:?}"); Ok(presentation) } @@ -357,7 +357,7 @@ impl<'a, 'b> PresentationBuilder<'a, 'b> { } fn process_element_for_presentation_mode(&mut self, element: MarkdownElement) -> BuildResult { - let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::Comment { .. }); + let should_clear_last = !matches!(element, MarkdownElement::List(_) | MarkdownElement::HtmlBlock { .. }); match element { // This one is processed before everything else as it affects how the rest of the // elements is rendered. @@ -369,7 +369,7 @@ impl<'a, 'b> PresentationBuilder<'a, 'b> { MarkdownElement::Snippet { info, code, source_position } => self.push_code(info, code, source_position)?, 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::HtmlBlock { block, source_position } => self.process_html_block(block, 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)? @@ -388,7 +388,7 @@ impl<'a, 'b> PresentationBuilder<'a, 'b> { fn process_element_for_speaker_notes_mode(&mut self, element: MarkdownElement) -> BuildResult { match element { - MarkdownElement::Comment { comment, source_position } => self.process_comment(comment, source_position)?, + MarkdownElement::HtmlBlock { block, source_position } => self.process_html_block(block, source_position)?, MarkdownElement::SetexHeading { text } => self.push_slide_title(text)?, MarkdownElement::ThematicBreak => { if self.options.end_slide_shorthand {