use std::collections::VecDeque;
use crate::converter;
use crate::markdown;
use crate::typst;
use crate::ParserEvent;
converter!(
ConvertParagraphs,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Paragraph))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Paragraph)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Paragraph))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Paragraph)))
},
x => x,
}
});
pub struct ConvertText<T> {
code: VecDeque<()>,
iter: T,
}
impl<'a, T> ConvertText<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
pub fn new(iter: T) -> Self {
ConvertText {
code: VecDeque::new(),
iter,
}
}
}
impl<'a, T> Iterator for ConvertText<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
type Item = ParserEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
match (self.code.pop_back(), self.iter.next()) {
(Some(_), Some(ParserEvent::Markdown(markdown::Event::Text(t)))) => {
Some(ParserEvent::Typst(typst::Event::Text(t)))
}
(None, Some(ParserEvent::Markdown(markdown::Event::Text(t)))) => {
if t.trim().starts_with("\\[") && t.trim().ends_with("\\]") {
self.next()
} else {
Some(ParserEvent::Typst(typst::Event::Text(t)))
}
}
(
_,
event @ Some(ParserEvent::Markdown(markdown::Event::Start(
markdown::Tag::CodeBlock(_),
))),
) => {
self.code.push_back(());
event
}
(
_,
event @ Some(ParserEvent::Markdown(markdown::Event::End(
markdown::TagEnd::CodeBlock,
))),
) => {
let _ = self.code.pop_back();
event
}
(_, x) => x,
}
}
}
fn md_path_to_label(url: &str) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("mailto:") {
return None;
}
if let Some(anchor) = url.strip_prefix('#') {
if !anchor.is_empty() {
return Some(format!("<{}>", anchor.to_lowercase()));
}
return None;
}
let has_md = url.contains(".md");
let has_html = url.contains(".html");
if !has_md && !has_html {
return None;
}
if url.starts_with("../") && !has_md {
return None;
}
let mut parts = url.splitn(2, '#');
let path = parts.next().unwrap_or(url);
let anchor = parts.next();
let path_label = path
.trim_start_matches("./")
.trim_start_matches("../")
.trim_end_matches(".md")
.trim_end_matches(".html")
.replace(['/', '\\'], "-")
.to_lowercase();
if let Some(anchor) = anchor {
if !anchor.is_empty() {
let anchor_lower = anchor.to_lowercase();
if path_label.is_empty() {
return Some(format!("<{}>", anchor_lower));
} else {
return Some(format!("<{}-{}>", path_label, anchor_lower));
}
}
}
if path_label.is_empty() {
None
} else {
Some(format!("<{}>", path_label))
}
}
converter!(
ConvertLinks,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Link { link_type: kind, dest_url: url, .. }))) => {
match kind {
markdown::LinkType::Inline
| markdown::LinkType::Reference
| markdown::LinkType::ReferenceUnknown
| markdown::LinkType::Collapsed
| markdown::LinkType::CollapsedUnknown
| markdown::LinkType::Shortcut
| markdown::LinkType::ShortcutUnknown
| markdown::LinkType::WikiLink { .. } => {
if let Some(label) = md_path_to_label(&url) {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Link(typst::LinkType::Content, label.into()))))
} else {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Link(typst::LinkType::Content, url))))
}
},
markdown::LinkType::Autolink => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Link(typst::LinkType::Autolink, url))))
},
markdown::LinkType::Email => {
let url = "mailto:".to_string() + url.as_ref();
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Link(typst::LinkType::Url, url.into()))))
},
}
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Link))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Link(typst::LinkType::Content, "".into()))))
},
x => x,
}
});
converter!(
ConvertImages,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Image { dest_url: url, title, .. }))) => {
let alt = if title.is_empty() { "".into() } else { title };
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(url, alt, None, None, None))))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Image))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Image("".into(), "".into(), None, None, None))))
},
x => x,
}
});
converter!(
ConvertStrong,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Strong))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Strong)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Strong))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Strong)))
},
x => x,
}
});
converter!(
ConvertEmphasis,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Emphasis))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Emphasis)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Emphasis))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Emphasis)))
},
x => x,
}
});
converter!(
ConvertStrikethrough,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Strikethrough))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Strikethrough)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Strikethrough))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Strikethrough)))
},
x => x,
}
});
converter!(
ConvertSoftBreaks,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::SoftBreak)) => {
Some(ParserEvent::Typst(typst::Event::Text(" ".into())))
},
x => x,
}
});
converter!(
ConvertHardBreaks,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::HardBreak)) => {
Some(ParserEvent::Typst(typst::Event::Linebreak))
},
x => x,
}
});
converter!(
ConvertBlockQuotes,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::BlockQuote(_)))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Quote(typst::QuoteType::Block, typst::QuoteQuotes::Auto, None))))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::BlockQuote(_)))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Quote(typst::QuoteType::Block, typst::QuoteQuotes::Auto, None))))
},
x => x,
}
});
converter!(
ConvertCode,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Code(x))) => {
Some(ParserEvent::Typst(typst::Event::Code(x)))
},
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::CodeBlock(kind)))) => {
match kind {
markdown::CodeBlockKind::Indented => Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::CodeBlock(None, typst::CodeBlockDisplay::Block)))),
markdown::CodeBlockKind::Fenced(val) => {
let val = if val.as_ref() == "" {
None
} else {
Some(val)
};
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::CodeBlock(val, typst::CodeBlockDisplay::Block))))
},
}
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::CodeBlock))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::CodeBlock(None, typst::CodeBlockDisplay::Block))))
},
x => x,
}
});
converter!(
ConvertLists,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::List(number)))) => {
if let Some(start) = number {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::NumberedList(start, None, false))))
} else {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::BulletList(None, false))))
}
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::List(ordered)))) => {
if ordered {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::NumberedList(1, None, false))))
} else {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::BulletList(None, false))))
}
},
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Item))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Item)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Item))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Item)))
},
x => x,
}
}
);
fn generate_heading_id(text: &str) -> String {
let result: String = text
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c.to_ascii_lowercase()
} else if c == ' ' || c == '-' {
'-'
} else {
'\0'
}
})
.filter(|&c| c != '\0')
.collect();
result.trim_matches('-').to_string()
}
#[derive(Clone)]
enum HeadingContent {
Text(String),
Code(String),
SoftBreak,
HardBreak,
StrongStart,
StrongEnd,
EmphasisStart,
EmphasisEnd,
}
pub struct ConvertHeadings<T> {
iter: T,
buffer: VecDeque<HeadingContent>,
heading_text: String,
heading_level: Option<markdown::HeadingLevel>,
explicit_id: Option<String>,
emit_start: Option<(std::num::NonZeroU8, Option<String>)>,
emit_end: Option<(std::num::NonZeroU8, Option<String>)>,
}
impl<'a, T> ConvertHeadings<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
pub fn new(iter: T) -> Self {
ConvertHeadings {
iter,
buffer: VecDeque::new(),
heading_text: String::new(),
heading_level: None,
explicit_id: None,
emit_start: None,
emit_end: None,
}
}
fn level_to_typst(level: markdown::HeadingLevel) -> std::num::NonZeroU8 {
use markdown::HeadingLevel;
match level {
HeadingLevel::H1 => std::num::NonZeroU8::new(1).expect("non-zero"),
HeadingLevel::H2 => std::num::NonZeroU8::new(2).expect("non-zero"),
HeadingLevel::H3 => std::num::NonZeroU8::new(3).expect("non-zero"),
HeadingLevel::H4 => std::num::NonZeroU8::new(4).expect("non-zero"),
HeadingLevel::H5 => std::num::NonZeroU8::new(5).expect("non-zero"),
HeadingLevel::H6 => std::num::NonZeroU8::new(6).expect("non-zero"),
}
}
}
impl<'a, T> Iterator for ConvertHeadings<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
type Item = ParserEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
if let Some((level, label)) = self.emit_start.take() {
return Some(ParserEvent::Typst(typst::Event::Start(
typst::Tag::Heading(
level,
typst::TableOfContents::Include,
typst::Bookmarks::Include,
label.map(markdown::CowStr::from),
),
)));
}
if self.emit_end.is_some() {
if let Some(content) = self.buffer.pop_front() {
return Some(match content {
HeadingContent::Text(s) => {
ParserEvent::Markdown(markdown::Event::Text(markdown::CowStr::from(s)))
}
HeadingContent::Code(s) => {
ParserEvent::Markdown(markdown::Event::Code(markdown::CowStr::from(s)))
}
HeadingContent::SoftBreak => ParserEvent::Markdown(markdown::Event::SoftBreak),
HeadingContent::HardBreak => ParserEvent::Markdown(markdown::Event::HardBreak),
HeadingContent::StrongStart => {
ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Strong))
}
HeadingContent::StrongEnd => {
ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Strong))
}
HeadingContent::EmphasisStart => {
ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Emphasis))
}
HeadingContent::EmphasisEnd => {
ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Emphasis))
}
});
}
}
if let Some((level, label)) = self.emit_end.take() {
return Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Heading(
level,
typst::TableOfContents::Include,
typst::Bookmarks::Include,
label.map(markdown::CowStr::from),
))));
}
match self.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Heading {
level,
id,
..
}))) => {
self.heading_level = Some(level);
self.heading_text.clear();
self.explicit_id = id.map(|s| s.to_string());
self.next() }
Some(ParserEvent::Markdown(markdown::Event::Text(text)))
if self.heading_level.is_some() =>
{
self.heading_text.push_str(&text);
self.buffer
.push_back(HeadingContent::Text(text.to_string()));
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::Code(code)))
if self.heading_level.is_some() =>
{
self.heading_text.push_str(&code);
self.buffer
.push_back(HeadingContent::Code(code.to_string()));
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::SoftBreak))
if self.heading_level.is_some() =>
{
self.heading_text.push(' ');
self.buffer.push_back(HeadingContent::SoftBreak);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::HardBreak))
if self.heading_level.is_some() =>
{
self.heading_text.push(' ');
self.buffer.push_back(HeadingContent::HardBreak);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Strong)))
if self.heading_level.is_some() =>
{
self.buffer.push_back(HeadingContent::StrongStart);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Strong)))
if self.heading_level.is_some() =>
{
self.buffer.push_back(HeadingContent::StrongEnd);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Emphasis)))
if self.heading_level.is_some() =>
{
self.buffer.push_back(HeadingContent::EmphasisStart);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Emphasis)))
if self.heading_level.is_some() =>
{
self.buffer.push_back(HeadingContent::EmphasisEnd);
self.next()
}
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Heading(level)))) => {
let typst_level = Self::level_to_typst(level);
let generated_id = generate_heading_id(&self.heading_text);
let id = match self.explicit_id.take() {
Some(explicit) if explicit.starts_with('!') => {
explicit[1..].to_string()
}
Some(prefix) if !generated_id.is_empty() => {
format!("{}-{}", prefix, generated_id)
}
Some(prefix) => prefix,
None => generated_id,
};
let label = if id.is_empty() { None } else { Some(id) };
self.emit_start = Some((typst_level, label.clone()));
self.emit_end = Some((typst_level, label));
self.heading_level = None;
self.heading_text.clear();
self.next()
}
x => x,
}
}
}
converter!(
ConvertTables,
ParserEvent<'a> => ParserEvent<'a>,
|this: &mut Self| {
match this.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::Table(alignment)))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Table(
alignment.iter().map(|&a| match a {
markdown::Alignment::Left => typst::TableCellAlignment::Left,
markdown::Alignment::Center => typst::TableCellAlignment::Center,
markdown::Alignment::Right => typst::TableCellAlignment::Right,
markdown::Alignment::None => typst::TableCellAlignment::None,
}).collect(),
))))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::Table))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Table(vec![]))))
},
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::TableHead))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::TableHead)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::TableHead))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::TableHead)))
},
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::TableRow))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::TableRow)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::TableRow))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::TableRow)))
},
Some(ParserEvent::Markdown(markdown::Event::Start(markdown::Tag::TableCell))) => {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::TableCell)))
},
Some(ParserEvent::Markdown(markdown::Event::End(markdown::TagEnd::TableCell))) => {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::TableCell)))
},
x => x,
}
}
);
pub struct FilterImageParagraphs<T> {
iter: T,
buffer: VecDeque<ParserEvent<'static>>,
}
impl<'a, T> FilterImageParagraphs<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
pub fn new(iter: T) -> Self {
FilterImageParagraphs {
iter,
buffer: VecDeque::new(),
}
}
}
impl<'a, T> Iterator for FilterImageParagraphs<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
type Item = ParserEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(event) = self.buffer.pop_front() {
return Some(unsafe { std::mem::transmute::<ParserEvent<'_>, ParserEvent<'_>>(event) });
}
match self.iter.next() {
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Paragraph))) => {
let mut events: Vec<ParserEvent<'a>> = Vec::new();
let mut has_image = false;
let mut has_non_image_content = false;
let mut in_image = false;
loop {
match self.iter.next() {
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Paragraph))) => {
break;
}
Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(
src,
alt,
class,
width,
height,
)))) => {
has_image = true;
in_image = true;
events.push(ParserEvent::Typst(typst::Event::Start(
typst::Tag::Image(src, alt, class, width, height),
)));
}
Some(ParserEvent::Typst(typst::Event::End(typst::Tag::Image(
src,
alt,
class,
width,
height,
)))) => {
in_image = false;
events.push(ParserEvent::Typst(typst::Event::End(typst::Tag::Image(
src, alt, class, width, height,
))));
}
Some(ParserEvent::Typst(typst::Event::Raw(ref raw)))
if raw.trim_start().starts_with("#image(")
|| raw.trim_start().starts_with("#figure(") =>
{
has_image = true;
events.push(ParserEvent::Typst(typst::Event::Raw(raw.clone())));
}
Some(other) => {
if !in_image {
match &other {
ParserEvent::Typst(typst::Event::Text(t))
if t.trim().is_empty() => {}
ParserEvent::Markdown(markdown::Event::Text(t))
if t.trim().is_empty() => {}
ParserEvent::Markdown(markdown::Event::SoftBreak) => {}
_ => {
has_non_image_content = true;
}
}
}
events.push(other);
}
None => break,
}
}
if has_image && !has_non_image_content {
for event in events {
let event: ParserEvent<'static> = unsafe { std::mem::transmute(event) };
self.buffer.push_back(event);
}
} else {
self.buffer.push_back(unsafe {
std::mem::transmute::<ParserEvent<'_>, ParserEvent<'_>>(ParserEvent::Typst(
typst::Event::Start(typst::Tag::Paragraph),
))
});
for event in events {
let event: ParserEvent<'static> = unsafe { std::mem::transmute(event) };
self.buffer.push_back(event);
}
self.buffer.push_back(unsafe {
std::mem::transmute::<ParserEvent<'_>, ParserEvent<'_>>(ParserEvent::Typst(
typst::Event::End(typst::Tag::Paragraph),
))
});
}
self.next()
}
x => x,
}
}
}
struct HtmlImgAttrs {
src: String,
alt: String,
class: Option<String>,
width: Option<String>,
height: Option<String>,
}
fn extract_html_img_attrs(html: &str) -> Option<HtmlImgAttrs> {
use html5ever::tokenizer::{
BufferQueue, Tag, TagKind, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
};
use std::cell::RefCell;
struct ImgExtractor {
src: RefCell<Option<String>>,
alt: RefCell<Option<String>>,
class: RefCell<Option<String>>,
width: RefCell<Option<String>>,
height: RefCell<Option<String>>,
}
impl ImgExtractor {
fn parse_style(&self, style: &str) {
for part in style.split(';') {
let part = part.trim();
if let Some((prop, value)) = part.split_once(':') {
let prop = prop.trim().to_lowercase();
let value = value.trim();
if prop == "width" {
*self.width.borrow_mut() = Some(value.to_string());
} else if prop == "height" {
*self.height.borrow_mut() = Some(value.to_string());
}
}
}
}
}
impl TokenSink for ImgExtractor {
type Handle = ();
fn process_token(&self, token: Token, _line_number: u64) -> TokenSinkResult<()> {
if let Token::TagToken(Tag {
kind: TagKind::StartTag,
name,
attrs,
self_closing: _,
}) = token
{
if name.as_ref() == "img" {
for attr in attrs {
let attr_name = attr.name.local.as_ref();
if attr_name == "src" {
*self.src.borrow_mut() = Some(attr.value.to_string());
} else if attr_name == "alt" {
*self.alt.borrow_mut() = Some(attr.value.to_string());
} else if attr_name == "class" {
*self.class.borrow_mut() = Some(attr.value.to_string());
} else if attr_name == "style" {
self.parse_style(&attr.value);
} else if attr_name == "width" {
*self.width.borrow_mut() = Some(attr.value.to_string());
} else if attr_name == "height" {
*self.height.borrow_mut() = Some(attr.value.to_string());
}
}
}
}
TokenSinkResult::Continue
}
}
let sink = ImgExtractor {
src: RefCell::new(None),
alt: RefCell::new(None),
class: RefCell::new(None),
width: RefCell::new(None),
height: RefCell::new(None),
};
let tokenizer = Tokenizer::new(sink, TokenizerOpts::default());
let buffer = BufferQueue::default();
buffer.push_back(html.into());
let _ = tokenizer.feed(&buffer);
tokenizer.end();
let src = tokenizer.sink.src.into_inner()?;
let alt = tokenizer.sink.alt.into_inner().unwrap_or_default();
let class = tokenizer.sink.class.into_inner();
let width = tokenizer.sink.width.into_inner();
let height = tokenizer.sink.height.into_inner();
Some(HtmlImgAttrs {
src,
alt,
class,
width,
height,
})
}
fn format_img_dimension(value: &str) -> String {
let value = value.trim();
if value.chars().all(|c| c.is_ascii_digit()) {
format!("{}pt", value)
} else {
value.to_string()
}
}
pub struct ConvertHtmlImg<T> {
iter: T,
pending_end: Option<typst::Tag<'static>>,
}
impl<'a, T> ConvertHtmlImg<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
pub fn new(iter: T) -> Self {
ConvertHtmlImg {
iter,
pending_end: None,
}
}
}
impl<'a, T> Iterator for ConvertHtmlImg<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
type Item = ParserEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
if let Some(tag) = self.pending_end.take() {
return Some(ParserEvent::Typst(typst::Event::End(unsafe {
std::mem::transmute::<typst::Tag<'static>, typst::Tag<'a>>(tag)
})));
}
match self.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::InlineHtml(html))) => {
if html.to_lowercase().trim_start().starts_with("<img") {
if let Some(attrs) = extract_html_img_attrs(&html) {
self.pending_end =
Some(typst::Tag::Image("".into(), "".into(), None, None, None));
return Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(
attrs.src.into(),
attrs.alt.into(),
attrs.class.map(Into::into),
attrs.width.map(|w| format_img_dimension(&w).into()),
attrs.height.map(|h| format_img_dimension(&h).into()),
))));
}
}
Some(ParserEvent::Markdown(markdown::Event::InlineHtml(html)))
}
Some(ParserEvent::Markdown(markdown::Event::Html(html))) => {
if html.to_lowercase().trim_start().starts_with("<img") {
if let Some(attrs) = extract_html_img_attrs(&html) {
self.pending_end =
Some(typst::Tag::Image("".into(), "".into(), None, None, None));
return Some(ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(
attrs.src.into(),
attrs.alt.into(),
attrs.class.map(Into::into),
attrs.width.map(|w| format_img_dimension(&w).into()),
attrs.height.map(|h| format_img_dimension(&h).into()),
))));
}
}
Some(ParserEvent::Markdown(markdown::Event::Html(html)))
}
x => x,
}
}
}
pub struct ConvertHtmlSup<T> {
iter: T,
in_sup: bool,
}
impl<'a, T> ConvertHtmlSup<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
pub fn new(iter: T) -> Self {
ConvertHtmlSup {
iter,
in_sup: false,
}
}
}
impl<'a, T> Iterator for ConvertHtmlSup<T>
where
T: Iterator<Item = ParserEvent<'a>>,
{
type Item = ParserEvent<'a>;
fn next(&mut self) -> Option<Self::Item> {
match self.iter.next() {
Some(ParserEvent::Markdown(markdown::Event::InlineHtml(html))) => {
let html_lower = html.to_lowercase();
if html_lower.trim() == "<sup>" {
self.in_sup = true;
Some(ParserEvent::Typst(typst::Event::Raw("#super[".into())))
} else if html_lower.trim() == "</sup>" {
self.in_sup = false;
Some(ParserEvent::Typst(typst::Event::Raw("]".into())))
} else {
Some(ParserEvent::Markdown(markdown::Event::InlineHtml(html)))
}
}
Some(ParserEvent::Markdown(markdown::Event::Html(html))) => {
let html_lower = html.to_lowercase();
if html_lower.trim() == "<sup>" {
self.in_sup = true;
Some(ParserEvent::Typst(typst::Event::Raw("#super[".into())))
} else if html_lower.trim() == "</sup>" {
self.in_sup = false;
Some(ParserEvent::Typst(typst::Event::Raw("]".into())))
} else {
Some(ParserEvent::Markdown(markdown::Event::Html(html)))
}
}
x => x,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::CowStr;
use crate::markdown::{MarkdownIter, Parser};
use similar_asserts::assert_eq;
use std::num::NonZeroU8;
use markdown::Event as MdEvent;
use markdown::HeadingLevel;
use markdown::Tag as MdTag;
use markdown::TagEnd as MdTagEnd;
use typst::Event as TypstEvent;
use typst::Tag as TypstTag;
use ParserEvent::*;
mod headings {
use super::*;
#[test]
fn convert_headings() {
let md = "\
# Greetings
## This is **rad**!
";
let i = ConvertHeadings::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Heading(
NonZeroU8::new(1).unwrap(),
typst::TableOfContents::Include,
typst::Bookmarks::Include,
Some(CowStr::Boxed("greetings".into())),
))),
Markdown(MdEvent::Text(CowStr::Boxed("Greetings".into()))),
Typst(TypstEvent::End(TypstTag::Heading(
NonZeroU8::new(1).unwrap(),
typst::TableOfContents::Include,
typst::Bookmarks::Include,
Some(CowStr::Boxed("greetings".into())),
))),
Typst(TypstEvent::Start(TypstTag::Heading(
NonZeroU8::new(2).unwrap(),
typst::TableOfContents::Include,
typst::Bookmarks::Include,
Some(CowStr::Boxed("this-is-rad".into())),
))),
Markdown(MdEvent::Text(CowStr::Boxed("This is ".into()))),
Markdown(MdEvent::Start(MdTag::Strong)),
Markdown(MdEvent::Text(CowStr::Boxed("rad".into()))),
Markdown(MdEvent::End(MdTagEnd::Strong)),
Markdown(MdEvent::Text(CowStr::Boxed("!".into()))),
Typst(TypstEvent::End(TypstTag::Heading(
NonZeroU8::new(2).unwrap(),
typst::TableOfContents::Include,
typst::Bookmarks::Include,
Some(CowStr::Boxed("this-is-rad".into())),
))),
]
);
}
#[test]
fn preserves_underscores_in_heading_id() {
let md = "## Declarative Macros with `macro_rules!`\n";
let i = ConvertHeadings::new(MarkdownIter(Parser::new(&md)));
let events: Vec<_> = i.collect();
let label = events.iter().find_map(|e| match e {
Typst(TypstEvent::Start(TypstTag::Heading(_, _, _, Some(l)))) => Some(l.as_ref()),
_ => None,
});
self::assert_eq!(label, Some("declarative-macros-with-macro_rules"));
}
}
mod links {
use super::*;
#[test]
fn inline() {
let md = "\
Cool [beans](https://example.com)
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cool "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("https://example.com")
))),
Markdown(MdEvent::Text(CowStr::Borrowed("beans"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn auto() {
let md = "\
Cool <https://example.com>
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cool "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Autolink,
CowStr::Borrowed("https://example.com")
))),
Markdown(MdEvent::Text(CowStr::Borrowed("https://example.com"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn email() {
let md = "\
Who are <you@example.com>
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("Who are "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Url,
CowStr::Boxed("mailto:you@example.com".into())
))),
Markdown(MdEvent::Text(CowStr::Borrowed("you@example.com"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn intra_book_link() {
let md = "\
See [Chapter 2](./chapter2.md) for more info.
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("See "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Content,
CowStr::Boxed("<chapter2>".into())
))),
Markdown(MdEvent::Text(CowStr::Borrowed("Chapter 2"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::Text(CowStr::Borrowed(" for more info."))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn nested_intra_book_link_with_anchor() {
let md = "\
See [Other Section](../other/section.md#anchor) for details.
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("See "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Content,
CowStr::Boxed("<other-section-anchor>".into())
))),
Markdown(MdEvent::Text(CowStr::Borrowed("Other Section"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::Text(CowStr::Borrowed(" for details."))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn nested_intra_book_link_without_anchor() {
let md = "\
See [Other Section](../other/section.md) for details.
";
let i = ConvertLinks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("See "))),
Typst(TypstEvent::Start(TypstTag::Link(
typst::LinkType::Content,
CowStr::Boxed("<other-section>".into())
))),
Markdown(MdEvent::Text(CowStr::Borrowed("Other Section"))),
Typst(TypstEvent::End(TypstTag::Link(
typst::LinkType::Content,
CowStr::Borrowed("")
))),
Markdown(MdEvent::Text(CowStr::Borrowed(" for details."))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
}
mod images {
use super::*;
#[test]
fn simple_image() {
let md = "";
let i = ConvertImages::new(MarkdownIter(Parser::new(&md)));
let events: Vec<_> = i.collect();
assert!(events.iter().any(|e| matches!(
e,
Typst(TypstEvent::Start(TypstTag::Image(src, _, _, _, _))) if src.as_ref() == "image.png"
)));
}
#[test]
fn image_with_title() {
let md = r#""#;
let i = ConvertImages::new(MarkdownIter(Parser::new(&md)));
let events: Vec<_> = i.collect();
assert!(events.iter().any(|e| matches!(
e,
Typst(TypstEvent::Start(TypstTag::Image(src, title, _, _, _)))
if src.as_ref() == "image.png" && title.as_ref() == "Image Title"
)));
}
}
mod strong {
use super::*;
#[test]
fn convert_strong() {
let md = "\
## **Foo**
I **love** cake!
";
let i = ConvertStrong::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Heading {
level: HeadingLevel::H2,
id: None,
classes: vec![],
attrs: vec![],
})),
Typst(TypstEvent::Start(TypstTag::Strong)),
Markdown(MdEvent::Text(CowStr::Borrowed("Foo"))),
Typst(TypstEvent::End(TypstTag::Strong)),
Markdown(MdEvent::End(MdTagEnd::Heading(HeadingLevel::H2))),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("I "))),
Typst(TypstEvent::Start(TypstTag::Strong)),
Markdown(MdEvent::Text(CowStr::Borrowed("love"))),
Typst(TypstEvent::End(TypstTag::Strong)),
Markdown(MdEvent::Text(CowStr::Borrowed(" cake!"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
}
mod emphasis {
use super::*;
#[test]
fn convert_emphasis() {
let md = "\
## _Foo_
I *love* cake!
";
let i = ConvertEmphasis::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Heading {
level: HeadingLevel::H2,
id: None,
classes: vec![],
attrs: vec![],
})),
Typst(TypstEvent::Start(TypstTag::Emphasis)),
Markdown(MdEvent::Text(CowStr::Borrowed("Foo"))),
Typst(TypstEvent::End(TypstTag::Emphasis)),
Markdown(MdEvent::End(MdTagEnd::Heading(HeadingLevel::H2))),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("I "))),
Typst(TypstEvent::Start(TypstTag::Emphasis)),
Markdown(MdEvent::Text(CowStr::Borrowed("love"))),
Typst(TypstEvent::End(TypstTag::Emphasis)),
Markdown(MdEvent::Text(CowStr::Borrowed(" cake!"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
}
mod strikethrough {
use super::*;
#[test]
fn convert_strikethrough() {
let md = "This is ~~deleted~~ text.";
let i = ConvertStrikethrough::new(MarkdownIter(Parser::new_ext(
&md,
pulldown_cmark::Options::ENABLE_STRIKETHROUGH,
)));
let events: Vec<_> = i.collect();
assert!(events
.iter()
.any(|e| matches!(e, Typst(TypstEvent::Start(TypstTag::Strikethrough)))));
assert!(events
.iter()
.any(|e| matches!(e, Typst(TypstEvent::End(TypstTag::Strikethrough)))));
}
}
mod code {
use super::*;
#[test]
fn inline() {
let md = "\
foo `bar` baz
";
let i = ConvertCode::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("foo "))),
Typst(TypstEvent::Code(CowStr::Borrowed("bar"))),
Markdown(MdEvent::Text(CowStr::Borrowed(" baz"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn block_indent() {
let md = "\
whatever
code 1
code 2
";
let i = ConvertCode::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("whatever"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
Typst(TypstEvent::Start(TypstTag::CodeBlock(
None,
typst::CodeBlockDisplay::Block
))),
Markdown(MdEvent::Text(CowStr::Borrowed("code 1\n"))),
Markdown(MdEvent::Text(CowStr::Borrowed("code 2\n"))),
Typst(TypstEvent::End(TypstTag::CodeBlock(
None,
typst::CodeBlockDisplay::Block
))),
]
);
}
#[test]
fn block() {
let md = "\
```
blah
```
";
let i = ConvertCode::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::CodeBlock(
None,
typst::CodeBlockDisplay::Block
))),
Markdown(MdEvent::Text(CowStr::Borrowed("blah\n"))),
Typst(TypstEvent::End(TypstTag::CodeBlock(
None,
typst::CodeBlockDisplay::Block
))),
]
);
}
#[test]
fn block_with_fence() {
let md = "\
```foo
blah
```
";
let i = ConvertCode::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::CodeBlock(
Some(CowStr::Borrowed("foo")),
typst::CodeBlockDisplay::Block
))),
Markdown(MdEvent::Text(CowStr::Borrowed("blah\n"))),
Typst(TypstEvent::End(TypstTag::CodeBlock(
None,
typst::CodeBlockDisplay::Block
))),
]
);
}
}
mod text {
use super::*;
#[test]
fn convert_text() {
let md = "\
foo
bar
baz
";
let i = ConvertText::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Typst(TypstEvent::Text(CowStr::Borrowed("foo"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Typst(TypstEvent::Text(CowStr::Borrowed("bar"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Typst(TypstEvent::Text(CowStr::Borrowed("baz"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
}
mod breaks {
use super::*;
#[test]
fn soft() {
let md = "\
foo
bar
";
let i = ConvertSoftBreaks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("foo"))),
Typst(TypstEvent::Text(CowStr::Borrowed(" "))),
Markdown(MdEvent::Text(CowStr::Borrowed("bar"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
#[test]
fn hard() {
let md = "\
foo
bar
";
let i = ConvertHardBreaks::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("foo"))),
Typst(TypstEvent::Linebreak),
Markdown(MdEvent::Text(CowStr::Borrowed("bar"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
]
);
}
}
mod paragraphs {
use super::*;
#[test]
fn convert_paragraphs() {
let md = "\
foo
bar
baz
";
let i = ConvertParagraphs::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("foo"))),
Typst(TypstEvent::End(TypstTag::Paragraph)),
Typst(TypstEvent::Start(TypstTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("bar"))),
Typst(TypstEvent::End(TypstTag::Paragraph)),
Typst(TypstEvent::Start(TypstTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("baz"))),
Typst(TypstEvent::End(TypstTag::Paragraph)),
]
);
}
}
mod lists {
use super::*;
#[test]
fn bullet() {
let md = "\
* dogs
* are
* cool
";
let i = ConvertLists::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::BulletList(None, false))),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("dogs"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("are"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("cool"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::End(TypstTag::BulletList(None, false))),
],
);
}
#[test]
fn numbered() {
let md = "\
1. cats are _too_
2. birds are ok
";
let i = ConvertLists::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::NumberedList(1, None, false))),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("cats are "))),
Markdown(MdEvent::Start(MdTag::Emphasis)),
Markdown(MdEvent::Text(CowStr::Borrowed("too"))),
Markdown(MdEvent::End(MdTagEnd::Emphasis)),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("birds are ok"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::End(TypstTag::NumberedList(1, None, false))),
],
);
}
#[test]
fn numbered_custom_start() {
let md = "\
6. foo
1. bar
";
let i = ConvertLists::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::NumberedList(6, None, false))),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("foo"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("bar"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::End(TypstTag::NumberedList(1, None, false))),
],
);
}
#[test]
fn multiple_lines() {
let md = "\
* multiple
lines
";
let i = ConvertLists::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::BulletList(None, false))),
Typst(TypstEvent::Start(TypstTag::Item)),
Markdown(MdEvent::Text(CowStr::Borrowed("multiple"))),
Markdown(MdEvent::SoftBreak),
Markdown(MdEvent::Text(CowStr::Borrowed("lines"))),
Typst(TypstEvent::End(TypstTag::Item)),
Typst(TypstEvent::End(TypstTag::BulletList(None, false))),
]
);
}
}
mod issues {
use super::*;
#[test]
fn backslashes_in_backticks() {
let md = r###"before `\` after"###;
let i = ConvertText::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Markdown(MdEvent::Start(MdTag::Paragraph)),
Typst(TypstEvent::Text("before ".into())),
Markdown(MdEvent::Code(r#"\"#.into())),
Typst(TypstEvent::Text(" after".into())),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
],
);
}
#[test]
fn simple_blockquote() {
let md = "> test";
let i = ConvertBlockQuotes::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Quote(
typst::QuoteType::Block,
typst::QuoteQuotes::Auto,
None,
))),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("test"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
Typst(TypstEvent::End(TypstTag::Quote(
typst::QuoteType::Block,
typst::QuoteQuotes::Auto,
None,
))),
],
);
}
#[test]
fn complex_blockquote() {
let md = "> one\n> two\n> three";
let i = ConvertBlockQuotes::new(MarkdownIter(Parser::new(&md)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Quote(
typst::QuoteType::Block,
typst::QuoteQuotes::Auto,
None,
))),
Markdown(MdEvent::Start(MdTag::Paragraph)),
Markdown(MdEvent::Text(CowStr::Borrowed("one"))),
Markdown(MdEvent::SoftBreak),
Markdown(MdEvent::Text(CowStr::Borrowed("two"))),
Markdown(MdEvent::SoftBreak),
Markdown(MdEvent::Text(CowStr::Borrowed("three"))),
Markdown(MdEvent::End(MdTagEnd::Paragraph)),
Typst(TypstEvent::End(TypstTag::Quote(
typst::QuoteType::Block,
typst::QuoteQuotes::Auto,
None,
))),
],
);
}
}
mod tables {
use super::*;
#[test]
fn simple_table() {
let md = "\
| Header1 | Header2 |
|---------|---------|
| Cell1 | Cell2 |
";
let i = ConvertTables::new(MarkdownIter(Parser::new_ext(
&md,
pulldown_cmark::Options::ENABLE_TABLES,
)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Table(vec![
typst::TableCellAlignment::None,
typst::TableCellAlignment::None,
]))),
Typst(TypstEvent::Start(TypstTag::TableHead)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Header1"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Header2"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::End(TypstTag::TableHead)),
Typst(TypstEvent::Start(TypstTag::TableRow)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cell1"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cell2"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::End(TypstTag::TableRow)),
Typst(TypstEvent::End(TypstTag::Table(vec![]))),
]
);
}
#[test]
fn table_with_alignment() {
let md = "\
| Header1 | Header2 |
|:--------|:-------:|
| Cell1 | Cell2 |
";
let i = ConvertTables::new(MarkdownIter(Parser::new_ext(
&md,
pulldown_cmark::Options::ENABLE_TABLES,
)));
self::assert_eq!(
i.collect::<Vec<super::ParserEvent>>(),
vec![
Typst(TypstEvent::Start(TypstTag::Table(vec![
typst::TableCellAlignment::Left,
typst::TableCellAlignment::Center,
]))),
Typst(TypstEvent::Start(TypstTag::TableHead)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Header1"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Header2"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::End(TypstTag::TableHead)),
Typst(TypstEvent::Start(TypstTag::TableRow)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cell1"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::Start(TypstTag::TableCell)),
Markdown(MdEvent::Text(CowStr::Borrowed("Cell2"))),
Typst(TypstEvent::End(TypstTag::TableCell)),
Typst(TypstEvent::End(TypstTag::TableRow)),
Typst(TypstEvent::End(TypstTag::Table(vec![]))),
]
);
}
#[test]
fn table_to_typst_markup() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let md = "\
| Left | Center | Right |
|:-----|:------:|------:|
| A | B | C |
";
let parser = Parser::new_ext(&md, pulldown_cmark::Options::ENABLE_TABLES);
let md_iter = MarkdownIter(parser);
let converted = ConvertTables::new(ConvertText::new(md_iter));
let typst_only = TypstFilter(converted);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(output.contains("#table("));
assert!(output.contains("columns: 3"));
assert!(output.contains("align: (left, center, right)"));
assert!(output.contains("table.header("));
assert!(output.contains("[Left]"));
assert!(output.contains("[Center]"));
assert!(output.contains("[Right]"));
assert!(output.contains("[A]"));
assert!(output.contains("[B]"));
assert!(output.contains("[C]"));
}
}
mod filter_image_paragraphs {
use super::*;
#[test]
fn standalone_image_not_wrapped_in_paragraph() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let md = "\n";
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertImages::new(ConvertParagraphs::new(md_iter));
let filtered = FilterImageParagraphs::new(converted);
let typst_only = TypstFilter(filtered);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(
!output.contains("#par()"),
"Output should not contain #par(): {}",
output
);
assert!(output.contains("#image("), "Output should contain #image");
}
#[test]
fn image_with_text_stays_in_paragraph() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let md = "Some text  more text\n";
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertText::new(ConvertImages::new(ConvertParagraphs::new(md_iter)));
let filtered = FilterImageParagraphs::new(converted);
let typst_only = TypstFilter(filtered);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(
output.contains("#par()"),
"Output should contain #par(): {}",
output
);
}
#[test]
fn html_img_tag_not_wrapped_in_paragraph() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let events = vec![
ParserEvent::Typst(typst::Event::Start(typst::Tag::Paragraph)),
ParserEvent::Typst(typst::Event::Raw("#image(\"test.png\")".into())),
ParserEvent::Typst(typst::Event::End(typst::Tag::Paragraph)),
];
let filtered = FilterImageParagraphs::new(events.into_iter());
let typst_only = TypstFilter(filtered);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(
!output.contains("#par()"),
"Output should not contain #par(): {}",
output
);
assert!(
output.contains("#image("),
"Output should contain #image: {}",
output
);
}
}
mod html_sup {
use super::*;
#[test]
fn converts_sup_to_super() {
let md = "2<sup>8</sup> = 256";
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlSup::new(md_iter);
let events: Vec<_> = converted.collect();
let has_super_start = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Raw(s)) if s.as_ref() == "#super["
)
});
let has_super_end = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Raw(s)) if s.as_ref() == "]"
)
});
assert!(has_super_start, "Should have #super[ event");
assert!(has_super_end, "Should have ] event");
}
#[test]
fn end_to_end_sup_conversion() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let md = "2<sup>8</sup>";
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlSup::new(ConvertText::new(ConvertParagraphs::new(md_iter)));
let typst_only = TypstFilter(converted);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(
output.contains("#super["),
"Output should contain #super[: {}",
output
);
}
}
mod html_img {
use super::*;
#[test]
fn converts_html_img_to_typst_image() {
let md = r#"<img src="test.png" alt="Test image">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let has_image = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(src, alt, _, _, _)))
if src.as_ref() == "test.png" && alt.as_ref() == "Test image"
)
});
assert!(has_image, "Should have Image event: {:?}", events);
}
#[test]
fn html_img_without_alt_uses_image() {
let md = r#"<img src="test.png">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let has_image = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(src, alt, _, _, _)))
if src.as_ref() == "test.png" && alt.is_empty()
)
});
assert!(has_image, "Should have Image event: {:?}", events);
}
#[test]
fn end_to_end_html_img() {
use crate::typst::TypstFilter;
use pulldown_typst::markup::TypstMarkup;
let md = r#"<img src="test.png" alt="Test">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(ConvertParagraphs::new(md_iter));
let filtered = FilterImageParagraphs::new(converted);
let typst_only = TypstFilter(filtered);
let output: String = TypstMarkup::new(typst_only).collect();
assert!(
output.contains("#figure("),
"Output should contain #figure: {}",
output
);
assert!(
!output.contains("#par()"),
"Output should NOT contain #par(): {}",
output
);
}
#[test]
fn html_img_with_style_width() {
let md = r#"<img src="test.png" style="width: 50%;">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let has_width = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(_, _, _, Some(w), _)))
if w.as_ref() == "50%"
)
});
assert!(has_width, "Should have width: 50% in output: {:?}", events);
}
#[test]
fn html_img_with_width_height_attrs() {
let md = r#"<img src="test.png" width="200" height="100">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let has_dimensions = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(_, _, _, Some(w), Some(h))))
if w.as_ref() == "200pt" && h.as_ref() == "100pt"
)
});
assert!(
has_dimensions,
"Should have width: 200pt and height: 100pt: {:?}",
events
);
}
#[test]
fn html_img_with_class() {
let md = r#"<img src="test.png" class="center">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let has_class = events.iter().any(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(_, _, Some(c), _, _)))
if c.as_ref() == "center"
)
});
assert!(has_class, "Should have class=center: {:?}", events);
}
#[test]
fn html_img_emits_start_and_end_events() {
let md = r#"<img src="test.png">"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let start_count = events
.iter()
.filter(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(_, _, _, _, _)))
)
})
.count();
let end_count = events
.iter()
.filter(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::End(typst::Tag::Image(_, _, _, _, _)))
)
})
.count();
std::assert_eq!(start_count, 1, "Should have exactly 1 Start event");
std::assert_eq!(end_count, 1, "Should have exactly 1 End event");
}
#[test]
fn html_img_in_table_emits_matching_events() {
let md = r#"
| Column 1 | Column 2 |
|----------|----------|
| text | <img src="ferris.png" alt="Ferris"> |
"#;
let parser = Parser::new(&md);
let md_iter = MarkdownIter(parser);
let converted = ConvertHtmlImg::new(md_iter);
let events: Vec<_> = converted.collect();
let start_image_count = events
.iter()
.filter(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::Start(typst::Tag::Image(_, _, _, _, _)))
)
})
.count();
let end_image_count = events
.iter()
.filter(|e| {
matches!(
e,
ParserEvent::Typst(typst::Event::End(typst::Tag::Image(_, _, _, _, _)))
)
})
.count();
std::assert_eq!(
start_image_count,
end_image_count,
"Start and End Image events must be balanced"
);
std::assert_eq!(start_image_count, 1, "Should have exactly 1 image");
}
}
}