1use crate::entities::{Alignment, ListStyle, MarkerType, TextDirection};
2use crate::parser_tools::djot_options::DjotImportOptions;
3
4#[derive(Debug, Clone, Default)]
6pub struct ParsedSpan {
7 pub text: String,
8 pub bold: bool,
9 pub italic: bool,
10 pub underline: bool,
11 pub strikeout: bool,
12 pub code: bool,
13 pub superscript: bool,
15 pub subscript: bool,
17 pub link_href: Option<String>,
18}
19
20#[derive(Debug, Clone)]
22pub struct ParsedTableCell {
23 pub spans: Vec<ParsedSpan>,
24}
25
26#[derive(Debug, Clone)]
28pub struct ParsedTable {
29 pub header_rows: usize,
31 pub rows: Vec<Vec<ParsedTableCell>>,
33 pub blockquote_depth: u32,
36}
37
38#[derive(Debug, Clone)]
40pub enum ParsedElement {
41 Block(ParsedBlock),
42 Table(ParsedTable),
43}
44
45impl ParsedElement {
46 pub fn flatten_to_blocks(elements: Vec<ParsedElement>) -> Vec<ParsedBlock> {
49 let mut blocks = Vec::new();
50 for elem in elements {
51 match elem {
52 ParsedElement::Block(b) => blocks.push(b),
53 ParsedElement::Table(t) => {
54 for row in t.rows {
55 for cell in row {
56 blocks.push(ParsedBlock {
57 spans: cell.spans,
58 heading_level: None,
59 list_style: None,
60 list_indent: 0,
61 list_prefix: String::new(),
62 list_suffix: String::new(),
63 marker: None,
64 is_code_block: false,
65 code_language: None,
66 blockquote_depth: t.blockquote_depth,
67 line_height: None,
68 non_breakable_lines: None,
69 direction: None,
70 background_color: None,
71 alignment: None,
72 });
73 }
74 }
75 }
76 }
77 }
78 if blocks.is_empty() {
79 blocks.push(ParsedBlock {
80 spans: vec![ParsedSpan {
81 text: String::new(),
82 ..Default::default()
83 }],
84 heading_level: None,
85 list_style: None,
86 list_indent: 0,
87 list_prefix: String::new(),
88 list_suffix: String::new(),
89 marker: None,
90 is_code_block: false,
91 code_language: None,
92 blockquote_depth: 0,
93 line_height: None,
94 non_breakable_lines: None,
95 direction: None,
96 background_color: None,
97 alignment: None,
98 });
99 }
100 blocks
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct ParsedBlock {
107 pub spans: Vec<ParsedSpan>,
108 pub heading_level: Option<i64>,
109 pub list_style: Option<ListStyle>,
110 pub list_indent: u32,
111 pub list_prefix: String,
114 pub list_suffix: String,
117 pub marker: Option<MarkerType>,
120 pub is_code_block: bool,
121 pub code_language: Option<String>,
122 pub blockquote_depth: u32,
123 pub line_height: Option<i64>,
124 pub non_breakable_lines: Option<bool>,
125 pub direction: Option<TextDirection>,
126 pub background_color: Option<String>,
127 pub alignment: Option<Alignment>,
130}
131
132impl ParsedBlock {
133 pub fn is_inline_only(&self) -> bool {
136 self.heading_level.is_none()
137 && self.list_style.is_none()
138 && !self.is_code_block
139 && self.blockquote_depth == 0
140 && self.line_height.is_none()
141 && self.non_breakable_lines.is_none()
142 && self.direction.is_none()
143 && self.background_color.is_none()
144 && self.alignment.is_none()
145 }
146}
147
148pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
151 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
152
153 let options =
154 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS;
155 let parser = Parser::new_ext(markdown, options);
156
157 let mut elements: Vec<ParsedElement> = Vec::new();
158 let mut current_spans: Vec<ParsedSpan> = Vec::new();
159 let mut current_heading: Option<i64> = None;
160 let mut current_list_style: Option<ListStyle> = None;
161 let mut is_code_block = false;
162 let mut code_language: Option<String> = None;
163 let mut blockquote_depth: u32 = 0;
164 let mut in_block = false;
165
166 let mut bold = false;
168 let mut italic = false;
169 let mut strikeout = false;
170 let mut link_href: Option<String> = None;
171
172 let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
174 let mut current_list_indent: u32 = 0;
175
176 let mut in_table = false;
178 let mut in_table_head = false;
179 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
180 let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
181 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
182 let mut table_header_rows: usize = 0;
183
184 for event in parser {
185 match event {
186 Event::Start(Tag::Paragraph) => {
187 in_block = true;
188 current_heading = None;
189 is_code_block = false;
190 }
191 Event::End(TagEnd::Paragraph) => {
192 if !current_spans.is_empty() || in_block {
193 elements.push(ParsedElement::Block(ParsedBlock {
194 spans: std::mem::take(&mut current_spans),
195 heading_level: current_heading.take(),
196 list_style: current_list_style.clone(),
197 list_indent: current_list_indent,
198 list_prefix: String::new(),
199 list_suffix: String::new(),
200 marker: None,
201 is_code_block: false,
202 code_language: None,
203 blockquote_depth,
204 line_height: None,
205 non_breakable_lines: None,
206 direction: None,
207 background_color: None,
208 alignment: None,
209 }));
210 }
211 in_block = false;
212 current_list_style = None;
213 }
214 Event::Start(Tag::Heading { level, .. }) => {
215 in_block = true;
216 current_heading = Some(heading_level_to_i64(level));
217 is_code_block = false;
218 }
219 Event::End(TagEnd::Heading(_)) => {
220 elements.push(ParsedElement::Block(ParsedBlock {
221 spans: std::mem::take(&mut current_spans),
222 heading_level: current_heading.take(),
223 list_style: None,
224 list_indent: 0,
225 list_prefix: String::new(),
226 list_suffix: String::new(),
227 marker: None,
228 is_code_block: false,
229 code_language: None,
230 blockquote_depth,
231 line_height: None,
232 non_breakable_lines: None,
233 direction: None,
234 background_color: None,
235 alignment: None,
236 }));
237 in_block = false;
238 }
239 Event::Start(Tag::List(ordered)) => {
240 let style = if ordered.is_some() {
241 Some(ListStyle::Decimal)
242 } else {
243 Some(ListStyle::Disc)
244 };
245 list_stack.push(style);
246 }
247 Event::End(TagEnd::List(_)) => {
248 list_stack.pop();
249 }
250 Event::Start(Tag::Item) => {
251 if !current_spans.is_empty() {
254 elements.push(ParsedElement::Block(ParsedBlock {
255 spans: std::mem::take(&mut current_spans),
256 heading_level: None,
257 list_style: current_list_style.clone(),
258 list_indent: current_list_indent,
259 list_prefix: String::new(),
260 list_suffix: String::new(),
261 marker: None,
262 is_code_block: false,
263 code_language: None,
264 blockquote_depth,
265 line_height: None,
266 non_breakable_lines: None,
267 direction: None,
268 background_color: None,
269 alignment: None,
270 }));
271 }
272 in_block = true;
273 current_list_style = list_stack.last().cloned().flatten();
274 current_list_indent = if list_stack.is_empty() {
275 0
276 } else {
277 (list_stack.len() - 1) as u32
278 };
279 }
280 Event::End(TagEnd::Item) => {
281 if !current_spans.is_empty() {
284 elements.push(ParsedElement::Block(ParsedBlock {
285 spans: std::mem::take(&mut current_spans),
286 heading_level: None,
287 list_style: current_list_style.clone(),
288 list_indent: current_list_indent,
289 list_prefix: String::new(),
290 list_suffix: String::new(),
291 marker: None,
292 is_code_block: false,
293 code_language: None,
294 blockquote_depth,
295 line_height: None,
296 non_breakable_lines: None,
297 direction: None,
298 background_color: None,
299 alignment: None,
300 }));
301 }
302 in_block = false;
303 current_list_style = None;
304 }
305 Event::Start(Tag::CodeBlock(kind)) => {
306 in_block = true;
307 is_code_block = true;
308 code_language = match &kind {
309 pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
310 Some(lang.to_string())
311 }
312 _ => None,
313 };
314 }
315 Event::End(TagEnd::CodeBlock) => {
316 if let Some(last) = current_spans.last_mut()
318 && last.text.ends_with('\n')
319 {
320 last.text.truncate(last.text.len() - 1);
321 }
322 elements.push(ParsedElement::Block(ParsedBlock {
323 spans: std::mem::take(&mut current_spans),
324 heading_level: None,
325 list_style: None,
326 list_indent: 0,
327 list_prefix: String::new(),
328 list_suffix: String::new(),
329 marker: None,
330 is_code_block: true,
331 code_language: code_language.take(),
332 blockquote_depth,
333 line_height: None,
334 non_breakable_lines: None,
335 direction: None,
336 background_color: None,
337 alignment: None,
338 }));
339 in_block = false;
340 is_code_block = false;
341 }
342 Event::Start(Tag::Table(_)) => {
344 in_table = true;
345 in_table_head = false;
346 table_rows.clear();
347 current_row_cells.clear();
348 current_cell_spans.clear();
349 table_header_rows = 0;
350 }
351 Event::End(TagEnd::Table) => {
352 elements.push(ParsedElement::Table(ParsedTable {
353 header_rows: table_header_rows,
354 rows: std::mem::take(&mut table_rows),
355 blockquote_depth,
356 }));
357 in_table = false;
358 }
359 Event::Start(Tag::TableHead) => {
360 in_table_head = true;
361 current_row_cells.clear();
362 }
363 Event::End(TagEnd::TableHead) => {
364 table_rows.push(std::mem::take(&mut current_row_cells));
366 table_header_rows += 1;
367 in_table_head = false;
368 }
369 Event::Start(Tag::TableRow) => {
370 current_row_cells.clear();
371 }
372 Event::End(TagEnd::TableRow) if !in_table_head => {
373 table_rows.push(std::mem::take(&mut current_row_cells));
375 }
376 Event::Start(Tag::TableCell) => {
377 current_cell_spans.clear();
378 }
379 Event::End(TagEnd::TableCell) => {
380 current_row_cells.push(ParsedTableCell {
381 spans: std::mem::take(&mut current_cell_spans),
382 });
383 }
384 Event::Start(Tag::Emphasis) => {
386 italic = true;
387 }
388 Event::End(TagEnd::Emphasis) => {
389 italic = false;
390 }
391 Event::Start(Tag::Strong) => {
392 bold = true;
393 }
394 Event::End(TagEnd::Strong) => {
395 bold = false;
396 }
397 Event::Start(Tag::Strikethrough) => {
398 strikeout = true;
399 }
400 Event::End(TagEnd::Strikethrough) => {
401 strikeout = false;
402 }
403 Event::Start(Tag::Link { dest_url, .. }) => {
404 link_href = Some(dest_url.to_string());
405 }
406 Event::End(TagEnd::Link) => {
407 link_href = None;
408 }
409 Event::Text(text) => {
410 let span = ParsedSpan {
411 text: text.to_string(),
412 bold,
413 italic,
414 underline: false,
415 strikeout,
416 code: is_code_block,
417 superscript: false,
418 subscript: false,
419 link_href: link_href.clone(),
420 };
421 if in_table {
422 current_cell_spans.push(span);
423 } else {
424 if !in_block {
425 in_block = true;
426 }
427 current_spans.push(span);
428 }
429 }
430 Event::Code(text) => {
431 let span = ParsedSpan {
432 text: text.to_string(),
433 bold,
434 italic,
435 underline: false,
436 strikeout,
437 code: true,
438 superscript: false,
439 subscript: false,
440 link_href: link_href.clone(),
441 };
442 if in_table {
443 current_cell_spans.push(span);
444 } else {
445 if !in_block {
446 in_block = true;
447 }
448 current_spans.push(span);
449 }
450 }
451 Event::SoftBreak => {
452 let span = ParsedSpan {
453 text: " ".to_string(),
454 bold,
455 italic,
456 underline: false,
457 strikeout,
458 code: false,
459 superscript: false,
460 subscript: false,
461 link_href: link_href.clone(),
462 };
463 if in_table {
464 current_cell_spans.push(span);
465 } else {
466 current_spans.push(span);
467 }
468 }
469 Event::HardBreak if !current_spans.is_empty() || in_block => {
470 elements.push(ParsedElement::Block(ParsedBlock {
472 spans: std::mem::take(&mut current_spans),
473 heading_level: current_heading.take(),
474 list_style: current_list_style.clone(),
475 list_indent: current_list_indent,
476 list_prefix: String::new(),
477 list_suffix: String::new(),
478 marker: None,
479 is_code_block,
480 code_language: code_language.clone(),
481 blockquote_depth,
482 line_height: None,
483 non_breakable_lines: None,
484 direction: None,
485 background_color: None,
486 alignment: None,
487 }));
488 }
489 Event::Start(Tag::BlockQuote(_)) => {
490 blockquote_depth += 1;
491 }
492 Event::End(TagEnd::BlockQuote(_)) => {
493 blockquote_depth = blockquote_depth.saturating_sub(1);
494 }
495 _ => {}
496 }
497 }
498
499 if !current_spans.is_empty() {
501 elements.push(ParsedElement::Block(ParsedBlock {
502 spans: std::mem::take(&mut current_spans),
503 heading_level: current_heading,
504 list_style: current_list_style,
505 list_indent: current_list_indent,
506 list_prefix: String::new(),
507 list_suffix: String::new(),
508 marker: None,
509 is_code_block,
510 code_language: code_language.take(),
511 blockquote_depth,
512 line_height: None,
513 non_breakable_lines: None,
514 direction: None,
515 background_color: None,
516 alignment: None,
517 }));
518 }
519
520 if elements.is_empty() {
522 elements.push(ParsedElement::Block(ParsedBlock {
523 spans: vec![ParsedSpan {
524 text: String::new(),
525 ..Default::default()
526 }],
527 heading_level: None,
528 list_style: None,
529 list_indent: 0,
530 list_prefix: String::new(),
531 list_suffix: String::new(),
532 marker: None,
533 is_code_block: false,
534 code_language: None,
535 blockquote_depth: 0,
536 line_height: None,
537 non_breakable_lines: None,
538 direction: None,
539 background_color: None,
540 alignment: None,
541 }));
542 }
543
544 elements
545}
546
547fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
548 use pulldown_cmark::HeadingLevel;
549 match level {
550 HeadingLevel::H1 => 1,
551 HeadingLevel::H2 => 2,
552 HeadingLevel::H3 => 3,
553 HeadingLevel::H4 => 4,
554 HeadingLevel::H5 => 5,
555 HeadingLevel::H6 => 6,
556 }
557}
558
559use scraper::Node;
562
563#[derive(Debug, Clone, Default)]
565struct BlockStyles {
566 line_height: Option<i64>,
567 non_breakable_lines: Option<bool>,
568 direction: Option<TextDirection>,
569 background_color: Option<String>,
570}
571
572fn parse_block_styles(style: &str) -> BlockStyles {
575 let mut result = BlockStyles::default();
576 for part in style.split(';') {
577 let part = part.trim();
578 if let Some((prop, val)) = part.split_once(':') {
579 let prop = prop.trim().to_ascii_lowercase();
580 let val = val.trim();
581 match prop.as_str() {
582 "line-height" => {
583 if let Ok(v) = val.parse::<f64>() {
585 result.line_height = Some((v * 1000.0) as i64);
586 }
587 }
588 "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
589 result.non_breakable_lines = Some(true);
590 }
591 "direction" => {
592 if val.eq_ignore_ascii_case("rtl") {
593 result.direction = Some(TextDirection::RightToLeft);
594 } else if val.eq_ignore_ascii_case("ltr") {
595 result.direction = Some(TextDirection::LeftToRight);
596 }
597 }
598 "background-color" | "background" => {
599 result.background_color = Some(val.to_string());
600 }
601 _ => {}
602 }
603 }
604 }
605 result
606}
607
608pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
609 ParsedElement::flatten_to_blocks(parse_html_elements(html))
610}
611
612pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
613 use scraper::Html;
614
615 let fragment = Html::parse_fragment(html);
616 let mut elements: Vec<ParsedElement> = Vec::new();
617
618 let root = fragment.root_element();
620
621 #[derive(Clone, Default)]
622 struct FmtState {
623 bold: bool,
624 italic: bool,
625 underline: bool,
626 strikeout: bool,
627 code: bool,
628 link_href: Option<String>,
629 }
630
631 const MAX_RECURSION_DEPTH: usize = 256;
632
633 fn collect_cell_spans(
635 node: ego_tree::NodeRef<Node>,
636 state: &FmtState,
637 spans: &mut Vec<ParsedSpan>,
638 depth: usize,
639 ) {
640 if depth > MAX_RECURSION_DEPTH {
641 return;
642 }
643 for child in node.children() {
644 match child.value() {
645 Node::Text(text) => {
646 let t = text.text.to_string();
647 if !t.is_empty() {
648 spans.push(ParsedSpan {
649 text: t,
650 bold: state.bold,
651 italic: state.italic,
652 underline: state.underline,
653 strikeout: state.strikeout,
654 code: state.code,
655 superscript: false,
656 subscript: false,
657 link_href: state.link_href.clone(),
658 });
659 }
660 }
661 Node::Element(el) => {
662 let tag = el.name();
663 let mut new_state = state.clone();
664 match tag {
665 "b" | "strong" => new_state.bold = true,
666 "i" | "em" => new_state.italic = true,
667 "u" | "ins" => new_state.underline = true,
668 "s" | "del" | "strike" => new_state.strikeout = true,
669 "code" => new_state.code = true,
670 "a" => {
671 if let Some(href) = el.attr("href") {
672 new_state.link_href = Some(href.to_string());
673 }
674 }
675 _ => {}
676 }
677 collect_cell_spans(child, &new_state, spans, depth + 1);
678 }
679 _ => {}
680 }
681 }
682 }
683
684 fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
686 let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
687 let mut header_rows: usize = 0;
688
689 fn collect_rows(
690 node: ego_tree::NodeRef<Node>,
691 rows: &mut Vec<Vec<ParsedTableCell>>,
692 header_rows: &mut usize,
693 in_thead: bool,
694 ) {
695 for child in node.children() {
696 if let Node::Element(el) = child.value() {
697 match el.name() {
698 "thead" => collect_rows(child, rows, header_rows, true),
699 "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
700 "tr" => {
701 let mut cells: Vec<ParsedTableCell> = Vec::new();
702 for td in child.children() {
703 if let Node::Element(td_el) = td.value()
704 && matches!(td_el.name(), "td" | "th")
705 {
706 let mut spans = Vec::new();
707 let state = FmtState::default();
708 collect_cell_spans(td, &state, &mut spans, 0);
709 if spans.is_empty() {
710 spans.push(ParsedSpan::default());
711 }
712 cells.push(ParsedTableCell { spans });
713 }
714 }
715 if !cells.is_empty() {
716 rows.push(cells);
717 if in_thead {
718 *header_rows += 1;
719 }
720 }
721 }
722 _ => {}
723 }
724 }
725 }
726 }
727
728 collect_rows(table_node, &mut rows, &mut header_rows, false);
729
730 if header_rows == 0 && !rows.is_empty() {
732 header_rows = 1;
733 }
734
735 ParsedTable {
736 header_rows,
737 rows,
738 blockquote_depth: 0,
741 }
742 }
743
744 fn walk_node(
745 node: ego_tree::NodeRef<Node>,
746 state: &FmtState,
747 elements: &mut Vec<ParsedElement>,
748 current_list_style: &Option<ListStyle>,
749 blockquote_depth: u32,
750 list_depth: u32,
751 depth: usize,
752 ) {
753 if depth > MAX_RECURSION_DEPTH {
754 return;
755 }
756 match node.value() {
757 Node::Element(el) => {
758 let tag = el.name();
759 let mut new_state = state.clone();
760 let mut new_list_style = current_list_style.clone();
761 let mut bq_depth = blockquote_depth;
762 let mut new_list_depth = list_depth;
763
764 let is_block_tag = matches!(
766 tag,
767 "p" | "div"
768 | "h1"
769 | "h2"
770 | "h3"
771 | "h4"
772 | "h5"
773 | "h6"
774 | "li"
775 | "pre"
776 | "br"
777 | "blockquote"
778 | "body"
779 | "html"
780 );
781
782 match tag {
784 "b" | "strong" => new_state.bold = true,
785 "i" | "em" => new_state.italic = true,
786 "u" | "ins" => new_state.underline = true,
787 "s" | "del" | "strike" => new_state.strikeout = true,
788 "code" => new_state.code = true,
789 "a" => {
790 if let Some(href) = el.attr("href") {
791 new_state.link_href = Some(href.to_string());
792 }
793 }
794 "ul" => {
795 new_list_style = Some(ListStyle::Disc);
796 new_list_depth = list_depth + 1;
797 }
798 "ol" => {
799 new_list_style = Some(ListStyle::Decimal);
800 new_list_depth = list_depth + 1;
801 }
802 "blockquote" => {
803 bq_depth += 1;
804 }
805 _ => {}
806 }
807
808 let heading_level = match tag {
810 "h1" => Some(1),
811 "h2" => Some(2),
812 "h3" => Some(3),
813 "h4" => Some(4),
814 "h5" => Some(5),
815 "h6" => Some(6),
816 _ => None,
817 };
818
819 let is_code_block = tag == "pre";
820
821 let code_language = if is_code_block {
823 node.children().find_map(|child| {
824 if let Node::Element(cel) = child.value()
825 && cel.name() == "code"
826 && let Some(cls) = cel.attr("class")
827 {
828 return cls
829 .split_whitespace()
830 .find_map(|c| c.strip_prefix("language-"))
831 .map(|l| l.to_string());
832 }
833 None
834 })
835 } else {
836 None
837 };
838
839 let css = if is_block_tag {
841 el.attr("style").map(parse_block_styles).unwrap_or_default()
842 } else {
843 BlockStyles::default()
844 };
845
846 if tag == "table" {
847 let mut parsed_table = parse_table_element(node);
849 if !parsed_table.rows.is_empty() {
850 parsed_table.blockquote_depth = bq_depth;
851 elements.push(ParsedElement::Table(parsed_table));
852 }
853 return;
854 }
855
856 if tag == "br" {
857 elements.push(ParsedElement::Block(ParsedBlock {
859 spans: vec![ParsedSpan {
860 text: String::new(),
861 ..Default::default()
862 }],
863 heading_level: None,
864 list_style: None,
865 list_indent: 0,
866 list_prefix: String::new(),
867 list_suffix: String::new(),
868 marker: None,
869 is_code_block: false,
870 code_language: None,
871 blockquote_depth: bq_depth,
872 line_height: None,
873 non_breakable_lines: None,
874 direction: None,
875 background_color: None,
876 alignment: None,
877 }));
878 return;
879 }
880
881 if tag == "blockquote" {
882 for child in node.children() {
884 walk_node(
885 child,
886 &new_state,
887 elements,
888 &new_list_style,
889 bq_depth,
890 new_list_depth,
891 depth + 1,
892 );
893 }
894 } else if is_block_tag && tag != "br" {
895 let mut spans: Vec<ParsedSpan> = Vec::new();
900 let mut nested_elements: Vec<ParsedElement> = Vec::new();
901 collect_inline_spans(
902 node,
903 &new_state,
904 &mut spans,
905 &new_list_style,
906 &mut nested_elements,
907 bq_depth,
908 new_list_depth,
909 depth + 1,
910 );
911
912 let list_style_for_block = if tag == "li" {
913 new_list_style.clone()
914 } else {
915 None
916 };
917
918 let list_indent_for_block = if tag == "li" {
919 new_list_depth.saturating_sub(1)
920 } else {
921 0
922 };
923
924 if !spans.is_empty() || heading_level.is_some() {
925 elements.push(ParsedElement::Block(ParsedBlock {
926 spans,
927 heading_level,
928 list_style: list_style_for_block,
929 list_indent: list_indent_for_block,
930 list_prefix: String::new(),
931 list_suffix: String::new(),
932 marker: None,
933 is_code_block,
934 code_language,
935 blockquote_depth: bq_depth,
936 line_height: css.line_height,
937 non_breakable_lines: css.non_breakable_lines,
938 direction: css.direction,
939 background_color: css.background_color,
940 alignment: None,
941 }));
942 }
943 elements.append(&mut nested_elements);
945 } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
946 for child in node.children() {
948 walk_node(
949 child,
950 &new_state,
951 elements,
952 &new_list_style,
953 bq_depth,
954 new_list_depth,
955 depth + 1,
956 );
957 }
958 } else {
959 for child in node.children() {
961 walk_node(
962 child,
963 &new_state,
964 elements,
965 current_list_style,
966 bq_depth,
967 list_depth,
968 depth + 1,
969 );
970 }
971 }
972 }
973 Node::Text(text) => {
974 let t = text.text.to_string();
975 let trimmed = t.trim();
976 if !trimmed.is_empty() {
977 elements.push(ParsedElement::Block(ParsedBlock {
979 spans: vec![ParsedSpan {
980 text: trimmed.to_string(),
981 bold: state.bold,
982 italic: state.italic,
983 underline: state.underline,
984 strikeout: state.strikeout,
985 code: state.code,
986 superscript: false,
987 subscript: false,
988 link_href: state.link_href.clone(),
989 }],
990 heading_level: None,
991 list_style: None,
992 list_indent: 0,
993 list_prefix: String::new(),
994 list_suffix: String::new(),
995 marker: None,
996 is_code_block: false,
997 code_language: None,
998 blockquote_depth,
999 line_height: None,
1000 non_breakable_lines: None,
1001 direction: None,
1002 background_color: None,
1003 alignment: None,
1004 }));
1005 }
1006 }
1007 _ => {
1008 for child in node.children() {
1010 walk_node(
1011 child,
1012 state,
1013 elements,
1014 current_list_style,
1015 blockquote_depth,
1016 list_depth,
1017 depth + 1,
1018 );
1019 }
1020 }
1021 }
1022 }
1023
1024 #[allow(clippy::too_many_arguments)]
1028 fn collect_inline_spans(
1029 node: ego_tree::NodeRef<Node>,
1030 state: &FmtState,
1031 spans: &mut Vec<ParsedSpan>,
1032 current_list_style: &Option<ListStyle>,
1033 elements: &mut Vec<ParsedElement>,
1034 blockquote_depth: u32,
1035 list_depth: u32,
1036 depth: usize,
1037 ) {
1038 if depth > MAX_RECURSION_DEPTH {
1039 return;
1040 }
1041 for child in node.children() {
1042 match child.value() {
1043 Node::Text(text) => {
1044 let t = text.text.to_string();
1045 if !t.is_empty() {
1046 spans.push(ParsedSpan {
1047 text: t,
1048 bold: state.bold,
1049 italic: state.italic,
1050 underline: state.underline,
1051 strikeout: state.strikeout,
1052 code: state.code,
1053 superscript: false,
1054 subscript: false,
1055 link_href: state.link_href.clone(),
1056 });
1057 }
1058 }
1059 Node::Element(el) => {
1060 let tag = el.name();
1061 let mut new_state = state.clone();
1062
1063 match tag {
1064 "b" | "strong" => new_state.bold = true,
1065 "i" | "em" => new_state.italic = true,
1066 "u" | "ins" => new_state.underline = true,
1067 "s" | "del" | "strike" => new_state.strikeout = true,
1068 "code" => new_state.code = true,
1069 "a" => {
1070 if let Some(href) = el.attr("href") {
1071 new_state.link_href = Some(href.to_string());
1072 }
1073 }
1074 _ => {}
1075 }
1076
1077 let nested_block = matches!(
1079 tag,
1080 "p" | "div"
1081 | "h1"
1082 | "h2"
1083 | "h3"
1084 | "h4"
1085 | "h5"
1086 | "h6"
1087 | "li"
1088 | "pre"
1089 | "blockquote"
1090 | "ul"
1091 | "ol"
1092 );
1093
1094 if tag == "br" {
1095 spans.push(ParsedSpan {
1098 text: String::new(),
1099 ..Default::default()
1100 });
1101 } else if nested_block || tag == "table" {
1102 walk_node(
1104 child,
1105 &new_state,
1106 elements,
1107 current_list_style,
1108 blockquote_depth,
1109 list_depth,
1110 depth + 1,
1111 );
1112 } else {
1113 collect_inline_spans(
1115 child,
1116 &new_state,
1117 spans,
1118 current_list_style,
1119 elements,
1120 blockquote_depth,
1121 list_depth,
1122 depth + 1,
1123 );
1124 }
1125 }
1126 _ => {}
1127 }
1128 }
1129 }
1130
1131 let initial_state = FmtState::default();
1132 let mut root_spans: Vec<ParsedSpan> = Vec::new();
1136 collect_inline_spans(
1137 *root,
1138 &initial_state,
1139 &mut root_spans,
1140 &None,
1141 &mut elements,
1142 0,
1143 0,
1144 0,
1145 );
1146 if !root_spans.is_empty() {
1147 elements.push(ParsedElement::Block(ParsedBlock {
1148 spans: root_spans,
1149 heading_level: None,
1150 list_style: None,
1151 list_indent: 0,
1152 list_prefix: String::new(),
1153 list_suffix: String::new(),
1154 marker: None,
1155 is_code_block: false,
1156 code_language: None,
1157 blockquote_depth: 0,
1158 line_height: None,
1159 non_breakable_lines: None,
1160 direction: None,
1161 background_color: None,
1162 alignment: None,
1163 }));
1164 }
1165
1166 if elements.is_empty() {
1168 elements.push(ParsedElement::Block(ParsedBlock {
1169 spans: vec![ParsedSpan {
1170 text: String::new(),
1171 ..Default::default()
1172 }],
1173 heading_level: None,
1174 list_style: None,
1175 list_indent: 0,
1176 list_prefix: String::new(),
1177 list_suffix: String::new(),
1178 marker: None,
1179 is_code_block: false,
1180 code_language: None,
1181 blockquote_depth: 0,
1182 line_height: None,
1183 non_breakable_lines: None,
1184 direction: None,
1185 background_color: None,
1186 alignment: None,
1187 }));
1188 }
1189
1190 elements
1191}
1192
1193pub fn character_format_from_span(
1197 span: &ParsedSpan,
1198 is_code_block: bool,
1199) -> crate::format_runs::CharacterFormat {
1200 use crate::entities::CharVerticalAlignment;
1201 crate::format_runs::CharacterFormat {
1202 font_bold: if span.bold { Some(true) } else { None },
1203 font_italic: if span.italic { Some(true) } else { None },
1204 font_underline: if span.underline { Some(true) } else { None },
1205 font_strikeout: if span.strikeout { Some(true) } else { None },
1206 font_family: if span.code || is_code_block {
1207 Some("monospace".to_string())
1208 } else {
1209 None
1210 },
1211 anchor_href: span.link_href.clone(),
1212 is_anchor: if span.link_href.is_some() {
1213 Some(true)
1214 } else {
1215 None
1216 },
1217 vertical_alignment: if span.superscript {
1218 Some(CharVerticalAlignment::SuperScript)
1219 } else if span.subscript {
1220 Some(CharVerticalAlignment::SubScript)
1221 } else {
1222 None
1223 },
1224 ..Default::default()
1225 }
1226}
1227
1228pub fn format_runs_from_spans(
1240 spans: &[ParsedSpan],
1241 is_code_block: bool,
1242) -> (String, Vec<crate::format_runs::FormatRun>) {
1243 use crate::format_runs::{CharacterFormat, FormatRun, coalesce_in_place};
1244
1245 let mut plain_text = String::new();
1246 let mut runs: Vec<FormatRun> = Vec::new();
1247 let default = CharacterFormat::default();
1248
1249 for span in spans {
1250 let byte_start = plain_text.len() as u32;
1251 plain_text.push_str(&span.text);
1252 let byte_end = plain_text.len() as u32;
1253 if byte_start == byte_end {
1254 continue;
1255 }
1256 let format = character_format_from_span(span, is_code_block);
1257 if format == default {
1258 continue;
1259 }
1260 runs.push(FormatRun {
1261 byte_start,
1262 byte_end,
1263 format,
1264 });
1265 }
1266 coalesce_in_place(&mut runs);
1267 (plain_text, runs)
1268}
1269
1270fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1278 use jotdown::ListBulletType as B;
1279 match b {
1280 B::Dash => ListStyle::Disc,
1281 B::Star => ListStyle::Circle,
1282 B::Plus => ListStyle::Square,
1283 }
1284}
1285
1286fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1288 use jotdown::OrderedListNumbering as N;
1289 match n {
1290 N::Decimal => ListStyle::Decimal,
1291 N::AlphaLower => ListStyle::LowerAlpha,
1292 N::AlphaUpper => ListStyle::UpperAlpha,
1293 N::RomanLower => ListStyle::LowerRoman,
1294 N::RomanUpper => ListStyle::UpperRoman,
1295 }
1296}
1297
1298fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1302 use jotdown::OrderedListStyle as S;
1303 match style {
1304 S::Period => (String::new(), ".".to_string()),
1305 S::Paren => (String::new(), ")".to_string()),
1306 S::ParenParen => ("(".to_string(), ")".to_string()),
1307 }
1308}
1309
1310#[derive(Debug, Clone, Default)]
1314struct DjotBlockStyle {
1315 alignment: Option<Alignment>,
1316 line_height: Option<i64>,
1317 non_breakable_lines: Option<bool>,
1318 direction: Option<TextDirection>,
1319 background_color: Option<String>,
1320}
1321
1322impl DjotBlockStyle {
1323 fn merge_from(&mut self, other: DjotBlockStyle) {
1327 if other.alignment.is_some() {
1328 self.alignment = other.alignment;
1329 }
1330 if other.line_height.is_some() {
1331 self.line_height = other.line_height;
1332 }
1333 if other.non_breakable_lines.is_some() {
1334 self.non_breakable_lines = other.non_breakable_lines;
1335 }
1336 if other.direction.is_some() {
1337 self.direction = other.direction;
1338 }
1339 if other.background_color.is_some() {
1340 self.background_color = other.background_color;
1341 }
1342 }
1343}
1344
1345fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1350 let mut style = DjotBlockStyle::default();
1351
1352 if opts.alignment
1353 && let Some(v) = attrs.get_value("alignment")
1354 {
1355 style.alignment = match v.to_string().as_str() {
1356 "left" => Some(Alignment::Left),
1357 "right" => Some(Alignment::Right),
1358 "center" => Some(Alignment::Center),
1359 "justify" => Some(Alignment::Justify),
1360 _ => None,
1361 };
1362 }
1363 if opts.line_height
1364 && let Some(v) = attrs.get_value("line_height")
1365 {
1366 style.line_height = v.to_string().parse::<i64>().ok();
1367 }
1368 if opts.direction
1369 && let Some(v) = attrs.get_value("direction")
1370 {
1371 style.direction = match v.to_string().as_str() {
1372 "ltr" => Some(TextDirection::LeftToRight),
1373 "rtl" => Some(TextDirection::RightToLeft),
1374 _ => None,
1375 };
1376 }
1377 if opts.non_breakable_lines
1378 && let Some(v) = attrs.get_value("non_breakable_lines")
1379 {
1380 style.non_breakable_lines = match v.to_string().as_str() {
1381 "true" => Some(true),
1382 "false" => Some(false),
1383 _ => None,
1384 };
1385 }
1386 if opts.background_color
1387 && let Some(v) = attrs.get_value("background_color")
1388 {
1389 style.background_color = Some(v.to_string());
1390 }
1391
1392 style
1393}
1394
1395#[allow(clippy::too_many_arguments)]
1398fn djot_push_block(
1399 elements: &mut Vec<ParsedElement>,
1400 spans: Vec<ParsedSpan>,
1401 heading_level: Option<i64>,
1402 list_style: Option<ListStyle>,
1403 list_indent: u32,
1404 list_prefix: String,
1405 list_suffix: String,
1406 marker: Option<MarkerType>,
1407 is_code_block: bool,
1408 code_language: Option<String>,
1409 blockquote_depth: u32,
1410 style: DjotBlockStyle,
1411) {
1412 elements.push(ParsedElement::Block(ParsedBlock {
1413 spans,
1414 heading_level,
1415 list_style,
1416 list_indent,
1417 list_prefix,
1418 list_suffix,
1419 marker,
1420 is_code_block,
1421 code_language,
1422 blockquote_depth,
1423 line_height: style.line_height,
1424 non_breakable_lines: style.non_breakable_lines,
1425 direction: style.direction,
1426 background_color: style.background_color,
1427 alignment: style.alignment,
1428 }));
1429}
1430
1431pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
1452 use jotdown::{Container as C, Event as E, ListKind, Parser};
1453
1454 let mut elements: Vec<ParsedElement> = Vec::new();
1455 let mut current_spans: Vec<ParsedSpan> = Vec::new();
1456 let mut current_heading: Option<i64> = None;
1457 let mut is_code_block = false;
1458 let mut code_language: Option<String> = None;
1459 let mut blockquote_depth: u32 = 0;
1460 let mut pending_style = DjotBlockStyle::default();
1463
1464 let mut bold = false;
1466 let mut italic = false;
1467 let mut underline = false;
1468 let mut strikeout = false;
1469 let mut code = false;
1470 let mut superscript = false;
1471 let mut subscript = false;
1472 let mut link_href: Option<String> = None;
1473
1474 let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
1476 let mut cur_list_style: Option<ListStyle> = None;
1478 let mut cur_list_prefix = String::new();
1479 let mut cur_list_suffix = String::new();
1480 let mut cur_list_indent: u32 = 0;
1481 let mut cur_marker: Option<MarkerType> = None;
1482
1483 let mut in_table_cell = false;
1485 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1486 let mut current_row: Vec<ParsedTableCell> = Vec::new();
1487 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
1488 let mut table_header_rows: usize = 0;
1489 let mut row_is_head = false;
1490
1491 let mut skip_depth: u32 = 0;
1495
1496 macro_rules! push_text {
1501 ($t:expr) => {{
1502 let sp = ParsedSpan {
1503 text: ($t).to_string(),
1504 bold,
1505 italic,
1506 underline,
1507 strikeout,
1508 code,
1509 superscript,
1510 subscript,
1511 link_href: link_href.clone(),
1512 };
1513 if in_table_cell {
1514 current_cell_spans.push(sp);
1515 } else {
1516 current_spans.push(sp);
1517 }
1518 }};
1519 }
1520
1521 macro_rules! enter_item {
1524 ($marker:expr) => {{
1525 if !current_spans.is_empty() {
1526 djot_push_block(
1527 &mut elements,
1528 std::mem::take(&mut current_spans),
1529 None,
1530 cur_list_style.clone(),
1531 cur_list_indent,
1532 cur_list_prefix.clone(),
1533 cur_list_suffix.clone(),
1534 cur_marker.clone(),
1535 false,
1536 None,
1537 blockquote_depth,
1538 DjotBlockStyle::default(),
1539 );
1540 }
1541 let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
1542 ListStyle::Disc,
1543 String::new(),
1544 String::new(),
1545 ));
1546 cur_list_style = Some(style);
1547 cur_list_prefix = prefix;
1548 cur_list_suffix = suffix;
1549 cur_list_indent = list_stack.len().saturating_sub(1) as u32;
1550 cur_marker = $marker;
1551 }};
1552 }
1553
1554 for event in Parser::new(djot) {
1555 if skip_depth > 0 {
1556 match event {
1557 E::Start(..) => skip_depth += 1,
1558 E::End(_) => skip_depth -= 1,
1559 _ => {}
1560 }
1561 continue;
1562 }
1563
1564 match event {
1565 E::Start(C::Document, _) | E::End(C::Document) => {}
1567 E::Start(C::Section { .. }, attrs) => {
1568 if list_stack.is_empty() {
1571 pending_style.merge_from(block_attrs_to_style(&attrs, options));
1572 }
1573 }
1574 E::End(C::Section { .. }) => {}
1575 E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
1576
1577 E::Start(C::Blockquote, _) => blockquote_depth += 1,
1579 E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
1580
1581 E::Start(C::List { kind, .. }, _) => {
1583 let (style, prefix, suffix) = match kind {
1584 ListKind::Unordered(b) | ListKind::Task(b) => {
1585 (djot_bullet_style(b), String::new(), String::new())
1586 }
1587 ListKind::Ordered {
1588 numbering, style, ..
1589 } => {
1590 let (p, s) = djot_ordered_affixes(style);
1591 (djot_ordered_style(numbering), p, s)
1592 }
1593 };
1594 list_stack.push((style, prefix, suffix));
1595 }
1596 E::End(C::List { .. }) => {
1597 list_stack.pop();
1598 cur_list_style = None;
1599 cur_marker = None;
1600 }
1601 E::Start(C::ListItem, _) => enter_item!(None),
1602 E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
1603 MarkerType::Checked
1604 } else {
1605 MarkerType::Unchecked
1606 })),
1607 E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
1608 if !current_spans.is_empty() {
1610 djot_push_block(
1611 &mut elements,
1612 std::mem::take(&mut current_spans),
1613 None,
1614 cur_list_style.clone(),
1615 cur_list_indent,
1616 cur_list_prefix.clone(),
1617 cur_list_suffix.clone(),
1618 cur_marker.clone(),
1619 false,
1620 None,
1621 blockquote_depth,
1622 DjotBlockStyle::default(),
1623 );
1624 }
1625 cur_list_style = None;
1626 cur_marker = None;
1627 }
1628
1629 E::Start(C::Heading { level, .. }, attrs) => {
1631 current_heading = Some(level as i64);
1632 pending_style.merge_from(block_attrs_to_style(&attrs, options));
1635 }
1636 E::End(C::Heading { .. }) => {
1637 djot_push_block(
1638 &mut elements,
1639 std::mem::take(&mut current_spans),
1640 current_heading.take(),
1641 None,
1642 0,
1643 String::new(),
1644 String::new(),
1645 None,
1646 false,
1647 None,
1648 blockquote_depth,
1649 std::mem::take(&mut pending_style),
1650 );
1651 }
1652 E::Start(C::Paragraph, attrs) => {
1653 current_heading = None;
1654 pending_style = if list_stack.is_empty() {
1658 block_attrs_to_style(&attrs, options)
1659 } else {
1660 DjotBlockStyle::default()
1661 };
1662 }
1663 E::End(C::Paragraph) => {
1664 if !current_spans.is_empty() {
1665 djot_push_block(
1666 &mut elements,
1667 std::mem::take(&mut current_spans),
1668 None,
1669 cur_list_style.clone(),
1670 cur_list_indent,
1671 cur_list_prefix.clone(),
1672 cur_list_suffix.clone(),
1673 cur_marker.clone(),
1674 false,
1675 None,
1676 blockquote_depth,
1677 std::mem::take(&mut pending_style),
1678 );
1679 }
1680 cur_list_style = None;
1681 cur_marker = None;
1682 }
1683 E::Start(C::CodeBlock { language }, _) => {
1684 is_code_block = true;
1685 code_language = if language.is_empty() {
1686 None
1687 } else {
1688 Some(language.to_string())
1689 };
1690 }
1691 E::End(C::CodeBlock { .. }) => {
1692 if let Some(last) = current_spans.last_mut()
1694 && last.text.ends_with('\n')
1695 {
1696 last.text.pop();
1697 }
1698 djot_push_block(
1699 &mut elements,
1700 std::mem::take(&mut current_spans),
1701 None,
1702 None,
1703 0,
1704 String::new(),
1705 String::new(),
1706 None,
1707 true,
1708 code_language.take(),
1709 blockquote_depth,
1710 DjotBlockStyle::default(),
1711 );
1712 is_code_block = false;
1713 }
1714
1715 E::Start(C::Table, _) => {
1717 table_rows.clear();
1718 current_row.clear();
1719 current_cell_spans.clear();
1720 table_header_rows = 0;
1721 }
1722 E::End(C::Table) => {
1723 elements.push(ParsedElement::Table(ParsedTable {
1724 header_rows: table_header_rows,
1725 rows: std::mem::take(&mut table_rows),
1726 blockquote_depth,
1727 }));
1728 }
1729 E::Start(C::TableRow { head }, _) => {
1730 row_is_head = head;
1731 current_row.clear();
1732 }
1733 E::End(C::TableRow { .. }) => {
1734 if row_is_head {
1735 table_header_rows += 1;
1736 }
1737 table_rows.push(std::mem::take(&mut current_row));
1738 }
1739 E::Start(C::TableCell { .. }, _) => {
1740 in_table_cell = true;
1741 current_cell_spans.clear();
1742 }
1743 E::End(C::TableCell { .. }) => {
1744 in_table_cell = false;
1745 current_row.push(ParsedTableCell {
1746 spans: std::mem::take(&mut current_cell_spans),
1747 });
1748 }
1749
1750 E::Start(C::Strong, _) => bold = true,
1752 E::End(C::Strong) => bold = false,
1753 E::Start(C::Emphasis, _) => italic = true,
1754 E::End(C::Emphasis) => italic = false,
1755 E::Start(C::Verbatim, _) => code = true,
1756 E::End(C::Verbatim) => code = false,
1757 E::Start(C::Superscript, _) => superscript = true,
1758 E::End(C::Superscript) => superscript = false,
1759 E::Start(C::Subscript, _) => subscript = true,
1760 E::End(C::Subscript) => subscript = false,
1761 E::Start(C::Insert, _) => underline = true,
1762 E::End(C::Insert) => underline = false,
1763 E::Start(C::Delete, _) => strikeout = true,
1764 E::End(C::Delete) => strikeout = false,
1765 E::Start(C::Mark, _) | E::End(C::Mark) => {}
1767 E::Start(C::Span, _) | E::End(C::Span) => {}
1768 E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
1769 E::End(C::Link(..)) => link_href = None,
1770 E::Start(C::Image(..), _) | E::End(C::Image(..)) => {}
1772
1773 E::Start(
1775 C::Footnote { .. }
1776 | C::Math { .. }
1777 | C::RawBlock { .. }
1778 | C::RawInline { .. }
1779 | C::DescriptionList
1780 | C::DescriptionDetails
1781 | C::DescriptionTerm
1782 | C::Caption
1783 | C::LinkDefinition { .. },
1784 _,
1785 ) => skip_depth = 1,
1786
1787 E::Str(s) => push_text!(s.as_ref()),
1789 E::Softbreak => push_text!(" "),
1790 E::LeftSingleQuote => push_text!("\u{2018}"),
1791 E::RightSingleQuote => push_text!("\u{2019}"),
1792 E::LeftDoubleQuote => push_text!("\u{201C}"),
1793 E::RightDoubleQuote => push_text!("\u{201D}"),
1794 E::Ellipsis => push_text!("\u{2026}"),
1795 E::EnDash => push_text!("\u{2013}"),
1796 E::EmDash => push_text!("\u{2014}"),
1797 E::NonBreakingSpace => push_text!("\u{00A0}"),
1798 E::Hardbreak => {
1799 if in_table_cell {
1800 push_text!(" ");
1801 } else if !current_spans.is_empty() {
1802 djot_push_block(
1805 &mut elements,
1806 std::mem::take(&mut current_spans),
1807 None,
1808 cur_list_style.clone(),
1809 cur_list_indent,
1810 cur_list_prefix.clone(),
1811 cur_list_suffix.clone(),
1812 cur_marker.clone(),
1813 is_code_block,
1814 code_language.clone(),
1815 blockquote_depth,
1816 pending_style.clone(),
1817 );
1818 }
1819 }
1820 E::Symbol(_) | E::FootnoteReference(_) => {}
1823 E::Escape | E::Blankline => {}
1824 E::ThematicBreak(_) | E::Attributes(_) => {}
1825
1826 _ => {}
1829 }
1830 }
1831
1832 if !current_spans.is_empty() {
1834 djot_push_block(
1835 &mut elements,
1836 std::mem::take(&mut current_spans),
1837 current_heading.take(),
1838 cur_list_style.clone(),
1839 cur_list_indent,
1840 cur_list_prefix.clone(),
1841 cur_list_suffix.clone(),
1842 cur_marker.clone(),
1843 is_code_block,
1844 code_language.take(),
1845 blockquote_depth,
1846 std::mem::take(&mut pending_style),
1847 );
1848 }
1849
1850 if elements.is_empty() {
1853 djot_push_block(
1854 &mut elements,
1855 vec![ParsedSpan {
1856 text: String::new(),
1857 ..Default::default()
1858 }],
1859 None,
1860 None,
1861 0,
1862 String::new(),
1863 String::new(),
1864 None,
1865 false,
1866 None,
1867 0,
1868 DjotBlockStyle::default(),
1869 );
1870 }
1871
1872 elements
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877 use super::*;
1878
1879 fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
1881 ParsedElement::flatten_to_blocks(parse_markdown(md))
1882 }
1883
1884 #[test]
1885 fn test_parse_markdown_simple_paragraph() {
1886 let blocks = parse_markdown_blocks("Hello **world**");
1887 assert_eq!(blocks.len(), 1);
1888 assert!(blocks[0].spans.len() >= 2);
1889 let plain_span = blocks[0]
1891 .spans
1892 .iter()
1893 .find(|s| s.text.contains("Hello"))
1894 .unwrap();
1895 assert!(!plain_span.bold);
1896 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
1897 assert!(bold_span.bold);
1898 }
1899
1900 #[test]
1901 fn test_parse_markdown_heading() {
1902 let blocks = parse_markdown_blocks("# Title");
1903 assert_eq!(blocks.len(), 1);
1904 assert_eq!(blocks[0].heading_level, Some(1));
1905 assert_eq!(blocks[0].spans[0].text, "Title");
1906 }
1907
1908 #[test]
1909 fn test_parse_markdown_list() {
1910 let blocks = parse_markdown_blocks("- item1\n- item2");
1911 assert!(blocks.len() >= 2);
1912 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
1913 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
1914 }
1915
1916 fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
1918 elements
1919 .iter()
1920 .map(|e| match e {
1921 ParsedElement::Block(b) => (false, b.blockquote_depth),
1922 ParsedElement::Table(t) => (true, t.blockquote_depth),
1923 })
1924 .collect()
1925 }
1926
1927 #[test]
1928 fn test_parse_markdown_table_in_blockquote_records_depth() {
1929 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
1930 assert_eq!(element_depths(&elements), vec![(true, 1)]);
1931 }
1932
1933 #[test]
1934 fn test_parse_markdown_text_then_table_in_blockquote() {
1935 let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
1936 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
1937 }
1938
1939 #[test]
1940 fn test_parse_markdown_table_after_blockquote_closes() {
1941 let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
1942 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
1943 }
1944
1945 #[test]
1946 fn test_parse_markdown_table_in_nested_blockquote() {
1947 let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
1948 assert_eq!(element_depths(&elements), vec![(true, 2)]);
1949 }
1950
1951 #[test]
1952 fn test_parse_markdown_list_in_blockquote_records_depth() {
1953 let elements = parse_markdown("> - item1\n> - item2");
1954 let depths = element_depths(&elements);
1955 assert_eq!(depths, vec![(false, 1), (false, 1)]);
1956 for e in &elements {
1957 if let ParsedElement::Block(b) = e {
1958 assert_eq!(b.list_style, Some(ListStyle::Disc));
1959 }
1960 }
1961 }
1962
1963 #[test]
1964 fn test_parse_html_table_in_blockquote_records_depth() {
1965 let elements = parse_html_elements(
1966 "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
1967 );
1968 assert_eq!(element_depths(&elements), vec![(true, 1)]);
1969 }
1970
1971 #[test]
1972 fn test_parse_html_table_after_blockquote() {
1973 let elements = parse_html_elements(
1974 "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
1975 );
1976 let depths = element_depths(&elements);
1977 assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
1979 assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
1980 }
1981
1982 #[test]
1983 fn test_flatten_to_blocks_propagates_blockquote_depth() {
1984 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
1985 let blocks = ParsedElement::flatten_to_blocks(elements);
1986 assert!(!blocks.is_empty());
1987 for b in &blocks {
1988 assert_eq!(b.blockquote_depth, 1);
1989 }
1990 }
1991
1992 #[test]
1993 fn test_parse_html_simple() {
1994 let blocks = parse_html("<p>Hello <b>world</b></p>");
1995 assert_eq!(blocks.len(), 1);
1996 assert!(blocks[0].spans.len() >= 2);
1997 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
1998 assert!(bold_span.bold);
1999 }
2000
2001 #[test]
2002 fn test_parse_html_multiple_paragraphs() {
2003 let blocks = parse_html("<p>A</p><p>B</p>");
2004 assert_eq!(blocks.len(), 2);
2005 }
2006
2007 #[test]
2008 fn test_parse_html_heading() {
2009 let blocks = parse_html("<h2>Subtitle</h2>");
2010 assert_eq!(blocks.len(), 1);
2011 assert_eq!(blocks[0].heading_level, Some(2));
2012 }
2013
2014 #[test]
2015 fn test_parse_html_list() {
2016 let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2017 assert!(blocks.len() >= 2);
2018 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2019 }
2020
2021 #[test]
2022 fn test_parse_markdown_code_block() {
2023 let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2024 assert_eq!(blocks.len(), 1);
2025 assert!(blocks[0].is_code_block);
2026 assert!(blocks[0].spans[0].code);
2027 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2029 assert_eq!(
2030 text, "fn main() {}",
2031 "code block text should not have trailing newline"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_parse_markdown_nested_formatting() {
2037 let blocks = parse_markdown_blocks("***bold italic***");
2038 assert_eq!(blocks.len(), 1);
2039 let span = &blocks[0].spans[0];
2040 assert!(span.bold);
2041 assert!(span.italic);
2042 }
2043
2044 #[test]
2045 fn test_parse_markdown_link() {
2046 let blocks = parse_markdown_blocks("[click](http://example.com)");
2047 assert_eq!(blocks.len(), 1);
2048 let span = &blocks[0].spans[0];
2049 assert_eq!(span.text, "click");
2050 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2051 }
2052
2053 #[test]
2054 fn test_parse_markdown_empty() {
2055 let blocks = parse_markdown_blocks("");
2056 assert_eq!(blocks.len(), 1);
2057 assert!(blocks[0].spans[0].text.is_empty());
2058 }
2059
2060 #[test]
2061 fn test_parse_html_empty() {
2062 let blocks = parse_html("");
2063 assert_eq!(blocks.len(), 1);
2064 assert!(blocks[0].spans[0].text.is_empty());
2065 }
2066
2067 #[test]
2068 fn test_parse_html_nested_formatting() {
2069 let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2070 assert_eq!(blocks.len(), 1);
2071 let span = &blocks[0].spans[0];
2072 assert!(span.bold);
2073 assert!(span.italic);
2074 }
2075
2076 #[test]
2077 fn test_parse_html_link() {
2078 let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2079 assert_eq!(blocks.len(), 1);
2080 let span = &blocks[0].spans[0];
2081 assert_eq!(span.text, "click");
2082 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2083 }
2084
2085 #[test]
2086 fn test_parse_html_ordered_list() {
2087 let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2088 assert!(blocks.len() >= 2);
2089 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2090 }
2091
2092 #[test]
2093 fn test_parse_markdown_ordered_list() {
2094 let blocks = parse_markdown_blocks("1. first\n2. second");
2095 assert!(blocks.len() >= 2);
2096 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2097 }
2098
2099 #[test]
2100 fn test_parse_html_blockquote_nested() {
2101 let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2102 assert!(blocks.len() >= 3);
2103 }
2104
2105 #[test]
2106 fn test_parse_block_styles_line_height() {
2107 let styles = parse_block_styles("line-height: 1.5");
2108 assert_eq!(styles.line_height, Some(1500));
2109 }
2110
2111 #[test]
2112 fn test_parse_block_styles_direction_rtl() {
2113 let styles = parse_block_styles("direction: rtl");
2114 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2115 }
2116
2117 #[test]
2118 fn test_parse_block_styles_background_color() {
2119 let styles = parse_block_styles("background-color: #ff0000");
2120 assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2121 }
2122
2123 #[test]
2124 fn test_parse_block_styles_white_space_pre() {
2125 let styles = parse_block_styles("white-space: pre");
2126 assert_eq!(styles.non_breakable_lines, Some(true));
2127 }
2128
2129 #[test]
2130 fn test_parse_block_styles_multiple() {
2131 let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2132 assert_eq!(styles.line_height, Some(2000));
2133 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2134 assert_eq!(styles.background_color, Some("blue".to_string()));
2135 }
2136
2137 #[test]
2138 fn test_parse_html_block_styles_extracted() {
2139 let blocks = parse_html(
2140 r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2141 );
2142 assert_eq!(blocks.len(), 1);
2143 assert_eq!(blocks[0].line_height, Some(1500));
2144 assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2145 assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2146 }
2147
2148 #[test]
2149 fn test_parse_html_white_space_pre() {
2150 let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2151 assert_eq!(blocks.len(), 1);
2152 assert_eq!(blocks[0].non_breakable_lines, Some(true));
2153 }
2154
2155 #[test]
2156 fn test_parse_html_no_styles_returns_none() {
2157 let blocks = parse_html("<p>plain</p>");
2158 assert_eq!(blocks.len(), 1);
2159 assert_eq!(blocks[0].line_height, None);
2160 assert_eq!(blocks[0].direction, None);
2161 assert_eq!(blocks[0].background_color, None);
2162 assert_eq!(blocks[0].non_breakable_lines, None);
2163 }
2164
2165 #[test]
2166 fn test_parse_markdown_nested_list_indent() {
2167 let md = "- top\n - nested\n - deep";
2168 let blocks = parse_markdown_blocks(md);
2169 assert_eq!(blocks.len(), 3);
2170 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2171 assert_eq!(blocks[0].list_indent, 0);
2172 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2173 assert_eq!(blocks[1].list_indent, 1);
2174 assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2175 assert_eq!(blocks[2].list_indent, 2);
2176 }
2177
2178 #[test]
2179 fn test_parse_markdown_nested_ordered_list_indent() {
2180 let md = "1. first\n 1. nested\n 2. nested2";
2181 let blocks = parse_markdown_blocks(md);
2182 assert_eq!(blocks.len(), 3);
2183 assert_eq!(blocks[0].list_indent, 0);
2184 assert_eq!(blocks[1].list_indent, 1);
2185 assert_eq!(blocks[2].list_indent, 1);
2186 }
2187
2188 #[test]
2189 fn test_parse_html_nested_list_indent() {
2190 let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2191 let blocks = parse_html(html);
2192 assert!(blocks.len() >= 2);
2193 assert_eq!(blocks[0].list_indent, 0);
2194 assert_eq!(blocks[1].list_indent, 1);
2195 }
2196
2197 #[test]
2198 fn test_parse_markdown_table() {
2199 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2200 let elements = parse_markdown(md);
2201 assert_eq!(elements.len(), 1);
2202 match &elements[0] {
2203 ParsedElement::Table(table) => {
2204 assert_eq!(table.header_rows, 1);
2205 assert_eq!(table.rows.len(), 2); assert_eq!(table.rows[0].len(), 2);
2208 assert_eq!(table.rows[0][0].spans[0].text, "A");
2209 assert_eq!(table.rows[0][1].spans[0].text, "B");
2210 assert_eq!(table.rows[1].len(), 2);
2212 assert_eq!(table.rows[1][0].spans[0].text, "1");
2213 assert_eq!(table.rows[1][1].spans[0].text, "2");
2214 }
2215 _ => panic!("Expected ParsedElement::Table"),
2216 }
2217 }
2218
2219 #[test]
2220 fn test_parse_markdown_table_with_formatting() {
2221 let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2222 let elements = parse_markdown(md);
2223 assert_eq!(elements.len(), 1);
2224 match &elements[0] {
2225 ParsedElement::Table(table) => {
2226 assert_eq!(table.rows.len(), 2);
2227 assert!(table.rows[0][0].spans[0].bold);
2229 assert!(table.rows[0][1].spans[0].code);
2231 assert!(table.rows[0][2].spans[0].italic);
2233 assert!(table.rows[1][0].spans[0].strikeout);
2235 assert_eq!(
2237 table.rows[1][2].spans[0].link_href,
2238 Some("http://x.com".to_string())
2239 );
2240 }
2241 _ => panic!("Expected ParsedElement::Table"),
2242 }
2243 }
2244
2245 #[test]
2246 fn test_parse_markdown_mixed_content_with_table() {
2247 let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
2248 let elements = parse_markdown(md);
2249 assert_eq!(elements.len(), 3);
2250 assert!(matches!(&elements[0], ParsedElement::Block(_)));
2251 assert!(matches!(&elements[1], ParsedElement::Table(_)));
2252 assert!(matches!(&elements[2], ParsedElement::Block(_)));
2253 }
2254}
2255
2256#[cfg(test)]
2257mod djot_tests {
2258 use super::*;
2259 use crate::entities::MarkerType;
2260
2261 fn blocks(d: &str) -> Vec<ParsedBlock> {
2262 ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
2263 }
2264
2265 fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
2266 b.spans.iter().find(|s| pred(s)).expect("span not found")
2267 }
2268
2269 #[test]
2270 fn paragraph_bold_italic() {
2271 let b = blocks("normal *bold* _italic_");
2272 assert_eq!(b.len(), 1);
2273 assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
2274 assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
2275 }
2276
2277 #[test]
2278 fn heading_levels() {
2279 assert_eq!(blocks("# H1")[0].heading_level, Some(1));
2280 assert_eq!(blocks("### H3")[0].heading_level, Some(3));
2281 assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
2282 }
2283
2284 #[test]
2285 fn unordered_bullet_styles_are_distinct() {
2286 assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
2287 assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
2288 assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
2289 }
2290
2291 #[test]
2292 fn ordered_delimiters() {
2293 let period = blocks("1. a");
2294 assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
2295 assert_eq!(period[0].list_prefix, "");
2296 assert_eq!(period[0].list_suffix, ".");
2297
2298 let paren = blocks("1) a");
2299 assert_eq!(paren[0].list_suffix, ")");
2300 assert_eq!(paren[0].list_prefix, "");
2301
2302 let paren_paren = blocks("(1) a");
2303 assert_eq!(paren_paren[0].list_prefix, "(");
2304 assert_eq!(paren_paren[0].list_suffix, ")");
2305 }
2306
2307 #[test]
2308 fn task_list_markers() {
2309 let b = blocks("- [ ] a\n- [x] b");
2310 assert_eq!(b.len(), 2);
2311 assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
2312 assert_eq!(b[1].marker, Some(MarkerType::Checked));
2313 }
2314
2315 #[test]
2316 fn code_block_with_language() {
2317 let b = blocks("```rust\nfn main() {}\n```");
2318 assert_eq!(b.len(), 1);
2319 assert!(b[0].is_code_block);
2320 assert_eq!(b[0].code_language.as_deref(), Some("rust"));
2321 let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
2322 assert_eq!(text, "fn main() {}");
2323 }
2324
2325 #[test]
2326 fn link_href() {
2327 let b = blocks("[text](http://example.com)");
2328 let s = first_span_with(&b[0], |s| s.text == "text");
2329 assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
2330 }
2331
2332 #[test]
2333 fn superscript_subscript() {
2334 assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
2335 assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
2336 }
2337
2338 #[test]
2339 fn delete_insert_verbatim() {
2340 assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
2341 assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
2342 assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
2343 }
2344
2345 #[test]
2346 fn blockquote_depth() {
2347 let els = parse_djot("> quoted", &DjotImportOptions::default());
2348 match &els[0] {
2349 ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
2350 _ => panic!("expected block"),
2351 }
2352 }
2353
2354 #[test]
2355 fn nested_list_indent() {
2356 let b = blocks("- a\n\n - b\n\n - c");
2361 assert_eq!(b.len(), 3);
2362 assert_eq!(b[0].list_indent, 0);
2363 assert_eq!(b[1].list_indent, 1);
2364 assert_eq!(b[2].list_indent, 2);
2365 }
2366
2367 #[test]
2368 fn table_parsed_as_table() {
2369 let els = parse_djot(
2370 "| a | b |\n|---|---|\n| c | d |",
2371 &DjotImportOptions::default(),
2372 );
2373 assert_eq!(els.len(), 1);
2374 match &els[0] {
2375 ParsedElement::Table(t) => {
2376 assert_eq!(t.header_rows, 1);
2377 assert_eq!(t.rows.len(), 2);
2378 assert_eq!(t.rows[0][0].spans[0].text, "a");
2379 assert_eq!(t.rows[1][1].spans[0].text, "d");
2380 }
2381 _ => panic!("expected table"),
2382 }
2383 }
2384
2385 #[test]
2386 fn smart_punctuation_normalised_to_unicode() {
2387 let text: String = blocks("a... b---c")[0]
2388 .spans
2389 .iter()
2390 .map(|s| s.text.as_str())
2391 .collect();
2392 assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
2393 assert!(text.contains('\u{2014}'), "em dash: {text:?}");
2394 }
2395
2396 #[test]
2397 fn unrepresentable_constructs_dropped_without_leaking_text() {
2398 let b = blocks("para1\n\n---\n\npara2");
2400 assert_eq!(b.len(), 2);
2401 assert_eq!(
2402 b[0].spans
2403 .iter()
2404 .map(|s| s.text.as_str())
2405 .collect::<String>(),
2406 "para1"
2407 );
2408 assert_eq!(
2409 b[1].spans
2410 .iter()
2411 .map(|s| s.text.as_str())
2412 .collect::<String>(),
2413 "para2"
2414 );
2415
2416 let d = blocks(":::\ninside\n:::");
2418 let joined: String = d
2419 .iter()
2420 .flat_map(|b| b.spans.iter())
2421 .map(|s| s.text.as_str())
2422 .collect();
2423 assert_eq!(joined, "inside");
2424
2425 let m = blocks("before $`E=mc^2` after");
2427 let joined: String = m
2428 .iter()
2429 .flat_map(|b| b.spans.iter())
2430 .map(|s| s.text.as_str())
2431 .collect();
2432 assert!(joined.contains("before"), "{joined:?}");
2433 assert!(joined.contains("after"), "{joined:?}");
2434 assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
2435 }
2436
2437 #[test]
2438 fn empty_document_yields_one_empty_block() {
2439 let b = blocks("");
2440 assert_eq!(b.len(), 1);
2441 assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
2442 }
2443
2444 #[test]
2445 fn block_attributes_parse_into_block() {
2446 let b = blocks(
2447 "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
2448 );
2449 assert_eq!(b.len(), 1);
2450 assert_eq!(b[0].alignment, Some(Alignment::Center));
2451 assert_eq!(b[0].line_height, Some(1500));
2452 assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
2453 assert_eq!(b[0].non_breakable_lines, Some(true));
2454 assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
2455 }
2456
2457 #[test]
2458 fn block_attributes_on_heading() {
2459 let b = blocks("{alignment=right}\n# Title");
2460 assert_eq!(b[0].heading_level, Some(1));
2461 assert_eq!(b[0].alignment, Some(Alignment::Right));
2462 }
2463
2464 #[test]
2465 fn block_attributes_respect_import_options() {
2466 let src = "{alignment=center line_height=1500}\nhello";
2469 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
2470 assert_eq!(b[0].alignment, None);
2471 assert_eq!(b[0].line_height, None);
2472 assert_eq!(
2473 b[0].spans
2474 .iter()
2475 .map(|s| s.text.as_str())
2476 .collect::<String>(),
2477 "hello"
2478 );
2479 }
2480
2481 #[test]
2482 fn list_item_block_attributes_are_dropped() {
2483 let b = blocks("{alignment=center}\n- item");
2486 assert!(b.iter().all(|blk| blk.alignment.is_none()));
2487 }
2488
2489 #[test]
2490 fn unknown_alignment_value_is_ignored() {
2491 let b = blocks("{alignment=sideways}\nhello");
2492 assert_eq!(b[0].alignment, None);
2493 }
2494}