1use crate::entities::{Alignment, ListStyle, MarkerType, SemanticRole, TextDirection};
2use crate::parser_tools::djot_options::DjotImportOptions;
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct ParsedImage {
11 pub src: String,
12 pub alt: String,
13 pub width: i64,
19 pub height: i64,
20}
21
22#[derive(Debug, Clone, Default)]
29pub struct ParsedSpan {
30 pub text: String,
31 pub bold: bool,
32 pub italic: bool,
33 pub underline: bool,
34 pub strikeout: bool,
35 pub code: bool,
36 pub superscript: bool,
38 pub subscript: bool,
40 pub link_href: Option<String>,
41 pub image: Option<ParsedImage>,
43 pub footnote_ref: Option<String>,
46}
47
48#[derive(Debug, Clone)]
50pub struct ParsedTableCell {
51 pub spans: Vec<ParsedSpan>,
52}
53
54#[derive(Debug, Clone)]
56pub struct ParsedTable {
57 pub header_rows: usize,
59 pub rows: Vec<Vec<ParsedTableCell>>,
61 pub blockquote_depth: u32,
64}
65
66#[derive(Debug, Clone)]
68pub enum ParsedElement {
69 Block(ParsedBlock),
70 Table(ParsedTable),
71 FootnoteDefinition {
78 label: String,
79 blocks: Vec<ParsedBlock>,
80 },
81}
82
83impl ParsedElement {
84 pub fn flatten_to_blocks(elements: Vec<ParsedElement>) -> Vec<ParsedBlock> {
87 let mut blocks = Vec::new();
88 for elem in elements {
89 match elem {
90 ParsedElement::Block(b) => blocks.push(b),
91 ParsedElement::FootnoteDefinition { .. } => {}
96 ParsedElement::Table(t) => {
97 for row in t.rows {
98 for cell in row {
99 blocks.push(ParsedBlock {
100 spans: cell.spans,
101 heading_level: None,
102 list_style: None,
103 list_indent: 0,
104 list_prefix: String::new(),
105 list_suffix: String::new(),
106 marker: None,
107 is_code_block: false,
108 code_language: None,
109 blockquote_depth: t.blockquote_depth,
110 line_height: None,
111 non_breakable_lines: None,
112 page_break_before: None,
113 direction: None,
114 background_color: None,
115 alignment: None,
116 top_margin: None,
117 text_indent: None,
118 semantic_role: None,
119 });
120 }
121 }
122 }
123 }
124 }
125 if blocks.is_empty() {
126 blocks.push(ParsedBlock {
127 spans: vec![ParsedSpan {
128 text: String::new(),
129 ..Default::default()
130 }],
131 heading_level: None,
132 list_style: None,
133 list_indent: 0,
134 list_prefix: String::new(),
135 list_suffix: String::new(),
136 marker: None,
137 is_code_block: false,
138 code_language: None,
139 blockquote_depth: 0,
140 line_height: None,
141 non_breakable_lines: None,
142 page_break_before: None,
143 direction: None,
144 background_color: None,
145 alignment: None,
146 top_margin: None,
147 text_indent: None,
148 semantic_role: None,
149 });
150 }
151 blocks
152 }
153}
154
155#[derive(Debug, Clone)]
157pub struct ParsedBlock {
158 pub spans: Vec<ParsedSpan>,
159 pub heading_level: Option<i64>,
160 pub list_style: Option<ListStyle>,
161 pub list_indent: u32,
162 pub list_prefix: String,
165 pub list_suffix: String,
168 pub marker: Option<MarkerType>,
171 pub is_code_block: bool,
172 pub code_language: Option<String>,
173 pub blockquote_depth: u32,
174 pub line_height: Option<i64>,
175 pub non_breakable_lines: Option<bool>,
176 pub page_break_before: Option<bool>,
179 pub direction: Option<TextDirection>,
180 pub background_color: Option<String>,
181 pub alignment: Option<Alignment>,
184 pub top_margin: Option<i64>,
188 pub text_indent: Option<i64>,
192 pub semantic_role: Option<SemanticRole>,
196}
197
198impl ParsedBlock {
199 pub fn is_inline_only(&self) -> bool {
202 self.heading_level.is_none()
203 && self.list_style.is_none()
204 && !self.is_code_block
205 && self.blockquote_depth == 0
206 && self.line_height.is_none()
207 && self.non_breakable_lines.is_none()
208 && self.page_break_before.is_none()
209 && self.direction.is_none()
210 && self.background_color.is_none()
211 && self.alignment.is_none()
212 && self.top_margin.is_none()
213 && self.text_indent.is_none()
214 }
215}
216
217fn dangling_footnote_labels(
246 markdown: &str,
247 options: pulldown_cmark::Options,
248) -> std::collections::BTreeSet<String> {
249 use pulldown_cmark::{Event, Parser, Tag};
250
251 let mut defined: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
252 for event in Parser::new_ext(markdown, options) {
253 if let Event::Start(Tag::FootnoteDefinition(label)) = event {
254 defined.insert(label.to_string());
255 }
256 }
257
258 let mut referenced: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
259 let bytes = markdown.as_bytes();
260 let mut search_from = 0usize;
261 while let Some(rel) = markdown[search_from..].find("[^") {
262 let open = search_from + rel;
263 let label_start = open + 2;
264 let Some(close_rel) = markdown[label_start..].find(']') else {
265 break;
266 };
267 let close = label_start + close_rel;
268 let label = &markdown[label_start..close];
269 let looks_like_definition = bytes.get(close + 1) == Some(&b':');
274 if !label.is_empty() && !looks_like_definition && !label.chars().any(char::is_whitespace) {
275 referenced.insert(label.to_string());
276 }
277 search_from = close + 1;
278 }
279
280 referenced.difference(&defined).cloned().collect()
281}
282
283pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
284 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
285
286 let options = Options::ENABLE_STRIKETHROUGH
297 | Options::ENABLE_TABLES
298 | Options::ENABLE_TASKLISTS
299 | Options::ENABLE_FOOTNOTES;
300
301 let dangling = dangling_footnote_labels(markdown, options);
308 let augmented_owner;
309 let source: &str = if dangling.is_empty() {
310 markdown
311 } else {
312 augmented_owner = dangling
313 .iter()
314 .fold(markdown.to_string(), |mut acc, label| {
315 acc.push_str("\n\n[^");
316 acc.push_str(label);
317 acc.push_str("]:\n");
318 acc
319 });
320 &augmented_owner
321 };
322 let parser = Parser::new_ext(source, options);
323
324 let mut elements: Vec<ParsedElement> = Vec::new();
325 let mut current_spans: Vec<ParsedSpan> = Vec::new();
326 let mut current_heading: Option<i64> = None;
327 let mut current_list_style: Option<ListStyle> = None;
328 let mut is_code_block = false;
329 let mut code_language: Option<String> = None;
330 let mut blockquote_depth: u32 = 0;
331 let mut in_block = false;
332
333 let mut bold = false;
335 let mut italic = false;
336 let mut strikeout = false;
337 let mut link_href: Option<String> = None;
338 let mut pending_image: Option<ParsedImage> = None;
340
341 let mut footnote_open: Option<(String, usize)> = None;
348
349 let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
351 let mut current_list_indent: u32 = 0;
352
353 let mut in_table = false;
355 let mut in_table_head = false;
356 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
357 let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
358 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
359 let mut table_header_rows: usize = 0;
360
361 for event in parser {
362 match event {
363 Event::Start(Tag::Paragraph) => {
364 in_block = true;
365 current_heading = None;
366 is_code_block = false;
367 }
368 Event::End(TagEnd::Paragraph) => {
369 if !current_spans.is_empty() || in_block {
370 elements.push(ParsedElement::Block(ParsedBlock {
371 spans: std::mem::take(&mut current_spans),
372 heading_level: current_heading.take(),
373 list_style: current_list_style.clone(),
374 list_indent: current_list_indent,
375 list_prefix: String::new(),
376 list_suffix: String::new(),
377 marker: None,
378 is_code_block: false,
379 code_language: None,
380 blockquote_depth,
381 line_height: None,
382 non_breakable_lines: None,
383 page_break_before: None,
384 direction: None,
385 background_color: None,
386 alignment: None,
387 top_margin: None,
388 text_indent: None,
389 semantic_role: None,
390 }));
391 }
392 in_block = false;
393 current_list_style = None;
394 }
395 Event::Start(Tag::Heading { level, .. }) => {
396 in_block = true;
397 current_heading = Some(heading_level_to_i64(level));
398 is_code_block = false;
399 }
400 Event::End(TagEnd::Heading(_)) => {
401 elements.push(ParsedElement::Block(ParsedBlock {
402 spans: std::mem::take(&mut current_spans),
403 heading_level: current_heading.take(),
404 list_style: None,
405 list_indent: 0,
406 list_prefix: String::new(),
407 list_suffix: String::new(),
408 marker: None,
409 is_code_block: false,
410 code_language: None,
411 blockquote_depth,
412 line_height: None,
413 non_breakable_lines: None,
414 page_break_before: None,
415 direction: None,
416 background_color: None,
417 alignment: None,
418 top_margin: None,
419 text_indent: None,
420 semantic_role: None,
421 }));
422 in_block = false;
423 }
424 Event::Start(Tag::List(ordered)) => {
425 let style = if ordered.is_some() {
426 Some(ListStyle::Decimal)
427 } else {
428 Some(ListStyle::Disc)
429 };
430 list_stack.push(style);
431 }
432 Event::End(TagEnd::List(_)) => {
433 list_stack.pop();
434 }
435 Event::Start(Tag::Item) => {
436 if !current_spans.is_empty() {
439 elements.push(ParsedElement::Block(ParsedBlock {
440 spans: std::mem::take(&mut current_spans),
441 heading_level: None,
442 list_style: current_list_style.clone(),
443 list_indent: current_list_indent,
444 list_prefix: String::new(),
445 list_suffix: String::new(),
446 marker: None,
447 is_code_block: false,
448 code_language: None,
449 blockquote_depth,
450 line_height: None,
451 non_breakable_lines: None,
452 page_break_before: None,
453 direction: None,
454 background_color: None,
455 alignment: None,
456 top_margin: None,
457 text_indent: None,
458 semantic_role: None,
459 }));
460 }
461 in_block = true;
462 current_list_style = list_stack.last().cloned().flatten();
463 current_list_indent = if list_stack.is_empty() {
464 0
465 } else {
466 (list_stack.len() - 1) as u32
467 };
468 }
469 Event::End(TagEnd::Item) => {
470 if !current_spans.is_empty() {
473 elements.push(ParsedElement::Block(ParsedBlock {
474 spans: std::mem::take(&mut current_spans),
475 heading_level: None,
476 list_style: current_list_style.clone(),
477 list_indent: current_list_indent,
478 list_prefix: String::new(),
479 list_suffix: String::new(),
480 marker: None,
481 is_code_block: false,
482 code_language: None,
483 blockquote_depth,
484 line_height: None,
485 non_breakable_lines: None,
486 page_break_before: None,
487 direction: None,
488 background_color: None,
489 alignment: None,
490 top_margin: None,
491 text_indent: None,
492 semantic_role: None,
493 }));
494 }
495 in_block = false;
496 current_list_style = None;
497 }
498 Event::Start(Tag::CodeBlock(kind)) => {
499 in_block = true;
500 is_code_block = true;
501 code_language = match &kind {
502 pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
503 Some(lang.to_string())
504 }
505 _ => None,
506 };
507 }
508 Event::End(TagEnd::CodeBlock) => {
509 if let Some(last) = current_spans.last_mut()
511 && last.text.ends_with('\n')
512 {
513 last.text.truncate(last.text.len() - 1);
514 }
515 elements.push(ParsedElement::Block(ParsedBlock {
516 spans: std::mem::take(&mut current_spans),
517 heading_level: None,
518 list_style: None,
519 list_indent: 0,
520 list_prefix: String::new(),
521 list_suffix: String::new(),
522 marker: None,
523 is_code_block: true,
524 code_language: code_language.take(),
525 blockquote_depth,
526 line_height: None,
527 non_breakable_lines: None,
528 page_break_before: None,
529 direction: None,
530 background_color: None,
531 alignment: None,
532 top_margin: None,
533 text_indent: None,
534 semantic_role: None,
535 }));
536 in_block = false;
537 is_code_block = false;
538 }
539 Event::Start(Tag::Table(_)) => {
541 in_table = true;
542 in_table_head = false;
543 table_rows.clear();
544 current_row_cells.clear();
545 current_cell_spans.clear();
546 table_header_rows = 0;
547 }
548 Event::End(TagEnd::Table) => {
549 elements.push(ParsedElement::Table(ParsedTable {
550 header_rows: table_header_rows,
551 rows: std::mem::take(&mut table_rows),
552 blockquote_depth,
553 }));
554 in_table = false;
555 }
556 Event::Start(Tag::TableHead) => {
557 in_table_head = true;
558 current_row_cells.clear();
559 }
560 Event::End(TagEnd::TableHead) => {
561 table_rows.push(std::mem::take(&mut current_row_cells));
563 table_header_rows += 1;
564 in_table_head = false;
565 }
566 Event::Start(Tag::TableRow) => {
567 current_row_cells.clear();
568 }
569 Event::End(TagEnd::TableRow) if !in_table_head => {
570 table_rows.push(std::mem::take(&mut current_row_cells));
572 }
573 Event::Start(Tag::TableCell) => {
574 current_cell_spans.clear();
575 }
576 Event::End(TagEnd::TableCell) => {
577 current_row_cells.push(ParsedTableCell {
578 spans: std::mem::take(&mut current_cell_spans),
579 });
580 }
581 Event::Start(Tag::Emphasis) => {
583 italic = true;
584 }
585 Event::End(TagEnd::Emphasis) => {
586 italic = false;
587 }
588 Event::Start(Tag::Strong) => {
589 bold = true;
590 }
591 Event::End(TagEnd::Strong) => {
592 bold = false;
593 }
594 Event::Start(Tag::Strikethrough) => {
595 strikeout = true;
596 }
597 Event::End(TagEnd::Strikethrough) => {
598 strikeout = false;
599 }
600 Event::Start(Tag::Link { dest_url, .. }) => {
601 link_href = Some(dest_url.to_string());
602 }
603 Event::End(TagEnd::Link) => {
604 link_href = None;
605 }
606 Event::Start(Tag::Image { dest_url, .. }) => {
609 pending_image = Some(ParsedImage {
610 src: dest_url.to_string(),
611 alt: String::new(),
612 width: 0,
613 height: 0,
614 });
615 }
616 Event::End(TagEnd::Image) => {
617 if let Some(image) = pending_image.take() {
618 let span = ParsedSpan {
619 text: String::new(),
620 bold,
621 italic,
622 underline: false,
623 strikeout,
624 code: false,
625 superscript: false,
626 subscript: false,
627 link_href: link_href.clone(),
628 image: Some(image),
629 footnote_ref: None,
630 };
631 if in_table {
632 current_cell_spans.push(span);
633 } else {
634 if !in_block {
635 in_block = true;
636 }
637 current_spans.push(span);
638 }
639 }
640 }
641 Event::Text(text) => {
642 if let Some(img) = pending_image.as_mut() {
647 img.alt.push_str(&text);
648 continue;
649 }
650 let span = ParsedSpan {
651 text: text.to_string(),
652 bold,
653 italic,
654 underline: false,
655 strikeout,
656 code: is_code_block,
657 superscript: false,
658 subscript: false,
659 link_href: link_href.clone(),
660 image: None,
661 footnote_ref: None,
662 };
663 if in_table {
664 current_cell_spans.push(span);
665 } else {
666 if !in_block {
667 in_block = true;
668 }
669 current_spans.push(span);
670 }
671 }
672 Event::Code(text) => {
673 let span = ParsedSpan {
674 text: text.to_string(),
675 bold,
676 italic,
677 underline: false,
678 strikeout,
679 code: true,
680 superscript: false,
681 subscript: false,
682 link_href: link_href.clone(),
683 image: None,
684 footnote_ref: None,
685 };
686 if in_table {
687 current_cell_spans.push(span);
688 } else {
689 if !in_block {
690 in_block = true;
691 }
692 current_spans.push(span);
693 }
694 }
695 Event::SoftBreak => {
696 let span = ParsedSpan {
697 text: " ".to_string(),
698 bold,
699 italic,
700 underline: false,
701 strikeout,
702 code: false,
703 superscript: false,
704 subscript: false,
705 link_href: link_href.clone(),
706 image: None,
707 footnote_ref: None,
708 };
709 if in_table {
710 current_cell_spans.push(span);
711 } else {
712 current_spans.push(span);
713 }
714 }
715 Event::HardBreak if !current_spans.is_empty() || in_block => {
716 elements.push(ParsedElement::Block(ParsedBlock {
718 spans: std::mem::take(&mut current_spans),
719 heading_level: current_heading.take(),
720 list_style: current_list_style.clone(),
721 list_indent: current_list_indent,
722 list_prefix: String::new(),
723 list_suffix: String::new(),
724 marker: None,
725 is_code_block,
726 code_language: code_language.clone(),
727 blockquote_depth,
728 line_height: None,
729 non_breakable_lines: None,
730 page_break_before: None,
731 direction: None,
732 background_color: None,
733 alignment: None,
734 top_margin: None,
735 text_indent: None,
736 semantic_role: None,
737 }));
738 }
739 Event::Start(Tag::BlockQuote(_)) => {
740 blockquote_depth += 1;
741 }
742 Event::End(TagEnd::BlockQuote(_)) => {
743 blockquote_depth = blockquote_depth.saturating_sub(1);
744 }
745 Event::Start(Tag::FootnoteDefinition(label)) => {
756 if !current_spans.is_empty() {
757 elements.push(ParsedElement::Block(ParsedBlock {
758 spans: std::mem::take(&mut current_spans),
759 heading_level: current_heading.take(),
760 list_style: current_list_style.clone(),
761 list_indent: current_list_indent,
762 list_prefix: String::new(),
763 list_suffix: String::new(),
764 marker: None,
765 is_code_block: false,
766 code_language: None,
767 blockquote_depth,
768 line_height: None,
769 non_breakable_lines: None,
770 page_break_before: None,
771 direction: None,
772 background_color: None,
773 alignment: None,
774 top_margin: None,
775 text_indent: None,
776 semantic_role: None,
777 }));
778 }
779 footnote_open = Some((label.to_string(), elements.len()));
780 }
781 Event::End(TagEnd::FootnoteDefinition) => {
782 if !current_spans.is_empty() {
783 elements.push(ParsedElement::Block(ParsedBlock {
784 spans: std::mem::take(&mut current_spans),
785 heading_level: current_heading.take(),
786 list_style: current_list_style.clone(),
787 list_indent: current_list_indent,
788 list_prefix: String::new(),
789 list_suffix: String::new(),
790 marker: None,
791 is_code_block: false,
792 code_language: None,
793 blockquote_depth,
794 line_height: None,
795 non_breakable_lines: None,
796 page_break_before: None,
797 direction: None,
798 background_color: None,
799 alignment: None,
800 top_margin: None,
801 text_indent: None,
802 semantic_role: None,
803 }));
804 }
805 if let Some((label, start)) = footnote_open.take() {
806 let blocks: Vec<ParsedBlock> = elements
807 .drain(start..)
808 .filter_map(|e| match e {
809 ParsedElement::Block(b) => Some(b),
810 _ => None,
813 })
814 .collect();
815 elements.push(ParsedElement::FootnoteDefinition { label, blocks });
816 }
817 }
818 Event::FootnoteReference(label) => {
824 let span = ParsedSpan {
825 text: String::new(),
826 bold,
827 italic,
828 underline: false,
829 strikeout,
830 code: false,
831 superscript: false,
832 subscript: false,
833 link_href: link_href.clone(),
834 image: None,
835 footnote_ref: Some(label.to_string()),
836 };
837 if in_table {
838 current_cell_spans.push(span);
839 } else {
840 if !in_block {
841 in_block = true;
842 }
843 current_spans.push(span);
844 }
845 }
846 _ => {}
847 }
848 }
849
850 if !current_spans.is_empty() {
852 elements.push(ParsedElement::Block(ParsedBlock {
853 spans: std::mem::take(&mut current_spans),
854 heading_level: current_heading,
855 list_style: current_list_style,
856 list_indent: current_list_indent,
857 list_prefix: String::new(),
858 list_suffix: String::new(),
859 marker: None,
860 is_code_block,
861 code_language: code_language.take(),
862 blockquote_depth,
863 line_height: None,
864 non_breakable_lines: None,
865 page_break_before: None,
866 direction: None,
867 background_color: None,
868 alignment: None,
869 top_margin: None,
870 text_indent: None,
871 semantic_role: None,
872 }));
873 }
874
875 if !dangling.is_empty() {
882 elements.retain(
883 |e| !matches!(e, ParsedElement::FootnoteDefinition { label, .. } if dangling.contains(label)),
884 );
885 }
886
887 if elements.is_empty() {
889 elements.push(ParsedElement::Block(ParsedBlock {
890 spans: vec![ParsedSpan {
891 text: String::new(),
892 ..Default::default()
893 }],
894 heading_level: None,
895 list_style: None,
896 list_indent: 0,
897 list_prefix: String::new(),
898 list_suffix: String::new(),
899 marker: None,
900 is_code_block: false,
901 code_language: None,
902 blockquote_depth: 0,
903 line_height: None,
904 non_breakable_lines: None,
905 page_break_before: None,
906 direction: None,
907 background_color: None,
908 alignment: None,
909 top_margin: None,
910 text_indent: None,
911 semantic_role: None,
912 }));
913 }
914
915 elements
916}
917
918fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
919 use pulldown_cmark::HeadingLevel;
920 match level {
921 HeadingLevel::H1 => 1,
922 HeadingLevel::H2 => 2,
923 HeadingLevel::H3 => 3,
924 HeadingLevel::H4 => 4,
925 HeadingLevel::H5 => 5,
926 HeadingLevel::H6 => 6,
927 }
928}
929
930use scraper::Node;
933
934#[derive(Debug, Clone, Default)]
936struct BlockStyles {
937 line_height: Option<i64>,
938 non_breakable_lines: Option<bool>,
939 page_break_before: Option<bool>,
940 direction: Option<TextDirection>,
941 background_color: Option<String>,
942}
943
944fn parse_block_styles(style: &str) -> BlockStyles {
948 let mut result = BlockStyles::default();
949 for part in style.split(';') {
950 let part = part.trim();
951 if let Some((prop, val)) = part.split_once(':') {
952 let prop = prop.trim().to_ascii_lowercase();
953 let val = val.trim();
954 match prop.as_str() {
955 "line-height" => {
956 if let Ok(v) = val.parse::<f64>() {
958 result.line_height = Some((v * 1000.0) as i64);
959 }
960 }
961 "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
962 result.non_breakable_lines = Some(true);
963 }
964 "break-before" | "page-break-before" => {
968 result.page_break_before = match val.to_ascii_lowercase().as_str() {
969 "page" | "always" | "left" | "right" | "recto" | "verso" => Some(true),
970 "avoid" | "auto" => Some(false),
971 _ => None,
972 };
973 }
974 "direction" => {
975 if val.eq_ignore_ascii_case("rtl") {
976 result.direction = Some(TextDirection::RightToLeft);
977 } else if val.eq_ignore_ascii_case("ltr") {
978 result.direction = Some(TextDirection::LeftToRight);
979 }
980 }
981 "background-color" | "background" => {
982 result.background_color = Some(val.to_string());
983 }
984 _ => {}
985 }
986 }
987 }
988 result
989}
990
991pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
992 ParsedElement::flatten_to_blocks(parse_html_elements(html))
993}
994
995fn html_img_span(el: &scraper::node::Element, link_href: Option<String>) -> Option<ParsedSpan> {
1003 let src = el.attr("src")?;
1004 if src.is_empty() {
1005 return None;
1006 }
1007 let dim = |name: &str| -> i64 {
1008 el.attr(name)
1009 .and_then(|v| v.trim().trim_end_matches("px").parse::<i64>().ok())
1010 .filter(|n| *n > 0)
1011 .unwrap_or(0)
1012 };
1013 Some(ParsedSpan {
1014 text: String::new(),
1015 link_href,
1016 image: Some(ParsedImage {
1017 src: src.to_string(),
1018 alt: el.attr("alt").unwrap_or_default().to_string(),
1019 width: dim("width"),
1020 height: dim("height"),
1021 }),
1022 ..Default::default()
1023 })
1024}
1025
1026pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
1027 use scraper::Html;
1028
1029 let fragment = Html::parse_fragment(html);
1030 let mut elements: Vec<ParsedElement> = Vec::new();
1031
1032 let root = fragment.root_element();
1034
1035 #[derive(Clone, Default)]
1036 struct FmtState {
1037 bold: bool,
1038 italic: bool,
1039 underline: bool,
1040 strikeout: bool,
1041 code: bool,
1042 link_href: Option<String>,
1043 }
1044
1045 const MAX_RECURSION_DEPTH: usize = 256;
1046
1047 fn collect_cell_spans(
1049 node: ego_tree::NodeRef<Node>,
1050 state: &FmtState,
1051 spans: &mut Vec<ParsedSpan>,
1052 depth: usize,
1053 ) {
1054 if depth > MAX_RECURSION_DEPTH {
1055 return;
1056 }
1057 for child in node.children() {
1058 match child.value() {
1059 Node::Text(text) => {
1060 let t = text.text.to_string();
1061 if !t.is_empty() {
1062 spans.push(ParsedSpan {
1063 text: t,
1064 bold: state.bold,
1065 italic: state.italic,
1066 underline: state.underline,
1067 strikeout: state.strikeout,
1068 code: state.code,
1069 superscript: false,
1070 subscript: false,
1071 link_href: state.link_href.clone(),
1072 image: None,
1073 footnote_ref: None,
1074 });
1075 }
1076 }
1077 Node::Element(el) => {
1078 let tag = el.name();
1079 let mut new_state = state.clone();
1080 match tag {
1081 "b" | "strong" => new_state.bold = true,
1082 "i" | "em" => new_state.italic = true,
1083 "u" | "ins" => new_state.underline = true,
1084 "s" | "del" | "strike" => new_state.strikeout = true,
1085 "code" => new_state.code = true,
1086 "a" => {
1087 if let Some(href) = el.attr("href") {
1088 new_state.link_href = Some(href.to_string());
1089 }
1090 }
1091 "img" => {
1092 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1093 spans.push(span);
1094 }
1095 continue;
1096 }
1097 _ => {}
1098 }
1099 collect_cell_spans(child, &new_state, spans, depth + 1);
1100 }
1101 _ => {}
1102 }
1103 }
1104 }
1105
1106 fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1108 let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1109 let mut header_rows: usize = 0;
1110
1111 fn collect_rows(
1112 node: ego_tree::NodeRef<Node>,
1113 rows: &mut Vec<Vec<ParsedTableCell>>,
1114 header_rows: &mut usize,
1115 in_thead: bool,
1116 ) {
1117 for child in node.children() {
1118 if let Node::Element(el) = child.value() {
1119 match el.name() {
1120 "thead" => collect_rows(child, rows, header_rows, true),
1121 "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1122 "tr" => {
1123 let mut cells: Vec<ParsedTableCell> = Vec::new();
1124 for td in child.children() {
1125 if let Node::Element(td_el) = td.value()
1126 && matches!(td_el.name(), "td" | "th")
1127 {
1128 let mut spans = Vec::new();
1129 let state = FmtState::default();
1130 collect_cell_spans(td, &state, &mut spans, 0);
1131 if spans.is_empty() {
1132 spans.push(ParsedSpan::default());
1133 }
1134 cells.push(ParsedTableCell { spans });
1135 }
1136 }
1137 if !cells.is_empty() {
1138 rows.push(cells);
1139 if in_thead {
1140 *header_rows += 1;
1141 }
1142 }
1143 }
1144 _ => {}
1145 }
1146 }
1147 }
1148 }
1149
1150 collect_rows(table_node, &mut rows, &mut header_rows, false);
1151
1152 if header_rows == 0 && !rows.is_empty() {
1154 header_rows = 1;
1155 }
1156
1157 ParsedTable {
1158 header_rows,
1159 rows,
1160 blockquote_depth: 0,
1163 }
1164 }
1165
1166 fn walk_node(
1167 node: ego_tree::NodeRef<Node>,
1168 state: &FmtState,
1169 elements: &mut Vec<ParsedElement>,
1170 current_list_style: &Option<ListStyle>,
1171 blockquote_depth: u32,
1172 list_depth: u32,
1173 depth: usize,
1174 ) {
1175 if depth > MAX_RECURSION_DEPTH {
1176 return;
1177 }
1178 match node.value() {
1179 Node::Element(el) => {
1180 let tag = el.name();
1181 let mut new_state = state.clone();
1182 let mut new_list_style = current_list_style.clone();
1183 let mut bq_depth = blockquote_depth;
1184 let mut new_list_depth = list_depth;
1185
1186 let is_block_tag = matches!(
1188 tag,
1189 "p" | "div"
1190 | "h1"
1191 | "h2"
1192 | "h3"
1193 | "h4"
1194 | "h5"
1195 | "h6"
1196 | "li"
1197 | "pre"
1198 | "br"
1199 | "blockquote"
1200 | "body"
1201 | "html"
1202 );
1203
1204 match tag {
1206 "b" | "strong" => new_state.bold = true,
1207 "i" | "em" => new_state.italic = true,
1208 "u" | "ins" => new_state.underline = true,
1209 "s" | "del" | "strike" => new_state.strikeout = true,
1210 "code" => new_state.code = true,
1211 "a" => {
1212 if let Some(href) = el.attr("href") {
1213 new_state.link_href = Some(href.to_string());
1214 }
1215 }
1216 "ul" => {
1217 new_list_style = Some(ListStyle::Disc);
1218 new_list_depth = list_depth + 1;
1219 }
1220 "ol" => {
1221 new_list_style = Some(ListStyle::Decimal);
1222 new_list_depth = list_depth + 1;
1223 }
1224 "blockquote" => {
1225 bq_depth += 1;
1226 }
1227 _ => {}
1228 }
1229
1230 let heading_level = match tag {
1232 "h1" => Some(1),
1233 "h2" => Some(2),
1234 "h3" => Some(3),
1235 "h4" => Some(4),
1236 "h5" => Some(5),
1237 "h6" => Some(6),
1238 _ => None,
1239 };
1240
1241 let is_code_block = tag == "pre";
1242
1243 let code_language = if is_code_block {
1245 node.children().find_map(|child| {
1246 if let Node::Element(cel) = child.value()
1247 && cel.name() == "code"
1248 && let Some(cls) = cel.attr("class")
1249 {
1250 return cls
1251 .split_whitespace()
1252 .find_map(|c| c.strip_prefix("language-"))
1253 .map(|l| l.to_string());
1254 }
1255 None
1256 })
1257 } else {
1258 None
1259 };
1260
1261 let css = if is_block_tag {
1263 el.attr("style").map(parse_block_styles).unwrap_or_default()
1264 } else {
1265 BlockStyles::default()
1266 };
1267
1268 if tag == "table" {
1269 let mut parsed_table = parse_table_element(node);
1271 if !parsed_table.rows.is_empty() {
1272 parsed_table.blockquote_depth = bq_depth;
1273 elements.push(ParsedElement::Table(parsed_table));
1274 }
1275 return;
1276 }
1277
1278 if tag == "br" {
1279 elements.push(ParsedElement::Block(ParsedBlock {
1281 spans: vec![ParsedSpan {
1282 text: String::new(),
1283 ..Default::default()
1284 }],
1285 heading_level: None,
1286 list_style: None,
1287 list_indent: 0,
1288 list_prefix: String::new(),
1289 list_suffix: String::new(),
1290 marker: None,
1291 is_code_block: false,
1292 code_language: None,
1293 blockquote_depth: bq_depth,
1294 line_height: None,
1295 non_breakable_lines: None,
1296 page_break_before: None,
1297 direction: None,
1298 background_color: None,
1299 alignment: None,
1300 top_margin: None,
1301 text_indent: None,
1302 semantic_role: None,
1303 }));
1304 return;
1305 }
1306
1307 if tag == "blockquote" {
1308 for child in node.children() {
1310 walk_node(
1311 child,
1312 &new_state,
1313 elements,
1314 &new_list_style,
1315 bq_depth,
1316 new_list_depth,
1317 depth + 1,
1318 );
1319 }
1320 } else if is_block_tag && tag != "br" {
1321 let mut spans: Vec<ParsedSpan> = Vec::new();
1326 let mut nested_elements: Vec<ParsedElement> = Vec::new();
1327 collect_inline_spans(
1328 node,
1329 &new_state,
1330 &mut spans,
1331 &new_list_style,
1332 &mut nested_elements,
1333 bq_depth,
1334 new_list_depth,
1335 depth + 1,
1336 );
1337
1338 let list_style_for_block = if tag == "li" {
1339 new_list_style.clone()
1340 } else {
1341 None
1342 };
1343
1344 let list_indent_for_block = if tag == "li" {
1345 new_list_depth.saturating_sub(1)
1346 } else {
1347 0
1348 };
1349
1350 if !spans.is_empty() || heading_level.is_some() {
1351 elements.push(ParsedElement::Block(ParsedBlock {
1352 spans,
1353 heading_level,
1354 list_style: list_style_for_block,
1355 list_indent: list_indent_for_block,
1356 list_prefix: String::new(),
1357 list_suffix: String::new(),
1358 marker: None,
1359 is_code_block,
1360 code_language,
1361 blockquote_depth: bq_depth,
1362 line_height: css.line_height,
1363 non_breakable_lines: css.non_breakable_lines,
1364 page_break_before: css.page_break_before,
1365 direction: css.direction,
1366 background_color: css.background_color,
1367 alignment: None,
1368 top_margin: None,
1369 text_indent: None,
1370 semantic_role: None,
1371 }));
1372 }
1373 elements.append(&mut nested_elements);
1375 } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1376 for child in node.children() {
1378 walk_node(
1379 child,
1380 &new_state,
1381 elements,
1382 &new_list_style,
1383 bq_depth,
1384 new_list_depth,
1385 depth + 1,
1386 );
1387 }
1388 } else {
1389 for child in node.children() {
1391 walk_node(
1392 child,
1393 &new_state,
1394 elements,
1395 current_list_style,
1396 bq_depth,
1397 list_depth,
1398 depth + 1,
1399 );
1400 }
1401 }
1402 }
1403 Node::Text(text) => {
1404 let t = text.text.to_string();
1405 let trimmed = t.trim();
1406 if !trimmed.is_empty() {
1407 elements.push(ParsedElement::Block(ParsedBlock {
1409 spans: vec![ParsedSpan {
1410 text: trimmed.to_string(),
1411 bold: state.bold,
1412 italic: state.italic,
1413 underline: state.underline,
1414 strikeout: state.strikeout,
1415 code: state.code,
1416 superscript: false,
1417 subscript: false,
1418 link_href: state.link_href.clone(),
1419 image: None,
1420 footnote_ref: None,
1421 }],
1422 heading_level: None,
1423 list_style: None,
1424 list_indent: 0,
1425 list_prefix: String::new(),
1426 list_suffix: String::new(),
1427 marker: None,
1428 is_code_block: false,
1429 code_language: None,
1430 blockquote_depth,
1431 line_height: None,
1432 non_breakable_lines: None,
1433 page_break_before: None,
1434 direction: None,
1435 background_color: None,
1436 alignment: None,
1437 top_margin: None,
1438 text_indent: None,
1439 semantic_role: None,
1440 }));
1441 }
1442 }
1443 _ => {
1444 for child in node.children() {
1446 walk_node(
1447 child,
1448 state,
1449 elements,
1450 current_list_style,
1451 blockquote_depth,
1452 list_depth,
1453 depth + 1,
1454 );
1455 }
1456 }
1457 }
1458 }
1459
1460 #[allow(clippy::too_many_arguments)]
1464 fn collect_inline_spans(
1465 node: ego_tree::NodeRef<Node>,
1466 state: &FmtState,
1467 spans: &mut Vec<ParsedSpan>,
1468 current_list_style: &Option<ListStyle>,
1469 elements: &mut Vec<ParsedElement>,
1470 blockquote_depth: u32,
1471 list_depth: u32,
1472 depth: usize,
1473 ) {
1474 if depth > MAX_RECURSION_DEPTH {
1475 return;
1476 }
1477 for child in node.children() {
1478 match child.value() {
1479 Node::Text(text) => {
1480 let t = text.text.to_string();
1481 if !t.is_empty() {
1482 spans.push(ParsedSpan {
1483 text: t,
1484 bold: state.bold,
1485 italic: state.italic,
1486 underline: state.underline,
1487 strikeout: state.strikeout,
1488 code: state.code,
1489 superscript: false,
1490 subscript: false,
1491 link_href: state.link_href.clone(),
1492 image: None,
1493 footnote_ref: None,
1494 });
1495 }
1496 }
1497 Node::Element(el) => {
1498 let tag = el.name();
1499 let mut new_state = state.clone();
1500
1501 match tag {
1502 "b" | "strong" => new_state.bold = true,
1503 "i" | "em" => new_state.italic = true,
1504 "u" | "ins" => new_state.underline = true,
1505 "s" | "del" | "strike" => new_state.strikeout = true,
1506 "code" => new_state.code = true,
1507 "a" => {
1508 if let Some(href) = el.attr("href") {
1509 new_state.link_href = Some(href.to_string());
1510 }
1511 }
1512 "img" => {
1513 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1514 spans.push(span);
1515 }
1516 continue;
1517 }
1518 _ => {}
1519 }
1520
1521 let nested_block = matches!(
1523 tag,
1524 "p" | "div"
1525 | "h1"
1526 | "h2"
1527 | "h3"
1528 | "h4"
1529 | "h5"
1530 | "h6"
1531 | "li"
1532 | "pre"
1533 | "blockquote"
1534 | "ul"
1535 | "ol"
1536 );
1537
1538 if tag == "br" {
1539 spans.push(ParsedSpan {
1542 text: String::new(),
1543 ..Default::default()
1544 });
1545 } else if nested_block || tag == "table" {
1546 walk_node(
1548 child,
1549 &new_state,
1550 elements,
1551 current_list_style,
1552 blockquote_depth,
1553 list_depth,
1554 depth + 1,
1555 );
1556 } else {
1557 collect_inline_spans(
1559 child,
1560 &new_state,
1561 spans,
1562 current_list_style,
1563 elements,
1564 blockquote_depth,
1565 list_depth,
1566 depth + 1,
1567 );
1568 }
1569 }
1570 _ => {}
1571 }
1572 }
1573 }
1574
1575 let initial_state = FmtState::default();
1576 let mut root_spans: Vec<ParsedSpan> = Vec::new();
1580 collect_inline_spans(
1581 *root,
1582 &initial_state,
1583 &mut root_spans,
1584 &None,
1585 &mut elements,
1586 0,
1587 0,
1588 0,
1589 );
1590 if !root_spans.is_empty() {
1591 elements.push(ParsedElement::Block(ParsedBlock {
1592 spans: root_spans,
1593 heading_level: None,
1594 list_style: None,
1595 list_indent: 0,
1596 list_prefix: String::new(),
1597 list_suffix: String::new(),
1598 marker: None,
1599 is_code_block: false,
1600 code_language: None,
1601 blockquote_depth: 0,
1602 line_height: None,
1603 non_breakable_lines: None,
1604 page_break_before: None,
1605 direction: None,
1606 background_color: None,
1607 alignment: None,
1608 top_margin: None,
1609 text_indent: None,
1610 semantic_role: None,
1611 }));
1612 }
1613
1614 if elements.is_empty() {
1616 elements.push(ParsedElement::Block(ParsedBlock {
1617 spans: vec![ParsedSpan {
1618 text: String::new(),
1619 ..Default::default()
1620 }],
1621 heading_level: None,
1622 list_style: None,
1623 list_indent: 0,
1624 list_prefix: String::new(),
1625 list_suffix: String::new(),
1626 marker: None,
1627 is_code_block: false,
1628 code_language: None,
1629 blockquote_depth: 0,
1630 line_height: None,
1631 non_breakable_lines: None,
1632 page_break_before: None,
1633 direction: None,
1634 background_color: None,
1635 alignment: None,
1636 top_margin: None,
1637 text_indent: None,
1638 semantic_role: None,
1639 }));
1640 }
1641
1642 elements
1643}
1644
1645pub fn character_format_from_span(
1649 span: &ParsedSpan,
1650 is_code_block: bool,
1651) -> crate::format_runs::CharacterFormat {
1652 use crate::entities::CharVerticalAlignment;
1653 crate::format_runs::CharacterFormat {
1654 font_bold: if span.bold { Some(true) } else { None },
1655 font_italic: if span.italic { Some(true) } else { None },
1656 font_underline: if span.underline { Some(true) } else { None },
1657 font_strikeout: if span.strikeout { Some(true) } else { None },
1658 font_family: if span.code || is_code_block {
1659 Some("monospace".to_string())
1660 } else {
1661 None
1662 },
1663 anchor_href: span.link_href.clone(),
1664 is_anchor: if span.link_href.is_some() {
1665 Some(true)
1666 } else {
1667 None
1668 },
1669 vertical_alignment: if span.superscript {
1670 Some(CharVerticalAlignment::SuperScript)
1671 } else if span.subscript {
1672 Some(CharVerticalAlignment::SubScript)
1673 } else {
1674 None
1675 },
1676 ..Default::default()
1677 }
1678}
1679
1680pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1692 use crate::format_runs::{
1693 CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1694 };
1695
1696 let mut plain_text = String::new();
1697 let mut runs: Vec<FormatRun> = Vec::new();
1698 let mut images: Vec<ImageAnchor> = Vec::new();
1699 let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1700 let default = CharacterFormat::default();
1701
1702 for span in spans {
1703 let byte_start = plain_text.len() as u32;
1704
1705 if let Some(label) = &span.footnote_ref {
1706 plain_text.push('\u{FFFC}');
1709 let mut format = character_format_from_span(span, is_code_block);
1724 format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1725 footnote_refs.push(FootnoteRefAnchor {
1726 byte_offset: byte_start,
1727 label: label.clone(),
1728 format,
1729 });
1730 continue;
1731 }
1732
1733 if let Some(image) = &span.image {
1734 plain_text.push('\u{FFFC}');
1738 images.push(ImageAnchor {
1739 byte_offset: byte_start,
1740 name: image.src.clone(),
1741 alt: image.alt.clone(),
1742 width: image.width,
1743 height: image.height,
1744 quality: 100,
1745 format: character_format_from_span(span, is_code_block),
1746 });
1747 continue;
1748 }
1749
1750 plain_text.push_str(&span.text);
1751 let byte_end = plain_text.len() as u32;
1752 if byte_start == byte_end {
1753 continue;
1754 }
1755 let format = character_format_from_span(span, is_code_block);
1756 if format == default {
1757 continue;
1758 }
1759 runs.push(FormatRun {
1760 byte_start,
1761 byte_end,
1762 format,
1763 });
1764 }
1765 coalesce_in_place(&mut runs);
1766 ParsedInline {
1767 plain_text,
1768 runs,
1769 images,
1770 footnote_refs,
1771 }
1772}
1773
1774#[derive(Debug, Clone, Default)]
1781pub struct ParsedInline {
1782 pub plain_text: String,
1783 pub runs: Vec<crate::format_runs::FormatRun>,
1784 pub images: Vec<crate::format_runs::ImageAnchor>,
1785 pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
1786}
1787
1788fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1796 use jotdown::ListBulletType as B;
1797 match b {
1798 B::Dash => ListStyle::Disc,
1799 B::Star => ListStyle::Circle,
1800 B::Plus => ListStyle::Square,
1801 }
1802}
1803
1804fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1806 use jotdown::OrderedListNumbering as N;
1807 match n {
1808 N::Decimal => ListStyle::Decimal,
1809 N::AlphaLower => ListStyle::LowerAlpha,
1810 N::AlphaUpper => ListStyle::UpperAlpha,
1811 N::RomanLower => ListStyle::LowerRoman,
1812 N::RomanUpper => ListStyle::UpperRoman,
1813 }
1814}
1815
1816fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1820 use jotdown::OrderedListStyle as S;
1821 match style {
1822 S::Period => (String::new(), ".".to_string()),
1823 S::Paren => (String::new(), ")".to_string()),
1824 S::ParenParen => ("(".to_string(), ")".to_string()),
1825 }
1826}
1827
1828#[derive(Debug, Clone, Default)]
1832struct DjotBlockStyle {
1833 alignment: Option<Alignment>,
1834 line_height: Option<i64>,
1835 non_breakable_lines: Option<bool>,
1836 page_break_before: Option<bool>,
1837 direction: Option<TextDirection>,
1838 background_color: Option<String>,
1839 top_margin: Option<i64>,
1840 text_indent: Option<i64>,
1841 semantic_role: Option<SemanticRole>,
1842}
1843
1844impl DjotBlockStyle {
1845 fn merge_from(&mut self, other: DjotBlockStyle) {
1849 if other.alignment.is_some() {
1850 self.alignment = other.alignment;
1851 }
1852 if other.line_height.is_some() {
1853 self.line_height = other.line_height;
1854 }
1855 if other.non_breakable_lines.is_some() {
1856 self.non_breakable_lines = other.non_breakable_lines;
1857 }
1858 if other.page_break_before.is_some() {
1859 self.page_break_before = other.page_break_before;
1860 }
1861 if other.direction.is_some() {
1862 self.direction = other.direction;
1863 }
1864 if other.background_color.is_some() {
1865 self.background_color = other.background_color;
1866 }
1867 if other.top_margin.is_some() {
1868 self.top_margin = other.top_margin;
1869 }
1870 if other.text_indent.is_some() {
1871 self.text_indent = other.text_indent;
1872 }
1873 if other.semantic_role.is_some() {
1874 self.semantic_role = other.semantic_role.clone();
1875 }
1876 }
1877}
1878
1879fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1885 let mut style = DjotBlockStyle::default();
1886
1887 if opts.alignment
1888 && let Some(v) = attrs.get_value("alignment")
1889 {
1890 style.alignment = match v.to_string().as_str() {
1891 "left" => Some(Alignment::Left),
1892 "right" => Some(Alignment::Right),
1893 "center" => Some(Alignment::Center),
1894 "justify" => Some(Alignment::Justify),
1895 _ => None,
1896 };
1897 }
1898 if opts.line_height
1899 && let Some(v) = attrs.get_value("line_height")
1900 {
1901 style.line_height = v.to_string().parse::<i64>().ok();
1902 }
1903 if opts.direction
1904 && let Some(v) = attrs.get_value("direction")
1905 {
1906 style.direction = match v.to_string().as_str() {
1907 "ltr" => Some(TextDirection::LeftToRight),
1908 "rtl" => Some(TextDirection::RightToLeft),
1909 _ => None,
1910 };
1911 }
1912 if opts.non_breakable_lines
1913 && let Some(v) = attrs.get_value("non_breakable_lines")
1914 {
1915 style.non_breakable_lines = match v.to_string().as_str() {
1916 "true" => Some(true),
1917 "false" => Some(false),
1918 _ => None,
1919 };
1920 }
1921 if opts.page_break_before
1922 && let Some(v) = attrs.get_value("page_break_before")
1923 {
1924 style.page_break_before = match v.to_string().as_str() {
1925 "true" => Some(true),
1926 "false" => Some(false),
1927 _ => None,
1928 };
1929 }
1930 if opts.background_color
1931 && let Some(v) = attrs.get_value("background_color")
1932 {
1933 style.background_color = Some(v.to_string());
1934 }
1935 if opts.top_margin
1936 && let Some(v) = attrs.get_value("top_margin")
1937 {
1938 style.top_margin = v.to_string().parse::<i64>().ok();
1939 }
1940 if opts.text_indent
1941 && let Some(v) = attrs.get_value("text_indent")
1942 {
1943 style.text_indent = v.to_string().parse::<i64>().ok();
1944 }
1945 if opts.semantic_role
1946 && let Some(v) = attrs.get_value("semantic_role")
1947 {
1948 style.semantic_role = match v.to_string().as_str() {
1949 "epigraph" => Some(SemanticRole::Epigraph),
1950 _ => None,
1954 };
1955 }
1956
1957 style
1958}
1959
1960#[allow(clippy::too_many_arguments)]
1963fn djot_push_block(
1964 elements: &mut Vec<ParsedElement>,
1965 spans: Vec<ParsedSpan>,
1966 heading_level: Option<i64>,
1967 list_style: Option<ListStyle>,
1968 list_indent: u32,
1969 list_prefix: String,
1970 list_suffix: String,
1971 marker: Option<MarkerType>,
1972 is_code_block: bool,
1973 code_language: Option<String>,
1974 blockquote_depth: u32,
1975 style: DjotBlockStyle,
1976) {
1977 elements.push(ParsedElement::Block(ParsedBlock {
1978 spans,
1979 heading_level,
1980 list_style,
1981 list_indent,
1982 list_prefix,
1983 list_suffix,
1984 marker,
1985 is_code_block,
1986 code_language,
1987 blockquote_depth,
1988 line_height: style.line_height,
1989 non_breakable_lines: style.non_breakable_lines,
1990 page_break_before: style.page_break_before,
1991 direction: style.direction,
1992 background_color: style.background_color,
1993 alignment: style.alignment,
1994 top_margin: style.top_margin,
1995 text_indent: style.text_indent,
1996 semantic_role: style.semantic_role.clone(),
1997 }));
1998}
1999
2000pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2021 use jotdown::{Container as C, Event as E, ListKind, Parser};
2022
2023 let mut elements: Vec<ParsedElement> = Vec::new();
2024 let mut current_spans: Vec<ParsedSpan> = Vec::new();
2025 let mut current_heading: Option<i64> = None;
2026 let mut is_code_block = false;
2027 let mut code_language: Option<String> = None;
2028 let mut blockquote_depth: u32 = 0;
2029 let mut pending_style = DjotBlockStyle::default();
2032
2033 let mut bold = false;
2035 let mut italic = false;
2036 let mut underline = false;
2037 let mut strikeout = false;
2038 let mut code = false;
2039 let mut superscript = false;
2040 let mut subscript = false;
2041 let mut link_href: Option<String> = None;
2042 let mut pending_image: Option<ParsedImage> = None;
2045
2046 let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2048 let mut cur_list_style: Option<ListStyle> = None;
2050 let mut cur_list_prefix = String::new();
2051 let mut cur_list_suffix = String::new();
2052 let mut cur_list_indent: u32 = 0;
2053 let mut cur_marker: Option<MarkerType> = None;
2054
2055 let mut in_table_cell = false;
2057 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2058 let mut current_row: Vec<ParsedTableCell> = Vec::new();
2059 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2060 let mut table_header_rows: usize = 0;
2061 let mut row_is_head = false;
2062
2063 let mut skip_depth: u32 = 0;
2067
2068 let mut footnote_open: Option<(String, usize)> = None;
2072
2073 macro_rules! push_text {
2078 ($t:expr) => {{
2079 if let Some(img) = pending_image.as_mut() {
2084 img.alt.push_str(($t).as_ref());
2085 } else {
2086 let sp = ParsedSpan {
2087 text: ($t).to_string(),
2088 bold,
2089 italic,
2090 underline,
2091 strikeout,
2092 code,
2093 superscript,
2094 subscript,
2095 link_href: link_href.clone(),
2096 image: None,
2097 footnote_ref: None,
2098 };
2099 if in_table_cell {
2100 current_cell_spans.push(sp);
2101 } else {
2102 current_spans.push(sp);
2103 }
2104 }
2105 }};
2106 }
2107
2108 macro_rules! push_image {
2110 ($img:expr) => {{
2111 let sp = ParsedSpan {
2112 text: String::new(),
2113 bold,
2114 italic,
2115 underline,
2116 strikeout,
2117 code,
2118 superscript,
2119 subscript,
2120 link_href: link_href.clone(),
2121 image: Some($img),
2122 footnote_ref: None,
2123 };
2124 if in_table_cell {
2125 current_cell_spans.push(sp);
2126 } else {
2127 current_spans.push(sp);
2128 }
2129 }};
2130 }
2131
2132 macro_rules! enter_item {
2135 ($marker:expr) => {{
2136 if !current_spans.is_empty() {
2137 djot_push_block(
2138 &mut elements,
2139 std::mem::take(&mut current_spans),
2140 None,
2141 cur_list_style.clone(),
2142 cur_list_indent,
2143 cur_list_prefix.clone(),
2144 cur_list_suffix.clone(),
2145 cur_marker.clone(),
2146 false,
2147 None,
2148 blockquote_depth,
2149 DjotBlockStyle::default(),
2150 );
2151 }
2152 let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2153 ListStyle::Disc,
2154 String::new(),
2155 String::new(),
2156 ));
2157 cur_list_style = Some(style);
2158 cur_list_prefix = prefix;
2159 cur_list_suffix = suffix;
2160 cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2161 cur_marker = $marker;
2162 }};
2163 }
2164
2165 for event in Parser::new(djot) {
2166 if skip_depth > 0 {
2167 match event {
2168 E::Start(..) => skip_depth += 1,
2169 E::End(_) => skip_depth -= 1,
2170 _ => {}
2171 }
2172 continue;
2173 }
2174
2175 match event {
2176 E::Start(C::Document, _) | E::End(C::Document) => {}
2178 E::Start(C::Section { .. }, attrs) => {
2179 if list_stack.is_empty() {
2182 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2183 }
2184 }
2185 E::End(C::Section { .. }) => {}
2186 E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2187
2188 E::Start(C::Blockquote, _) => blockquote_depth += 1,
2190 E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2191
2192 E::Start(C::List { kind, .. }, _) => {
2194 let (style, prefix, suffix) = match kind {
2195 ListKind::Unordered(b) | ListKind::Task(b) => {
2196 (djot_bullet_style(b), String::new(), String::new())
2197 }
2198 ListKind::Ordered {
2199 numbering, style, ..
2200 } => {
2201 let (p, s) = djot_ordered_affixes(style);
2202 (djot_ordered_style(numbering), p, s)
2203 }
2204 };
2205 list_stack.push((style, prefix, suffix));
2206 }
2207 E::End(C::List { .. }) => {
2208 list_stack.pop();
2209 cur_list_style = None;
2210 cur_marker = None;
2211 }
2212 E::Start(C::ListItem, _) => enter_item!(None),
2213 E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2214 MarkerType::Checked
2215 } else {
2216 MarkerType::Unchecked
2217 })),
2218 E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2219 if !current_spans.is_empty() {
2221 djot_push_block(
2222 &mut elements,
2223 std::mem::take(&mut current_spans),
2224 None,
2225 cur_list_style.clone(),
2226 cur_list_indent,
2227 cur_list_prefix.clone(),
2228 cur_list_suffix.clone(),
2229 cur_marker.clone(),
2230 false,
2231 None,
2232 blockquote_depth,
2233 DjotBlockStyle::default(),
2234 );
2235 }
2236 cur_list_style = None;
2237 cur_marker = None;
2238 }
2239
2240 E::Start(C::Heading { level, .. }, attrs) => {
2242 current_heading = Some(level as i64);
2243 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2246 }
2247 E::End(C::Heading { .. }) => {
2248 djot_push_block(
2249 &mut elements,
2250 std::mem::take(&mut current_spans),
2251 current_heading.take(),
2252 None,
2253 0,
2254 String::new(),
2255 String::new(),
2256 None,
2257 false,
2258 None,
2259 blockquote_depth,
2260 std::mem::take(&mut pending_style),
2261 );
2262 }
2263 E::Start(C::Paragraph, attrs) => {
2264 current_heading = None;
2265 pending_style = if list_stack.is_empty() {
2269 block_attrs_to_style(&attrs, options)
2270 } else {
2271 DjotBlockStyle::default()
2272 };
2273 }
2274 E::End(C::Paragraph) => {
2275 if !current_spans.is_empty() {
2276 djot_push_block(
2277 &mut elements,
2278 std::mem::take(&mut current_spans),
2279 None,
2280 cur_list_style.clone(),
2281 cur_list_indent,
2282 cur_list_prefix.clone(),
2283 cur_list_suffix.clone(),
2284 cur_marker.clone(),
2285 false,
2286 None,
2287 blockquote_depth,
2288 std::mem::take(&mut pending_style),
2289 );
2290 }
2291 cur_list_style = None;
2292 cur_marker = None;
2293 }
2294 E::Start(C::CodeBlock { language }, _) => {
2295 is_code_block = true;
2296 code_language = if language.is_empty() {
2297 None
2298 } else {
2299 Some(language.to_string())
2300 };
2301 }
2302 E::End(C::CodeBlock { .. }) => {
2303 if let Some(last) = current_spans.last_mut()
2305 && last.text.ends_with('\n')
2306 {
2307 last.text.pop();
2308 }
2309 djot_push_block(
2310 &mut elements,
2311 std::mem::take(&mut current_spans),
2312 None,
2313 None,
2314 0,
2315 String::new(),
2316 String::new(),
2317 None,
2318 true,
2319 code_language.take(),
2320 blockquote_depth,
2321 DjotBlockStyle::default(),
2322 );
2323 is_code_block = false;
2324 }
2325
2326 E::Start(C::Table, _) => {
2328 table_rows.clear();
2329 current_row.clear();
2330 current_cell_spans.clear();
2331 table_header_rows = 0;
2332 }
2333 E::End(C::Table) => {
2334 elements.push(ParsedElement::Table(ParsedTable {
2335 header_rows: table_header_rows,
2336 rows: std::mem::take(&mut table_rows),
2337 blockquote_depth,
2338 }));
2339 }
2340 E::Start(C::TableRow { head }, _) => {
2341 row_is_head = head;
2342 current_row.clear();
2343 }
2344 E::End(C::TableRow { .. }) => {
2345 if row_is_head {
2346 table_header_rows += 1;
2347 }
2348 table_rows.push(std::mem::take(&mut current_row));
2349 }
2350 E::Start(C::TableCell { .. }, _) => {
2351 in_table_cell = true;
2352 current_cell_spans.clear();
2353 }
2354 E::End(C::TableCell { .. }) => {
2355 in_table_cell = false;
2356 current_row.push(ParsedTableCell {
2357 spans: std::mem::take(&mut current_cell_spans),
2358 });
2359 }
2360
2361 E::Start(C::Strong, _) => bold = true,
2363 E::End(C::Strong) => bold = false,
2364 E::Start(C::Emphasis, _) => italic = true,
2365 E::End(C::Emphasis) => italic = false,
2366 E::Start(C::Verbatim, _) => code = true,
2367 E::End(C::Verbatim) => code = false,
2368 E::Start(C::Superscript, _) => superscript = true,
2369 E::End(C::Superscript) => superscript = false,
2370 E::Start(C::Subscript, _) => subscript = true,
2371 E::End(C::Subscript) => subscript = false,
2372 E::Start(C::Insert, _) => underline = true,
2373 E::End(C::Insert) => underline = false,
2374 E::Start(C::Delete, _) => strikeout = true,
2375 E::End(C::Delete) => strikeout = false,
2376 E::Start(C::Mark, _) | E::End(C::Mark) => {}
2378 E::Start(C::Span, _) | E::End(C::Span) => {}
2379 E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2380 E::End(C::Link(..)) => link_href = None,
2381 E::Start(C::Image(src, _), attrs) => {
2386 let attr_num = |key: &str| -> i64 {
2387 attrs
2388 .get_value(key)
2389 .map(|v| v.to_string())
2390 .and_then(|v| v.trim().parse::<i64>().ok())
2391 .filter(|n| *n > 0)
2392 .unwrap_or(0)
2393 };
2394 pending_image = Some(ParsedImage {
2395 src: src.to_string(),
2396 alt: String::new(),
2397 width: attr_num("width"),
2398 height: attr_num("height"),
2399 });
2400 }
2401 E::End(C::Image(..)) => {
2402 if let Some(img) = pending_image.take() {
2403 push_image!(img);
2404 }
2405 }
2406
2407 E::Start(C::Footnote { label }, _) => {
2416 if !current_spans.is_empty() {
2417 djot_push_block(
2418 &mut elements,
2419 std::mem::take(&mut current_spans),
2420 None,
2421 cur_list_style.clone(),
2422 cur_list_indent,
2423 cur_list_prefix.clone(),
2424 cur_list_suffix.clone(),
2425 cur_marker.clone(),
2426 false,
2427 None,
2428 blockquote_depth,
2429 DjotBlockStyle::default(),
2430 );
2431 }
2432 footnote_open = Some((label.to_string(), elements.len()));
2433 }
2434 E::End(C::Footnote { .. }) => {
2435 if !current_spans.is_empty() {
2436 djot_push_block(
2437 &mut elements,
2438 std::mem::take(&mut current_spans),
2439 None,
2440 cur_list_style.clone(),
2441 cur_list_indent,
2442 cur_list_prefix.clone(),
2443 cur_list_suffix.clone(),
2444 cur_marker.clone(),
2445 false,
2446 None,
2447 blockquote_depth,
2448 DjotBlockStyle::default(),
2449 );
2450 }
2451 if let Some((label, start)) = footnote_open.take() {
2452 let blocks: Vec<ParsedBlock> = elements
2453 .drain(start..)
2454 .filter_map(|e| match e {
2455 ParsedElement::Block(b) => Some(b),
2456 _ => None,
2460 })
2461 .collect();
2462 elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2463 }
2464 }
2465
2466 E::Start(
2468 C::Math { .. }
2469 | C::RawBlock { .. }
2470 | C::RawInline { .. }
2471 | C::DescriptionList
2472 | C::DescriptionDetails
2473 | C::DescriptionTerm
2474 | C::Caption
2475 | C::LinkDefinition { .. },
2476 _,
2477 ) => skip_depth = 1,
2478
2479 E::Str(s) => push_text!(s.as_ref()),
2481 E::Softbreak => push_text!(" "),
2482 E::LeftSingleQuote => push_text!("\u{2018}"),
2483 E::RightSingleQuote => push_text!("\u{2019}"),
2484 E::LeftDoubleQuote => push_text!("\u{201C}"),
2485 E::RightDoubleQuote => push_text!("\u{201D}"),
2486 E::Ellipsis => push_text!("\u{2026}"),
2487 E::EnDash => push_text!("\u{2013}"),
2488 E::EmDash => push_text!("\u{2014}"),
2489 E::NonBreakingSpace => push_text!("\u{00A0}"),
2490 E::Hardbreak => {
2491 if in_table_cell {
2492 push_text!(" ");
2493 } else if !current_spans.is_empty() {
2494 djot_push_block(
2497 &mut elements,
2498 std::mem::take(&mut current_spans),
2499 None,
2500 cur_list_style.clone(),
2501 cur_list_indent,
2502 cur_list_prefix.clone(),
2503 cur_list_suffix.clone(),
2504 cur_marker.clone(),
2505 is_code_block,
2506 code_language.clone(),
2507 blockquote_depth,
2508 pending_style.clone(),
2509 );
2510 }
2511 }
2512 E::FootnoteReference(label) => {
2518 let sp = ParsedSpan {
2519 text: String::new(),
2520 bold,
2521 italic,
2522 underline,
2523 strikeout,
2524 code,
2525 superscript,
2526 subscript,
2527 link_href: link_href.clone(),
2528 image: None,
2529 footnote_ref: Some(label.to_string()),
2530 };
2531 if in_table_cell {
2532 current_cell_spans.push(sp);
2533 } else {
2534 current_spans.push(sp);
2535 }
2536 }
2537 E::Symbol(_) => {}
2540 E::Escape | E::Blankline => {}
2541 E::ThematicBreak(_) | E::Attributes(_) => {}
2542
2543 _ => {}
2546 }
2547 }
2548
2549 if !current_spans.is_empty() {
2551 djot_push_block(
2552 &mut elements,
2553 std::mem::take(&mut current_spans),
2554 current_heading.take(),
2555 cur_list_style.clone(),
2556 cur_list_indent,
2557 cur_list_prefix.clone(),
2558 cur_list_suffix.clone(),
2559 cur_marker.clone(),
2560 is_code_block,
2561 code_language.take(),
2562 blockquote_depth,
2563 std::mem::take(&mut pending_style),
2564 );
2565 }
2566
2567 if elements.is_empty() {
2570 djot_push_block(
2571 &mut elements,
2572 vec![ParsedSpan {
2573 text: String::new(),
2574 ..Default::default()
2575 }],
2576 None,
2577 None,
2578 0,
2579 String::new(),
2580 String::new(),
2581 None,
2582 false,
2583 None,
2584 0,
2585 DjotBlockStyle::default(),
2586 );
2587 }
2588
2589 elements
2590}
2591
2592#[cfg(test)]
2593mod tests {
2594 use super::*;
2595
2596 fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2598 ParsedElement::flatten_to_blocks(parse_markdown(md))
2599 }
2600
2601 #[test]
2602 fn test_parse_markdown_simple_paragraph() {
2603 let blocks = parse_markdown_blocks("Hello **world**");
2604 assert_eq!(blocks.len(), 1);
2605 assert!(blocks[0].spans.len() >= 2);
2606 let plain_span = blocks[0]
2608 .spans
2609 .iter()
2610 .find(|s| s.text.contains("Hello"))
2611 .unwrap();
2612 assert!(!plain_span.bold);
2613 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2614 assert!(bold_span.bold);
2615 }
2616
2617 #[test]
2618 fn test_parse_markdown_heading() {
2619 let blocks = parse_markdown_blocks("# Title");
2620 assert_eq!(blocks.len(), 1);
2621 assert_eq!(blocks[0].heading_level, Some(1));
2622 assert_eq!(blocks[0].spans[0].text, "Title");
2623 }
2624
2625 #[test]
2626 fn test_parse_markdown_list() {
2627 let blocks = parse_markdown_blocks("- item1\n- item2");
2628 assert!(blocks.len() >= 2);
2629 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2630 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2631 }
2632
2633 fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2635 elements
2636 .iter()
2637 .map(|e| match e {
2638 ParsedElement::Block(b) => (false, b.blockquote_depth),
2639 ParsedElement::Table(t) => (true, t.blockquote_depth),
2640 ParsedElement::FootnoteDefinition { .. } => (false, 0),
2643 })
2644 .collect()
2645 }
2646
2647 #[test]
2648 fn test_parse_markdown_table_in_blockquote_records_depth() {
2649 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2650 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2651 }
2652
2653 #[test]
2654 fn test_parse_markdown_text_then_table_in_blockquote() {
2655 let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2656 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2657 }
2658
2659 #[test]
2660 fn test_parse_markdown_table_after_blockquote_closes() {
2661 let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2662 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2663 }
2664
2665 #[test]
2666 fn test_parse_markdown_table_in_nested_blockquote() {
2667 let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2668 assert_eq!(element_depths(&elements), vec![(true, 2)]);
2669 }
2670
2671 #[test]
2672 fn test_parse_markdown_list_in_blockquote_records_depth() {
2673 let elements = parse_markdown("> - item1\n> - item2");
2674 let depths = element_depths(&elements);
2675 assert_eq!(depths, vec![(false, 1), (false, 1)]);
2676 for e in &elements {
2677 if let ParsedElement::Block(b) = e {
2678 assert_eq!(b.list_style, Some(ListStyle::Disc));
2679 }
2680 }
2681 }
2682
2683 #[test]
2684 fn test_parse_html_table_in_blockquote_records_depth() {
2685 let elements = parse_html_elements(
2686 "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2687 );
2688 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2689 }
2690
2691 #[test]
2692 fn test_parse_html_table_after_blockquote() {
2693 let elements = parse_html_elements(
2694 "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2695 );
2696 let depths = element_depths(&elements);
2697 assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2699 assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2700 }
2701
2702 #[test]
2703 fn test_flatten_to_blocks_propagates_blockquote_depth() {
2704 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2705 let blocks = ParsedElement::flatten_to_blocks(elements);
2706 assert!(!blocks.is_empty());
2707 for b in &blocks {
2708 assert_eq!(b.blockquote_depth, 1);
2709 }
2710 }
2711
2712 #[test]
2713 fn test_parse_html_simple() {
2714 let blocks = parse_html("<p>Hello <b>world</b></p>");
2715 assert_eq!(blocks.len(), 1);
2716 assert!(blocks[0].spans.len() >= 2);
2717 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2718 assert!(bold_span.bold);
2719 }
2720
2721 #[test]
2722 fn test_parse_html_multiple_paragraphs() {
2723 let blocks = parse_html("<p>A</p><p>B</p>");
2724 assert_eq!(blocks.len(), 2);
2725 }
2726
2727 #[test]
2728 fn test_parse_html_heading() {
2729 let blocks = parse_html("<h2>Subtitle</h2>");
2730 assert_eq!(blocks.len(), 1);
2731 assert_eq!(blocks[0].heading_level, Some(2));
2732 }
2733
2734 #[test]
2735 fn test_parse_html_list() {
2736 let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2737 assert!(blocks.len() >= 2);
2738 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2739 }
2740
2741 #[test]
2742 fn test_parse_markdown_code_block() {
2743 let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2744 assert_eq!(blocks.len(), 1);
2745 assert!(blocks[0].is_code_block);
2746 assert!(blocks[0].spans[0].code);
2747 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2749 assert_eq!(
2750 text, "fn main() {}",
2751 "code block text should not have trailing newline"
2752 );
2753 }
2754
2755 #[test]
2756 fn test_parse_markdown_nested_formatting() {
2757 let blocks = parse_markdown_blocks("***bold italic***");
2758 assert_eq!(blocks.len(), 1);
2759 let span = &blocks[0].spans[0];
2760 assert!(span.bold);
2761 assert!(span.italic);
2762 }
2763
2764 #[test]
2765 fn test_parse_markdown_link() {
2766 let blocks = parse_markdown_blocks("[click](http://example.com)");
2767 assert_eq!(blocks.len(), 1);
2768 let span = &blocks[0].spans[0];
2769 assert_eq!(span.text, "click");
2770 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2771 }
2772
2773 #[test]
2774 fn test_parse_markdown_empty() {
2775 let blocks = parse_markdown_blocks("");
2776 assert_eq!(blocks.len(), 1);
2777 assert!(blocks[0].spans[0].text.is_empty());
2778 }
2779
2780 #[test]
2781 fn test_parse_html_empty() {
2782 let blocks = parse_html("");
2783 assert_eq!(blocks.len(), 1);
2784 assert!(blocks[0].spans[0].text.is_empty());
2785 }
2786
2787 #[test]
2788 fn test_parse_html_nested_formatting() {
2789 let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2790 assert_eq!(blocks.len(), 1);
2791 let span = &blocks[0].spans[0];
2792 assert!(span.bold);
2793 assert!(span.italic);
2794 }
2795
2796 #[test]
2797 fn test_parse_html_link() {
2798 let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2799 assert_eq!(blocks.len(), 1);
2800 let span = &blocks[0].spans[0];
2801 assert_eq!(span.text, "click");
2802 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2803 }
2804
2805 #[test]
2806 fn test_parse_html_ordered_list() {
2807 let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2808 assert!(blocks.len() >= 2);
2809 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2810 }
2811
2812 #[test]
2813 fn test_parse_markdown_ordered_list() {
2814 let blocks = parse_markdown_blocks("1. first\n2. second");
2815 assert!(blocks.len() >= 2);
2816 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2817 }
2818
2819 #[test]
2820 fn test_parse_html_blockquote_nested() {
2821 let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2822 assert!(blocks.len() >= 3);
2823 }
2824
2825 #[test]
2826 fn test_parse_block_styles_line_height() {
2827 let styles = parse_block_styles("line-height: 1.5");
2828 assert_eq!(styles.line_height, Some(1500));
2829 }
2830
2831 #[test]
2832 fn test_parse_block_styles_direction_rtl() {
2833 let styles = parse_block_styles("direction: rtl");
2834 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2835 }
2836
2837 #[test]
2838 fn test_parse_block_styles_background_color() {
2839 let styles = parse_block_styles("background-color: #ff0000");
2840 assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2841 }
2842
2843 #[test]
2844 fn test_parse_block_styles_white_space_pre() {
2845 let styles = parse_block_styles("white-space: pre");
2846 assert_eq!(styles.non_breakable_lines, Some(true));
2847 }
2848
2849 #[test]
2850 fn test_parse_block_styles_multiple() {
2851 let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2852 assert_eq!(styles.line_height, Some(2000));
2853 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2854 assert_eq!(styles.background_color, Some("blue".to_string()));
2855 }
2856
2857 #[test]
2858 fn test_parse_html_block_styles_extracted() {
2859 let blocks = parse_html(
2860 r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2861 );
2862 assert_eq!(blocks.len(), 1);
2863 assert_eq!(blocks[0].line_height, Some(1500));
2864 assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2865 assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2866 }
2867
2868 #[test]
2869 fn test_parse_html_white_space_pre() {
2870 let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2871 assert_eq!(blocks.len(), 1);
2872 assert_eq!(blocks[0].non_breakable_lines, Some(true));
2873 }
2874
2875 #[test]
2876 fn test_parse_html_no_styles_returns_none() {
2877 let blocks = parse_html("<p>plain</p>");
2878 assert_eq!(blocks.len(), 1);
2879 assert_eq!(blocks[0].line_height, None);
2880 assert_eq!(blocks[0].direction, None);
2881 assert_eq!(blocks[0].background_color, None);
2882 assert_eq!(blocks[0].non_breakable_lines, None);
2883 }
2884
2885 #[test]
2886 fn test_parse_markdown_nested_list_indent() {
2887 let md = "- top\n - nested\n - deep";
2888 let blocks = parse_markdown_blocks(md);
2889 assert_eq!(blocks.len(), 3);
2890 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2891 assert_eq!(blocks[0].list_indent, 0);
2892 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2893 assert_eq!(blocks[1].list_indent, 1);
2894 assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2895 assert_eq!(blocks[2].list_indent, 2);
2896 }
2897
2898 #[test]
2899 fn test_parse_markdown_nested_ordered_list_indent() {
2900 let md = "1. first\n 1. nested\n 2. nested2";
2901 let blocks = parse_markdown_blocks(md);
2902 assert_eq!(blocks.len(), 3);
2903 assert_eq!(blocks[0].list_indent, 0);
2904 assert_eq!(blocks[1].list_indent, 1);
2905 assert_eq!(blocks[2].list_indent, 1);
2906 }
2907
2908 #[test]
2909 fn test_parse_html_nested_list_indent() {
2910 let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2911 let blocks = parse_html(html);
2912 assert!(blocks.len() >= 2);
2913 assert_eq!(blocks[0].list_indent, 0);
2914 assert_eq!(blocks[1].list_indent, 1);
2915 }
2916
2917 #[test]
2918 fn test_parse_markdown_table() {
2919 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2920 let elements = parse_markdown(md);
2921 assert_eq!(elements.len(), 1);
2922 match &elements[0] {
2923 ParsedElement::Table(table) => {
2924 assert_eq!(table.header_rows, 1);
2925 assert_eq!(table.rows.len(), 2); assert_eq!(table.rows[0].len(), 2);
2928 assert_eq!(table.rows[0][0].spans[0].text, "A");
2929 assert_eq!(table.rows[0][1].spans[0].text, "B");
2930 assert_eq!(table.rows[1].len(), 2);
2932 assert_eq!(table.rows[1][0].spans[0].text, "1");
2933 assert_eq!(table.rows[1][1].spans[0].text, "2");
2934 }
2935 _ => panic!("Expected ParsedElement::Table"),
2936 }
2937 }
2938
2939 #[test]
2940 fn test_parse_markdown_table_with_formatting() {
2941 let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2942 let elements = parse_markdown(md);
2943 assert_eq!(elements.len(), 1);
2944 match &elements[0] {
2945 ParsedElement::Table(table) => {
2946 assert_eq!(table.rows.len(), 2);
2947 assert!(table.rows[0][0].spans[0].bold);
2949 assert!(table.rows[0][1].spans[0].code);
2951 assert!(table.rows[0][2].spans[0].italic);
2953 assert!(table.rows[1][0].spans[0].strikeout);
2955 assert_eq!(
2957 table.rows[1][2].spans[0].link_href,
2958 Some("http://x.com".to_string())
2959 );
2960 }
2961 _ => panic!("Expected ParsedElement::Table"),
2962 }
2963 }
2964
2965 #[test]
2966 fn test_parse_markdown_mixed_content_with_table() {
2967 let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
2968 let elements = parse_markdown(md);
2969 assert_eq!(elements.len(), 3);
2970 assert!(matches!(&elements[0], ParsedElement::Block(_)));
2971 assert!(matches!(&elements[1], ParsedElement::Table(_)));
2972 assert!(matches!(&elements[2], ParsedElement::Block(_)));
2973 }
2974}
2975
2976#[cfg(test)]
2977mod djot_tests {
2978 use super::*;
2979 use crate::entities::MarkerType;
2980
2981 fn blocks(d: &str) -> Vec<ParsedBlock> {
2982 ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
2983 }
2984
2985 fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
2986 b.spans.iter().find(|s| pred(s)).expect("span not found")
2987 }
2988
2989 #[test]
2990 fn paragraph_bold_italic() {
2991 let b = blocks("normal *bold* _italic_");
2992 assert_eq!(b.len(), 1);
2993 assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
2994 assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
2995 }
2996
2997 #[test]
2998 fn heading_levels() {
2999 assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3000 assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3001 assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3002 }
3003
3004 #[test]
3005 fn unordered_bullet_styles_are_distinct() {
3006 assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3007 assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3008 assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3009 }
3010
3011 #[test]
3012 fn ordered_delimiters() {
3013 let period = blocks("1. a");
3014 assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3015 assert_eq!(period[0].list_prefix, "");
3016 assert_eq!(period[0].list_suffix, ".");
3017
3018 let paren = blocks("1) a");
3019 assert_eq!(paren[0].list_suffix, ")");
3020 assert_eq!(paren[0].list_prefix, "");
3021
3022 let paren_paren = blocks("(1) a");
3023 assert_eq!(paren_paren[0].list_prefix, "(");
3024 assert_eq!(paren_paren[0].list_suffix, ")");
3025 }
3026
3027 #[test]
3028 fn task_list_markers() {
3029 let b = blocks("- [ ] a\n- [x] b");
3030 assert_eq!(b.len(), 2);
3031 assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3032 assert_eq!(b[1].marker, Some(MarkerType::Checked));
3033 }
3034
3035 #[test]
3036 fn code_block_with_language() {
3037 let b = blocks("```rust\nfn main() {}\n```");
3038 assert_eq!(b.len(), 1);
3039 assert!(b[0].is_code_block);
3040 assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3041 let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3042 assert_eq!(text, "fn main() {}");
3043 }
3044
3045 #[test]
3046 fn link_href() {
3047 let b = blocks("[text](http://example.com)");
3048 let s = first_span_with(&b[0], |s| s.text == "text");
3049 assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3050 }
3051
3052 #[test]
3053 fn superscript_subscript() {
3054 assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3055 assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3056 }
3057
3058 #[test]
3059 fn delete_insert_verbatim() {
3060 assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3061 assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3062 assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3063 }
3064
3065 #[test]
3066 fn blockquote_depth() {
3067 let els = parse_djot("> quoted", &DjotImportOptions::default());
3068 match &els[0] {
3069 ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3070 _ => panic!("expected block"),
3071 }
3072 }
3073
3074 #[test]
3075 fn nested_list_indent() {
3076 let b = blocks("- a\n\n - b\n\n - c");
3081 assert_eq!(b.len(), 3);
3082 assert_eq!(b[0].list_indent, 0);
3083 assert_eq!(b[1].list_indent, 1);
3084 assert_eq!(b[2].list_indent, 2);
3085 }
3086
3087 #[test]
3088 fn table_parsed_as_table() {
3089 let els = parse_djot(
3090 "| a | b |\n|---|---|\n| c | d |",
3091 &DjotImportOptions::default(),
3092 );
3093 assert_eq!(els.len(), 1);
3094 match &els[0] {
3095 ParsedElement::Table(t) => {
3096 assert_eq!(t.header_rows, 1);
3097 assert_eq!(t.rows.len(), 2);
3098 assert_eq!(t.rows[0][0].spans[0].text, "a");
3099 assert_eq!(t.rows[1][1].spans[0].text, "d");
3100 }
3101 _ => panic!("expected table"),
3102 }
3103 }
3104
3105 #[test]
3106 fn smart_punctuation_normalised_to_unicode() {
3107 let text: String = blocks("a... b---c")[0]
3108 .spans
3109 .iter()
3110 .map(|s| s.text.as_str())
3111 .collect();
3112 assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3113 assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3114 }
3115
3116 #[test]
3117 fn unrepresentable_constructs_dropped_without_leaking_text() {
3118 let b = blocks("para1\n\n---\n\npara2");
3120 assert_eq!(b.len(), 2);
3121 assert_eq!(
3122 b[0].spans
3123 .iter()
3124 .map(|s| s.text.as_str())
3125 .collect::<String>(),
3126 "para1"
3127 );
3128 assert_eq!(
3129 b[1].spans
3130 .iter()
3131 .map(|s| s.text.as_str())
3132 .collect::<String>(),
3133 "para2"
3134 );
3135
3136 let d = blocks(":::\ninside\n:::");
3138 let joined: String = d
3139 .iter()
3140 .flat_map(|b| b.spans.iter())
3141 .map(|s| s.text.as_str())
3142 .collect();
3143 assert_eq!(joined, "inside");
3144
3145 let m = blocks("before $`E=mc^2` after");
3147 let joined: String = m
3148 .iter()
3149 .flat_map(|b| b.spans.iter())
3150 .map(|s| s.text.as_str())
3151 .collect();
3152 assert!(joined.contains("before"), "{joined:?}");
3153 assert!(joined.contains("after"), "{joined:?}");
3154 assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3155 }
3156
3157 #[test]
3158 fn empty_document_yields_one_empty_block() {
3159 let b = blocks("");
3160 assert_eq!(b.len(), 1);
3161 assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3162 }
3163
3164 #[test]
3165 fn block_attributes_parse_into_block() {
3166 let b = blocks(
3167 "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3168 );
3169 assert_eq!(b.len(), 1);
3170 assert_eq!(b[0].alignment, Some(Alignment::Center));
3171 assert_eq!(b[0].line_height, Some(1500));
3172 assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3173 assert_eq!(b[0].non_breakable_lines, Some(true));
3174 assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3175 }
3176
3177 #[test]
3178 fn spacing_block_attributes_parse_into_block() {
3179 let b = blocks("{top_margin=24 text_indent=0}\nhello");
3183 assert_eq!(b.len(), 1);
3184 assert_eq!(b[0].top_margin, Some(24));
3185 assert_eq!(b[0].text_indent, Some(0));
3186 }
3187
3188 #[test]
3189 fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3190 let explicit = blocks("{text_indent=0}\nhello");
3194 let absent = blocks("hello");
3195 assert_eq!(explicit[0].text_indent, Some(0));
3196 assert_eq!(absent[0].text_indent, None);
3197 assert!(!explicit[0].is_inline_only());
3198 assert!(absent[0].is_inline_only());
3199 }
3200
3201 #[test]
3202 fn spacing_block_attributes_respect_import_options() {
3203 let src = "{top_margin=24 text_indent=0}\nhello";
3204 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3205 assert_eq!(b[0].top_margin, None);
3206 assert_eq!(b[0].text_indent, None);
3207 }
3208
3209 #[test]
3210 fn block_attributes_on_heading() {
3211 let b = blocks("{alignment=right}\n# Title");
3212 assert_eq!(b[0].heading_level, Some(1));
3213 assert_eq!(b[0].alignment, Some(Alignment::Right));
3214 }
3215
3216 #[test]
3217 fn block_attributes_respect_import_options() {
3218 let src = "{alignment=center line_height=1500}\nhello";
3221 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3222 assert_eq!(b[0].alignment, None);
3223 assert_eq!(b[0].line_height, None);
3224 assert_eq!(
3225 b[0].spans
3226 .iter()
3227 .map(|s| s.text.as_str())
3228 .collect::<String>(),
3229 "hello"
3230 );
3231 }
3232
3233 #[test]
3234 fn list_item_block_attributes_are_dropped() {
3235 let b = blocks("{alignment=center}\n- item");
3238 assert!(b.iter().all(|blk| blk.alignment.is_none()));
3239 }
3240
3241 #[test]
3242 fn unknown_alignment_value_is_ignored() {
3243 let b = blocks("{alignment=sideways}\nhello");
3244 assert_eq!(b[0].alignment, None);
3245 }
3246}
3247
3248pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3260
3261fn span_prose(span: &ParsedSpan, out: &mut String) {
3304 if span.image.is_some() || span.footnote_ref.is_some() {
3305 out.push('\u{FFFC}');
3306 return;
3307 }
3308 out.push_str(&span.text);
3309}
3310
3311fn block_prose(block: &ParsedBlock) -> String {
3312 let mut prose = String::new();
3313 for span in &block.spans {
3314 span_prose(span, &mut prose);
3315 }
3316 prose
3317}
3318
3319fn cell_prose(cell: &ParsedTableCell) -> String {
3320 let mut prose = String::new();
3321 for span in &cell.spans {
3322 span_prose(span, &mut prose);
3323 }
3324 prose
3325}
3326
3327pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3328 let elements = parse_djot(djot, options);
3332
3333 let mut out = String::with_capacity(djot.len());
3336
3337 let mut first = true;
3342 let push = |text: &str, out: &mut String, first: &mut bool| {
3343 if *first {
3344 *first = false;
3345 } else {
3346 out.push('\n');
3347 }
3348 out.push_str(text);
3349 };
3350
3351 for element in &elements {
3352 match element {
3353 ParsedElement::Block(block) => {
3354 push(&block_prose(block), &mut out, &mut first);
3355 }
3356 ParsedElement::FootnoteDefinition { .. } => {}
3366 ParsedElement::Table(table) => {
3367 push(TABLE_ANCHOR, &mut out, &mut first);
3374 for row in &table.rows {
3375 for cell in row {
3376 push(&cell_prose(cell), &mut out, &mut first);
3377 }
3378 }
3379 }
3380 }
3381 }
3382 out
3383}