1use docling_core::{DoclingDocument, Node, Table};
19use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
20
21use crate::backend::DeclarativeBackend;
22use crate::error::ConversionError;
23use crate::source::SourceDocument;
24
25#[derive(Default)]
26pub struct MarkdownBackend {
27 pub strict: bool,
29}
30
31fn is_delimiter_row(line: &str) -> bool {
35 let line = line.trim();
36 if !line.contains('|') {
37 return false;
38 }
39 line.trim_start_matches('|')
40 .trim_end_matches('|')
41 .split('|')
42 .all(|cell| {
43 let dashes = cell.trim().trim_start_matches(':').trim_end_matches(':');
44 !dashes.is_empty() && dashes.bytes().all(|b| b == b'-')
45 })
46}
47
48fn normalize_table_edge_pipes(text: &str) -> std::borrow::Cow<'_, str> {
56 let cell_count = |line: &str| {
57 line.trim()
58 .trim_start_matches('|')
59 .trim_end_matches('|')
60 .split('|')
61 .count()
62 };
63 let is_fence = |line: &str| {
64 let t = line.trim_start();
65 t.starts_with("```") || t.starts_with("~~~")
66 };
67
68 let lines: Vec<&str> = text.split('\n').collect();
69 let mut needs_fix = false;
70 {
71 let mut in_fence = false;
72 for w in lines.windows(2) {
73 let head = w[0].trim_end_matches('\r');
74 if is_fence(head) {
75 in_fence = !in_fence;
76 continue;
77 }
78 let delim = w[1].trim_end_matches('\r');
79 if !in_fence
80 && head.contains('|')
81 && !head.trim_start().starts_with('|')
82 && is_delimiter_row(delim)
83 && cell_count(head) == cell_count(delim)
84 {
85 needs_fix = true;
86 break;
87 }
88 }
89 }
90 if !needs_fix {
91 return std::borrow::Cow::Borrowed(text);
92 }
93
94 let mut out: Vec<String> = Vec::with_capacity(lines.len());
95 let mut in_fence = false;
96 let mut i = 0;
97 while i < lines.len() {
98 let head = lines[i].trim_end_matches('\r');
99 if is_fence(head) {
100 in_fence = !in_fence;
101 out.push(lines[i].to_string());
102 i += 1;
103 continue;
104 }
105 let is_table_start = !in_fence
106 && head.contains('|')
107 && !head.trim_start().starts_with('|')
108 && lines
109 .get(i + 1)
110 .is_some_and(|d| is_delimiter_row(d.trim_end_matches('\r')))
111 && cell_count(head) == cell_count(lines[i + 1].trim_end_matches('\r'));
112 if !is_table_start {
113 out.push(lines[i].to_string());
114 i += 1;
115 continue;
116 }
117 while i < lines.len() {
120 let (body, cr) = match lines[i].strip_suffix('\r') {
121 Some(b) => (b, "\r"),
122 None => (lines[i], ""),
123 };
124 if !body.contains('|') || body.trim().is_empty() {
125 break;
126 }
127 let t = body.trim();
128 let mut fixed = String::with_capacity(t.len() + 4);
129 if !t.starts_with('|') {
130 fixed.push_str("| ");
131 }
132 fixed.push_str(t);
133 if !t.ends_with('|') {
134 fixed.push_str(" |");
135 }
136 fixed.push_str(cr);
137 out.push(fixed);
138 i += 1;
139 }
140 }
141 std::borrow::Cow::Owned(out.join("\n"))
142}
143
144impl DeclarativeBackend for MarkdownBackend {
145 fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
146 let text = source.text()?;
152 let normalized = normalize_table_edge_pipes(&text);
153 let text = normalized.as_ref();
154 let mut opts = Options::empty();
155 opts.insert(Options::ENABLE_TABLES);
156 opts.insert(Options::ENABLE_STRIKETHROUGH);
157
158 let raw: Vec<(Event, std::ops::Range<usize>)> =
168 Parser::new_ext(text, opts).into_offset_iter().collect();
169 let mut events: Vec<Event> = Vec::with_capacity(raw.len());
170 let mut k = 0;
171 let mut in_table = false;
172 while k < raw.len() {
173 match &raw[k].0 {
174 Event::Start(Tag::Table(_)) => in_table = true,
175 Event::End(TagEnd::Table) => in_table = false,
176 _ => {}
177 }
178 if matches!(raw[k].0, Event::Text(_)) {
179 let mut merged = String::new();
180 let mut end = raw[k].1.start;
181 while let Some((Event::Text(t), range)) = raw.get(k) {
182 let escape_gap = in_table
183 && range.start == end + 1
184 && text.as_bytes().get(end) == Some(&b'\\');
185 if range.start != end && !escape_gap {
186 break;
187 }
188 merged.push_str(t);
189 end = range.end;
190 k += 1;
191 }
192 events.push(Event::Text(merged.into()));
193 } else {
194 events.push(raw[k].0.clone());
195 k += 1;
196 }
197 }
198
199 let mut doc = DoclingDocument::new(&source.name);
200 let mut i = 0;
201 self.parse_blocks(&events, &mut i, &mut doc.nodes, 0, Stop::Eof, 0);
202 doc.tree = super::md_tree::build_tree(text);
207 Ok(doc)
208 }
209}
210
211#[derive(Clone, Copy, PartialEq)]
213enum Stop {
214 Eof,
215 BlockQuote,
216}
217
218const MAX_NESTING: u16 = 100;
224
225pub(super) fn skip_subtree(events: &[Event], i: &mut usize) {
228 let mut depth = 0usize;
229 while *i < events.len() {
230 match &events[*i] {
231 Event::Start(_) => depth += 1,
232 Event::End(_) => {
233 depth = depth.saturating_sub(1);
234 if depth == 0 {
235 *i += 1;
236 return;
237 }
238 }
239 _ => {}
240 }
241 *i += 1;
242 }
243}
244
245impl MarkdownBackend {
246 fn parse_blocks(
251 &self,
252 events: &[Event],
253 i: &mut usize,
254 out: &mut Vec<Node>,
255 list_level: u8,
256 stop: Stop,
257 depth: u16,
258 ) {
259 while *i < events.len() {
260 match &events[*i] {
261 Event::Start(Tag::BlockQuote(_) | Tag::List(_)) if depth >= MAX_NESTING => {
262 skip_subtree(events, i);
263 }
264 Event::End(TagEnd::BlockQuote(_)) if stop == Stop::BlockQuote => {
265 *i += 1;
266 return;
267 }
268 Event::End(_) => {
271 *i += 1;
272 }
273 Event::Start(Tag::HtmlBlock) => {
274 *i += 1;
275 let mut html = String::new();
276 while *i < events.len() && !matches!(events[*i], Event::End(TagEnd::HtmlBlock))
277 {
278 if let Event::Html(t) = &events[*i] {
279 html.push_str(t);
280 }
281 *i += 1;
282 }
283 consume_end(events, i);
284 super::html::append_fragment(&html, out, &super::images::NoFetch);
287 }
288 Event::Start(Tag::BlockQuote(_)) => {
289 *i += 1;
290 self.parse_blocks(events, i, out, list_level, Stop::BlockQuote, depth + 1);
291 }
292 Event::Start(Tag::Paragraph) => {
293 if !self.strict
295 && matches!(events.get(*i + 1), Some(Event::Code(_)))
296 && matches!(events.get(*i + 2), Some(Event::End(TagEnd::Paragraph)))
297 {
298 if let Some(Event::Code(c)) = events.get(*i + 1) {
299 let text = unescape_entities(c.trim());
300 if !text.is_empty() {
301 out.push(Node::Code {
302 language: None,
303 text,
304 orig: None,
305 pretty: None,
306 });
307 }
308 }
309 *i += 3;
310 continue;
311 }
312 *i += 1;
313 let runs = self.collect_inline(events, i, false);
314 consume_end(events, i);
315 let text = self.join_runs(&runs, false);
316 if !text.is_empty() {
317 out.push(Node::Paragraph { text });
318 }
319 }
320 Event::Start(Tag::Heading { level, .. }) => {
321 let lvl = heading_level(*level);
322 *i += 1;
323 let runs = self.collect_inline(events, i, false);
324 consume_end(events, i);
325 let text = self.join_runs(&runs, false);
326 if !text.is_empty() {
327 out.push(Node::Heading { level: lvl, text });
328 }
329 }
330 Event::Start(Tag::List(start)) => {
331 let ordered = start.is_some();
332 let start_num = start.unwrap_or(1);
333 *i += 1;
334 self.parse_list(events, i, out, list_level, ordered, start_num, depth + 1);
335 }
336 Event::Start(Tag::CodeBlock(kind)) => {
337 let language = match kind {
338 CodeBlockKind::Fenced(info) => {
339 let lang = info.split_whitespace().next().unwrap_or("");
340 (!lang.is_empty()).then(|| lang.to_string())
341 }
342 CodeBlockKind::Indented => None,
343 };
344 *i += 1;
345 let mut code = String::new();
346 while *i < events.len() && !matches!(events[*i], Event::End(TagEnd::CodeBlock))
347 {
348 if let Event::Text(t) = &events[*i] {
349 code.push_str(t);
350 }
351 *i += 1;
352 }
353 consume_end(events, i);
354 let trimmed = code.trim_end();
355 let text = if self.strict {
357 trimmed.to_string()
358 } else {
359 unescape_entities(trimmed)
360 };
361 if !text.is_empty() {
362 out.push(Node::Code {
363 language,
364 text,
365 orig: None,
366 pretty: None,
367 });
368 }
369 }
370 Event::Start(Tag::Table(_)) => {
371 *i += 1;
372 self.parse_table(events, i, out);
373 }
374 _ => {
375 *i += 1;
376 }
377 }
378 }
379 }
380
381 #[allow(clippy::too_many_arguments)]
382 fn parse_list(
383 &self,
384 events: &[Event],
385 i: &mut usize,
386 out: &mut Vec<Node>,
387 level: u8,
388 ordered: bool,
389 start: u64,
390 depth: u16,
391 ) {
392 let mut number = start;
393 let mut first = true;
394 while *i < events.len() {
395 match &events[*i] {
396 Event::Start(Tag::Item) => {
397 *i += 1;
398 self.parse_item(events, i, out, level, ordered, number, first, depth);
399 number += 1;
400 first = false;
401 }
402 Event::End(TagEnd::List(_)) => {
403 *i += 1;
404 return;
405 }
406 _ => {
407 *i += 1;
408 }
409 }
410 }
411 }
412
413 #[allow(clippy::too_many_arguments)]
414 fn parse_item(
415 &self,
416 events: &[Event],
417 i: &mut usize,
418 out: &mut Vec<Node>,
419 level: u8,
420 ordered: bool,
421 number: u64,
422 first_in_list: bool,
423 depth: u16,
424 ) {
425 let mut emitted = false;
426 while *i < events.len() {
427 match &events[*i] {
428 Event::End(TagEnd::Item) => {
429 *i += 1;
430 break;
431 }
432 Event::Start(Tag::List(_) | Tag::BlockQuote(_)) if depth >= MAX_NESTING => {
433 skip_subtree(events, i);
434 }
435 Event::Start(Tag::List(start)) => {
436 let nested_ordered = start.is_some();
437 let nested_start = start.unwrap_or(1);
438 *i += 1;
439 self.parse_list(
440 events,
441 i,
442 out,
443 level + 1,
444 nested_ordered,
445 nested_start,
446 depth + 1,
447 );
448 }
449 Event::Start(Tag::Paragraph) => {
450 *i += 1;
451 let runs = self.collect_inline(events, i, false);
452 consume_end(events, i);
453 emit_item(
454 out,
455 &mut emitted,
456 ordered,
457 number,
458 first_in_list,
459 self.join_runs(&runs, false),
460 level,
461 );
462 }
463 ev if is_inline_start(ev) => {
464 let runs = self.collect_inline(events, i, false);
465 emit_item(
466 out,
467 &mut emitted,
468 ordered,
469 number,
470 first_in_list,
471 self.join_runs(&runs, false),
472 level,
473 );
474 }
475 _ => {
476 *i += 1;
477 }
478 }
479 }
480 }
481
482 fn parse_table(&self, events: &[Event], i: &mut usize, out: &mut Vec<Node>) {
483 let mut rows: Vec<Vec<String>> = Vec::new();
484 while *i < events.len() {
485 match &events[*i] {
486 Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {
487 *i += 1;
488 rows.push(self.parse_table_row(events, i));
489 }
490 Event::End(TagEnd::Table) => {
491 *i += 1;
492 break;
493 }
494 _ => {
495 *i += 1;
496 }
497 }
498 }
499 if !rows.is_empty() {
500 out.push(Node::Table(Table {
501 rows,
502 location: None,
503 structure: None,
504 cell_blocks: None,
505 cells: None,
506 caption: None,
507 caption_parent: Default::default(),
508 }));
509 }
510 }
511
512 fn parse_table_row(&self, events: &[Event], i: &mut usize) -> Vec<String> {
513 let mut cells = Vec::new();
514 while *i < events.len() {
515 match &events[*i] {
516 Event::Start(Tag::TableCell) => {
517 *i += 1;
518 let runs = self.collect_inline(events, i, true);
519 consume_end(events, i);
520 cells.push(self.join_runs(&runs, true));
521 }
522 Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
523 *i += 1;
524 break;
525 }
526 _ => {
527 *i += 1;
528 }
529 }
530 }
531 cells
532 }
533
534 fn collect_inline(&self, events: &[Event], i: &mut usize, table: bool) -> Vec<String> {
543 let mut runs: Vec<String> = Vec::new();
544 let mut pending: Option<Break> = None;
551 while *i < events.len() {
552 match &events[*i] {
553 Event::Text(t) => {
554 let before = runs.len();
555 self.push_text(t, table, &mut runs);
556 if runs.len() > before {
557 self.apply_break(&mut runs, pending.take(), table);
558 }
559 *i += 1;
560 }
561 Event::SoftBreak | Event::HardBreak => {
562 let hard = matches!(events[*i], Event::HardBreak);
563 if table {
564 } else if self.strict {
567 runs.push(if hard { "\n" } else { " " }.to_string());
569 } else {
570 pending = Some(if hard { Break::Hard } else { Break::Soft });
571 }
572 *i += 1;
573 }
574 Event::InlineHtml(_) | Event::Html(_) => {
576 *i += 1;
577 }
578 Event::Code(t) => {
579 let content = if self.strict && !table {
582 t.to_string()
583 } else {
584 unescape_entities(t)
585 };
586 runs.push(if table {
587 content
588 } else {
589 format!("`{content}`")
590 });
591 *i += 1;
592 }
593 Event::Start(Tag::Emphasis) => {
594 runs.push(self.wrap_inline(events, i, table, "*"));
595 self.apply_break(&mut runs, pending.take(), table);
596 }
597 Event::Start(Tag::Strong) => {
598 runs.push(self.wrap_inline(events, i, table, "**"));
599 self.apply_break(&mut runs, pending.take(), table);
600 }
601 Event::Start(Tag::Strikethrough) => {
602 runs.push(self.wrap_inline(events, i, table, "~~"));
603 self.apply_break(&mut runs, pending.take(), table);
604 }
605 Event::Start(Tag::Link { dest_url, .. }) => {
606 let url = dest_url.to_string();
607 *i += 1;
608 let inner = self.join_runs(&self.collect_inline(events, i, table), table);
609 consume_end(events, i);
610 runs.push(if table {
611 inner
612 } else {
613 format!("[{inner}]({url})")
614 });
615 }
616 Event::Start(Tag::Image { dest_url, .. }) => {
617 let url = dest_url.to_string();
618 *i += 1;
619 let alt = self.join_runs(&self.collect_inline(events, i, table), table);
620 consume_end(events, i);
621 runs.push(if table {
622 alt
623 } else {
624 format!("")
625 });
626 }
627 _ => break,
628 }
629 }
630 runs
631 }
632
633 fn apply_break(&self, runs: &mut Vec<String>, pending: Option<Break>, table: bool) {
639 let Some(brk) = pending else {
640 return;
641 };
642 if table || self.strict || runs.len() < 2 {
643 return;
644 }
645 let new = runs.pop().expect("two runs");
646 let prev = runs.pop().expect("two runs");
647 let sep = match brk {
648 Break::Hard => "\n",
649 Break::Soft => " ",
650 };
651 let marker_of = |r: &str| -> Option<&'static str> {
652 ["**", "~~", "*"]
653 .into_iter()
654 .find(|m| r.len() > 2 * m.len() && r.starts_with(m) && r.ends_with(m))
655 };
656 let plain = |r: &str| !r.starts_with(['*', '~', '`', '[', '!']);
657 match (marker_of(&prev), marker_of(&new)) {
658 (None, None) if plain(&prev) && plain(&new) => {
659 runs.push(format!("{prev}{sep}{new}"));
660 }
661 (Some(m), Some(n)) if m == n => {
662 let inner_prev = &prev[m.len()..prev.len() - m.len()];
663 let inner_new = &new[m.len()..new.len() - m.len()];
664 runs.push(format!("{m}{inner_prev}{sep}{inner_new}{m}"));
665 }
666 _ => {
667 runs.push(prev);
668 runs.push(match brk {
669 Break::Hard => format!("\n{new}"),
670 Break::Soft => new,
671 });
672 }
673 }
674 }
675
676 fn wrap_inline(&self, events: &[Event], i: &mut usize, table: bool, marker: &str) -> String {
677 *i += 1;
678 let inner = self.join_runs(&self.collect_inline(events, i, table), table);
679 consume_end(events, i);
680 if table {
681 inner
682 } else {
683 format!("{marker}{inner}{marker}")
684 }
685 }
686
687 fn push_text(&self, text: &str, table: bool, runs: &mut Vec<String>) {
691 if table {
692 let trimmed = text.trim();
693 if !trimmed.is_empty() {
694 runs.push(trimmed.to_string());
695 }
696 } else if self.strict {
697 if !text.is_empty() {
698 runs.push(text.to_string());
699 }
700 } else {
701 let trimmed = text.trim();
702 if !trimmed.is_empty() {
703 runs.push(escape_html(&escape_underscores(trimmed)));
704 }
705 }
706 }
707
708 fn join_runs(&self, runs: &[String], table: bool) -> String {
712 if self.strict && !table {
713 runs.concat()
714 } else {
715 runs.iter()
716 .filter(|r| !r.is_empty())
717 .cloned()
718 .collect::<Vec<_>>()
719 .join(" ")
720 }
721 }
722}
723
724#[allow(clippy::too_many_arguments)]
726fn emit_item(
727 out: &mut Vec<Node>,
728 emitted: &mut bool,
729 ordered: bool,
730 number: u64,
731 first_in_list: bool,
732 text: String,
733 level: u8,
734) {
735 if !*emitted && !text.is_empty() {
736 out.push(Node::ListItem {
737 ordered,
738 number,
739 first_in_list,
740 text,
741 level,
742 marker: None,
743 location: None,
744 dclx: None,
745 href: None,
746 layer: None,
747 });
748 *emitted = true;
749 }
750}
751
752fn consume_end(events: &[Event], i: &mut usize) {
753 if matches!(events.get(*i), Some(Event::End(_))) {
754 *i += 1;
755 }
756}
757
758#[derive(Clone, Copy)]
760enum Break {
761 Soft,
762 Hard,
763}
764
765fn is_inline_start(event: &Event) -> bool {
766 matches!(
767 event,
768 Event::Text(_)
769 | Event::Code(_)
770 | Event::SoftBreak
771 | Event::HardBreak
772 | Event::InlineHtml(_)
773 | Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough)
774 | Event::Start(Tag::Link { .. } | Tag::Image { .. })
775 )
776}
777
778fn heading_level(level: HeadingLevel) -> u8 {
779 match level {
780 HeadingLevel::H1 => 1,
781 HeadingLevel::H2 => 2,
782 HeadingLevel::H3 => 3,
783 HeadingLevel::H4 => 4,
784 HeadingLevel::H5 => 5,
785 HeadingLevel::H6 => 6,
786 }
787}
788
789pub(crate) fn escape_html(text: &str) -> String {
795 text.replace('&', "&")
796 .replace('<', "<")
797 .replace('>', ">")
798}
799
800pub(crate) fn escape_text(text: &str) -> String {
803 escape_html(&escape_underscores(text))
804}
805
806pub(crate) fn escape_underscores(text: &str) -> String {
808 let mut out = String::with_capacity(text.len());
809 let mut prev = '\0';
810 for ch in text.chars() {
811 if ch == '_' && prev != '\\' {
812 out.push('\\');
813 }
814 out.push(ch);
815 prev = ch;
816 }
817 out
818}
819
820pub(super) fn unescape_entities(text: &str) -> String {
824 let mut out = String::with_capacity(text.len());
825 let bytes = text.as_bytes();
826 let mut idx = 0;
827 while idx < bytes.len() {
828 if bytes[idx] == b'&' {
829 if let Some(semi) = text[idx..].find(';') {
830 let entity = &text[idx + 1..idx + semi];
831 if let Some(ch) = decode_entity(entity) {
832 out.push(ch);
833 idx += semi + 1;
834 continue;
835 }
836 }
837 }
838 let ch = text[idx..].chars().next().unwrap();
839 out.push(ch);
840 idx += ch.len_utf8();
841 }
842 out
843}
844
845fn decode_entity(entity: &str) -> Option<char> {
846 match entity {
847 "amp" => Some('&'),
848 "lt" => Some('<'),
849 "gt" => Some('>'),
850 "quot" => Some('"'),
851 "apos" | "#39" => Some('\''),
852 "vert" => Some('|'),
853 _ => {
854 let code = if let Some(hex) = entity.strip_prefix("#x").or(entity.strip_prefix("#X")) {
855 u32::from_str_radix(hex, 16).ok()?
856 } else if let Some(dec) = entity.strip_prefix('#') {
857 dec.parse().ok()?
858 } else {
859 return None;
860 };
861 char::from_u32(code)
862 }
863 }
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869 use crate::format::InputFormat;
870
871 #[test]
874 fn table_cells_keep_the_space_around_inline_emphasis() {
875 let doc = convert(
876 "| H | I |\n|---|---|\n| **C** Cadre | x |\n| foo **bar** | y |\n| **A** **B** | z |\n| *italic* and **bold** | w |\n",
877 );
878 let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
879 let cells: Vec<String> = json["tables"][0]["data"]["table_cells"]
880 .as_array()
881 .unwrap()
882 .iter()
883 .map(|c| c["text"].as_str().unwrap().to_string())
884 .collect();
885 assert_eq!(
886 cells,
887 [
888 "H",
889 "I",
890 "C Cadre",
891 "x",
892 "foo bar",
893 "y",
894 "A B",
895 "z",
896 "italic and bold",
897 "w"
898 ]
899 );
900 }
901
902 fn convert(md: &str) -> DoclingDocument {
903 let src = SourceDocument::from_bytes("t", InputFormat::Md, md.as_bytes().to_vec());
904 MarkdownBackend { strict: false }.convert(&src).unwrap()
905 }
906
907 fn convert_strict(md: &str) -> DoclingDocument {
908 let src = SourceDocument::from_bytes("t", InputFormat::Md, md.as_bytes().to_vec());
909 let mut doc = MarkdownBackend { strict: true }.convert(&src).unwrap();
910 doc.strict_markdown = true;
911 doc
912 }
913
914 #[test]
918 fn pathological_nesting_converts_instead_of_overflowing() {
919 let quotes = ">".repeat(50_000) + " deep\n\nafter\n";
920 let md = convert("es).export_to_markdown();
921 assert!(md.contains("after"), "{md:?}");
922 let list: String = (0..5_000)
923 .map(|i| format!("{}- item {i}\n", " ".repeat(i)))
924 .collect();
925 let md = convert(&list).export_to_markdown();
926 assert!(md.contains("item 0") && md.contains("item 99"), "{md:?}");
927 let shallow: String = (0..20)
929 .map(|i| format!("{}- item {i}\n", " ".repeat(i)))
930 .collect();
931 let md = convert(&shallow).export_to_markdown();
932 assert!(md.contains("item 19"), "{md:?}");
933 }
934
935 #[test]
936 fn inline_runs_get_spaced_in_legacy() {
937 let doc = convert("Foo *emphasis* **strong** ***both***.\n");
938 assert_eq!(
939 doc.export_to_markdown(),
940 "Foo *emphasis* **strong** ***both*** .\n"
941 );
942 }
943
944 #[test]
945 fn strict_keeps_inline_clean() {
946 let doc = convert_strict("Foo *emphasis* **strong** ***both***.\n");
947 assert_eq!(
948 doc.export_to_markdown(),
949 "Foo *emphasis* **strong** ***both***.\n"
950 );
951 }
952
953 #[test]
954 fn strict_keeps_code_language_and_inline_code() {
955 let doc = convert_strict("```rust\nlet x = 1;\n```\n");
956 assert_eq!(doc.export_to_markdown(), "```rust\nlet x = 1;\n```\n");
957 }
958
959 #[test]
960 fn ordered_list_honors_start() {
961 let doc = convert("3. third\n4. fourth\n");
962 assert_eq!(doc.export_to_markdown(), "3. third\n4. fourth\n");
963 }
964
965 #[test]
966 fn parses_github_table_plain_cells() {
967 let doc = convert("| **A** | B |\n|---|---|\n| x | y |\n");
968 assert_eq!(
969 doc.export_to_markdown(),
970 "| A | B |\n|-----|-----|\n| x | y |\n"
971 );
972 }
973
974 #[test]
975 fn lone_code_span_becomes_code_block_in_legacy() {
976 let doc = convert("`& <`\n");
977 assert_eq!(
978 doc.nodes,
979 vec![Node::Code {
980 language: None,
981 text: "& <".into(),
982 orig: None,
983 pretty: None,
984 }]
985 );
986 }
987}