use super::format::OutputFormat;
use jotdown::{Attributes, Container, Event, Parser};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct InlineRenderContext {
pub quote_depth: usize,
}
#[derive(Default)]
struct DjotFrame {
children: Vec<String>,
classes: Vec<String>,
link_url: Option<String>,
has_explicit_link: bool,
last_char: Option<char>,
case_protected: bool,
}
impl DjotFrame {
fn push_rendered(&mut self, rendered: String, logical_last_char: Option<char>) {
self.children.push(rendered);
if let Some(ch) = logical_last_char {
self.last_char = Some(ch);
}
}
fn prev_opens_quote(&self) -> bool {
self.last_char
.is_none_or(|c| c.is_whitespace() || "([{\u{2018}\u{201C}'\"".contains(c))
}
}
fn opening_quote_depth(
context: InlineRenderContext,
current_depth: usize,
source_inner: bool,
) -> usize {
if source_inner && current_depth <= context.quote_depth {
context.quote_depth + 1
} else {
current_depth
}
}
fn push_open_quote<F: OutputFormat<Output = String>>(frame: &mut DjotFrame, fmt: &F, depth: usize) {
let (open, _) = fmt.quote_marks(depth);
let logical_char = open.chars().next();
frame.push_rendered(fmt.text(open), logical_char);
}
fn push_close_quote<F: OutputFormat<Output = String>>(
frame: &mut DjotFrame,
fmt: &F,
depth: usize,
) {
let (_, close) = fmt.quote_marks(depth);
let logical_char = close.chars().last();
frame.push_rendered(fmt.text(close), logical_char);
}
struct QuoteRenderState {
depth: usize,
stack: Vec<usize>,
}
impl QuoteRenderState {
fn new(context: InlineRenderContext) -> Self {
Self {
depth: context.quote_depth,
stack: Vec::new(),
}
}
fn render_event<F: OutputFormat<Output = String>>(
&mut self,
frame: &mut DjotFrame,
fmt: &F,
context: InlineRenderContext,
source_inner: bool,
opens_quote: bool,
) {
if opens_quote {
let depth = opening_quote_depth(context, self.depth, source_inner);
push_open_quote(frame, fmt, depth);
self.stack.push(depth);
self.depth = depth + 1;
} else {
let fallback_depth = context.quote_depth + usize::from(source_inner);
let depth = self.stack.pop().unwrap_or(fallback_depth);
push_close_quote(frame, fmt, depth);
self.depth = self.stack.last().map_or(context.quote_depth, |d| d + 1);
}
}
}
fn span_classes(attrs: Option<&Attributes>) -> Vec<String> {
attrs
.into_iter()
.flat_map(|attrs| attrs.iter())
.filter_map(|(kind, val)| {
use jotdown::AttributeKind;
if matches!(kind, AttributeKind::Class) {
Some(val.to_string())
} else {
None
}
})
.flat_map(|classes| {
classes
.split_whitespace()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
})
.collect()
}
fn handle_end_event<F: OutputFormat<Output = String>>(
container: Container,
frame: DjotFrame,
parent: &mut DjotFrame,
fmt: &F,
) {
let inner_text = frame.children.join("");
let formatted = match container {
Container::Emphasis => fmt.emph(inner_text),
Container::Strong => fmt.strong(inner_text),
Container::Link(_, _) => {
if let Some(url) = frame.link_url.as_deref() {
fmt.link(url, inner_text)
} else {
inner_text
}
}
Container::Span => {
if frame
.classes
.iter()
.any(|class| class == "smallcaps" || class == "small-caps")
{
fmt.small_caps(inner_text)
} else {
inner_text
}
}
_ => inner_text,
};
parent.push_rendered(formatted, frame.last_char);
parent.has_explicit_link |= frame.has_explicit_link;
}
fn render_djot_inline_internal<F, G>(
src: &str,
fmt: &F,
context: InlineRenderContext,
mut transform_text: G,
) -> (String, bool)
where
F: OutputFormat<Output = String>,
G: FnMut(&str) -> String,
{
let parser = Parser::new(src);
let mut stack = vec![DjotFrame::default()];
let mut quote_state = QuoteRenderState::new(context);
for event in parser {
match event {
Event::Start(container, attrs) => {
let link_url = if let Container::Link(url, _) = &container {
Some(url.to_string())
} else {
None
};
let classes = span_classes(Some(&attrs));
let parent_protected = stack.last().is_some_and(|f| f.case_protected);
let is_nocase = classes.iter().any(|c| c == "nocase");
stack.push(DjotFrame {
case_protected: parent_protected || is_nocase,
has_explicit_link: link_url.is_some(),
link_url,
classes,
..Default::default()
});
}
Event::End(container) => {
if let (Some(frame), Some(parent)) = (stack.pop(), stack.last_mut()) {
handle_end_event(container, frame, parent, fmt);
}
}
Event::Str(s) => {
if let Some(frame) = stack.last_mut() {
let transformed = transform_text(s.as_ref());
let render_text = if frame.case_protected {
s.to_string()
} else {
transformed
};
frame.push_rendered(fmt.text(&render_text), render_text.chars().last());
}
}
Event::Symbol(sym) => {
if let Some(frame) = stack.last_mut() {
frame.push_rendered(fmt.text(sym.as_ref()), sym.chars().last());
}
}
Event::LeftSingleQuote => {
if let Some(frame) = stack.last_mut() {
quote_state.render_event(frame, fmt, context, true, true);
}
}
Event::RightSingleQuote => {
if let Some(frame) = stack.last_mut() {
quote_state.render_event(frame, fmt, context, true, frame.prev_opens_quote());
}
}
Event::LeftDoubleQuote => {
if let Some(frame) = stack.last_mut() {
quote_state.render_event(frame, fmt, context, false, true);
}
}
Event::RightDoubleQuote => {
if let Some(frame) = stack.last_mut() {
quote_state.render_event(frame, fmt, context, false, frame.prev_opens_quote());
}
}
Event::Softbreak | Event::Hardbreak => {
if let Some(frame) = stack.last_mut() {
frame.push_rendered(fmt.text(" "), Some(' '));
}
}
_ => {}
}
}
stack
.into_iter()
.next()
.map(|frame| (frame.children.join(""), frame.has_explicit_link))
.unwrap_or_default()
}
pub fn render_djot_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
render_djot_inline_internal(src, fmt, InlineRenderContext::default(), str::to_string).0
}
pub fn render_djot_inline_with_context<F: OutputFormat<Output = String>>(
src: &str,
fmt: &F,
context: InlineRenderContext,
) -> String {
render_djot_inline_internal(src, fmt, context, str::to_string).0
}
pub(crate) fn render_djot_inline_with_transform<F, G>(
src: &str,
fmt: &F,
transform_text: G,
) -> (String, bool)
where
F: OutputFormat<Output = String>,
G: FnMut(&str) -> String,
{
render_djot_inline_internal(src, fmt, InlineRenderContext::default(), transform_text)
}
pub(crate) fn render_djot_inline_with_transform_and_context<F, G>(
src: &str,
fmt: &F,
context: InlineRenderContext,
transform_text: G,
) -> (String, bool)
where
F: OutputFormat<Output = String>,
G: FnMut(&str) -> String,
{
render_djot_inline_internal(src, fmt, context, transform_text)
}
pub fn render_org_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
use orgize::Event;
use orgize::Org;
use orgize::elements::Element;
let org = Org::parse(src);
let mut stack: Vec<(u8, String)> = vec![(2, String::new())];
for event in org.iter() {
match event {
Event::Start(Element::Bold) => stack.push((0, String::new())),
Event::Start(Element::Italic) => stack.push((1, String::new())),
Event::End(Element::Bold) => {
if let Some((0, inner)) = stack.pop() {
let rendered = fmt.strong(inner);
if let Some(top) = stack.last_mut() {
top.1.push_str(&rendered);
}
}
}
Event::End(Element::Italic) => {
if let Some((1, inner)) = stack.pop() {
let rendered = fmt.emph(inner);
if let Some(top) = stack.last_mut() {
top.1.push_str(&rendered);
}
}
}
Event::Start(Element::Link(link)) => {
let desc = link.desc.as_deref().unwrap_or(&link.path);
let rendered = fmt.link(&link.path, fmt.text(desc));
if let Some(top) = stack.last_mut() {
top.1.push_str(&rendered);
}
}
Event::Start(Element::Text { value }) => {
if let Some(top) = stack.last_mut() {
top.1.push_str(&fmt.text(value));
}
}
Event::Start(Element::Verbatim { value } | Element::Code { value }) => {
if let Some(top) = stack.last_mut() {
top.1.push_str(&fmt.text(value));
}
}
_ => {}
}
}
stack.into_iter().next().map(|(_, s)| s).unwrap_or_default()
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
use crate::render::html::Html;
use crate::render::plain::PlainText;
use crate::render::typst::Typst;
#[test]
fn test_djot_emphasis_plain() {
let fmt = PlainText;
let result = render_djot_inline("_foo_", &fmt);
assert_eq!(result, "_foo_");
}
#[test]
fn test_djot_strong_single_asterisk() {
let fmt = PlainText;
let result = render_djot_inline("*bar*", &fmt);
assert_eq!(result, "**bar**");
}
#[test]
fn test_djot_unicode_math() {
let fmt = PlainText;
let result = render_djot_inline("H₂O", &fmt);
assert_eq!(result, "H₂O");
}
#[test]
fn test_djot_plain_no_markup() {
let fmt = PlainText;
let result = render_djot_inline("plain text with no markup", &fmt);
assert_eq!(result, "plain text with no markup");
}
#[test]
fn test_djot_combined_formatting() {
let fmt = PlainText;
let result = render_djot_inline("_emphasized *bold* text_", &fmt);
assert_eq!(result, "_emphasized **bold** text_");
}
#[test]
fn test_djot_link() {
let fmt = PlainText;
let result = render_djot_inline("[click here](https://example.com)", &fmt);
assert_eq!(result, "click here");
}
#[test]
fn test_djot_nested_formatting_preserves_typst_markup() {
let fmt = Typst;
let result = render_djot_inline("_emphasized *bold* text_", &fmt);
assert_eq!(result, "#emph[emphasized *bold* text]");
}
#[test]
fn test_djot_nested_link_preserves_inner_markup_html() {
let fmt = Html;
let result = render_djot_inline("[_linked emphasis_](https://example.com)", &fmt);
assert_eq!(
result,
r#"<a href="https://example.com"><em>linked emphasis</em></a>"#
);
}
#[test]
fn test_djot_quotes_inside_emphasis_open_correctly() {
let fmt = PlainText;
let result = render_djot_inline("_\"Parmenides\" dialogue_", &fmt);
assert_eq!(result, "_“Parmenides” dialogue_");
}
#[test]
fn test_djot_quotes_with_ambient_quote_depth_use_inner_marks() {
let fmt = PlainText;
let result = render_djot_inline_with_context(
"\"Parmenides\" dialogue",
&fmt,
InlineRenderContext { quote_depth: 1 },
);
assert_eq!(result, "‘Parmenides’ dialogue");
}
#[test]
fn test_djot_nested_quotes_alternate_marks() {
let fmt = PlainText;
let result = render_djot_inline("\"outer \"inner\" claim\"", &fmt);
assert_eq!(result, "“outer ‘inner’ claim”");
}
#[test]
fn test_djot_quotes_inside_emphasis_use_ambient_quote_depth() {
let fmt = PlainText;
let result = render_djot_inline_with_context(
"_\"Parmenides\" dialogue_",
&fmt,
InlineRenderContext { quote_depth: 1 },
);
assert_eq!(result, "_‘Parmenides’ dialogue_");
}
#[test]
fn test_org_plain_text() {
let fmt = PlainText;
let result = render_org_inline("plain text with no markup", &fmt);
assert_eq!(result, "plain text with no markup");
}
#[test]
fn test_org_bold() {
let fmt = PlainText;
let result = render_org_inline("*bold*", &fmt);
assert_eq!(result, "**bold**");
}
#[test]
fn test_org_italic() {
let fmt = PlainText;
let result = render_org_inline("/italic/", &fmt);
assert_eq!(result, "_italic_");
}
}