1use super::format::{OutputFormat, QuoteMarks};
9use jotdown::{Attributes, Container, Event, Parser};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub struct InlineRenderContext {
14 pub quote_depth: usize,
16}
17
18#[derive(Default)]
19struct DjotFrame {
20 children: Vec<String>,
21 classes: Vec<String>,
22 link_url: Option<String>,
23 has_explicit_link: bool,
24 last_char: Option<char>,
25 case_protected: bool,
27}
28
29impl DjotFrame {
30 fn push_rendered(&mut self, rendered: String, logical_last_char: Option<char>) {
31 self.children.push(rendered);
32 if let Some(ch) = logical_last_char {
33 self.last_char = Some(ch);
34 }
35 }
36
37 fn prev_opens_quote(&self) -> bool {
38 self.last_char
39 .is_none_or(|c| c.is_whitespace() || "([{\u{2018}\u{201C}'\"".contains(c))
40 }
41}
42
43fn opening_quote_depth(
44 context: InlineRenderContext,
45 current_depth: usize,
46 source_inner: bool,
47) -> usize {
48 if source_inner && current_depth <= context.quote_depth {
49 context.quote_depth + 1
50 } else {
51 current_depth
52 }
53}
54
55fn push_open_quote<F: OutputFormat<Output = String>>(
56 frame: &mut DjotFrame,
57 fmt: &F,
58 depth: usize,
59 marks: &QuoteMarks,
60) {
61 let (open, _) = fmt.quote_marks(depth, marks);
62 let logical_char = open.chars().next();
63 frame.push_rendered(fmt.text(open), logical_char);
64}
65
66fn push_close_quote<F: OutputFormat<Output = String>>(
67 frame: &mut DjotFrame,
68 fmt: &F,
69 depth: usize,
70 marks: &QuoteMarks,
71) {
72 let (_, close) = fmt.quote_marks(depth, marks);
73 let logical_char = close.chars().last();
74 frame.push_rendered(fmt.text(close), logical_char);
75}
76
77struct QuoteRenderState {
78 depth: usize,
79 stack: Vec<usize>,
80 marks: QuoteMarks,
85}
86
87impl QuoteRenderState {
88 fn new(context: InlineRenderContext) -> Self {
89 Self {
90 depth: context.quote_depth,
91 stack: Vec::new(),
92 marks: QuoteMarks::default(),
93 }
94 }
95
96 fn render_event<F: OutputFormat<Output = String>>(
97 &mut self,
98 frame: &mut DjotFrame,
99 fmt: &F,
100 context: InlineRenderContext,
101 source_inner: bool,
102 opens_quote: bool,
103 ) {
104 if opens_quote {
105 let depth = opening_quote_depth(context, self.depth, source_inner);
106 push_open_quote(frame, fmt, depth, &self.marks);
107 self.stack.push(depth);
108 self.depth = depth + 1;
109 } else {
110 let fallback_depth = context.quote_depth + usize::from(source_inner);
111 let depth = self.stack.pop().unwrap_or(fallback_depth);
112 push_close_quote(frame, fmt, depth, &self.marks);
113 self.depth = self.stack.last().map_or(context.quote_depth, |d| d + 1);
114 }
115 }
116}
117
118fn span_classes(attrs: Option<&Attributes>) -> Vec<String> {
119 attrs
120 .into_iter()
121 .flat_map(|attrs| attrs.iter())
122 .filter_map(|(kind, val)| {
123 use jotdown::AttributeKind;
124 if matches!(kind, AttributeKind::Class) {
125 Some(val.to_string())
126 } else {
127 None
128 }
129 })
130 .flat_map(|classes| {
131 classes
132 .split_whitespace()
133 .map(std::string::ToString::to_string)
134 .collect::<Vec<_>>()
135 })
136 .collect()
137}
138
139fn handle_end_event<F: OutputFormat<Output = String>>(
141 container: Container,
142 frame: DjotFrame,
143 parent: &mut DjotFrame,
144 fmt: &F,
145) {
146 let inner_text = frame.children.join("");
147 let formatted = match container {
148 Container::Emphasis => fmt.emph(inner_text),
149 Container::Strong => fmt.strong(inner_text),
150 Container::Link(_, _) => {
151 if let Some(url) = frame.link_url.as_deref() {
152 fmt.link(url, inner_text)
153 } else {
154 inner_text
155 }
156 }
157 Container::Span => {
158 if frame
159 .classes
160 .iter()
161 .any(|class| class == "smallcaps" || class == "small-caps")
162 {
163 fmt.small_caps(inner_text)
164 } else {
165 inner_text
166 }
167 }
168 _ => inner_text,
169 };
170 parent.push_rendered(formatted, frame.last_char);
171 parent.has_explicit_link |= frame.has_explicit_link;
172}
173
174fn render_djot_inline_internal<F, G>(
175 src: &str,
176 fmt: &F,
177 context: InlineRenderContext,
178 mut transform_text: G,
179) -> (String, bool)
180where
181 F: OutputFormat<Output = String>,
182 G: FnMut(&str) -> String,
183{
184 let parser = Parser::new(src);
185 let mut stack = vec![DjotFrame::default()];
186 let mut quote_state = QuoteRenderState::new(context);
187
188 for event in parser {
189 match event {
190 Event::Start(container, attrs) => {
191 let link_url = if let Container::Link(url, _) = &container {
192 Some(url.to_string())
193 } else {
194 None
195 };
196 let classes = span_classes(Some(&attrs));
197 let parent_protected = stack.last().is_some_and(|f| f.case_protected);
198 let is_nocase = classes.iter().any(|c| c == "nocase");
199 stack.push(DjotFrame {
200 case_protected: parent_protected || is_nocase,
201 has_explicit_link: link_url.is_some(),
202 link_url,
203 classes,
204 ..Default::default()
205 });
206 }
207 Event::End(container) => {
208 if let (Some(frame), Some(parent)) = (stack.pop(), stack.last_mut()) {
209 handle_end_event(container, frame, parent, fmt);
210 }
211 }
212 Event::Str(s) => {
213 if let Some(frame) = stack.last_mut() {
214 let transformed = transform_text(s.as_ref());
217 let render_text = if frame.case_protected {
218 s.to_string()
219 } else {
220 transformed
221 };
222 frame.push_rendered(fmt.text(&render_text), render_text.chars().last());
223 }
224 }
225 Event::Symbol(sym) => {
226 if let Some(frame) = stack.last_mut() {
227 frame.push_rendered(fmt.text(sym.as_ref()), sym.chars().last());
228 }
229 }
230 Event::LeftSingleQuote => {
231 if let Some(frame) = stack.last_mut() {
232 quote_state.render_event(frame, fmt, context, true, true);
233 }
234 }
235 Event::RightSingleQuote => {
236 if let Some(frame) = stack.last_mut() {
237 quote_state.render_event(frame, fmt, context, true, frame.prev_opens_quote());
238 }
239 }
240 Event::LeftDoubleQuote => {
241 if let Some(frame) = stack.last_mut() {
242 quote_state.render_event(frame, fmt, context, false, true);
243 }
244 }
245 Event::RightDoubleQuote => {
246 if let Some(frame) = stack.last_mut() {
247 quote_state.render_event(frame, fmt, context, false, frame.prev_opens_quote());
248 }
249 }
250 Event::Softbreak | Event::Hardbreak => {
251 if let Some(frame) = stack.last_mut() {
252 frame.push_rendered(fmt.text(" "), Some(' '));
253 }
254 }
255 _ => {}
256 }
257 }
258
259 stack
260 .into_iter()
261 .next()
262 .map(|frame| (frame.children.join(""), frame.has_explicit_link))
263 .unwrap_or_default()
264}
265
266pub fn render_djot_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
280 render_djot_inline_internal(src, fmt, InlineRenderContext::default(), str::to_string).0
281}
282
283pub fn render_djot_inline_with_context<F: OutputFormat<Output = String>>(
285 src: &str,
286 fmt: &F,
287 context: InlineRenderContext,
288) -> String {
289 render_djot_inline_internal(src, fmt, context, str::to_string).0
290}
291
292pub(crate) fn render_djot_inline_with_transform<F, G>(
294 src: &str,
295 fmt: &F,
296 transform_text: G,
297) -> (String, bool)
298where
299 F: OutputFormat<Output = String>,
300 G: FnMut(&str) -> String,
301{
302 render_djot_inline_internal(src, fmt, InlineRenderContext::default(), transform_text)
303}
304
305pub(crate) fn render_djot_inline_with_transform_and_context<F, G>(
307 src: &str,
308 fmt: &F,
309 context: InlineRenderContext,
310 transform_text: G,
311) -> (String, bool)
312where
313 F: OutputFormat<Output = String>,
314 G: FnMut(&str) -> String,
315{
316 render_djot_inline_internal(src, fmt, context, transform_text)
317}
318
319pub fn render_org_inline<F: OutputFormat<Output = String>>(src: &str, fmt: &F) -> String {
327 use orgize::Event;
328 use orgize::Org;
329 use orgize::elements::Element;
330
331 let org = Org::parse(src);
332 let mut stack: Vec<(u8, String)> = vec![(2, String::new())];
335
336 for event in org.iter() {
337 match event {
338 Event::Start(Element::Bold) => stack.push((0, String::new())),
339 Event::Start(Element::Italic) => stack.push((1, String::new())),
340 Event::End(Element::Bold) => {
341 if let Some((0, inner)) = stack.pop() {
342 let rendered = fmt.strong(inner);
343 if let Some(top) = stack.last_mut() {
344 top.1.push_str(&rendered);
345 }
346 }
347 }
348 Event::End(Element::Italic) => {
349 if let Some((1, inner)) = stack.pop() {
350 let rendered = fmt.emph(inner);
351 if let Some(top) = stack.last_mut() {
352 top.1.push_str(&rendered);
353 }
354 }
355 }
356 Event::Start(Element::Link(link)) => {
357 let desc = link.desc.as_deref().unwrap_or(&link.path);
358 let rendered = fmt.link(&link.path, fmt.text(desc));
359 if let Some(top) = stack.last_mut() {
360 top.1.push_str(&rendered);
361 }
362 }
363 Event::Start(Element::Text { value }) => {
364 if let Some(top) = stack.last_mut() {
365 top.1.push_str(&fmt.text(value));
366 }
367 }
368 Event::Start(Element::Verbatim { value } | Element::Code { value }) => {
369 if let Some(top) = stack.last_mut() {
370 top.1.push_str(&fmt.text(value));
371 }
372 }
373 _ => {}
374 }
375 }
376
377 stack.into_iter().next().map(|(_, s)| s).unwrap_or_default()
378}
379
380#[cfg(test)]
381#[allow(
382 clippy::unwrap_used,
383 clippy::expect_used,
384 clippy::panic,
385 clippy::indexing_slicing,
386 clippy::todo,
387 clippy::unimplemented,
388 clippy::unreachable,
389 clippy::get_unwrap,
390 reason = "Panicking is acceptable and often desired in tests."
391)]
392mod tests {
393 use super::*;
394 use crate::render::html::Html;
395 use crate::render::plain::PlainText;
396 use crate::render::typst::Typst;
397
398 #[test]
399 fn test_djot_emphasis_plain() {
400 let fmt = PlainText;
401 let result = render_djot_inline("_foo_", &fmt);
402 assert_eq!(result, "_foo_");
404 }
405
406 #[test]
407 fn test_djot_strong_single_asterisk() {
408 let fmt = PlainText;
409 let result = render_djot_inline("*bar*", &fmt);
411 assert_eq!(result, "**bar**");
413 }
414
415 #[test]
416 fn test_djot_unicode_math() {
417 let fmt = PlainText;
418 let result = render_djot_inline("H₂O", &fmt);
419 assert_eq!(result, "H₂O");
420 }
421
422 #[test]
423 fn test_djot_plain_no_markup() {
424 let fmt = PlainText;
425 let result = render_djot_inline("plain text with no markup", &fmt);
426 assert_eq!(result, "plain text with no markup");
427 }
428
429 #[test]
430 fn test_djot_combined_formatting() {
431 let fmt = PlainText;
432 let result = render_djot_inline("_emphasized *bold* text_", &fmt);
434 assert_eq!(result, "_emphasized **bold** text_");
436 }
437
438 #[test]
439 fn test_djot_link() {
440 let fmt = PlainText;
441 let result = render_djot_inline("[click here](https://example.com)", &fmt);
443 assert_eq!(result, "click here");
445 }
446
447 #[test]
448 fn test_djot_nested_formatting_preserves_typst_markup() {
449 let fmt = Typst;
450 let result = render_djot_inline("_emphasized *bold* text_", &fmt);
451 assert_eq!(result, "#emph[emphasized #strong[bold] text]");
452 }
453
454 #[test]
455 fn test_djot_nested_link_preserves_inner_markup_html() {
456 let fmt = Html;
457 let result = render_djot_inline("[_linked emphasis_](https://example.com)", &fmt);
458 assert_eq!(
459 result,
460 r#"<a href="https://example.com"><em>linked emphasis</em></a>"#
461 );
462 }
463
464 #[test]
465 fn test_djot_quotes_inside_emphasis_open_correctly() {
466 let fmt = PlainText;
467 let result = render_djot_inline("_\"Parmenides\" dialogue_", &fmt);
468 assert_eq!(result, "_“Parmenides” dialogue_");
469 }
470
471 #[test]
472 fn test_djot_quotes_with_ambient_quote_depth_use_inner_marks() {
473 let fmt = PlainText;
474 let result = render_djot_inline_with_context(
475 "\"Parmenides\" dialogue",
476 &fmt,
477 InlineRenderContext { quote_depth: 1 },
478 );
479 assert_eq!(result, "‘Parmenides’ dialogue");
480 }
481
482 #[test]
483 fn test_djot_nested_quotes_alternate_marks() {
484 let fmt = PlainText;
485 let result = render_djot_inline("\"outer \"inner\" claim\"", &fmt);
486 assert_eq!(result, "“outer ‘inner’ claim”");
487 }
488
489 #[test]
490 fn test_djot_quotes_inside_emphasis_use_ambient_quote_depth() {
491 let fmt = PlainText;
492 let result = render_djot_inline_with_context(
493 "_\"Parmenides\" dialogue_",
494 &fmt,
495 InlineRenderContext { quote_depth: 1 },
496 );
497 assert_eq!(result, "_‘Parmenides’ dialogue_");
498 }
499
500 #[test]
501 fn test_org_plain_text() {
502 let fmt = PlainText;
503 let result = render_org_inline("plain text with no markup", &fmt);
504 assert_eq!(result, "plain text with no markup");
505 }
506
507 #[test]
508 fn test_org_bold() {
509 let fmt = PlainText;
510 let result = render_org_inline("*bold*", &fmt);
512 assert_eq!(result, "**bold**");
513 }
514
515 #[test]
516 fn test_org_italic() {
517 let fmt = PlainText;
518 let result = render_org_inline("/italic/", &fmt);
520 assert_eq!(result, "_italic_");
521 }
522}