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 superscript: bool,
1043 subscript: bool,
1044 link_href: Option<String>,
1045 }
1046
1047 const MAX_RECURSION_DEPTH: usize = 256;
1048
1049 fn is_metadata_tag(tag: &str) -> bool {
1059 matches!(
1060 tag,
1061 "head"
1062 | "style"
1063 | "script"
1064 | "title"
1065 | "meta"
1066 | "link"
1067 | "base"
1068 | "noscript"
1069 | "template"
1070 )
1071 }
1072
1073 fn collect_cell_spans(
1075 node: ego_tree::NodeRef<Node>,
1076 state: &FmtState,
1077 spans: &mut Vec<ParsedSpan>,
1078 depth: usize,
1079 ) {
1080 if depth > MAX_RECURSION_DEPTH {
1081 return;
1082 }
1083 for child in node.children() {
1084 match child.value() {
1085 Node::Text(text) => {
1086 let t = text.text.to_string();
1087 if !t.is_empty() {
1088 spans.push(ParsedSpan {
1089 text: t,
1090 bold: state.bold,
1091 italic: state.italic,
1092 underline: state.underline,
1093 strikeout: state.strikeout,
1094 code: state.code,
1095 superscript: state.superscript,
1096 subscript: state.subscript,
1097 link_href: state.link_href.clone(),
1098 image: None,
1099 footnote_ref: None,
1100 });
1101 }
1102 }
1103 Node::Element(el) => {
1104 let tag = el.name();
1105 if is_metadata_tag(tag) {
1106 continue;
1107 }
1108 let mut new_state = state.clone();
1109 match tag {
1110 "b" | "strong" => new_state.bold = true,
1111 "i" | "em" => new_state.italic = true,
1112 "u" | "ins" => new_state.underline = true,
1113 "s" | "del" | "strike" => new_state.strikeout = true,
1114 "code" => new_state.code = true,
1115 "sup" => new_state.superscript = true,
1116 "sub" => new_state.subscript = true,
1117 "a" => {
1118 if let Some(href) = el.attr("href") {
1119 new_state.link_href = Some(href.to_string());
1120 }
1121 }
1122 "img" => {
1123 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1124 spans.push(span);
1125 }
1126 continue;
1127 }
1128 _ => {}
1129 }
1130 collect_cell_spans(child, &new_state, spans, depth + 1);
1131 }
1132 _ => {}
1133 }
1134 }
1135 }
1136
1137 fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1139 let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1140 let mut header_rows: usize = 0;
1141
1142 fn collect_rows(
1143 node: ego_tree::NodeRef<Node>,
1144 rows: &mut Vec<Vec<ParsedTableCell>>,
1145 header_rows: &mut usize,
1146 in_thead: bool,
1147 ) {
1148 for child in node.children() {
1149 if let Node::Element(el) = child.value() {
1150 match el.name() {
1151 "thead" => collect_rows(child, rows, header_rows, true),
1152 "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1153 "tr" => {
1154 let mut cells: Vec<ParsedTableCell> = Vec::new();
1155 for td in child.children() {
1156 if let Node::Element(td_el) = td.value()
1157 && matches!(td_el.name(), "td" | "th")
1158 {
1159 let mut spans = Vec::new();
1160 let state = FmtState::default();
1161 collect_cell_spans(td, &state, &mut spans, 0);
1162 if spans.is_empty() {
1163 spans.push(ParsedSpan::default());
1164 }
1165 cells.push(ParsedTableCell { spans });
1166 }
1167 }
1168 if !cells.is_empty() {
1169 rows.push(cells);
1170 if in_thead {
1171 *header_rows += 1;
1172 }
1173 }
1174 }
1175 _ => {}
1176 }
1177 }
1178 }
1179 }
1180
1181 collect_rows(table_node, &mut rows, &mut header_rows, false);
1182
1183 if header_rows == 0 && !rows.is_empty() {
1185 header_rows = 1;
1186 }
1187
1188 ParsedTable {
1189 header_rows,
1190 rows,
1191 blockquote_depth: 0,
1194 }
1195 }
1196
1197 fn walk_node(
1198 node: ego_tree::NodeRef<Node>,
1199 state: &FmtState,
1200 elements: &mut Vec<ParsedElement>,
1201 current_list_style: &Option<ListStyle>,
1202 blockquote_depth: u32,
1203 list_depth: u32,
1204 depth: usize,
1205 ) {
1206 if depth > MAX_RECURSION_DEPTH {
1207 return;
1208 }
1209 match node.value() {
1210 Node::Element(el) => {
1211 let tag = el.name();
1212 if is_metadata_tag(tag) {
1213 return;
1214 }
1215 let mut new_state = state.clone();
1216 let mut new_list_style = current_list_style.clone();
1217 let mut bq_depth = blockquote_depth;
1218 let mut new_list_depth = list_depth;
1219
1220 let is_block_tag = matches!(
1222 tag,
1223 "p" | "div"
1224 | "h1"
1225 | "h2"
1226 | "h3"
1227 | "h4"
1228 | "h5"
1229 | "h6"
1230 | "li"
1231 | "pre"
1232 | "br"
1233 | "blockquote"
1234 | "body"
1235 | "html"
1236 );
1237
1238 match tag {
1240 "b" | "strong" => new_state.bold = true,
1241 "i" | "em" => new_state.italic = true,
1242 "u" | "ins" => new_state.underline = true,
1243 "s" | "del" | "strike" => new_state.strikeout = true,
1244 "code" => new_state.code = true,
1245 "sup" => new_state.superscript = true,
1246 "sub" => new_state.subscript = true,
1247 "a" => {
1248 if let Some(href) = el.attr("href") {
1249 new_state.link_href = Some(href.to_string());
1250 }
1251 }
1252 "ul" => {
1253 new_list_style = Some(ListStyle::Disc);
1254 new_list_depth = list_depth + 1;
1255 }
1256 "ol" => {
1257 new_list_style = Some(ListStyle::Decimal);
1258 new_list_depth = list_depth + 1;
1259 }
1260 "blockquote" => {
1261 bq_depth += 1;
1262 }
1263 _ => {}
1264 }
1265
1266 let heading_level = match tag {
1268 "h1" => Some(1),
1269 "h2" => Some(2),
1270 "h3" => Some(3),
1271 "h4" => Some(4),
1272 "h5" => Some(5),
1273 "h6" => Some(6),
1274 _ => None,
1275 };
1276
1277 let is_code_block = tag == "pre";
1278
1279 let code_language = if is_code_block {
1281 node.children().find_map(|child| {
1282 if let Node::Element(cel) = child.value()
1283 && cel.name() == "code"
1284 && let Some(cls) = cel.attr("class")
1285 {
1286 return cls
1287 .split_whitespace()
1288 .find_map(|c| c.strip_prefix("language-"))
1289 .map(|l| l.to_string());
1290 }
1291 None
1292 })
1293 } else {
1294 None
1295 };
1296
1297 let css = if is_block_tag {
1299 el.attr("style").map(parse_block_styles).unwrap_or_default()
1300 } else {
1301 BlockStyles::default()
1302 };
1303
1304 if tag == "table" {
1305 let mut parsed_table = parse_table_element(node);
1307 if !parsed_table.rows.is_empty() {
1308 parsed_table.blockquote_depth = bq_depth;
1309 elements.push(ParsedElement::Table(parsed_table));
1310 }
1311 return;
1312 }
1313
1314 if tag == "br" {
1315 elements.push(ParsedElement::Block(ParsedBlock {
1317 spans: vec![ParsedSpan {
1318 text: String::new(),
1319 ..Default::default()
1320 }],
1321 heading_level: None,
1322 list_style: None,
1323 list_indent: 0,
1324 list_prefix: String::new(),
1325 list_suffix: String::new(),
1326 marker: None,
1327 is_code_block: false,
1328 code_language: None,
1329 blockquote_depth: bq_depth,
1330 line_height: None,
1331 non_breakable_lines: None,
1332 page_break_before: None,
1333 direction: None,
1334 background_color: None,
1335 alignment: None,
1336 top_margin: None,
1337 text_indent: None,
1338 semantic_role: None,
1339 }));
1340 return;
1341 }
1342
1343 if tag == "blockquote" {
1344 for child in node.children() {
1346 walk_node(
1347 child,
1348 &new_state,
1349 elements,
1350 &new_list_style,
1351 bq_depth,
1352 new_list_depth,
1353 depth + 1,
1354 );
1355 }
1356 } else if is_block_tag && tag != "br" {
1357 let mut spans: Vec<ParsedSpan> = Vec::new();
1362 let mut nested_elements: Vec<ParsedElement> = Vec::new();
1363 collect_inline_spans(
1364 node,
1365 &new_state,
1366 &mut spans,
1367 &new_list_style,
1368 &mut nested_elements,
1369 bq_depth,
1370 new_list_depth,
1371 depth + 1,
1372 );
1373
1374 let list_style_for_block = if tag == "li" {
1375 new_list_style.clone()
1376 } else {
1377 None
1378 };
1379
1380 let list_indent_for_block = if tag == "li" {
1381 new_list_depth.saturating_sub(1)
1382 } else {
1383 0
1384 };
1385
1386 if !spans.is_empty() || heading_level.is_some() {
1387 elements.push(ParsedElement::Block(ParsedBlock {
1388 spans,
1389 heading_level,
1390 list_style: list_style_for_block,
1391 list_indent: list_indent_for_block,
1392 list_prefix: String::new(),
1393 list_suffix: String::new(),
1394 marker: None,
1395 is_code_block,
1396 code_language,
1397 blockquote_depth: bq_depth,
1398 line_height: css.line_height,
1399 non_breakable_lines: css.non_breakable_lines,
1400 page_break_before: css.page_break_before,
1401 direction: css.direction,
1402 background_color: css.background_color,
1403 alignment: None,
1404 top_margin: None,
1405 text_indent: None,
1406 semantic_role: None,
1407 }));
1408 }
1409 elements.append(&mut nested_elements);
1411 } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1412 for child in node.children() {
1414 walk_node(
1415 child,
1416 &new_state,
1417 elements,
1418 &new_list_style,
1419 bq_depth,
1420 new_list_depth,
1421 depth + 1,
1422 );
1423 }
1424 } else {
1425 for child in node.children() {
1427 walk_node(
1428 child,
1429 &new_state,
1430 elements,
1431 current_list_style,
1432 bq_depth,
1433 list_depth,
1434 depth + 1,
1435 );
1436 }
1437 }
1438 }
1439 Node::Text(text) => {
1440 let t = text.text.to_string();
1441 let trimmed = t.trim();
1442 if !trimmed.is_empty() {
1443 elements.push(ParsedElement::Block(ParsedBlock {
1445 spans: vec![ParsedSpan {
1446 text: trimmed.to_string(),
1447 bold: state.bold,
1448 italic: state.italic,
1449 underline: state.underline,
1450 strikeout: state.strikeout,
1451 code: state.code,
1452 superscript: state.superscript,
1453 subscript: state.subscript,
1454 link_href: state.link_href.clone(),
1455 image: None,
1456 footnote_ref: None,
1457 }],
1458 heading_level: None,
1459 list_style: None,
1460 list_indent: 0,
1461 list_prefix: String::new(),
1462 list_suffix: String::new(),
1463 marker: None,
1464 is_code_block: false,
1465 code_language: None,
1466 blockquote_depth,
1467 line_height: None,
1468 non_breakable_lines: None,
1469 page_break_before: None,
1470 direction: None,
1471 background_color: None,
1472 alignment: None,
1473 top_margin: None,
1474 text_indent: None,
1475 semantic_role: None,
1476 }));
1477 }
1478 }
1479 _ => {
1480 for child in node.children() {
1482 walk_node(
1483 child,
1484 state,
1485 elements,
1486 current_list_style,
1487 blockquote_depth,
1488 list_depth,
1489 depth + 1,
1490 );
1491 }
1492 }
1493 }
1494 }
1495
1496 #[allow(clippy::too_many_arguments)]
1500 fn collect_inline_spans(
1501 node: ego_tree::NodeRef<Node>,
1502 state: &FmtState,
1503 spans: &mut Vec<ParsedSpan>,
1504 current_list_style: &Option<ListStyle>,
1505 elements: &mut Vec<ParsedElement>,
1506 blockquote_depth: u32,
1507 list_depth: u32,
1508 depth: usize,
1509 ) {
1510 if depth > MAX_RECURSION_DEPTH {
1511 return;
1512 }
1513 for child in node.children() {
1514 match child.value() {
1515 Node::Text(text) => {
1516 let t = text.text.to_string();
1517 if !t.is_empty() {
1518 spans.push(ParsedSpan {
1519 text: t,
1520 bold: state.bold,
1521 italic: state.italic,
1522 underline: state.underline,
1523 strikeout: state.strikeout,
1524 code: state.code,
1525 superscript: state.superscript,
1526 subscript: state.subscript,
1527 link_href: state.link_href.clone(),
1528 image: None,
1529 footnote_ref: None,
1530 });
1531 }
1532 }
1533 Node::Element(el) => {
1534 let tag = el.name();
1535 if is_metadata_tag(tag) {
1536 continue;
1537 }
1538 let mut new_state = state.clone();
1539
1540 match tag {
1541 "b" | "strong" => new_state.bold = true,
1542 "i" | "em" => new_state.italic = true,
1543 "u" | "ins" => new_state.underline = true,
1544 "s" | "del" | "strike" => new_state.strikeout = true,
1545 "code" => new_state.code = true,
1546 "sup" => new_state.superscript = true,
1547 "sub" => new_state.subscript = true,
1548 "a" => {
1549 if let Some(href) = el.attr("href") {
1550 new_state.link_href = Some(href.to_string());
1551 }
1552 }
1553 "img" => {
1554 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1555 spans.push(span);
1556 }
1557 continue;
1558 }
1559 _ => {}
1560 }
1561
1562 let nested_block = matches!(
1564 tag,
1565 "p" | "div"
1566 | "h1"
1567 | "h2"
1568 | "h3"
1569 | "h4"
1570 | "h5"
1571 | "h6"
1572 | "li"
1573 | "pre"
1574 | "blockquote"
1575 | "ul"
1576 | "ol"
1577 );
1578
1579 if tag == "br" {
1580 spans.push(ParsedSpan {
1583 text: String::new(),
1584 ..Default::default()
1585 });
1586 } else if nested_block || tag == "table" {
1587 walk_node(
1589 child,
1590 &new_state,
1591 elements,
1592 current_list_style,
1593 blockquote_depth,
1594 list_depth,
1595 depth + 1,
1596 );
1597 } else {
1598 collect_inline_spans(
1600 child,
1601 &new_state,
1602 spans,
1603 current_list_style,
1604 elements,
1605 blockquote_depth,
1606 list_depth,
1607 depth + 1,
1608 );
1609 }
1610 }
1611 _ => {}
1612 }
1613 }
1614 }
1615
1616 let initial_state = FmtState::default();
1617 let mut root_spans: Vec<ParsedSpan> = Vec::new();
1621 collect_inline_spans(
1622 *root,
1623 &initial_state,
1624 &mut root_spans,
1625 &None,
1626 &mut elements,
1627 0,
1628 0,
1629 0,
1630 );
1631 if !root_spans.is_empty() {
1632 elements.push(ParsedElement::Block(ParsedBlock {
1633 spans: root_spans,
1634 heading_level: None,
1635 list_style: None,
1636 list_indent: 0,
1637 list_prefix: String::new(),
1638 list_suffix: String::new(),
1639 marker: None,
1640 is_code_block: false,
1641 code_language: None,
1642 blockquote_depth: 0,
1643 line_height: None,
1644 non_breakable_lines: None,
1645 page_break_before: None,
1646 direction: None,
1647 background_color: None,
1648 alignment: None,
1649 top_margin: None,
1650 text_indent: None,
1651 semantic_role: None,
1652 }));
1653 }
1654
1655 if elements.is_empty() {
1657 elements.push(ParsedElement::Block(ParsedBlock {
1658 spans: vec![ParsedSpan {
1659 text: String::new(),
1660 ..Default::default()
1661 }],
1662 heading_level: None,
1663 list_style: None,
1664 list_indent: 0,
1665 list_prefix: String::new(),
1666 list_suffix: String::new(),
1667 marker: None,
1668 is_code_block: false,
1669 code_language: None,
1670 blockquote_depth: 0,
1671 line_height: None,
1672 non_breakable_lines: None,
1673 page_break_before: None,
1674 direction: None,
1675 background_color: None,
1676 alignment: None,
1677 top_margin: None,
1678 text_indent: None,
1679 semantic_role: None,
1680 }));
1681 }
1682
1683 elements
1684}
1685
1686pub fn character_format_from_span(
1690 span: &ParsedSpan,
1691 is_code_block: bool,
1692) -> crate::format_runs::CharacterFormat {
1693 use crate::entities::CharVerticalAlignment;
1694 crate::format_runs::CharacterFormat {
1695 font_bold: if span.bold { Some(true) } else { None },
1696 font_italic: if span.italic { Some(true) } else { None },
1697 font_underline: if span.underline { Some(true) } else { None },
1698 font_strikeout: if span.strikeout { Some(true) } else { None },
1699 font_family: if span.code || is_code_block {
1700 Some("monospace".to_string())
1701 } else {
1702 None
1703 },
1704 anchor_href: span.link_href.clone(),
1705 is_anchor: if span.link_href.is_some() {
1706 Some(true)
1707 } else {
1708 None
1709 },
1710 vertical_alignment: if span.superscript {
1711 Some(CharVerticalAlignment::SuperScript)
1712 } else if span.subscript {
1713 Some(CharVerticalAlignment::SubScript)
1714 } else {
1715 None
1716 },
1717 ..Default::default()
1718 }
1719}
1720
1721pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1733 use crate::format_runs::{
1734 CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1735 };
1736
1737 let mut plain_text = String::new();
1738 let mut runs: Vec<FormatRun> = Vec::new();
1739 let mut images: Vec<ImageAnchor> = Vec::new();
1740 let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1741 let default = CharacterFormat::default();
1742
1743 for span in spans {
1744 let byte_start = plain_text.len() as u32;
1745
1746 if let Some(label) = &span.footnote_ref {
1747 plain_text.push('\u{FFFC}');
1750 let mut format = character_format_from_span(span, is_code_block);
1765 format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1766 footnote_refs.push(FootnoteRefAnchor {
1767 byte_offset: byte_start,
1768 label: label.clone(),
1769 format,
1770 });
1771 continue;
1772 }
1773
1774 if let Some(image) = &span.image {
1775 plain_text.push('\u{FFFC}');
1779 images.push(ImageAnchor {
1780 byte_offset: byte_start,
1781 name: image.src.clone(),
1782 alt: image.alt.clone(),
1783 width: image.width,
1784 height: image.height,
1785 quality: 100,
1786 format: character_format_from_span(span, is_code_block),
1787 });
1788 continue;
1789 }
1790
1791 plain_text.push_str(&span.text);
1792 let byte_end = plain_text.len() as u32;
1793 if byte_start == byte_end {
1794 continue;
1795 }
1796 let format = character_format_from_span(span, is_code_block);
1797 if format == default {
1798 continue;
1799 }
1800 runs.push(FormatRun {
1801 byte_start,
1802 byte_end,
1803 format,
1804 });
1805 }
1806 coalesce_in_place(&mut runs);
1807 ParsedInline {
1808 plain_text,
1809 runs,
1810 images,
1811 footnote_refs,
1812 }
1813}
1814
1815#[derive(Debug, Clone, Default)]
1822pub struct ParsedInline {
1823 pub plain_text: String,
1824 pub runs: Vec<crate::format_runs::FormatRun>,
1825 pub images: Vec<crate::format_runs::ImageAnchor>,
1826 pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
1827}
1828
1829fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1837 use jotdown::ListBulletType as B;
1838 match b {
1839 B::Dash => ListStyle::Disc,
1840 B::Star => ListStyle::Circle,
1841 B::Plus => ListStyle::Square,
1842 }
1843}
1844
1845fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1847 use jotdown::OrderedListNumbering as N;
1848 match n {
1849 N::Decimal => ListStyle::Decimal,
1850 N::AlphaLower => ListStyle::LowerAlpha,
1851 N::AlphaUpper => ListStyle::UpperAlpha,
1852 N::RomanLower => ListStyle::LowerRoman,
1853 N::RomanUpper => ListStyle::UpperRoman,
1854 }
1855}
1856
1857fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1861 use jotdown::OrderedListStyle as S;
1862 match style {
1863 S::Period => (String::new(), ".".to_string()),
1864 S::Paren => (String::new(), ")".to_string()),
1865 S::ParenParen => ("(".to_string(), ")".to_string()),
1866 }
1867}
1868
1869#[derive(Debug, Clone, Default)]
1873struct DjotBlockStyle {
1874 alignment: Option<Alignment>,
1875 line_height: Option<i64>,
1876 non_breakable_lines: Option<bool>,
1877 page_break_before: Option<bool>,
1878 direction: Option<TextDirection>,
1879 background_color: Option<String>,
1880 top_margin: Option<i64>,
1881 text_indent: Option<i64>,
1882 semantic_role: Option<SemanticRole>,
1883}
1884
1885impl DjotBlockStyle {
1886 fn merge_from(&mut self, other: DjotBlockStyle) {
1890 if other.alignment.is_some() {
1891 self.alignment = other.alignment;
1892 }
1893 if other.line_height.is_some() {
1894 self.line_height = other.line_height;
1895 }
1896 if other.non_breakable_lines.is_some() {
1897 self.non_breakable_lines = other.non_breakable_lines;
1898 }
1899 if other.page_break_before.is_some() {
1900 self.page_break_before = other.page_break_before;
1901 }
1902 if other.direction.is_some() {
1903 self.direction = other.direction;
1904 }
1905 if other.background_color.is_some() {
1906 self.background_color = other.background_color;
1907 }
1908 if other.top_margin.is_some() {
1909 self.top_margin = other.top_margin;
1910 }
1911 if other.text_indent.is_some() {
1912 self.text_indent = other.text_indent;
1913 }
1914 if other.semantic_role.is_some() {
1915 self.semantic_role = other.semantic_role.clone();
1916 }
1917 }
1918}
1919
1920fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1926 let mut style = DjotBlockStyle::default();
1927
1928 if opts.alignment
1929 && let Some(v) = attrs.get_value("alignment")
1930 {
1931 style.alignment = match v.to_string().as_str() {
1932 "left" => Some(Alignment::Left),
1933 "right" => Some(Alignment::Right),
1934 "center" => Some(Alignment::Center),
1935 "justify" => Some(Alignment::Justify),
1936 _ => None,
1937 };
1938 }
1939 if opts.line_height
1940 && let Some(v) = attrs.get_value("line_height")
1941 {
1942 style.line_height = v.to_string().parse::<i64>().ok();
1943 }
1944 if opts.direction
1945 && let Some(v) = attrs.get_value("direction")
1946 {
1947 style.direction = match v.to_string().as_str() {
1948 "ltr" => Some(TextDirection::LeftToRight),
1949 "rtl" => Some(TextDirection::RightToLeft),
1950 _ => None,
1951 };
1952 }
1953 if opts.non_breakable_lines
1954 && let Some(v) = attrs.get_value("non_breakable_lines")
1955 {
1956 style.non_breakable_lines = match v.to_string().as_str() {
1957 "true" => Some(true),
1958 "false" => Some(false),
1959 _ => None,
1960 };
1961 }
1962 if opts.page_break_before
1963 && let Some(v) = attrs.get_value("page_break_before")
1964 {
1965 style.page_break_before = match v.to_string().as_str() {
1966 "true" => Some(true),
1967 "false" => Some(false),
1968 _ => None,
1969 };
1970 }
1971 if opts.background_color
1972 && let Some(v) = attrs.get_value("background_color")
1973 {
1974 style.background_color = Some(v.to_string());
1975 }
1976 if opts.top_margin
1977 && let Some(v) = attrs.get_value("top_margin")
1978 {
1979 style.top_margin = v.to_string().parse::<i64>().ok();
1980 }
1981 if opts.text_indent
1982 && let Some(v) = attrs.get_value("text_indent")
1983 {
1984 style.text_indent = v.to_string().parse::<i64>().ok();
1985 }
1986 if opts.semantic_role
1987 && let Some(v) = attrs.get_value("semantic_role")
1988 {
1989 style.semantic_role = match v.to_string().as_str() {
1990 "epigraph" => Some(SemanticRole::Epigraph),
1991 _ => None,
1995 };
1996 }
1997
1998 style
1999}
2000
2001#[allow(clippy::too_many_arguments)]
2004fn djot_push_block(
2005 elements: &mut Vec<ParsedElement>,
2006 spans: Vec<ParsedSpan>,
2007 heading_level: Option<i64>,
2008 list_style: Option<ListStyle>,
2009 list_indent: u32,
2010 list_prefix: String,
2011 list_suffix: String,
2012 marker: Option<MarkerType>,
2013 is_code_block: bool,
2014 code_language: Option<String>,
2015 blockquote_depth: u32,
2016 style: DjotBlockStyle,
2017) {
2018 elements.push(ParsedElement::Block(ParsedBlock {
2019 spans,
2020 heading_level,
2021 list_style,
2022 list_indent,
2023 list_prefix,
2024 list_suffix,
2025 marker,
2026 is_code_block,
2027 code_language,
2028 blockquote_depth,
2029 line_height: style.line_height,
2030 non_breakable_lines: style.non_breakable_lines,
2031 page_break_before: style.page_break_before,
2032 direction: style.direction,
2033 background_color: style.background_color,
2034 alignment: style.alignment,
2035 top_margin: style.top_margin,
2036 text_indent: style.text_indent,
2037 semantic_role: style.semantic_role.clone(),
2038 }));
2039}
2040
2041pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2062 use jotdown::{Container as C, Event as E, ListKind, Parser};
2063
2064 let mut elements: Vec<ParsedElement> = Vec::new();
2065 let mut current_spans: Vec<ParsedSpan> = Vec::new();
2066 let mut current_heading: Option<i64> = None;
2067 let mut is_code_block = false;
2068 let mut code_language: Option<String> = None;
2069 let mut blockquote_depth: u32 = 0;
2070 let mut pending_style = DjotBlockStyle::default();
2073
2074 let mut bold = false;
2076 let mut italic = false;
2077 let mut underline = false;
2078 let mut strikeout = false;
2079 let mut code = false;
2080 let mut superscript = false;
2081 let mut subscript = false;
2082 let mut link_href: Option<String> = None;
2083 let mut pending_image: Option<ParsedImage> = None;
2086
2087 let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2089 let mut cur_list_style: Option<ListStyle> = None;
2091 let mut cur_list_prefix = String::new();
2092 let mut cur_list_suffix = String::new();
2093 let mut cur_list_indent: u32 = 0;
2094 let mut cur_marker: Option<MarkerType> = None;
2095
2096 let mut in_table_cell = false;
2098 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2099 let mut current_row: Vec<ParsedTableCell> = Vec::new();
2100 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2101 let mut table_header_rows: usize = 0;
2102 let mut row_is_head = false;
2103
2104 let mut skip_depth: u32 = 0;
2108
2109 let mut footnote_open: Option<(String, usize)> = None;
2113
2114 macro_rules! push_text {
2119 ($t:expr) => {{
2120 if let Some(img) = pending_image.as_mut() {
2125 img.alt.push_str(($t).as_ref());
2126 } else {
2127 let sp = ParsedSpan {
2128 text: ($t).to_string(),
2129 bold,
2130 italic,
2131 underline,
2132 strikeout,
2133 code,
2134 superscript,
2135 subscript,
2136 link_href: link_href.clone(),
2137 image: None,
2138 footnote_ref: None,
2139 };
2140 if in_table_cell {
2141 current_cell_spans.push(sp);
2142 } else {
2143 current_spans.push(sp);
2144 }
2145 }
2146 }};
2147 }
2148
2149 macro_rules! push_image {
2151 ($img:expr) => {{
2152 let sp = ParsedSpan {
2153 text: String::new(),
2154 bold,
2155 italic,
2156 underline,
2157 strikeout,
2158 code,
2159 superscript,
2160 subscript,
2161 link_href: link_href.clone(),
2162 image: Some($img),
2163 footnote_ref: None,
2164 };
2165 if in_table_cell {
2166 current_cell_spans.push(sp);
2167 } else {
2168 current_spans.push(sp);
2169 }
2170 }};
2171 }
2172
2173 macro_rules! enter_item {
2176 ($marker:expr) => {{
2177 if !current_spans.is_empty() {
2178 djot_push_block(
2179 &mut elements,
2180 std::mem::take(&mut current_spans),
2181 None,
2182 cur_list_style.clone(),
2183 cur_list_indent,
2184 cur_list_prefix.clone(),
2185 cur_list_suffix.clone(),
2186 cur_marker.clone(),
2187 false,
2188 None,
2189 blockquote_depth,
2190 DjotBlockStyle::default(),
2191 );
2192 }
2193 let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2194 ListStyle::Disc,
2195 String::new(),
2196 String::new(),
2197 ));
2198 cur_list_style = Some(style);
2199 cur_list_prefix = prefix;
2200 cur_list_suffix = suffix;
2201 cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2202 cur_marker = $marker;
2203 }};
2204 }
2205
2206 for event in Parser::new(djot) {
2207 if skip_depth > 0 {
2208 match event {
2209 E::Start(..) => skip_depth += 1,
2210 E::End(_) => skip_depth -= 1,
2211 _ => {}
2212 }
2213 continue;
2214 }
2215
2216 match event {
2217 E::Start(C::Document, _) | E::End(C::Document) => {}
2219 E::Start(C::Section { .. }, attrs) => {
2220 if list_stack.is_empty() {
2223 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2224 }
2225 }
2226 E::End(C::Section { .. }) => {}
2227 E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2228
2229 E::Start(C::Blockquote, _) => blockquote_depth += 1,
2231 E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2232
2233 E::Start(C::List { kind, .. }, _) => {
2235 let (style, prefix, suffix) = match kind {
2236 ListKind::Unordered(b) | ListKind::Task(b) => {
2237 (djot_bullet_style(b), String::new(), String::new())
2238 }
2239 ListKind::Ordered {
2240 numbering, style, ..
2241 } => {
2242 let (p, s) = djot_ordered_affixes(style);
2243 (djot_ordered_style(numbering), p, s)
2244 }
2245 };
2246 list_stack.push((style, prefix, suffix));
2247 }
2248 E::End(C::List { .. }) => {
2249 list_stack.pop();
2250 cur_list_style = None;
2251 cur_marker = None;
2252 }
2253 E::Start(C::ListItem, _) => enter_item!(None),
2254 E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2255 MarkerType::Checked
2256 } else {
2257 MarkerType::Unchecked
2258 })),
2259 E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2260 if !current_spans.is_empty() {
2262 djot_push_block(
2263 &mut elements,
2264 std::mem::take(&mut current_spans),
2265 None,
2266 cur_list_style.clone(),
2267 cur_list_indent,
2268 cur_list_prefix.clone(),
2269 cur_list_suffix.clone(),
2270 cur_marker.clone(),
2271 false,
2272 None,
2273 blockquote_depth,
2274 DjotBlockStyle::default(),
2275 );
2276 }
2277 cur_list_style = None;
2278 cur_marker = None;
2279 }
2280
2281 E::Start(C::Heading { level, .. }, attrs) => {
2283 current_heading = Some(level as i64);
2284 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2287 }
2288 E::End(C::Heading { .. }) => {
2289 djot_push_block(
2290 &mut elements,
2291 std::mem::take(&mut current_spans),
2292 current_heading.take(),
2293 None,
2294 0,
2295 String::new(),
2296 String::new(),
2297 None,
2298 false,
2299 None,
2300 blockquote_depth,
2301 std::mem::take(&mut pending_style),
2302 );
2303 }
2304 E::Start(C::Paragraph, attrs) => {
2305 current_heading = None;
2306 pending_style = if list_stack.is_empty() {
2310 block_attrs_to_style(&attrs, options)
2311 } else {
2312 DjotBlockStyle::default()
2313 };
2314 }
2315 E::End(C::Paragraph) => {
2316 if !current_spans.is_empty() {
2317 djot_push_block(
2318 &mut elements,
2319 std::mem::take(&mut current_spans),
2320 None,
2321 cur_list_style.clone(),
2322 cur_list_indent,
2323 cur_list_prefix.clone(),
2324 cur_list_suffix.clone(),
2325 cur_marker.clone(),
2326 false,
2327 None,
2328 blockquote_depth,
2329 std::mem::take(&mut pending_style),
2330 );
2331 }
2332 cur_list_style = None;
2333 cur_marker = None;
2334 }
2335 E::Start(C::CodeBlock { language }, _) => {
2336 is_code_block = true;
2337 code_language = if language.is_empty() {
2338 None
2339 } else {
2340 Some(language.to_string())
2341 };
2342 }
2343 E::End(C::CodeBlock { .. }) => {
2344 if let Some(last) = current_spans.last_mut()
2346 && last.text.ends_with('\n')
2347 {
2348 last.text.pop();
2349 }
2350 djot_push_block(
2351 &mut elements,
2352 std::mem::take(&mut current_spans),
2353 None,
2354 None,
2355 0,
2356 String::new(),
2357 String::new(),
2358 None,
2359 true,
2360 code_language.take(),
2361 blockquote_depth,
2362 DjotBlockStyle::default(),
2363 );
2364 is_code_block = false;
2365 }
2366
2367 E::Start(C::Table, _) => {
2369 table_rows.clear();
2370 current_row.clear();
2371 current_cell_spans.clear();
2372 table_header_rows = 0;
2373 }
2374 E::End(C::Table) => {
2375 elements.push(ParsedElement::Table(ParsedTable {
2376 header_rows: table_header_rows,
2377 rows: std::mem::take(&mut table_rows),
2378 blockquote_depth,
2379 }));
2380 }
2381 E::Start(C::TableRow { head }, _) => {
2382 row_is_head = head;
2383 current_row.clear();
2384 }
2385 E::End(C::TableRow { .. }) => {
2386 if row_is_head {
2387 table_header_rows += 1;
2388 }
2389 table_rows.push(std::mem::take(&mut current_row));
2390 }
2391 E::Start(C::TableCell { .. }, _) => {
2392 in_table_cell = true;
2393 current_cell_spans.clear();
2394 }
2395 E::End(C::TableCell { .. }) => {
2396 in_table_cell = false;
2397 current_row.push(ParsedTableCell {
2398 spans: std::mem::take(&mut current_cell_spans),
2399 });
2400 }
2401
2402 E::Start(C::Strong, _) => bold = true,
2404 E::End(C::Strong) => bold = false,
2405 E::Start(C::Emphasis, _) => italic = true,
2406 E::End(C::Emphasis) => italic = false,
2407 E::Start(C::Verbatim, _) => code = true,
2408 E::End(C::Verbatim) => code = false,
2409 E::Start(C::Superscript, _) => superscript = true,
2410 E::End(C::Superscript) => superscript = false,
2411 E::Start(C::Subscript, _) => subscript = true,
2412 E::End(C::Subscript) => subscript = false,
2413 E::Start(C::Insert, _) => underline = true,
2414 E::End(C::Insert) => underline = false,
2415 E::Start(C::Delete, _) => strikeout = true,
2416 E::End(C::Delete) => strikeout = false,
2417 E::Start(C::Mark, _) | E::End(C::Mark) => {}
2419 E::Start(C::Span, _) | E::End(C::Span) => {}
2420 E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2421 E::End(C::Link(..)) => link_href = None,
2422 E::Start(C::Image(src, _), attrs) => {
2427 let attr_num = |key: &str| -> i64 {
2428 attrs
2429 .get_value(key)
2430 .map(|v| v.to_string())
2431 .and_then(|v| v.trim().parse::<i64>().ok())
2432 .filter(|n| *n > 0)
2433 .unwrap_or(0)
2434 };
2435 pending_image = Some(ParsedImage {
2436 src: src.to_string(),
2437 alt: String::new(),
2438 width: attr_num("width"),
2439 height: attr_num("height"),
2440 });
2441 }
2442 E::End(C::Image(..)) => {
2443 if let Some(img) = pending_image.take() {
2444 push_image!(img);
2445 }
2446 }
2447
2448 E::Start(C::Footnote { label }, _) => {
2457 if !current_spans.is_empty() {
2458 djot_push_block(
2459 &mut elements,
2460 std::mem::take(&mut current_spans),
2461 None,
2462 cur_list_style.clone(),
2463 cur_list_indent,
2464 cur_list_prefix.clone(),
2465 cur_list_suffix.clone(),
2466 cur_marker.clone(),
2467 false,
2468 None,
2469 blockquote_depth,
2470 DjotBlockStyle::default(),
2471 );
2472 }
2473 footnote_open = Some((label.to_string(), elements.len()));
2474 }
2475 E::End(C::Footnote { .. }) => {
2476 if !current_spans.is_empty() {
2477 djot_push_block(
2478 &mut elements,
2479 std::mem::take(&mut current_spans),
2480 None,
2481 cur_list_style.clone(),
2482 cur_list_indent,
2483 cur_list_prefix.clone(),
2484 cur_list_suffix.clone(),
2485 cur_marker.clone(),
2486 false,
2487 None,
2488 blockquote_depth,
2489 DjotBlockStyle::default(),
2490 );
2491 }
2492 if let Some((label, start)) = footnote_open.take() {
2493 let blocks: Vec<ParsedBlock> = elements
2494 .drain(start..)
2495 .filter_map(|e| match e {
2496 ParsedElement::Block(b) => Some(b),
2497 _ => None,
2501 })
2502 .collect();
2503 elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2504 }
2505 }
2506
2507 E::Start(
2509 C::Math { .. }
2510 | C::RawBlock { .. }
2511 | C::RawInline { .. }
2512 | C::DescriptionList
2513 | C::DescriptionDetails
2514 | C::DescriptionTerm
2515 | C::Caption
2516 | C::LinkDefinition { .. },
2517 _,
2518 ) => skip_depth = 1,
2519
2520 E::Str(s) => push_text!(s.as_ref()),
2522 E::Softbreak => push_text!(" "),
2523 E::LeftSingleQuote => push_text!("\u{2018}"),
2524 E::RightSingleQuote => push_text!("\u{2019}"),
2525 E::LeftDoubleQuote => push_text!("\u{201C}"),
2526 E::RightDoubleQuote => push_text!("\u{201D}"),
2527 E::Ellipsis => push_text!("\u{2026}"),
2528 E::EnDash => push_text!("\u{2013}"),
2529 E::EmDash => push_text!("\u{2014}"),
2530 E::NonBreakingSpace => push_text!("\u{00A0}"),
2531 E::Hardbreak => {
2532 if in_table_cell {
2533 push_text!(" ");
2534 } else if !current_spans.is_empty() {
2535 djot_push_block(
2538 &mut elements,
2539 std::mem::take(&mut current_spans),
2540 None,
2541 cur_list_style.clone(),
2542 cur_list_indent,
2543 cur_list_prefix.clone(),
2544 cur_list_suffix.clone(),
2545 cur_marker.clone(),
2546 is_code_block,
2547 code_language.clone(),
2548 blockquote_depth,
2549 pending_style.clone(),
2550 );
2551 }
2552 }
2553 E::FootnoteReference(label) => {
2559 let sp = ParsedSpan {
2560 text: String::new(),
2561 bold,
2562 italic,
2563 underline,
2564 strikeout,
2565 code,
2566 superscript,
2567 subscript,
2568 link_href: link_href.clone(),
2569 image: None,
2570 footnote_ref: Some(label.to_string()),
2571 };
2572 if in_table_cell {
2573 current_cell_spans.push(sp);
2574 } else {
2575 current_spans.push(sp);
2576 }
2577 }
2578 E::Symbol(_) => {}
2581 E::Escape | E::Blankline => {}
2582 E::ThematicBreak(_) | E::Attributes(_) => {}
2583
2584 _ => {}
2587 }
2588 }
2589
2590 if !current_spans.is_empty() {
2592 djot_push_block(
2593 &mut elements,
2594 std::mem::take(&mut current_spans),
2595 current_heading.take(),
2596 cur_list_style.clone(),
2597 cur_list_indent,
2598 cur_list_prefix.clone(),
2599 cur_list_suffix.clone(),
2600 cur_marker.clone(),
2601 is_code_block,
2602 code_language.take(),
2603 blockquote_depth,
2604 std::mem::take(&mut pending_style),
2605 );
2606 }
2607
2608 if elements.is_empty() {
2611 djot_push_block(
2612 &mut elements,
2613 vec![ParsedSpan {
2614 text: String::new(),
2615 ..Default::default()
2616 }],
2617 None,
2618 None,
2619 0,
2620 String::new(),
2621 String::new(),
2622 None,
2623 false,
2624 None,
2625 0,
2626 DjotBlockStyle::default(),
2627 );
2628 }
2629
2630 elements
2631}
2632
2633#[cfg(test)]
2634mod tests {
2635 use super::*;
2636
2637 fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2639 ParsedElement::flatten_to_blocks(parse_markdown(md))
2640 }
2641
2642 #[test]
2643 fn test_parse_markdown_simple_paragraph() {
2644 let blocks = parse_markdown_blocks("Hello **world**");
2645 assert_eq!(blocks.len(), 1);
2646 assert!(blocks[0].spans.len() >= 2);
2647 let plain_span = blocks[0]
2649 .spans
2650 .iter()
2651 .find(|s| s.text.contains("Hello"))
2652 .unwrap();
2653 assert!(!plain_span.bold);
2654 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2655 assert!(bold_span.bold);
2656 }
2657
2658 #[test]
2659 fn test_parse_markdown_heading() {
2660 let blocks = parse_markdown_blocks("# Title");
2661 assert_eq!(blocks.len(), 1);
2662 assert_eq!(blocks[0].heading_level, Some(1));
2663 assert_eq!(blocks[0].spans[0].text, "Title");
2664 }
2665
2666 #[test]
2667 fn test_parse_markdown_list() {
2668 let blocks = parse_markdown_blocks("- item1\n- item2");
2669 assert!(blocks.len() >= 2);
2670 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2671 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2672 }
2673
2674 fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2676 elements
2677 .iter()
2678 .map(|e| match e {
2679 ParsedElement::Block(b) => (false, b.blockquote_depth),
2680 ParsedElement::Table(t) => (true, t.blockquote_depth),
2681 ParsedElement::FootnoteDefinition { .. } => (false, 0),
2684 })
2685 .collect()
2686 }
2687
2688 #[test]
2689 fn test_parse_markdown_table_in_blockquote_records_depth() {
2690 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2691 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2692 }
2693
2694 #[test]
2695 fn test_parse_markdown_text_then_table_in_blockquote() {
2696 let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2697 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2698 }
2699
2700 #[test]
2701 fn test_parse_markdown_table_after_blockquote_closes() {
2702 let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2703 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2704 }
2705
2706 #[test]
2707 fn test_parse_markdown_table_in_nested_blockquote() {
2708 let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2709 assert_eq!(element_depths(&elements), vec![(true, 2)]);
2710 }
2711
2712 #[test]
2713 fn test_parse_markdown_list_in_blockquote_records_depth() {
2714 let elements = parse_markdown("> - item1\n> - item2");
2715 let depths = element_depths(&elements);
2716 assert_eq!(depths, vec![(false, 1), (false, 1)]);
2717 for e in &elements {
2718 if let ParsedElement::Block(b) = e {
2719 assert_eq!(b.list_style, Some(ListStyle::Disc));
2720 }
2721 }
2722 }
2723
2724 #[test]
2725 fn test_parse_html_table_in_blockquote_records_depth() {
2726 let elements = parse_html_elements(
2727 "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2728 );
2729 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2730 }
2731
2732 #[test]
2733 fn test_parse_html_table_after_blockquote() {
2734 let elements = parse_html_elements(
2735 "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2736 );
2737 let depths = element_depths(&elements);
2738 assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2740 assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2741 }
2742
2743 #[test]
2744 fn test_flatten_to_blocks_propagates_blockquote_depth() {
2745 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2746 let blocks = ParsedElement::flatten_to_blocks(elements);
2747 assert!(!blocks.is_empty());
2748 for b in &blocks {
2749 assert_eq!(b.blockquote_depth, 1);
2750 }
2751 }
2752
2753 #[test]
2754 fn test_parse_html_simple() {
2755 let blocks = parse_html("<p>Hello <b>world</b></p>");
2756 assert_eq!(blocks.len(), 1);
2757 assert!(blocks[0].spans.len() >= 2);
2758 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2759 assert!(bold_span.bold);
2760 }
2761
2762 #[test]
2763 fn test_parse_html_multiple_paragraphs() {
2764 let blocks = parse_html("<p>A</p><p>B</p>");
2765 assert_eq!(blocks.len(), 2);
2766 }
2767
2768 #[test]
2769 fn test_parse_html_heading() {
2770 let blocks = parse_html("<h2>Subtitle</h2>");
2771 assert_eq!(blocks.len(), 1);
2772 assert_eq!(blocks[0].heading_level, Some(2));
2773 }
2774
2775 #[test]
2776 fn test_parse_html_list() {
2777 let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2778 assert!(blocks.len() >= 2);
2779 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2780 }
2781
2782 #[test]
2783 fn test_parse_markdown_code_block() {
2784 let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2785 assert_eq!(blocks.len(), 1);
2786 assert!(blocks[0].is_code_block);
2787 assert!(blocks[0].spans[0].code);
2788 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2790 assert_eq!(
2791 text, "fn main() {}",
2792 "code block text should not have trailing newline"
2793 );
2794 }
2795
2796 #[test]
2797 fn test_parse_markdown_nested_formatting() {
2798 let blocks = parse_markdown_blocks("***bold italic***");
2799 assert_eq!(blocks.len(), 1);
2800 let span = &blocks[0].spans[0];
2801 assert!(span.bold);
2802 assert!(span.italic);
2803 }
2804
2805 #[test]
2806 fn test_parse_markdown_link() {
2807 let blocks = parse_markdown_blocks("[click](http://example.com)");
2808 assert_eq!(blocks.len(), 1);
2809 let span = &blocks[0].spans[0];
2810 assert_eq!(span.text, "click");
2811 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2812 }
2813
2814 #[test]
2815 fn test_parse_markdown_empty() {
2816 let blocks = parse_markdown_blocks("");
2817 assert_eq!(blocks.len(), 1);
2818 assert!(blocks[0].spans[0].text.is_empty());
2819 }
2820
2821 #[test]
2822 fn test_parse_html_empty() {
2823 let blocks = parse_html("");
2824 assert_eq!(blocks.len(), 1);
2825 assert!(blocks[0].spans[0].text.is_empty());
2826 }
2827
2828 #[test]
2829 fn test_parse_html_nested_formatting() {
2830 let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2831 assert_eq!(blocks.len(), 1);
2832 let span = &blocks[0].spans[0];
2833 assert!(span.bold);
2834 assert!(span.italic);
2835 }
2836
2837 #[test]
2838 fn test_parse_html_link() {
2839 let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2840 assert_eq!(blocks.len(), 1);
2841 let span = &blocks[0].spans[0];
2842 assert_eq!(span.text, "click");
2843 assert_eq!(span.link_href, Some("http://example.com".to_string()));
2844 }
2845
2846 #[test]
2847 fn test_parse_html_ordered_list() {
2848 let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2849 assert!(blocks.len() >= 2);
2850 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2851 }
2852
2853 #[test]
2854 fn test_parse_markdown_ordered_list() {
2855 let blocks = parse_markdown_blocks("1. first\n2. second");
2856 assert!(blocks.len() >= 2);
2857 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2858 }
2859
2860 #[test]
2861 fn test_parse_html_blockquote_nested() {
2862 let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2863 assert!(blocks.len() >= 3);
2864 }
2865
2866 #[test]
2867 fn test_parse_block_styles_line_height() {
2868 let styles = parse_block_styles("line-height: 1.5");
2869 assert_eq!(styles.line_height, Some(1500));
2870 }
2871
2872 #[test]
2873 fn test_parse_block_styles_direction_rtl() {
2874 let styles = parse_block_styles("direction: rtl");
2875 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2876 }
2877
2878 #[test]
2879 fn test_parse_block_styles_background_color() {
2880 let styles = parse_block_styles("background-color: #ff0000");
2881 assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2882 }
2883
2884 #[test]
2885 fn test_parse_block_styles_white_space_pre() {
2886 let styles = parse_block_styles("white-space: pre");
2887 assert_eq!(styles.non_breakable_lines, Some(true));
2888 }
2889
2890 #[test]
2891 fn test_parse_block_styles_multiple() {
2892 let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2893 assert_eq!(styles.line_height, Some(2000));
2894 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2895 assert_eq!(styles.background_color, Some("blue".to_string()));
2896 }
2897
2898 #[test]
2899 fn test_parse_html_block_styles_extracted() {
2900 let blocks = parse_html(
2901 r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2902 );
2903 assert_eq!(blocks.len(), 1);
2904 assert_eq!(blocks[0].line_height, Some(1500));
2905 assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2906 assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2907 }
2908
2909 #[test]
2910 fn test_parse_html_white_space_pre() {
2911 let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2912 assert_eq!(blocks.len(), 1);
2913 assert_eq!(blocks[0].non_breakable_lines, Some(true));
2914 }
2915
2916 #[test]
2917 fn test_parse_html_no_styles_returns_none() {
2918 let blocks = parse_html("<p>plain</p>");
2919 assert_eq!(blocks.len(), 1);
2920 assert_eq!(blocks[0].line_height, None);
2921 assert_eq!(blocks[0].direction, None);
2922 assert_eq!(blocks[0].background_color, None);
2923 assert_eq!(blocks[0].non_breakable_lines, None);
2924 }
2925
2926 #[test]
2927 fn test_parse_markdown_nested_list_indent() {
2928 let md = "- top\n - nested\n - deep";
2929 let blocks = parse_markdown_blocks(md);
2930 assert_eq!(blocks.len(), 3);
2931 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2932 assert_eq!(blocks[0].list_indent, 0);
2933 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2934 assert_eq!(blocks[1].list_indent, 1);
2935 assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2936 assert_eq!(blocks[2].list_indent, 2);
2937 }
2938
2939 #[test]
2940 fn test_parse_markdown_nested_ordered_list_indent() {
2941 let md = "1. first\n 1. nested\n 2. nested2";
2942 let blocks = parse_markdown_blocks(md);
2943 assert_eq!(blocks.len(), 3);
2944 assert_eq!(blocks[0].list_indent, 0);
2945 assert_eq!(blocks[1].list_indent, 1);
2946 assert_eq!(blocks[2].list_indent, 1);
2947 }
2948
2949 #[test]
2950 fn test_parse_html_nested_list_indent() {
2951 let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2952 let blocks = parse_html(html);
2953 assert!(blocks.len() >= 2);
2954 assert_eq!(blocks[0].list_indent, 0);
2955 assert_eq!(blocks[1].list_indent, 1);
2956 }
2957
2958 #[test]
2959 fn test_parse_markdown_table() {
2960 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2961 let elements = parse_markdown(md);
2962 assert_eq!(elements.len(), 1);
2963 match &elements[0] {
2964 ParsedElement::Table(table) => {
2965 assert_eq!(table.header_rows, 1);
2966 assert_eq!(table.rows.len(), 2); assert_eq!(table.rows[0].len(), 2);
2969 assert_eq!(table.rows[0][0].spans[0].text, "A");
2970 assert_eq!(table.rows[0][1].spans[0].text, "B");
2971 assert_eq!(table.rows[1].len(), 2);
2973 assert_eq!(table.rows[1][0].spans[0].text, "1");
2974 assert_eq!(table.rows[1][1].spans[0].text, "2");
2975 }
2976 _ => panic!("Expected ParsedElement::Table"),
2977 }
2978 }
2979
2980 #[test]
2981 fn test_parse_markdown_table_with_formatting() {
2982 let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2983 let elements = parse_markdown(md);
2984 assert_eq!(elements.len(), 1);
2985 match &elements[0] {
2986 ParsedElement::Table(table) => {
2987 assert_eq!(table.rows.len(), 2);
2988 assert!(table.rows[0][0].spans[0].bold);
2990 assert!(table.rows[0][1].spans[0].code);
2992 assert!(table.rows[0][2].spans[0].italic);
2994 assert!(table.rows[1][0].spans[0].strikeout);
2996 assert_eq!(
2998 table.rows[1][2].spans[0].link_href,
2999 Some("http://x.com".to_string())
3000 );
3001 }
3002 _ => panic!("Expected ParsedElement::Table"),
3003 }
3004 }
3005
3006 #[test]
3007 fn test_parse_markdown_mixed_content_with_table() {
3008 let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
3009 let elements = parse_markdown(md);
3010 assert_eq!(elements.len(), 3);
3011 assert!(matches!(&elements[0], ParsedElement::Block(_)));
3012 assert!(matches!(&elements[1], ParsedElement::Table(_)));
3013 assert!(matches!(&elements[2], ParsedElement::Block(_)));
3014 }
3015}
3016
3017#[cfg(test)]
3018mod djot_tests {
3019 use super::*;
3020 use crate::entities::MarkerType;
3021
3022 fn blocks(d: &str) -> Vec<ParsedBlock> {
3023 ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
3024 }
3025
3026 fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
3027 b.spans.iter().find(|s| pred(s)).expect("span not found")
3028 }
3029
3030 #[test]
3031 fn paragraph_bold_italic() {
3032 let b = blocks("normal *bold* _italic_");
3033 assert_eq!(b.len(), 1);
3034 assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
3035 assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
3036 }
3037
3038 #[test]
3039 fn heading_levels() {
3040 assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3041 assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3042 assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3043 }
3044
3045 #[test]
3046 fn unordered_bullet_styles_are_distinct() {
3047 assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3048 assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3049 assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3050 }
3051
3052 #[test]
3053 fn ordered_delimiters() {
3054 let period = blocks("1. a");
3055 assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3056 assert_eq!(period[0].list_prefix, "");
3057 assert_eq!(period[0].list_suffix, ".");
3058
3059 let paren = blocks("1) a");
3060 assert_eq!(paren[0].list_suffix, ")");
3061 assert_eq!(paren[0].list_prefix, "");
3062
3063 let paren_paren = blocks("(1) a");
3064 assert_eq!(paren_paren[0].list_prefix, "(");
3065 assert_eq!(paren_paren[0].list_suffix, ")");
3066 }
3067
3068 #[test]
3069 fn task_list_markers() {
3070 let b = blocks("- [ ] a\n- [x] b");
3071 assert_eq!(b.len(), 2);
3072 assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3073 assert_eq!(b[1].marker, Some(MarkerType::Checked));
3074 }
3075
3076 #[test]
3077 fn code_block_with_language() {
3078 let b = blocks("```rust\nfn main() {}\n```");
3079 assert_eq!(b.len(), 1);
3080 assert!(b[0].is_code_block);
3081 assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3082 let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3083 assert_eq!(text, "fn main() {}");
3084 }
3085
3086 #[test]
3087 fn link_href() {
3088 let b = blocks("[text](http://example.com)");
3089 let s = first_span_with(&b[0], |s| s.text == "text");
3090 assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3091 }
3092
3093 #[test]
3094 fn superscript_subscript() {
3095 assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3096 assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3097 }
3098
3099 #[test]
3100 fn delete_insert_verbatim() {
3101 assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3102 assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3103 assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3104 }
3105
3106 #[test]
3107 fn blockquote_depth() {
3108 let els = parse_djot("> quoted", &DjotImportOptions::default());
3109 match &els[0] {
3110 ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3111 _ => panic!("expected block"),
3112 }
3113 }
3114
3115 #[test]
3116 fn nested_list_indent() {
3117 let b = blocks("- a\n\n - b\n\n - c");
3122 assert_eq!(b.len(), 3);
3123 assert_eq!(b[0].list_indent, 0);
3124 assert_eq!(b[1].list_indent, 1);
3125 assert_eq!(b[2].list_indent, 2);
3126 }
3127
3128 #[test]
3129 fn table_parsed_as_table() {
3130 let els = parse_djot(
3131 "| a | b |\n|---|---|\n| c | d |",
3132 &DjotImportOptions::default(),
3133 );
3134 assert_eq!(els.len(), 1);
3135 match &els[0] {
3136 ParsedElement::Table(t) => {
3137 assert_eq!(t.header_rows, 1);
3138 assert_eq!(t.rows.len(), 2);
3139 assert_eq!(t.rows[0][0].spans[0].text, "a");
3140 assert_eq!(t.rows[1][1].spans[0].text, "d");
3141 }
3142 _ => panic!("expected table"),
3143 }
3144 }
3145
3146 #[test]
3147 fn smart_punctuation_normalised_to_unicode() {
3148 let text: String = blocks("a... b---c")[0]
3149 .spans
3150 .iter()
3151 .map(|s| s.text.as_str())
3152 .collect();
3153 assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3154 assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3155 }
3156
3157 #[test]
3158 fn unrepresentable_constructs_dropped_without_leaking_text() {
3159 let b = blocks("para1\n\n---\n\npara2");
3161 assert_eq!(b.len(), 2);
3162 assert_eq!(
3163 b[0].spans
3164 .iter()
3165 .map(|s| s.text.as_str())
3166 .collect::<String>(),
3167 "para1"
3168 );
3169 assert_eq!(
3170 b[1].spans
3171 .iter()
3172 .map(|s| s.text.as_str())
3173 .collect::<String>(),
3174 "para2"
3175 );
3176
3177 let d = blocks(":::\ninside\n:::");
3179 let joined: String = d
3180 .iter()
3181 .flat_map(|b| b.spans.iter())
3182 .map(|s| s.text.as_str())
3183 .collect();
3184 assert_eq!(joined, "inside");
3185
3186 let m = blocks("before $`E=mc^2` after");
3188 let joined: String = m
3189 .iter()
3190 .flat_map(|b| b.spans.iter())
3191 .map(|s| s.text.as_str())
3192 .collect();
3193 assert!(joined.contains("before"), "{joined:?}");
3194 assert!(joined.contains("after"), "{joined:?}");
3195 assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3196 }
3197
3198 #[test]
3199 fn empty_document_yields_one_empty_block() {
3200 let b = blocks("");
3201 assert_eq!(b.len(), 1);
3202 assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3203 }
3204
3205 #[test]
3206 fn block_attributes_parse_into_block() {
3207 let b = blocks(
3208 "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3209 );
3210 assert_eq!(b.len(), 1);
3211 assert_eq!(b[0].alignment, Some(Alignment::Center));
3212 assert_eq!(b[0].line_height, Some(1500));
3213 assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3214 assert_eq!(b[0].non_breakable_lines, Some(true));
3215 assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3216 }
3217
3218 #[test]
3219 fn spacing_block_attributes_parse_into_block() {
3220 let b = blocks("{top_margin=24 text_indent=0}\nhello");
3224 assert_eq!(b.len(), 1);
3225 assert_eq!(b[0].top_margin, Some(24));
3226 assert_eq!(b[0].text_indent, Some(0));
3227 }
3228
3229 #[test]
3230 fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3231 let explicit = blocks("{text_indent=0}\nhello");
3235 let absent = blocks("hello");
3236 assert_eq!(explicit[0].text_indent, Some(0));
3237 assert_eq!(absent[0].text_indent, None);
3238 assert!(!explicit[0].is_inline_only());
3239 assert!(absent[0].is_inline_only());
3240 }
3241
3242 #[test]
3243 fn spacing_block_attributes_respect_import_options() {
3244 let src = "{top_margin=24 text_indent=0}\nhello";
3245 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3246 assert_eq!(b[0].top_margin, None);
3247 assert_eq!(b[0].text_indent, None);
3248 }
3249
3250 #[test]
3251 fn block_attributes_on_heading() {
3252 let b = blocks("{alignment=right}\n# Title");
3253 assert_eq!(b[0].heading_level, Some(1));
3254 assert_eq!(b[0].alignment, Some(Alignment::Right));
3255 }
3256
3257 #[test]
3258 fn block_attributes_respect_import_options() {
3259 let src = "{alignment=center line_height=1500}\nhello";
3262 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3263 assert_eq!(b[0].alignment, None);
3264 assert_eq!(b[0].line_height, None);
3265 assert_eq!(
3266 b[0].spans
3267 .iter()
3268 .map(|s| s.text.as_str())
3269 .collect::<String>(),
3270 "hello"
3271 );
3272 }
3273
3274 #[test]
3275 fn list_item_block_attributes_are_dropped() {
3276 let b = blocks("{alignment=center}\n- item");
3279 assert!(b.iter().all(|blk| blk.alignment.is_none()));
3280 }
3281
3282 #[test]
3283 fn unknown_alignment_value_is_ignored() {
3284 let b = blocks("{alignment=sideways}\nhello");
3285 assert_eq!(b[0].alignment, None);
3286 }
3287}
3288
3289pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3301
3302fn span_prose(span: &ParsedSpan, out: &mut String) {
3310 if span.image.is_some() || span.footnote_ref.is_some() {
3311 out.push('\u{FFFC}');
3312 return;
3313 }
3314 out.push_str(&span.text);
3315}
3316
3317fn block_prose(block: &ParsedBlock) -> String {
3318 let mut prose = String::new();
3319 for span in &block.spans {
3320 span_prose(span, &mut prose);
3321 }
3322 prose
3323}
3324
3325fn cell_prose(cell: &ParsedTableCell) -> String {
3326 let mut prose = String::new();
3327 for span in &cell.spans {
3328 span_prose(span, &mut prose);
3329 }
3330 prose
3331}
3332
3333pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3382 let elements = parse_djot(djot, options);
3386
3387 let mut out = String::with_capacity(djot.len());
3390
3391 let mut first = true;
3396 let push = |text: &str, out: &mut String, first: &mut bool| {
3397 if *first {
3398 *first = false;
3399 } else {
3400 out.push('\n');
3401 }
3402 out.push_str(text);
3403 };
3404
3405 for element in &elements {
3406 match element {
3407 ParsedElement::Block(block) => {
3408 push(&block_prose(block), &mut out, &mut first);
3409 }
3410 ParsedElement::FootnoteDefinition { .. } => {}
3420 ParsedElement::Table(table) => {
3421 push(TABLE_ANCHOR, &mut out, &mut first);
3428 for row in &table.rows {
3429 for cell in row {
3430 push(&cell_prose(cell), &mut out, &mut first);
3431 }
3432 }
3433 }
3434 }
3435 }
3436 out
3437}