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, Default)]
162pub struct ParsedBlock {
163 pub spans: Vec<ParsedSpan>,
164 pub heading_level: Option<i64>,
165 pub list_style: Option<ListStyle>,
166 pub list_indent: u32,
167 pub list_prefix: String,
170 pub list_suffix: String,
173 pub marker: Option<MarkerType>,
176 pub is_code_block: bool,
177 pub code_language: Option<String>,
178 pub blockquote_depth: u32,
179 pub line_height: Option<i64>,
180 pub non_breakable_lines: Option<bool>,
181 pub page_break_before: Option<bool>,
184 pub direction: Option<TextDirection>,
185 pub background_color: Option<String>,
186 pub alignment: Option<Alignment>,
189 pub top_margin: Option<i64>,
193 pub text_indent: Option<i64>,
197 pub semantic_role: Option<SemanticRole>,
201}
202
203impl ParsedBlock {
204 pub fn is_inline_only(&self) -> bool {
207 self.heading_level.is_none()
208 && self.list_style.is_none()
209 && !self.is_code_block
210 && self.blockquote_depth == 0
211 && self.line_height.is_none()
212 && self.non_breakable_lines.is_none()
213 && self.page_break_before.is_none()
214 && self.direction.is_none()
215 && self.background_color.is_none()
216 && self.alignment.is_none()
217 && self.top_margin.is_none()
218 && self.text_indent.is_none()
219 }
220}
221
222fn dangling_footnote_labels(
251 markdown: &str,
252 options: pulldown_cmark::Options,
253) -> std::collections::BTreeSet<String> {
254 use pulldown_cmark::{Event, Parser, Tag};
255
256 let mut defined: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
257 for event in Parser::new_ext(markdown, options) {
258 if let Event::Start(Tag::FootnoteDefinition(label)) = event {
259 defined.insert(label.to_string());
260 }
261 }
262
263 let mut referenced: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
264 let bytes = markdown.as_bytes();
265 let mut search_from = 0usize;
266 while let Some(rel) = markdown[search_from..].find("[^") {
267 let open = search_from + rel;
268 let label_start = open + 2;
269 let Some(close_rel) = markdown[label_start..].find(']') else {
270 break;
271 };
272 let close = label_start + close_rel;
273 let label = &markdown[label_start..close];
274 let looks_like_definition = bytes.get(close + 1) == Some(&b':');
279 if !label.is_empty() && !looks_like_definition && !label.chars().any(char::is_whitespace) {
280 referenced.insert(label.to_string());
281 }
282 search_from = close + 1;
283 }
284
285 referenced.difference(&defined).cloned().collect()
286}
287
288pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
289 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
290
291 let options = Options::ENABLE_STRIKETHROUGH
302 | Options::ENABLE_TABLES
303 | Options::ENABLE_TASKLISTS
304 | Options::ENABLE_FOOTNOTES;
305
306 let dangling = dangling_footnote_labels(markdown, options);
313 let augmented_owner;
314 let source: &str = if dangling.is_empty() {
315 markdown
316 } else {
317 augmented_owner = dangling
318 .iter()
319 .fold(markdown.to_string(), |mut acc, label| {
320 acc.push_str("\n\n[^");
321 acc.push_str(label);
322 acc.push_str("]:\n");
323 acc
324 });
325 &augmented_owner
326 };
327 let parser = Parser::new_ext(source, options);
328
329 let mut elements: Vec<ParsedElement> = Vec::new();
330 let mut current_spans: Vec<ParsedSpan> = Vec::new();
331 let mut current_heading: Option<i64> = None;
332 let mut current_list_style: Option<ListStyle> = None;
333 let mut is_code_block = false;
334 let mut code_language: Option<String> = None;
335 let mut blockquote_depth: u32 = 0;
336 let mut in_block = false;
337
338 let mut bold = false;
340 let mut italic = false;
341 let mut strikeout = false;
342 let mut link_href: Option<String> = None;
343 let mut pending_image: Option<ParsedImage> = None;
345
346 let mut footnote_open: Option<(String, usize)> = None;
353
354 let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
356 let mut current_list_indent: u32 = 0;
357
358 let mut in_table = false;
360 let mut in_table_head = false;
361 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
362 let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
363 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
364 let mut table_header_rows: usize = 0;
365
366 for event in parser {
367 match event {
368 Event::Start(Tag::Paragraph) => {
369 in_block = true;
370 current_heading = None;
371 is_code_block = false;
372 }
373 Event::End(TagEnd::Paragraph) => {
374 if !current_spans.is_empty() || in_block {
375 elements.push(ParsedElement::Block(ParsedBlock {
376 spans: std::mem::take(&mut current_spans),
377 heading_level: current_heading.take(),
378 list_style: current_list_style.clone(),
379 list_indent: current_list_indent,
380 list_prefix: String::new(),
381 list_suffix: String::new(),
382 marker: None,
383 is_code_block: false,
384 code_language: None,
385 blockquote_depth,
386 line_height: None,
387 non_breakable_lines: None,
388 page_break_before: None,
389 direction: None,
390 background_color: None,
391 alignment: None,
392 top_margin: None,
393 text_indent: None,
394 semantic_role: None,
395 }));
396 }
397 in_block = false;
398 current_list_style = None;
399 }
400 Event::Start(Tag::Heading { level, .. }) => {
401 in_block = true;
402 current_heading = Some(heading_level_to_i64(level));
403 is_code_block = false;
404 }
405 Event::End(TagEnd::Heading(_)) => {
406 elements.push(ParsedElement::Block(ParsedBlock {
407 spans: std::mem::take(&mut current_spans),
408 heading_level: current_heading.take(),
409 list_style: None,
410 list_indent: 0,
411 list_prefix: String::new(),
412 list_suffix: String::new(),
413 marker: None,
414 is_code_block: false,
415 code_language: None,
416 blockquote_depth,
417 line_height: None,
418 non_breakable_lines: None,
419 page_break_before: None,
420 direction: None,
421 background_color: None,
422 alignment: None,
423 top_margin: None,
424 text_indent: None,
425 semantic_role: None,
426 }));
427 in_block = false;
428 }
429 Event::Start(Tag::List(ordered)) => {
430 let style = if ordered.is_some() {
431 Some(ListStyle::Decimal)
432 } else {
433 Some(ListStyle::Disc)
434 };
435 list_stack.push(style);
436 }
437 Event::End(TagEnd::List(_)) => {
438 list_stack.pop();
439 }
440 Event::Start(Tag::Item) => {
441 if !current_spans.is_empty() {
444 elements.push(ParsedElement::Block(ParsedBlock {
445 spans: std::mem::take(&mut current_spans),
446 heading_level: None,
447 list_style: current_list_style.clone(),
448 list_indent: current_list_indent,
449 list_prefix: String::new(),
450 list_suffix: String::new(),
451 marker: None,
452 is_code_block: false,
453 code_language: None,
454 blockquote_depth,
455 line_height: None,
456 non_breakable_lines: None,
457 page_break_before: None,
458 direction: None,
459 background_color: None,
460 alignment: None,
461 top_margin: None,
462 text_indent: None,
463 semantic_role: None,
464 }));
465 }
466 in_block = true;
467 current_list_style = list_stack.last().cloned().flatten();
468 current_list_indent = if list_stack.is_empty() {
469 0
470 } else {
471 (list_stack.len() - 1) as u32
472 };
473 }
474 Event::End(TagEnd::Item) => {
475 if !current_spans.is_empty() {
478 elements.push(ParsedElement::Block(ParsedBlock {
479 spans: std::mem::take(&mut current_spans),
480 heading_level: None,
481 list_style: current_list_style.clone(),
482 list_indent: current_list_indent,
483 list_prefix: String::new(),
484 list_suffix: String::new(),
485 marker: None,
486 is_code_block: false,
487 code_language: None,
488 blockquote_depth,
489 line_height: None,
490 non_breakable_lines: None,
491 page_break_before: None,
492 direction: None,
493 background_color: None,
494 alignment: None,
495 top_margin: None,
496 text_indent: None,
497 semantic_role: None,
498 }));
499 }
500 in_block = false;
501 current_list_style = None;
502 }
503 Event::Start(Tag::CodeBlock(kind)) => {
504 in_block = true;
505 is_code_block = true;
506 code_language = match &kind {
507 pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
508 Some(lang.to_string())
509 }
510 _ => None,
511 };
512 }
513 Event::End(TagEnd::CodeBlock) => {
514 if let Some(last) = current_spans.last_mut()
516 && last.text.ends_with('\n')
517 {
518 last.text.truncate(last.text.len() - 1);
519 }
520 elements.push(ParsedElement::Block(ParsedBlock {
521 spans: std::mem::take(&mut current_spans),
522 heading_level: None,
523 list_style: None,
524 list_indent: 0,
525 list_prefix: String::new(),
526 list_suffix: String::new(),
527 marker: None,
528 is_code_block: true,
529 code_language: code_language.take(),
530 blockquote_depth,
531 line_height: None,
532 non_breakable_lines: None,
533 page_break_before: None,
534 direction: None,
535 background_color: None,
536 alignment: None,
537 top_margin: None,
538 text_indent: None,
539 semantic_role: None,
540 }));
541 in_block = false;
542 is_code_block = false;
543 }
544 Event::Start(Tag::Table(_)) => {
546 in_table = true;
547 in_table_head = false;
548 table_rows.clear();
549 current_row_cells.clear();
550 current_cell_spans.clear();
551 table_header_rows = 0;
552 }
553 Event::End(TagEnd::Table) => {
554 elements.push(ParsedElement::Table(ParsedTable {
555 header_rows: table_header_rows,
556 rows: std::mem::take(&mut table_rows),
557 blockquote_depth,
558 }));
559 in_table = false;
560 }
561 Event::Start(Tag::TableHead) => {
562 in_table_head = true;
563 current_row_cells.clear();
564 }
565 Event::End(TagEnd::TableHead) => {
566 table_rows.push(std::mem::take(&mut current_row_cells));
568 table_header_rows += 1;
569 in_table_head = false;
570 }
571 Event::Start(Tag::TableRow) => {
572 current_row_cells.clear();
573 }
574 Event::End(TagEnd::TableRow) if !in_table_head => {
575 table_rows.push(std::mem::take(&mut current_row_cells));
577 }
578 Event::Start(Tag::TableCell) => {
579 current_cell_spans.clear();
580 }
581 Event::End(TagEnd::TableCell) => {
582 current_row_cells.push(ParsedTableCell {
583 spans: std::mem::take(&mut current_cell_spans),
584 });
585 }
586 Event::Start(Tag::Emphasis) => {
588 italic = true;
589 }
590 Event::End(TagEnd::Emphasis) => {
591 italic = false;
592 }
593 Event::Start(Tag::Strong) => {
594 bold = true;
595 }
596 Event::End(TagEnd::Strong) => {
597 bold = false;
598 }
599 Event::Start(Tag::Strikethrough) => {
600 strikeout = true;
601 }
602 Event::End(TagEnd::Strikethrough) => {
603 strikeout = false;
604 }
605 Event::Start(Tag::Link { dest_url, .. }) => {
606 link_href = Some(dest_url.to_string());
607 }
608 Event::End(TagEnd::Link) => {
609 link_href = None;
610 }
611 Event::Start(Tag::Image { dest_url, .. }) => {
614 pending_image = Some(ParsedImage {
615 src: dest_url.to_string(),
616 alt: String::new(),
617 width: 0,
618 height: 0,
619 });
620 }
621 Event::End(TagEnd::Image) => {
622 if let Some(image) = pending_image.take() {
623 let span = ParsedSpan {
624 text: String::new(),
625 bold,
626 italic,
627 underline: false,
628 strikeout,
629 code: false,
630 superscript: false,
631 subscript: false,
632 link_href: link_href.clone(),
633 image: Some(image),
634 footnote_ref: None,
635 };
636 if in_table {
637 current_cell_spans.push(span);
638 } else {
639 if !in_block {
640 in_block = true;
641 }
642 current_spans.push(span);
643 }
644 }
645 }
646 Event::Text(text) => {
647 if let Some(img) = pending_image.as_mut() {
652 img.alt.push_str(&text);
653 continue;
654 }
655 let span = ParsedSpan {
656 text: text.to_string(),
657 bold,
658 italic,
659 underline: false,
660 strikeout,
661 code: is_code_block,
662 superscript: false,
663 subscript: false,
664 link_href: link_href.clone(),
665 image: None,
666 footnote_ref: None,
667 };
668 if in_table {
669 current_cell_spans.push(span);
670 } else {
671 if !in_block {
672 in_block = true;
673 }
674 current_spans.push(span);
675 }
676 }
677 Event::Code(text) => {
678 let span = ParsedSpan {
679 text: text.to_string(),
680 bold,
681 italic,
682 underline: false,
683 strikeout,
684 code: true,
685 superscript: false,
686 subscript: false,
687 link_href: link_href.clone(),
688 image: None,
689 footnote_ref: None,
690 };
691 if in_table {
692 current_cell_spans.push(span);
693 } else {
694 if !in_block {
695 in_block = true;
696 }
697 current_spans.push(span);
698 }
699 }
700 Event::SoftBreak => {
701 let span = ParsedSpan {
702 text: " ".to_string(),
703 bold,
704 italic,
705 underline: false,
706 strikeout,
707 code: false,
708 superscript: false,
709 subscript: false,
710 link_href: link_href.clone(),
711 image: None,
712 footnote_ref: None,
713 };
714 if in_table {
715 current_cell_spans.push(span);
716 } else {
717 current_spans.push(span);
718 }
719 }
720 Event::HardBreak if !current_spans.is_empty() || in_block => {
721 elements.push(ParsedElement::Block(ParsedBlock {
723 spans: std::mem::take(&mut current_spans),
724 heading_level: current_heading.take(),
725 list_style: current_list_style.clone(),
726 list_indent: current_list_indent,
727 list_prefix: String::new(),
728 list_suffix: String::new(),
729 marker: None,
730 is_code_block,
731 code_language: code_language.clone(),
732 blockquote_depth,
733 line_height: None,
734 non_breakable_lines: None,
735 page_break_before: None,
736 direction: None,
737 background_color: None,
738 alignment: None,
739 top_margin: None,
740 text_indent: None,
741 semantic_role: None,
742 }));
743 }
744 Event::Start(Tag::BlockQuote(_)) => {
745 blockquote_depth += 1;
746 }
747 Event::End(TagEnd::BlockQuote(_)) => {
748 blockquote_depth = blockquote_depth.saturating_sub(1);
749 }
750 Event::Start(Tag::FootnoteDefinition(label)) => {
761 if !current_spans.is_empty() {
762 elements.push(ParsedElement::Block(ParsedBlock {
763 spans: std::mem::take(&mut current_spans),
764 heading_level: current_heading.take(),
765 list_style: current_list_style.clone(),
766 list_indent: current_list_indent,
767 list_prefix: String::new(),
768 list_suffix: String::new(),
769 marker: None,
770 is_code_block: false,
771 code_language: None,
772 blockquote_depth,
773 line_height: None,
774 non_breakable_lines: None,
775 page_break_before: None,
776 direction: None,
777 background_color: None,
778 alignment: None,
779 top_margin: None,
780 text_indent: None,
781 semantic_role: None,
782 }));
783 }
784 footnote_open = Some((label.to_string(), elements.len()));
785 }
786 Event::End(TagEnd::FootnoteDefinition) => {
787 if !current_spans.is_empty() {
788 elements.push(ParsedElement::Block(ParsedBlock {
789 spans: std::mem::take(&mut current_spans),
790 heading_level: current_heading.take(),
791 list_style: current_list_style.clone(),
792 list_indent: current_list_indent,
793 list_prefix: String::new(),
794 list_suffix: String::new(),
795 marker: None,
796 is_code_block: false,
797 code_language: None,
798 blockquote_depth,
799 line_height: None,
800 non_breakable_lines: None,
801 page_break_before: None,
802 direction: None,
803 background_color: None,
804 alignment: None,
805 top_margin: None,
806 text_indent: None,
807 semantic_role: None,
808 }));
809 }
810 if let Some((label, start)) = footnote_open.take() {
811 let blocks: Vec<ParsedBlock> = elements
812 .drain(start..)
813 .filter_map(|e| match e {
814 ParsedElement::Block(b) => Some(b),
815 _ => None,
818 })
819 .collect();
820 elements.push(ParsedElement::FootnoteDefinition { label, blocks });
821 }
822 }
823 Event::FootnoteReference(label) => {
829 let span = ParsedSpan {
830 text: String::new(),
831 bold,
832 italic,
833 underline: false,
834 strikeout,
835 code: false,
836 superscript: false,
837 subscript: false,
838 link_href: link_href.clone(),
839 image: None,
840 footnote_ref: Some(label.to_string()),
841 };
842 if in_table {
843 current_cell_spans.push(span);
844 } else {
845 if !in_block {
846 in_block = true;
847 }
848 current_spans.push(span);
849 }
850 }
851 _ => {}
852 }
853 }
854
855 if !current_spans.is_empty() {
857 elements.push(ParsedElement::Block(ParsedBlock {
858 spans: std::mem::take(&mut current_spans),
859 heading_level: current_heading,
860 list_style: current_list_style,
861 list_indent: current_list_indent,
862 list_prefix: String::new(),
863 list_suffix: String::new(),
864 marker: None,
865 is_code_block,
866 code_language: code_language.take(),
867 blockquote_depth,
868 line_height: None,
869 non_breakable_lines: None,
870 page_break_before: None,
871 direction: None,
872 background_color: None,
873 alignment: None,
874 top_margin: None,
875 text_indent: None,
876 semantic_role: None,
877 }));
878 }
879
880 if !dangling.is_empty() {
887 elements.retain(
888 |e| !matches!(e, ParsedElement::FootnoteDefinition { label, .. } if dangling.contains(label)),
889 );
890 }
891
892 if elements.is_empty() {
894 elements.push(ParsedElement::Block(ParsedBlock {
895 spans: vec![ParsedSpan {
896 text: String::new(),
897 ..Default::default()
898 }],
899 heading_level: None,
900 list_style: None,
901 list_indent: 0,
902 list_prefix: String::new(),
903 list_suffix: String::new(),
904 marker: None,
905 is_code_block: false,
906 code_language: None,
907 blockquote_depth: 0,
908 line_height: None,
909 non_breakable_lines: None,
910 page_break_before: None,
911 direction: None,
912 background_color: None,
913 alignment: None,
914 top_margin: None,
915 text_indent: None,
916 semantic_role: None,
917 }));
918 }
919
920 elements
921}
922
923fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
924 use pulldown_cmark::HeadingLevel;
925 match level {
926 HeadingLevel::H1 => 1,
927 HeadingLevel::H2 => 2,
928 HeadingLevel::H3 => 3,
929 HeadingLevel::H4 => 4,
930 HeadingLevel::H5 => 5,
931 HeadingLevel::H6 => 6,
932 }
933}
934
935use scraper::Node;
938
939#[derive(Debug, Clone, Default)]
941struct BlockStyles {
942 line_height: Option<i64>,
943 non_breakable_lines: Option<bool>,
944 page_break_before: Option<bool>,
945 direction: Option<TextDirection>,
946 background_color: Option<String>,
947 preserve_whitespace: Option<bool>,
952}
953
954fn parse_block_styles(style: &str) -> BlockStyles {
958 let mut result = BlockStyles::default();
959 for part in style.split(';') {
960 let part = part.trim();
961 if let Some((prop, val)) = part.split_once(':') {
962 let prop = prop.trim().to_ascii_lowercase();
963 let val = val.trim();
964 match prop.as_str() {
965 "line-height" => {
966 if let Ok(v) = val.parse::<f64>() {
968 result.line_height = Some((v * 1000.0) as i64);
969 }
970 }
971 "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
972 result.non_breakable_lines = Some(true);
973 if val != "nowrap" {
974 result.preserve_whitespace = Some(true);
975 }
976 }
977 "break-before" | "page-break-before" => {
981 result.page_break_before = match val.to_ascii_lowercase().as_str() {
982 "page" | "always" | "left" | "right" | "recto" | "verso" => Some(true),
983 "avoid" | "auto" => Some(false),
984 _ => None,
985 };
986 }
987 "direction" => {
988 if val.eq_ignore_ascii_case("rtl") {
989 result.direction = Some(TextDirection::RightToLeft);
990 } else if val.eq_ignore_ascii_case("ltr") {
991 result.direction = Some(TextDirection::LeftToRight);
992 }
993 }
994 "background-color" | "background" => {
995 result.background_color = Some(val.to_string());
996 }
997 _ => {}
998 }
999 }
1000 }
1001 result
1002}
1003
1004fn is_html_space(ch: char) -> bool {
1011 matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0C}')
1012}
1013
1014fn spans_carry_content(spans: &[ParsedSpan]) -> bool {
1016 spans
1017 .iter()
1018 .any(|s| !s.text.is_empty() || s.image.is_some() || s.footnote_ref.is_some())
1019}
1020
1021fn collapse_inline_whitespace(spans: &mut [ParsedSpan]) {
1035 fn is_replaced(span: &ParsedSpan) -> bool {
1036 span.image.is_some() || span.footnote_ref.is_some()
1037 }
1038
1039 for span in spans.iter_mut() {
1043 if is_replaced(span) {
1044 continue;
1045 }
1046 let mut out = String::with_capacity(span.text.len());
1047 let mut in_run = false;
1048 for ch in span.text.chars() {
1049 if is_html_space(ch) {
1050 if !in_run {
1051 out.push(' ');
1052 in_run = true;
1053 }
1054 } else {
1055 out.push(ch);
1056 in_run = false;
1057 }
1058 }
1059 span.text = out;
1060 }
1061
1062 let mut prev_ends_with_space = true;
1065 for span in spans.iter_mut() {
1066 if is_replaced(span) {
1067 prev_ends_with_space = false;
1068 continue;
1069 }
1070 if span.text.is_empty() {
1071 prev_ends_with_space = false;
1075 continue;
1076 }
1077 if prev_ends_with_space && span.text.starts_with(' ') {
1078 span.text.remove(0);
1079 }
1080 if !span.text.is_empty() {
1083 prev_ends_with_space = span.text.ends_with(' ');
1084 }
1085 }
1086
1087 for span in spans.iter_mut().rev() {
1091 if is_replaced(span) {
1092 break;
1093 }
1094 if span.text.is_empty() {
1095 continue;
1096 }
1097 if span.text.ends_with(' ') {
1098 span.text.pop();
1099 }
1100 break;
1101 }
1102}
1103
1104pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
1105 ParsedElement::flatten_to_blocks(parse_html_elements(html))
1106}
1107
1108pub const HTML_FOOTNOTE_ATTR: &str = "data-footnote-ref";
1122
1123fn html_footnote_span(
1126 el: &scraper::node::Element,
1127 link_href: Option<String>,
1128) -> Option<ParsedSpan> {
1129 let label = el.attr(HTML_FOOTNOTE_ATTR)?.trim();
1130 if label.is_empty() {
1131 return None;
1132 }
1133 Some(ParsedSpan {
1134 text: String::new(),
1135 link_href,
1136 footnote_ref: Some(label.to_string()),
1137 ..Default::default()
1138 })
1139}
1140
1141fn html_img_span(el: &scraper::node::Element, link_href: Option<String>) -> Option<ParsedSpan> {
1149 let src = el.attr("src")?;
1150 if src.is_empty() {
1151 return None;
1152 }
1153 let dim = |name: &str| -> i64 {
1154 el.attr(name)
1155 .and_then(|v| v.trim().trim_end_matches("px").parse::<i64>().ok())
1156 .filter(|n| *n > 0)
1157 .unwrap_or(0)
1158 };
1159 Some(ParsedSpan {
1160 text: String::new(),
1161 link_href,
1162 image: Some(ParsedImage {
1163 src: src.to_string(),
1164 alt: el.attr("alt").unwrap_or_default().to_string(),
1165 width: dim("width"),
1166 height: dim("height"),
1167 }),
1168 ..Default::default()
1169 })
1170}
1171
1172pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
1173 use scraper::Html;
1174
1175 let fragment = Html::parse_fragment(html);
1176 let mut elements: Vec<ParsedElement> = Vec::new();
1177
1178 let root = fragment.root_element();
1180
1181 #[derive(Clone, Default)]
1182 struct FmtState {
1183 bold: bool,
1184 italic: bool,
1185 underline: bool,
1186 strikeout: bool,
1187 code: bool,
1188 superscript: bool,
1189 subscript: bool,
1190 link_href: Option<String>,
1191 }
1192
1193 const MAX_RECURSION_DEPTH: usize = 256;
1194
1195 fn is_metadata_tag(tag: &str) -> bool {
1205 matches!(
1206 tag,
1207 "head"
1208 | "style"
1209 | "script"
1210 | "title"
1211 | "meta"
1212 | "link"
1213 | "base"
1214 | "noscript"
1215 | "template"
1216 )
1217 }
1218
1219 fn collect_cell_spans(
1221 node: ego_tree::NodeRef<Node>,
1222 state: &FmtState,
1223 spans: &mut Vec<ParsedSpan>,
1224 depth: usize,
1225 ) {
1226 if depth > MAX_RECURSION_DEPTH {
1227 return;
1228 }
1229 for child in node.children() {
1230 match child.value() {
1231 Node::Text(text) => {
1232 let t = text.text.to_string();
1233 if !t.is_empty() {
1234 spans.push(ParsedSpan {
1235 text: t,
1236 bold: state.bold,
1237 italic: state.italic,
1238 underline: state.underline,
1239 strikeout: state.strikeout,
1240 code: state.code,
1241 superscript: state.superscript,
1242 subscript: state.subscript,
1243 link_href: state.link_href.clone(),
1244 image: None,
1245 footnote_ref: None,
1246 });
1247 }
1248 }
1249 Node::Element(el) => {
1250 let tag = el.name();
1251 if is_metadata_tag(tag) {
1252 continue;
1253 }
1254 let mut new_state = state.clone();
1255 match tag {
1256 _ if el.attr(HTML_FOOTNOTE_ATTR).is_some() => {
1260 if let Some(span) = html_footnote_span(el, new_state.link_href.clone())
1261 {
1262 spans.push(span);
1263 }
1264 continue;
1265 }
1266 "b" | "strong" => new_state.bold = true,
1267 "i" | "em" => new_state.italic = true,
1268 "u" | "ins" => new_state.underline = true,
1269 "s" | "del" | "strike" => new_state.strikeout = true,
1270 "code" => new_state.code = true,
1271 "sup" => new_state.superscript = true,
1272 "sub" => new_state.subscript = true,
1273 "a" => {
1274 if let Some(href) = el.attr("href") {
1275 new_state.link_href = Some(href.to_string());
1276 }
1277 }
1278 "img" => {
1279 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1280 spans.push(span);
1281 }
1282 continue;
1283 }
1284 _ => {}
1285 }
1286 collect_cell_spans(child, &new_state, spans, depth + 1);
1287 }
1288 _ => {}
1289 }
1290 }
1291 }
1292
1293 fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1295 let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1296 let mut header_rows: usize = 0;
1297
1298 fn collect_rows(
1299 node: ego_tree::NodeRef<Node>,
1300 rows: &mut Vec<Vec<ParsedTableCell>>,
1301 header_rows: &mut usize,
1302 in_thead: bool,
1303 ) {
1304 for child in node.children() {
1305 if let Node::Element(el) = child.value() {
1306 match el.name() {
1307 "thead" => collect_rows(child, rows, header_rows, true),
1308 "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1309 "tr" => {
1310 let mut cells: Vec<ParsedTableCell> = Vec::new();
1311 for td in child.children() {
1312 if let Node::Element(td_el) = td.value()
1313 && matches!(td_el.name(), "td" | "th")
1314 {
1315 let mut spans = Vec::new();
1316 let state = FmtState::default();
1317 collect_cell_spans(td, &state, &mut spans, 0);
1318 collapse_inline_whitespace(&mut spans);
1319 if spans.is_empty() {
1320 spans.push(ParsedSpan::default());
1321 }
1322 cells.push(ParsedTableCell { spans });
1323 }
1324 }
1325 if !cells.is_empty() {
1326 rows.push(cells);
1327 if in_thead {
1328 *header_rows += 1;
1329 }
1330 }
1331 }
1332 _ => {}
1333 }
1334 }
1335 }
1336 }
1337
1338 collect_rows(table_node, &mut rows, &mut header_rows, false);
1339
1340 if header_rows == 0 && !rows.is_empty() {
1342 header_rows = 1;
1343 }
1344
1345 ParsedTable {
1346 header_rows,
1347 rows,
1348 blockquote_depth: 0,
1351 }
1352 }
1353
1354 fn walk_node(
1355 node: ego_tree::NodeRef<Node>,
1356 state: &FmtState,
1357 elements: &mut Vec<ParsedElement>,
1358 current_list_style: &Option<ListStyle>,
1359 blockquote_depth: u32,
1360 list_depth: u32,
1361 depth: usize,
1362 ) {
1363 if depth > MAX_RECURSION_DEPTH {
1364 return;
1365 }
1366 match node.value() {
1367 Node::Element(el) => {
1368 let tag = el.name();
1369 if is_metadata_tag(tag) {
1370 return;
1371 }
1372 let mut new_state = state.clone();
1373 let mut new_list_style = current_list_style.clone();
1374 let mut bq_depth = blockquote_depth;
1375 let mut new_list_depth = list_depth;
1376
1377 let is_block_tag = matches!(
1379 tag,
1380 "p" | "div"
1381 | "h1"
1382 | "h2"
1383 | "h3"
1384 | "h4"
1385 | "h5"
1386 | "h6"
1387 | "li"
1388 | "pre"
1389 | "br"
1390 | "blockquote"
1391 | "body"
1392 | "html"
1393 );
1394
1395 match tag {
1397 "b" | "strong" => new_state.bold = true,
1398 "i" | "em" => new_state.italic = true,
1399 "u" | "ins" => new_state.underline = true,
1400 "s" | "del" | "strike" => new_state.strikeout = true,
1401 "code" => new_state.code = true,
1402 "sup" => new_state.superscript = true,
1403 "sub" => new_state.subscript = true,
1404 "a" => {
1405 if let Some(href) = el.attr("href") {
1406 new_state.link_href = Some(href.to_string());
1407 }
1408 }
1409 "ul" => {
1410 new_list_style = Some(ListStyle::Disc);
1411 new_list_depth = list_depth + 1;
1412 }
1413 "ol" => {
1414 new_list_style = Some(ListStyle::Decimal);
1415 new_list_depth = list_depth + 1;
1416 }
1417 "blockquote" => {
1418 bq_depth += 1;
1419 }
1420 _ => {}
1421 }
1422
1423 let heading_level = match tag {
1425 "h1" => Some(1),
1426 "h2" => Some(2),
1427 "h3" => Some(3),
1428 "h4" => Some(4),
1429 "h5" => Some(5),
1430 "h6" => Some(6),
1431 _ => None,
1432 };
1433
1434 let is_code_block = tag == "pre";
1435
1436 let code_language = if is_code_block {
1438 node.children().find_map(|child| {
1439 if let Node::Element(cel) = child.value()
1440 && cel.name() == "code"
1441 && let Some(cls) = cel.attr("class")
1442 {
1443 return cls
1444 .split_whitespace()
1445 .find_map(|c| c.strip_prefix("language-"))
1446 .map(|l| l.to_string());
1447 }
1448 None
1449 })
1450 } else {
1451 None
1452 };
1453
1454 let css = if is_block_tag {
1456 el.attr("style").map(parse_block_styles).unwrap_or_default()
1457 } else {
1458 BlockStyles::default()
1459 };
1460
1461 if tag == "table" {
1462 let mut parsed_table = parse_table_element(node);
1464 if !parsed_table.rows.is_empty() {
1465 parsed_table.blockquote_depth = bq_depth;
1466 elements.push(ParsedElement::Table(parsed_table));
1467 }
1468 return;
1469 }
1470
1471 if tag == "br" {
1472 elements.push(ParsedElement::Block(ParsedBlock {
1474 spans: vec![ParsedSpan {
1475 text: String::new(),
1476 ..Default::default()
1477 }],
1478 heading_level: None,
1479 list_style: None,
1480 list_indent: 0,
1481 list_prefix: String::new(),
1482 list_suffix: String::new(),
1483 marker: None,
1484 is_code_block: false,
1485 code_language: None,
1486 blockquote_depth: bq_depth,
1487 line_height: None,
1488 non_breakable_lines: None,
1489 page_break_before: None,
1490 direction: None,
1491 background_color: None,
1492 alignment: None,
1493 top_margin: None,
1494 text_indent: None,
1495 semantic_role: None,
1496 }));
1497 return;
1498 }
1499
1500 if tag == "blockquote" {
1501 for child in node.children() {
1503 walk_node(
1504 child,
1505 &new_state,
1506 elements,
1507 &new_list_style,
1508 bq_depth,
1509 new_list_depth,
1510 depth + 1,
1511 );
1512 }
1513 } else if is_block_tag && tag != "br" {
1514 let mut spans: Vec<ParsedSpan> = Vec::new();
1519 let mut nested_elements: Vec<ParsedElement> = Vec::new();
1520 collect_inline_spans(
1521 node,
1522 &new_state,
1523 &mut spans,
1524 &new_list_style,
1525 &mut nested_elements,
1526 bq_depth,
1527 new_list_depth,
1528 depth + 1,
1529 );
1530
1531 let list_style_for_block = if tag == "li" {
1532 new_list_style.clone()
1533 } else {
1534 None
1535 };
1536
1537 let list_indent_for_block = if tag == "li" {
1538 new_list_depth.saturating_sub(1)
1539 } else {
1540 0
1541 };
1542
1543 if !(is_code_block || css.preserve_whitespace == Some(true)) {
1544 collapse_inline_whitespace(&mut spans);
1545 }
1546
1547 let own_run_is_content =
1555 spans_carry_content(&spans) || nested_elements.is_empty();
1556
1557 if (!spans.is_empty() && own_run_is_content) || heading_level.is_some() {
1558 elements.push(ParsedElement::Block(ParsedBlock {
1559 spans,
1560 heading_level,
1561 list_style: list_style_for_block,
1562 list_indent: list_indent_for_block,
1563 list_prefix: String::new(),
1564 list_suffix: String::new(),
1565 marker: None,
1566 is_code_block,
1567 code_language,
1568 blockquote_depth: bq_depth,
1569 line_height: css.line_height,
1570 non_breakable_lines: css.non_breakable_lines,
1571 page_break_before: css.page_break_before,
1572 direction: css.direction,
1573 background_color: css.background_color,
1574 alignment: None,
1575 top_margin: None,
1576 text_indent: None,
1577 semantic_role: None,
1578 }));
1579 }
1580 elements.append(&mut nested_elements);
1582 } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1583 for child in node.children() {
1585 walk_node(
1586 child,
1587 &new_state,
1588 elements,
1589 &new_list_style,
1590 bq_depth,
1591 new_list_depth,
1592 depth + 1,
1593 );
1594 }
1595 } else {
1596 for child in node.children() {
1598 walk_node(
1599 child,
1600 &new_state,
1601 elements,
1602 current_list_style,
1603 bq_depth,
1604 list_depth,
1605 depth + 1,
1606 );
1607 }
1608 }
1609 }
1610 Node::Text(text) => {
1611 let t = text.text.to_string();
1612 let trimmed = t.trim();
1613 if !trimmed.is_empty() {
1614 elements.push(ParsedElement::Block(ParsedBlock {
1616 spans: vec![ParsedSpan {
1617 text: trimmed.to_string(),
1618 bold: state.bold,
1619 italic: state.italic,
1620 underline: state.underline,
1621 strikeout: state.strikeout,
1622 code: state.code,
1623 superscript: state.superscript,
1624 subscript: state.subscript,
1625 link_href: state.link_href.clone(),
1626 image: None,
1627 footnote_ref: None,
1628 }],
1629 heading_level: None,
1630 list_style: None,
1631 list_indent: 0,
1632 list_prefix: String::new(),
1633 list_suffix: String::new(),
1634 marker: None,
1635 is_code_block: false,
1636 code_language: None,
1637 blockquote_depth,
1638 line_height: None,
1639 non_breakable_lines: None,
1640 page_break_before: None,
1641 direction: None,
1642 background_color: None,
1643 alignment: None,
1644 top_margin: None,
1645 text_indent: None,
1646 semantic_role: None,
1647 }));
1648 }
1649 }
1650 _ => {
1651 for child in node.children() {
1653 walk_node(
1654 child,
1655 state,
1656 elements,
1657 current_list_style,
1658 blockquote_depth,
1659 list_depth,
1660 depth + 1,
1661 );
1662 }
1663 }
1664 }
1665 }
1666
1667 #[allow(clippy::too_many_arguments)]
1671 fn collect_inline_spans(
1672 node: ego_tree::NodeRef<Node>,
1673 state: &FmtState,
1674 spans: &mut Vec<ParsedSpan>,
1675 current_list_style: &Option<ListStyle>,
1676 elements: &mut Vec<ParsedElement>,
1677 blockquote_depth: u32,
1678 list_depth: u32,
1679 depth: usize,
1680 ) {
1681 if depth > MAX_RECURSION_DEPTH {
1682 return;
1683 }
1684 for child in node.children() {
1685 match child.value() {
1686 Node::Text(text) => {
1687 let t = text.text.to_string();
1688 if !t.is_empty() {
1689 spans.push(ParsedSpan {
1690 text: t,
1691 bold: state.bold,
1692 italic: state.italic,
1693 underline: state.underline,
1694 strikeout: state.strikeout,
1695 code: state.code,
1696 superscript: state.superscript,
1697 subscript: state.subscript,
1698 link_href: state.link_href.clone(),
1699 image: None,
1700 footnote_ref: None,
1701 });
1702 }
1703 }
1704 Node::Element(el) => {
1705 let tag = el.name();
1706 if is_metadata_tag(tag) {
1707 continue;
1708 }
1709 let mut new_state = state.clone();
1710
1711 match tag {
1712 _ if el.attr(HTML_FOOTNOTE_ATTR).is_some() => {
1716 if let Some(span) = html_footnote_span(el, new_state.link_href.clone())
1717 {
1718 spans.push(span);
1719 }
1720 continue;
1721 }
1722 "b" | "strong" => new_state.bold = true,
1723 "i" | "em" => new_state.italic = true,
1724 "u" | "ins" => new_state.underline = true,
1725 "s" | "del" | "strike" => new_state.strikeout = true,
1726 "code" => new_state.code = true,
1727 "sup" => new_state.superscript = true,
1728 "sub" => new_state.subscript = true,
1729 "a" => {
1730 if let Some(href) = el.attr("href") {
1731 new_state.link_href = Some(href.to_string());
1732 }
1733 }
1734 "img" => {
1735 if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1736 spans.push(span);
1737 }
1738 continue;
1739 }
1740 _ => {}
1741 }
1742
1743 let nested_block = matches!(
1745 tag,
1746 "p" | "div"
1747 | "h1"
1748 | "h2"
1749 | "h3"
1750 | "h4"
1751 | "h5"
1752 | "h6"
1753 | "li"
1754 | "pre"
1755 | "blockquote"
1756 | "ul"
1757 | "ol"
1758 );
1759
1760 if tag == "br" {
1761 spans.push(ParsedSpan {
1764 text: String::new(),
1765 ..Default::default()
1766 });
1767 } else if nested_block || tag == "table" {
1768 walk_node(
1770 child,
1771 &new_state,
1772 elements,
1773 current_list_style,
1774 blockquote_depth,
1775 list_depth,
1776 depth + 1,
1777 );
1778 } else {
1779 collect_inline_spans(
1781 child,
1782 &new_state,
1783 spans,
1784 current_list_style,
1785 elements,
1786 blockquote_depth,
1787 list_depth,
1788 depth + 1,
1789 );
1790 }
1791 }
1792 _ => {}
1793 }
1794 }
1795 }
1796
1797 let initial_state = FmtState::default();
1798 let mut root_spans: Vec<ParsedSpan> = Vec::new();
1802 collect_inline_spans(
1803 *root,
1804 &initial_state,
1805 &mut root_spans,
1806 &None,
1807 &mut elements,
1808 0,
1809 0,
1810 0,
1811 );
1812 collapse_inline_whitespace(&mut root_spans);
1813 if spans_carry_content(&root_spans) {
1816 elements.push(ParsedElement::Block(ParsedBlock {
1817 spans: root_spans,
1818 heading_level: None,
1819 list_style: None,
1820 list_indent: 0,
1821 list_prefix: String::new(),
1822 list_suffix: String::new(),
1823 marker: None,
1824 is_code_block: false,
1825 code_language: None,
1826 blockquote_depth: 0,
1827 line_height: None,
1828 non_breakable_lines: None,
1829 page_break_before: None,
1830 direction: None,
1831 background_color: None,
1832 alignment: None,
1833 top_margin: None,
1834 text_indent: None,
1835 semantic_role: None,
1836 }));
1837 }
1838
1839 if elements.is_empty() {
1841 elements.push(ParsedElement::Block(ParsedBlock {
1842 spans: vec![ParsedSpan {
1843 text: String::new(),
1844 ..Default::default()
1845 }],
1846 heading_level: None,
1847 list_style: None,
1848 list_indent: 0,
1849 list_prefix: String::new(),
1850 list_suffix: String::new(),
1851 marker: None,
1852 is_code_block: false,
1853 code_language: None,
1854 blockquote_depth: 0,
1855 line_height: None,
1856 non_breakable_lines: None,
1857 page_break_before: None,
1858 direction: None,
1859 background_color: None,
1860 alignment: None,
1861 top_margin: None,
1862 text_indent: None,
1863 semantic_role: None,
1864 }));
1865 }
1866
1867 elements
1868}
1869
1870pub fn character_format_from_span(
1874 span: &ParsedSpan,
1875 is_code_block: bool,
1876) -> crate::format_runs::CharacterFormat {
1877 use crate::entities::CharVerticalAlignment;
1878 crate::format_runs::CharacterFormat {
1879 font_bold: if span.bold { Some(true) } else { None },
1880 font_italic: if span.italic { Some(true) } else { None },
1881 font_underline: if span.underline { Some(true) } else { None },
1882 font_strikeout: if span.strikeout { Some(true) } else { None },
1883 font_family: if span.code || is_code_block {
1884 Some("monospace".to_string())
1885 } else {
1886 None
1887 },
1888 anchor_href: span.link_href.clone(),
1889 is_anchor: if span.link_href.is_some() {
1890 Some(true)
1891 } else {
1892 None
1893 },
1894 vertical_alignment: if span.superscript {
1895 Some(CharVerticalAlignment::SuperScript)
1896 } else if span.subscript {
1897 Some(CharVerticalAlignment::SubScript)
1898 } else {
1899 None
1900 },
1901 ..Default::default()
1902 }
1903}
1904
1905pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1917 use crate::format_runs::{
1918 CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1919 };
1920
1921 let mut plain_text = String::new();
1922 let mut runs: Vec<FormatRun> = Vec::new();
1923 let mut images: Vec<ImageAnchor> = Vec::new();
1924 let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1925 let default = CharacterFormat::default();
1926
1927 for span in spans {
1928 let byte_start = plain_text.len() as u32;
1929
1930 if let Some(label) = &span.footnote_ref {
1931 plain_text.push('\u{FFFC}');
1934 let mut format = character_format_from_span(span, is_code_block);
1949 format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1950 footnote_refs.push(FootnoteRefAnchor {
1951 byte_offset: byte_start,
1952 label: label.clone(),
1953 format,
1954 });
1955 continue;
1956 }
1957
1958 if let Some(image) = &span.image {
1959 plain_text.push('\u{FFFC}');
1963 images.push(ImageAnchor {
1964 byte_offset: byte_start,
1965 name: image.src.clone(),
1966 alt: image.alt.clone(),
1967 width: image.width,
1968 height: image.height,
1969 quality: 100,
1970 format: character_format_from_span(span, is_code_block),
1971 });
1972 continue;
1973 }
1974
1975 plain_text.push_str(&span.text);
1976 let byte_end = plain_text.len() as u32;
1977 if byte_start == byte_end {
1978 continue;
1979 }
1980 let format = character_format_from_span(span, is_code_block);
1981 if format == default {
1982 continue;
1983 }
1984 runs.push(FormatRun {
1985 byte_start,
1986 byte_end,
1987 format,
1988 });
1989 }
1990 coalesce_in_place(&mut runs);
1991 ParsedInline {
1992 plain_text,
1993 runs,
1994 images,
1995 footnote_refs,
1996 }
1997}
1998
1999#[derive(Debug, Clone, Default)]
2006pub struct ParsedInline {
2007 pub plain_text: String,
2008 pub runs: Vec<crate::format_runs::FormatRun>,
2009 pub images: Vec<crate::format_runs::ImageAnchor>,
2010 pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
2011}
2012
2013fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
2021 use jotdown::ListBulletType as B;
2022 match b {
2023 B::Dash => ListStyle::Disc,
2024 B::Star => ListStyle::Circle,
2025 B::Plus => ListStyle::Square,
2026 }
2027}
2028
2029fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
2031 use jotdown::OrderedListNumbering as N;
2032 match n {
2033 N::Decimal => ListStyle::Decimal,
2034 N::AlphaLower => ListStyle::LowerAlpha,
2035 N::AlphaUpper => ListStyle::UpperAlpha,
2036 N::RomanLower => ListStyle::LowerRoman,
2037 N::RomanUpper => ListStyle::UpperRoman,
2038 }
2039}
2040
2041fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
2045 use jotdown::OrderedListStyle as S;
2046 match style {
2047 S::Period => (String::new(), ".".to_string()),
2048 S::Paren => (String::new(), ")".to_string()),
2049 S::ParenParen => ("(".to_string(), ")".to_string()),
2050 }
2051}
2052
2053#[derive(Debug, Clone, Default)]
2057struct DjotBlockStyle {
2058 alignment: Option<Alignment>,
2059 line_height: Option<i64>,
2060 non_breakable_lines: Option<bool>,
2061 page_break_before: Option<bool>,
2062 direction: Option<TextDirection>,
2063 background_color: Option<String>,
2064 top_margin: Option<i64>,
2065 text_indent: Option<i64>,
2066 semantic_role: Option<SemanticRole>,
2067}
2068
2069impl DjotBlockStyle {
2070 fn merge_from(&mut self, other: DjotBlockStyle) {
2074 if other.alignment.is_some() {
2075 self.alignment = other.alignment;
2076 }
2077 if other.line_height.is_some() {
2078 self.line_height = other.line_height;
2079 }
2080 if other.non_breakable_lines.is_some() {
2081 self.non_breakable_lines = other.non_breakable_lines;
2082 }
2083 if other.page_break_before.is_some() {
2084 self.page_break_before = other.page_break_before;
2085 }
2086 if other.direction.is_some() {
2087 self.direction = other.direction;
2088 }
2089 if other.background_color.is_some() {
2090 self.background_color = other.background_color;
2091 }
2092 if other.top_margin.is_some() {
2093 self.top_margin = other.top_margin;
2094 }
2095 if other.text_indent.is_some() {
2096 self.text_indent = other.text_indent;
2097 }
2098 if other.semantic_role.is_some() {
2099 self.semantic_role = other.semantic_role.clone();
2100 }
2101 }
2102}
2103
2104fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
2110 let mut style = DjotBlockStyle::default();
2111
2112 if opts.alignment
2113 && let Some(v) = attrs.get_value("alignment")
2114 {
2115 style.alignment = match v.to_string().as_str() {
2116 "left" => Some(Alignment::Left),
2117 "right" => Some(Alignment::Right),
2118 "center" => Some(Alignment::Center),
2119 "justify" => Some(Alignment::Justify),
2120 _ => None,
2121 };
2122 }
2123 if opts.line_height
2124 && let Some(v) = attrs.get_value("line_height")
2125 {
2126 style.line_height = v.to_string().parse::<i64>().ok();
2127 }
2128 if opts.direction
2129 && let Some(v) = attrs.get_value("direction")
2130 {
2131 style.direction = match v.to_string().as_str() {
2132 "ltr" => Some(TextDirection::LeftToRight),
2133 "rtl" => Some(TextDirection::RightToLeft),
2134 _ => None,
2135 };
2136 }
2137 if opts.non_breakable_lines
2138 && let Some(v) = attrs.get_value("non_breakable_lines")
2139 {
2140 style.non_breakable_lines = match v.to_string().as_str() {
2141 "true" => Some(true),
2142 "false" => Some(false),
2143 _ => None,
2144 };
2145 }
2146 if opts.page_break_before
2147 && let Some(v) = attrs.get_value("page_break_before")
2148 {
2149 style.page_break_before = match v.to_string().as_str() {
2150 "true" => Some(true),
2151 "false" => Some(false),
2152 _ => None,
2153 };
2154 }
2155 if opts.background_color
2156 && let Some(v) = attrs.get_value("background_color")
2157 {
2158 style.background_color = Some(v.to_string());
2159 }
2160 if opts.top_margin
2161 && let Some(v) = attrs.get_value("top_margin")
2162 {
2163 style.top_margin = v.to_string().parse::<i64>().ok();
2164 }
2165 if opts.text_indent
2166 && let Some(v) = attrs.get_value("text_indent")
2167 {
2168 style.text_indent = v.to_string().parse::<i64>().ok();
2169 }
2170 if opts.semantic_role
2171 && let Some(v) = attrs.get_value("semantic_role")
2172 {
2173 style.semantic_role = match v.to_string().as_str() {
2174 "epigraph" => Some(SemanticRole::Epigraph),
2175 _ => None,
2179 };
2180 }
2181
2182 style
2183}
2184
2185#[allow(clippy::too_many_arguments)]
2188fn djot_push_block(
2189 elements: &mut Vec<ParsedElement>,
2190 spans: Vec<ParsedSpan>,
2191 heading_level: Option<i64>,
2192 list_style: Option<ListStyle>,
2193 list_indent: u32,
2194 list_prefix: String,
2195 list_suffix: String,
2196 marker: Option<MarkerType>,
2197 is_code_block: bool,
2198 code_language: Option<String>,
2199 blockquote_depth: u32,
2200 style: DjotBlockStyle,
2201) {
2202 elements.push(ParsedElement::Block(ParsedBlock {
2203 spans,
2204 heading_level,
2205 list_style,
2206 list_indent,
2207 list_prefix,
2208 list_suffix,
2209 marker,
2210 is_code_block,
2211 code_language,
2212 blockquote_depth,
2213 line_height: style.line_height,
2214 non_breakable_lines: style.non_breakable_lines,
2215 page_break_before: style.page_break_before,
2216 direction: style.direction,
2217 background_color: style.background_color,
2218 alignment: style.alignment,
2219 top_margin: style.top_margin,
2220 text_indent: style.text_indent,
2221 semantic_role: style.semantic_role.clone(),
2222 }));
2223}
2224
2225pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2246 use jotdown::{Container as C, Event as E, ListKind, Parser};
2247
2248 if crate::parser_tools::djot_depth::is_too_deep(djot) {
2261 return vec![ParsedElement::Block(ParsedBlock {
2262 spans: vec![ParsedSpan {
2263 text: djot.to_string(),
2264 ..Default::default()
2265 }],
2266 ..Default::default()
2267 })];
2268 }
2269
2270 let mut elements: Vec<ParsedElement> = Vec::new();
2271 let mut current_spans: Vec<ParsedSpan> = Vec::new();
2272 let mut current_heading: Option<i64> = None;
2273 let mut is_code_block = false;
2274 let mut code_language: Option<String> = None;
2275 let mut blockquote_depth: u32 = 0;
2276 let mut pending_style = DjotBlockStyle::default();
2279
2280 let mut bold = false;
2282 let mut italic = false;
2283 let mut underline = false;
2284 let mut strikeout = false;
2285 let mut code = false;
2286 let mut superscript = false;
2287 let mut subscript = false;
2288 let mut link_href: Option<String> = None;
2289 let mut pending_image: Option<ParsedImage> = None;
2292
2293 let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2295 let mut cur_list_style: Option<ListStyle> = None;
2297 let mut cur_list_prefix = String::new();
2298 let mut cur_list_suffix = String::new();
2299 let mut cur_list_indent: u32 = 0;
2300 let mut cur_marker: Option<MarkerType> = None;
2301
2302 let mut in_table_cell = false;
2304 let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2305 let mut current_row: Vec<ParsedTableCell> = Vec::new();
2306 let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2307 let mut table_header_rows: usize = 0;
2308 let mut row_is_head = false;
2309
2310 let mut skip_depth: u32 = 0;
2314
2315 let mut footnote_open: Option<(String, usize)> = None;
2319
2320 macro_rules! push_text {
2325 ($t:expr) => {{
2326 if let Some(img) = pending_image.as_mut() {
2331 img.alt.push_str(($t).as_ref());
2332 } else {
2333 let sp = ParsedSpan {
2334 text: ($t).to_string(),
2335 bold,
2336 italic,
2337 underline,
2338 strikeout,
2339 code,
2340 superscript,
2341 subscript,
2342 link_href: link_href.clone(),
2343 image: None,
2344 footnote_ref: None,
2345 };
2346 if in_table_cell {
2347 current_cell_spans.push(sp);
2348 } else {
2349 current_spans.push(sp);
2350 }
2351 }
2352 }};
2353 }
2354
2355 macro_rules! push_image {
2357 ($img:expr) => {{
2358 let sp = ParsedSpan {
2359 text: String::new(),
2360 bold,
2361 italic,
2362 underline,
2363 strikeout,
2364 code,
2365 superscript,
2366 subscript,
2367 link_href: link_href.clone(),
2368 image: Some($img),
2369 footnote_ref: None,
2370 };
2371 if in_table_cell {
2372 current_cell_spans.push(sp);
2373 } else {
2374 current_spans.push(sp);
2375 }
2376 }};
2377 }
2378
2379 macro_rules! enter_item {
2382 ($marker:expr) => {{
2383 if !current_spans.is_empty() {
2384 djot_push_block(
2385 &mut elements,
2386 std::mem::take(&mut current_spans),
2387 None,
2388 cur_list_style.clone(),
2389 cur_list_indent,
2390 cur_list_prefix.clone(),
2391 cur_list_suffix.clone(),
2392 cur_marker.clone(),
2393 false,
2394 None,
2395 blockquote_depth,
2396 DjotBlockStyle::default(),
2397 );
2398 }
2399 let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2400 ListStyle::Disc,
2401 String::new(),
2402 String::new(),
2403 ));
2404 cur_list_style = Some(style);
2405 cur_list_prefix = prefix;
2406 cur_list_suffix = suffix;
2407 cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2408 cur_marker = $marker;
2409 }};
2410 }
2411
2412 for event in Parser::new(djot) {
2413 if skip_depth > 0 {
2414 match event {
2415 E::Start(..) => skip_depth += 1,
2416 E::End(_) => skip_depth -= 1,
2417 _ => {}
2418 }
2419 continue;
2420 }
2421
2422 match event {
2423 E::Start(C::Document, _) | E::End(C::Document) => {}
2425 E::Start(C::Section { .. }, attrs) => {
2426 if list_stack.is_empty() {
2429 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2430 }
2431 }
2432 E::End(C::Section { .. }) => {}
2433 E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2434
2435 E::Start(C::Blockquote, _) => blockquote_depth += 1,
2437 E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2438
2439 E::Start(C::List { kind, .. }, _) => {
2441 let (style, prefix, suffix) = match kind {
2442 ListKind::Unordered(b) | ListKind::Task(b) => {
2443 (djot_bullet_style(b), String::new(), String::new())
2444 }
2445 ListKind::Ordered {
2446 numbering, style, ..
2447 } => {
2448 let (p, s) = djot_ordered_affixes(style);
2449 (djot_ordered_style(numbering), p, s)
2450 }
2451 };
2452 list_stack.push((style, prefix, suffix));
2453 }
2454 E::End(C::List { .. }) => {
2455 list_stack.pop();
2456 cur_list_style = None;
2457 cur_marker = None;
2458 }
2459 E::Start(C::ListItem, _) => enter_item!(None),
2460 E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2461 MarkerType::Checked
2462 } else {
2463 MarkerType::Unchecked
2464 })),
2465 E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2466 if !current_spans.is_empty() {
2468 djot_push_block(
2469 &mut elements,
2470 std::mem::take(&mut current_spans),
2471 None,
2472 cur_list_style.clone(),
2473 cur_list_indent,
2474 cur_list_prefix.clone(),
2475 cur_list_suffix.clone(),
2476 cur_marker.clone(),
2477 false,
2478 None,
2479 blockquote_depth,
2480 DjotBlockStyle::default(),
2481 );
2482 }
2483 cur_list_style = None;
2484 cur_marker = None;
2485 }
2486
2487 E::Start(C::Heading { level, .. }, attrs) => {
2489 current_heading = Some(level as i64);
2490 pending_style.merge_from(block_attrs_to_style(&attrs, options));
2493 }
2494 E::End(C::Heading { .. }) => {
2495 djot_push_block(
2496 &mut elements,
2497 std::mem::take(&mut current_spans),
2498 current_heading.take(),
2499 None,
2500 0,
2501 String::new(),
2502 String::new(),
2503 None,
2504 false,
2505 None,
2506 blockquote_depth,
2507 std::mem::take(&mut pending_style),
2508 );
2509 }
2510 E::Start(C::Paragraph, attrs) => {
2511 current_heading = None;
2512 pending_style = if list_stack.is_empty() {
2516 block_attrs_to_style(&attrs, options)
2517 } else {
2518 DjotBlockStyle::default()
2519 };
2520 }
2521 E::End(C::Paragraph) => {
2522 if !current_spans.is_empty() {
2523 djot_push_block(
2524 &mut elements,
2525 std::mem::take(&mut current_spans),
2526 None,
2527 cur_list_style.clone(),
2528 cur_list_indent,
2529 cur_list_prefix.clone(),
2530 cur_list_suffix.clone(),
2531 cur_marker.clone(),
2532 false,
2533 None,
2534 blockquote_depth,
2535 std::mem::take(&mut pending_style),
2536 );
2537 }
2538 cur_list_style = None;
2539 cur_marker = None;
2540 }
2541 E::Start(C::CodeBlock { language }, _) => {
2542 is_code_block = true;
2543 code_language = if language.is_empty() {
2544 None
2545 } else {
2546 Some(language.to_string())
2547 };
2548 }
2549 E::End(C::CodeBlock { .. }) => {
2550 if let Some(last) = current_spans.last_mut()
2552 && last.text.ends_with('\n')
2553 {
2554 last.text.pop();
2555 }
2556 djot_push_block(
2557 &mut elements,
2558 std::mem::take(&mut current_spans),
2559 None,
2560 None,
2561 0,
2562 String::new(),
2563 String::new(),
2564 None,
2565 true,
2566 code_language.take(),
2567 blockquote_depth,
2568 DjotBlockStyle::default(),
2569 );
2570 is_code_block = false;
2571 }
2572
2573 E::Start(C::Table, _) => {
2575 table_rows.clear();
2576 current_row.clear();
2577 current_cell_spans.clear();
2578 table_header_rows = 0;
2579 }
2580 E::End(C::Table) => {
2581 elements.push(ParsedElement::Table(ParsedTable {
2582 header_rows: table_header_rows,
2583 rows: std::mem::take(&mut table_rows),
2584 blockquote_depth,
2585 }));
2586 }
2587 E::Start(C::TableRow { head }, _) => {
2588 row_is_head = head;
2589 current_row.clear();
2590 }
2591 E::End(C::TableRow { .. }) => {
2592 if row_is_head {
2593 table_header_rows += 1;
2594 }
2595 table_rows.push(std::mem::take(&mut current_row));
2596 }
2597 E::Start(C::TableCell { .. }, _) => {
2598 in_table_cell = true;
2599 current_cell_spans.clear();
2600 }
2601 E::End(C::TableCell { .. }) => {
2602 in_table_cell = false;
2603 current_row.push(ParsedTableCell {
2604 spans: std::mem::take(&mut current_cell_spans),
2605 });
2606 }
2607
2608 E::Start(C::Strong, _) => bold = true,
2610 E::End(C::Strong) => bold = false,
2611 E::Start(C::Emphasis, _) => italic = true,
2612 E::End(C::Emphasis) => italic = false,
2613 E::Start(C::Verbatim, _) => code = true,
2614 E::End(C::Verbatim) => code = false,
2615 E::Start(C::Superscript, _) => superscript = true,
2616 E::End(C::Superscript) => superscript = false,
2617 E::Start(C::Subscript, _) => subscript = true,
2618 E::End(C::Subscript) => subscript = false,
2619 E::Start(C::Insert, _) => underline = true,
2620 E::End(C::Insert) => underline = false,
2621 E::Start(C::Delete, _) => strikeout = true,
2622 E::End(C::Delete) => strikeout = false,
2623 E::Start(C::Mark, _) | E::End(C::Mark) => {}
2625 E::Start(C::Span, _) | E::End(C::Span) => {}
2626 E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2627 E::End(C::Link(..)) => link_href = None,
2628 E::Start(C::Image(src, _), attrs) => {
2633 let attr_num = |key: &str| -> i64 {
2634 attrs
2635 .get_value(key)
2636 .map(|v| v.to_string())
2637 .and_then(|v| v.trim().parse::<i64>().ok())
2638 .filter(|n| *n > 0)
2639 .unwrap_or(0)
2640 };
2641 pending_image = Some(ParsedImage {
2642 src: src.to_string(),
2643 alt: String::new(),
2644 width: attr_num("width"),
2645 height: attr_num("height"),
2646 });
2647 }
2648 E::End(C::Image(..)) => {
2649 if let Some(img) = pending_image.take() {
2650 push_image!(img);
2651 }
2652 }
2653
2654 E::Start(C::Footnote { label }, _) => {
2663 if !current_spans.is_empty() {
2664 djot_push_block(
2665 &mut elements,
2666 std::mem::take(&mut current_spans),
2667 None,
2668 cur_list_style.clone(),
2669 cur_list_indent,
2670 cur_list_prefix.clone(),
2671 cur_list_suffix.clone(),
2672 cur_marker.clone(),
2673 false,
2674 None,
2675 blockquote_depth,
2676 DjotBlockStyle::default(),
2677 );
2678 }
2679 footnote_open = Some((label.to_string(), elements.len()));
2680 }
2681 E::End(C::Footnote { .. }) => {
2682 if !current_spans.is_empty() {
2683 djot_push_block(
2684 &mut elements,
2685 std::mem::take(&mut current_spans),
2686 None,
2687 cur_list_style.clone(),
2688 cur_list_indent,
2689 cur_list_prefix.clone(),
2690 cur_list_suffix.clone(),
2691 cur_marker.clone(),
2692 false,
2693 None,
2694 blockquote_depth,
2695 DjotBlockStyle::default(),
2696 );
2697 }
2698 if let Some((label, start)) = footnote_open.take() {
2699 let blocks: Vec<ParsedBlock> = elements
2700 .drain(start..)
2701 .filter_map(|e| match e {
2702 ParsedElement::Block(b) => Some(b),
2703 _ => None,
2707 })
2708 .collect();
2709 elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2710 }
2711 }
2712
2713 E::Start(
2715 C::Math { .. }
2716 | C::RawBlock { .. }
2717 | C::RawInline { .. }
2718 | C::DescriptionList
2719 | C::DescriptionDetails
2720 | C::DescriptionTerm
2721 | C::Caption
2722 | C::LinkDefinition { .. },
2723 _,
2724 ) => skip_depth = 1,
2725
2726 E::Str(s) => push_text!(s.as_ref()),
2728 E::Softbreak => push_text!(" "),
2729 E::LeftSingleQuote => push_text!("\u{2018}"),
2730 E::RightSingleQuote => push_text!("\u{2019}"),
2731 E::LeftDoubleQuote => push_text!("\u{201C}"),
2732 E::RightDoubleQuote => push_text!("\u{201D}"),
2733 E::Ellipsis => push_text!("\u{2026}"),
2734 E::EnDash => push_text!("\u{2013}"),
2735 E::EmDash => push_text!("\u{2014}"),
2736 E::NonBreakingSpace => push_text!("\u{00A0}"),
2737 E::Hardbreak => {
2738 if in_table_cell {
2739 push_text!(" ");
2740 } else if !current_spans.is_empty() {
2741 djot_push_block(
2744 &mut elements,
2745 std::mem::take(&mut current_spans),
2746 None,
2747 cur_list_style.clone(),
2748 cur_list_indent,
2749 cur_list_prefix.clone(),
2750 cur_list_suffix.clone(),
2751 cur_marker.clone(),
2752 is_code_block,
2753 code_language.clone(),
2754 blockquote_depth,
2755 pending_style.clone(),
2756 );
2757 }
2758 }
2759 E::FootnoteReference(label) => {
2765 let sp = ParsedSpan {
2766 text: String::new(),
2767 bold,
2768 italic,
2769 underline,
2770 strikeout,
2771 code,
2772 superscript,
2773 subscript,
2774 link_href: link_href.clone(),
2775 image: None,
2776 footnote_ref: Some(label.to_string()),
2777 };
2778 if in_table_cell {
2779 current_cell_spans.push(sp);
2780 } else {
2781 current_spans.push(sp);
2782 }
2783 }
2784 E::Symbol(_) => {}
2787 E::Escape | E::Blankline => {}
2788 E::ThematicBreak(_) | E::Attributes(_) => {}
2789
2790 _ => {}
2793 }
2794 }
2795
2796 if !current_spans.is_empty() {
2798 djot_push_block(
2799 &mut elements,
2800 std::mem::take(&mut current_spans),
2801 current_heading.take(),
2802 cur_list_style.clone(),
2803 cur_list_indent,
2804 cur_list_prefix.clone(),
2805 cur_list_suffix.clone(),
2806 cur_marker.clone(),
2807 is_code_block,
2808 code_language.take(),
2809 blockquote_depth,
2810 std::mem::take(&mut pending_style),
2811 );
2812 }
2813
2814 if elements.is_empty() {
2817 djot_push_block(
2818 &mut elements,
2819 vec![ParsedSpan {
2820 text: String::new(),
2821 ..Default::default()
2822 }],
2823 None,
2824 None,
2825 0,
2826 String::new(),
2827 String::new(),
2828 None,
2829 false,
2830 None,
2831 0,
2832 DjotBlockStyle::default(),
2833 );
2834 }
2835
2836 elements
2837}
2838
2839#[cfg(test)]
2840mod tests {
2841 use super::*;
2842
2843 fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2845 ParsedElement::flatten_to_blocks(parse_markdown(md))
2846 }
2847
2848 #[test]
2849 fn test_parse_markdown_simple_paragraph() {
2850 let blocks = parse_markdown_blocks("Hello **world**");
2851 assert_eq!(blocks.len(), 1);
2852 assert!(blocks[0].spans.len() >= 2);
2853 let plain_span = blocks[0]
2855 .spans
2856 .iter()
2857 .find(|s| s.text.contains("Hello"))
2858 .unwrap();
2859 assert!(!plain_span.bold);
2860 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2861 assert!(bold_span.bold);
2862 }
2863
2864 #[test]
2865 fn test_parse_markdown_heading() {
2866 let blocks = parse_markdown_blocks("# Title");
2867 assert_eq!(blocks.len(), 1);
2868 assert_eq!(blocks[0].heading_level, Some(1));
2869 assert_eq!(blocks[0].spans[0].text, "Title");
2870 }
2871
2872 #[test]
2873 fn test_parse_markdown_list() {
2874 let blocks = parse_markdown_blocks("- item1\n- item2");
2875 assert!(blocks.len() >= 2);
2876 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2877 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2878 }
2879
2880 fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2882 elements
2883 .iter()
2884 .map(|e| match e {
2885 ParsedElement::Block(b) => (false, b.blockquote_depth),
2886 ParsedElement::Table(t) => (true, t.blockquote_depth),
2887 ParsedElement::FootnoteDefinition { .. } => (false, 0),
2890 })
2891 .collect()
2892 }
2893
2894 #[test]
2895 fn test_parse_markdown_table_in_blockquote_records_depth() {
2896 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2897 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2898 }
2899
2900 #[test]
2901 fn test_parse_markdown_text_then_table_in_blockquote() {
2902 let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2903 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2904 }
2905
2906 #[test]
2907 fn test_parse_markdown_table_after_blockquote_closes() {
2908 let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2909 assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2910 }
2911
2912 #[test]
2913 fn test_parse_markdown_table_in_nested_blockquote() {
2914 let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2915 assert_eq!(element_depths(&elements), vec![(true, 2)]);
2916 }
2917
2918 #[test]
2919 fn test_parse_markdown_list_in_blockquote_records_depth() {
2920 let elements = parse_markdown("> - item1\n> - item2");
2921 let depths = element_depths(&elements);
2922 assert_eq!(depths, vec![(false, 1), (false, 1)]);
2923 for e in &elements {
2924 if let ParsedElement::Block(b) = e {
2925 assert_eq!(b.list_style, Some(ListStyle::Disc));
2926 }
2927 }
2928 }
2929
2930 #[test]
2931 fn test_parse_html_table_in_blockquote_records_depth() {
2932 let elements = parse_html_elements(
2933 "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2934 );
2935 assert_eq!(element_depths(&elements), vec![(true, 1)]);
2936 }
2937
2938 #[test]
2939 fn test_parse_html_table_after_blockquote() {
2940 let elements = parse_html_elements(
2941 "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2942 );
2943 let depths = element_depths(&elements);
2944 assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2946 assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2947 }
2948
2949 #[test]
2950 fn test_flatten_to_blocks_propagates_blockquote_depth() {
2951 let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2952 let blocks = ParsedElement::flatten_to_blocks(elements);
2953 assert!(!blocks.is_empty());
2954 for b in &blocks {
2955 assert_eq!(b.blockquote_depth, 1);
2956 }
2957 }
2958
2959 #[test]
2960 fn test_parse_html_simple() {
2961 let blocks = parse_html("<p>Hello <b>world</b></p>");
2962 assert_eq!(blocks.len(), 1);
2963 assert!(blocks[0].spans.len() >= 2);
2964 let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2965 assert!(bold_span.bold);
2966 }
2967
2968 #[test]
2969 fn test_parse_html_multiple_paragraphs() {
2970 let blocks = parse_html("<p>A</p><p>B</p>");
2971 assert_eq!(blocks.len(), 2);
2972 }
2973
2974 #[test]
2975 fn test_parse_html_heading() {
2976 let blocks = parse_html("<h2>Subtitle</h2>");
2977 assert_eq!(blocks.len(), 1);
2978 assert_eq!(blocks[0].heading_level, Some(2));
2979 }
2980
2981 #[test]
2982 fn test_parse_html_list() {
2983 let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2984 assert!(blocks.len() >= 2);
2985 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2986 }
2987
2988 #[test]
2989 fn test_parse_markdown_code_block() {
2990 let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2991 assert_eq!(blocks.len(), 1);
2992 assert!(blocks[0].is_code_block);
2993 assert!(blocks[0].spans[0].code);
2994 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2996 assert_eq!(
2997 text, "fn main() {}",
2998 "code block text should not have trailing newline"
2999 );
3000 }
3001
3002 #[test]
3003 fn test_parse_markdown_nested_formatting() {
3004 let blocks = parse_markdown_blocks("***bold italic***");
3005 assert_eq!(blocks.len(), 1);
3006 let span = &blocks[0].spans[0];
3007 assert!(span.bold);
3008 assert!(span.italic);
3009 }
3010
3011 #[test]
3012 fn test_parse_markdown_link() {
3013 let blocks = parse_markdown_blocks("[click](http://example.com)");
3014 assert_eq!(blocks.len(), 1);
3015 let span = &blocks[0].spans[0];
3016 assert_eq!(span.text, "click");
3017 assert_eq!(span.link_href, Some("http://example.com".to_string()));
3018 }
3019
3020 #[test]
3021 fn test_parse_markdown_empty() {
3022 let blocks = parse_markdown_blocks("");
3023 assert_eq!(blocks.len(), 1);
3024 assert!(blocks[0].spans[0].text.is_empty());
3025 }
3026
3027 #[test]
3028 fn test_parse_html_empty() {
3029 let blocks = parse_html("");
3030 assert_eq!(blocks.len(), 1);
3031 assert!(blocks[0].spans[0].text.is_empty());
3032 }
3033
3034 #[test]
3035 fn test_parse_html_nested_formatting() {
3036 let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
3037 assert_eq!(blocks.len(), 1);
3038 let span = &blocks[0].spans[0];
3039 assert!(span.bold);
3040 assert!(span.italic);
3041 }
3042
3043 #[test]
3044 fn test_parse_html_link() {
3045 let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
3046 assert_eq!(blocks.len(), 1);
3047 let span = &blocks[0].spans[0];
3048 assert_eq!(span.text, "click");
3049 assert_eq!(span.link_href, Some("http://example.com".to_string()));
3050 }
3051
3052 #[test]
3053 fn test_parse_html_ordered_list() {
3054 let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
3055 assert!(blocks.len() >= 2);
3056 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
3057 }
3058
3059 #[test]
3060 fn test_parse_markdown_ordered_list() {
3061 let blocks = parse_markdown_blocks("1. first\n2. second");
3062 assert!(blocks.len() >= 2);
3063 assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
3064 }
3065
3066 #[test]
3067 fn test_parse_html_blockquote_nested() {
3068 let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
3069 assert!(blocks.len() >= 3);
3070 }
3071
3072 #[test]
3073 fn test_parse_block_styles_line_height() {
3074 let styles = parse_block_styles("line-height: 1.5");
3075 assert_eq!(styles.line_height, Some(1500));
3076 }
3077
3078 #[test]
3079 fn test_parse_block_styles_direction_rtl() {
3080 let styles = parse_block_styles("direction: rtl");
3081 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
3082 }
3083
3084 #[test]
3085 fn test_parse_block_styles_background_color() {
3086 let styles = parse_block_styles("background-color: #ff0000");
3087 assert_eq!(styles.background_color, Some("#ff0000".to_string()));
3088 }
3089
3090 #[test]
3091 fn test_parse_block_styles_white_space_pre() {
3092 let styles = parse_block_styles("white-space: pre");
3093 assert_eq!(styles.non_breakable_lines, Some(true));
3094 }
3095
3096 #[test]
3097 fn test_parse_block_styles_multiple() {
3098 let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
3099 assert_eq!(styles.line_height, Some(2000));
3100 assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
3101 assert_eq!(styles.background_color, Some("blue".to_string()));
3102 }
3103
3104 #[test]
3105 fn test_parse_html_block_styles_extracted() {
3106 let blocks = parse_html(
3107 r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
3108 );
3109 assert_eq!(blocks.len(), 1);
3110 assert_eq!(blocks[0].line_height, Some(1500));
3111 assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
3112 assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
3113 }
3114
3115 #[test]
3119 fn test_parse_html_collapses_exporter_line_wrapping() {
3120 let blocks = parse_html(
3121 "<body>\n<p style=\"line-height: 200%\">\nfirst line wrapped\nhere</p>\n\
3122 <p>\nsecond para</p>\n</body>",
3123 );
3124 let texts: Vec<String> = blocks
3125 .iter()
3126 .map(|b| b.spans.iter().map(|s| s.text.as_str()).collect())
3127 .collect();
3128 assert_eq!(texts, vec!["first line wrapped here", "second para"]);
3129 }
3130
3131 #[test]
3135 fn test_parse_html_drops_whitespace_between_blocks() {
3136 let blocks = parse_html("<html>\n<body>\n<p>a</p>\n<p>b</p>\n</body>\n</html>");
3137 assert_eq!(blocks.len(), 2, "no block for the whitespace between them");
3138 assert!(
3139 blocks
3140 .iter()
3141 .all(|b| b.spans.iter().all(|s| !s.text.contains('\n'))),
3142 "no block keeps a literal newline"
3143 );
3144 }
3145
3146 #[test]
3149 fn test_parse_html_collapses_across_span_boundaries() {
3150 let blocks = parse_html("<p> <b>bold</b>\n <i>italic</i> </p>");
3151 assert_eq!(blocks.len(), 1);
3152 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3153 assert_eq!(text, "bold italic");
3154 }
3155
3156 #[test]
3159 fn test_parse_html_keeps_no_break_space() {
3160 let blocks = parse_html("<p>\nAttention : ici</p>");
3161 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3162 assert_eq!(text, "Attention\u{a0}: ici");
3163 }
3164
3165 #[test]
3168 fn test_parse_html_keeps_empty_paragraph() {
3169 let blocks = parse_html("<body>\n<p>a</p>\n<p> </p>\n<p>b</p>\n</body>");
3170 assert_eq!(blocks.len(), 3);
3171 let text: String = blocks[1].spans.iter().map(|s| s.text.as_str()).collect();
3172 assert_eq!(text, "");
3173 }
3174
3175 #[test]
3179 fn test_parse_html_preserves_whitespace_in_pre() {
3180 let blocks = parse_html("<pre>\nfn main() {\n let x = 1;\n}</pre>");
3183 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3184 assert_eq!(text, "fn main() {\n let x = 1;\n}");
3185
3186 let styled = parse_html("<p style=\"white-space: pre-wrap\">a\n b</p>");
3187 let text: String = styled[0].spans.iter().map(|s| s.text.as_str()).collect();
3188 assert_eq!(text, "a\n b");
3189
3190 let nowrap = parse_html("<p style=\"white-space: nowrap\">a\n b</p>");
3191 let text: String = nowrap[0].spans.iter().map(|s| s.text.as_str()).collect();
3192 assert_eq!(text, "a b");
3193 }
3194
3195 #[test]
3198 fn test_parse_html_collapses_table_cell_whitespace() {
3199 let elements = parse_html_elements(
3200 "<table>\n<tr>\n<td>\n one\n</td>\n<td>\n two\n</td>\n</tr>\n</table>",
3201 );
3202 let table = elements
3203 .iter()
3204 .find_map(|e| match e {
3205 ParsedElement::Table(t) => Some(t),
3206 _ => None,
3207 })
3208 .expect("a table");
3209 let cells: Vec<String> = table.rows[0]
3210 .iter()
3211 .map(|c| c.spans.iter().map(|s| s.text.as_str()).collect())
3212 .collect();
3213 assert_eq!(cells, vec!["one", "two"]);
3214 }
3215
3216 #[test]
3217 fn test_parse_html_white_space_pre() {
3218 let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
3219 assert_eq!(blocks.len(), 1);
3220 assert_eq!(blocks[0].non_breakable_lines, Some(true));
3221 }
3222
3223 #[test]
3224 fn test_parse_html_no_styles_returns_none() {
3225 let blocks = parse_html("<p>plain</p>");
3226 assert_eq!(blocks.len(), 1);
3227 assert_eq!(blocks[0].line_height, None);
3228 assert_eq!(blocks[0].direction, None);
3229 assert_eq!(blocks[0].background_color, None);
3230 assert_eq!(blocks[0].non_breakable_lines, None);
3231 }
3232
3233 #[test]
3234 fn test_parse_markdown_nested_list_indent() {
3235 let md = "- top\n - nested\n - deep";
3236 let blocks = parse_markdown_blocks(md);
3237 assert_eq!(blocks.len(), 3);
3238 assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
3239 assert_eq!(blocks[0].list_indent, 0);
3240 assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
3241 assert_eq!(blocks[1].list_indent, 1);
3242 assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
3243 assert_eq!(blocks[2].list_indent, 2);
3244 }
3245
3246 #[test]
3247 fn test_parse_markdown_nested_ordered_list_indent() {
3248 let md = "1. first\n 1. nested\n 2. nested2";
3249 let blocks = parse_markdown_blocks(md);
3250 assert_eq!(blocks.len(), 3);
3251 assert_eq!(blocks[0].list_indent, 0);
3252 assert_eq!(blocks[1].list_indent, 1);
3253 assert_eq!(blocks[2].list_indent, 1);
3254 }
3255
3256 #[test]
3257 fn test_parse_html_nested_list_indent() {
3258 let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
3259 let blocks = parse_html(html);
3260 assert!(blocks.len() >= 2);
3261 assert_eq!(blocks[0].list_indent, 0);
3262 assert_eq!(blocks[1].list_indent, 1);
3263 }
3264
3265 #[test]
3266 fn test_parse_markdown_table() {
3267 let md = "| A | B |\n|---|---|\n| 1 | 2 |";
3268 let elements = parse_markdown(md);
3269 assert_eq!(elements.len(), 1);
3270 match &elements[0] {
3271 ParsedElement::Table(table) => {
3272 assert_eq!(table.header_rows, 1);
3273 assert_eq!(table.rows.len(), 2); assert_eq!(table.rows[0].len(), 2);
3276 assert_eq!(table.rows[0][0].spans[0].text, "A");
3277 assert_eq!(table.rows[0][1].spans[0].text, "B");
3278 assert_eq!(table.rows[1].len(), 2);
3280 assert_eq!(table.rows[1][0].spans[0].text, "1");
3281 assert_eq!(table.rows[1][1].spans[0].text, "2");
3282 }
3283 _ => panic!("Expected ParsedElement::Table"),
3284 }
3285 }
3286
3287 #[test]
3288 fn test_parse_markdown_table_with_formatting() {
3289 let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
3290 let elements = parse_markdown(md);
3291 assert_eq!(elements.len(), 1);
3292 match &elements[0] {
3293 ParsedElement::Table(table) => {
3294 assert_eq!(table.rows.len(), 2);
3295 assert!(table.rows[0][0].spans[0].bold);
3297 assert!(table.rows[0][1].spans[0].code);
3299 assert!(table.rows[0][2].spans[0].italic);
3301 assert!(table.rows[1][0].spans[0].strikeout);
3303 assert_eq!(
3305 table.rows[1][2].spans[0].link_href,
3306 Some("http://x.com".to_string())
3307 );
3308 }
3309 _ => panic!("Expected ParsedElement::Table"),
3310 }
3311 }
3312
3313 #[test]
3314 fn test_parse_markdown_mixed_content_with_table() {
3315 let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
3316 let elements = parse_markdown(md);
3317 assert_eq!(elements.len(), 3);
3318 assert!(matches!(&elements[0], ParsedElement::Block(_)));
3319 assert!(matches!(&elements[1], ParsedElement::Table(_)));
3320 assert!(matches!(&elements[2], ParsedElement::Block(_)));
3321 }
3322}
3323
3324#[cfg(test)]
3325mod djot_tests {
3326 use super::*;
3327 use crate::entities::MarkerType;
3328
3329 fn blocks(d: &str) -> Vec<ParsedBlock> {
3330 ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
3331 }
3332
3333 fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
3334 b.spans.iter().find(|s| pred(s)).expect("span not found")
3335 }
3336
3337 #[test]
3338 fn paragraph_bold_italic() {
3339 let b = blocks("normal *bold* _italic_");
3340 assert_eq!(b.len(), 1);
3341 assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
3342 assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
3343 }
3344
3345 #[test]
3346 fn heading_levels() {
3347 assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3348 assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3349 assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3350 }
3351
3352 #[test]
3353 fn unordered_bullet_styles_are_distinct() {
3354 assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3355 assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3356 assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3357 }
3358
3359 #[test]
3360 fn ordered_delimiters() {
3361 let period = blocks("1. a");
3362 assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3363 assert_eq!(period[0].list_prefix, "");
3364 assert_eq!(period[0].list_suffix, ".");
3365
3366 let paren = blocks("1) a");
3367 assert_eq!(paren[0].list_suffix, ")");
3368 assert_eq!(paren[0].list_prefix, "");
3369
3370 let paren_paren = blocks("(1) a");
3371 assert_eq!(paren_paren[0].list_prefix, "(");
3372 assert_eq!(paren_paren[0].list_suffix, ")");
3373 }
3374
3375 #[test]
3376 fn task_list_markers() {
3377 let b = blocks("- [ ] a\n- [x] b");
3378 assert_eq!(b.len(), 2);
3379 assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3380 assert_eq!(b[1].marker, Some(MarkerType::Checked));
3381 }
3382
3383 #[test]
3384 fn code_block_with_language() {
3385 let b = blocks("```rust\nfn main() {}\n```");
3386 assert_eq!(b.len(), 1);
3387 assert!(b[0].is_code_block);
3388 assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3389 let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3390 assert_eq!(text, "fn main() {}");
3391 }
3392
3393 #[test]
3394 fn link_href() {
3395 let b = blocks("[text](http://example.com)");
3396 let s = first_span_with(&b[0], |s| s.text == "text");
3397 assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3398 }
3399
3400 #[test]
3401 fn superscript_subscript() {
3402 assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3403 assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3404 }
3405
3406 #[test]
3407 fn delete_insert_verbatim() {
3408 assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3409 assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3410 assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3411 }
3412
3413 #[test]
3414 fn blockquote_depth() {
3415 let els = parse_djot("> quoted", &DjotImportOptions::default());
3416 match &els[0] {
3417 ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3418 _ => panic!("expected block"),
3419 }
3420 }
3421
3422 #[test]
3423 fn nested_list_indent() {
3424 let b = blocks("- a\n\n - b\n\n - c");
3429 assert_eq!(b.len(), 3);
3430 assert_eq!(b[0].list_indent, 0);
3431 assert_eq!(b[1].list_indent, 1);
3432 assert_eq!(b[2].list_indent, 2);
3433 }
3434
3435 #[test]
3436 fn table_parsed_as_table() {
3437 let els = parse_djot(
3438 "| a | b |\n|---|---|\n| c | d |",
3439 &DjotImportOptions::default(),
3440 );
3441 assert_eq!(els.len(), 1);
3442 match &els[0] {
3443 ParsedElement::Table(t) => {
3444 assert_eq!(t.header_rows, 1);
3445 assert_eq!(t.rows.len(), 2);
3446 assert_eq!(t.rows[0][0].spans[0].text, "a");
3447 assert_eq!(t.rows[1][1].spans[0].text, "d");
3448 }
3449 _ => panic!("expected table"),
3450 }
3451 }
3452
3453 #[test]
3454 fn smart_punctuation_normalised_to_unicode() {
3455 let text: String = blocks("a... b---c")[0]
3456 .spans
3457 .iter()
3458 .map(|s| s.text.as_str())
3459 .collect();
3460 assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3461 assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3462 }
3463
3464 #[test]
3465 fn unrepresentable_constructs_dropped_without_leaking_text() {
3466 let b = blocks("para1\n\n---\n\npara2");
3468 assert_eq!(b.len(), 2);
3469 assert_eq!(
3470 b[0].spans
3471 .iter()
3472 .map(|s| s.text.as_str())
3473 .collect::<String>(),
3474 "para1"
3475 );
3476 assert_eq!(
3477 b[1].spans
3478 .iter()
3479 .map(|s| s.text.as_str())
3480 .collect::<String>(),
3481 "para2"
3482 );
3483
3484 let d = blocks(":::\ninside\n:::");
3486 let joined: String = d
3487 .iter()
3488 .flat_map(|b| b.spans.iter())
3489 .map(|s| s.text.as_str())
3490 .collect();
3491 assert_eq!(joined, "inside");
3492
3493 let m = blocks("before $`E=mc^2` after");
3495 let joined: String = m
3496 .iter()
3497 .flat_map(|b| b.spans.iter())
3498 .map(|s| s.text.as_str())
3499 .collect();
3500 assert!(joined.contains("before"), "{joined:?}");
3501 assert!(joined.contains("after"), "{joined:?}");
3502 assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3503 }
3504
3505 #[test]
3506 fn empty_document_yields_one_empty_block() {
3507 let b = blocks("");
3508 assert_eq!(b.len(), 1);
3509 assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3510 }
3511
3512 #[test]
3513 fn block_attributes_parse_into_block() {
3514 let b = blocks(
3515 "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3516 );
3517 assert_eq!(b.len(), 1);
3518 assert_eq!(b[0].alignment, Some(Alignment::Center));
3519 assert_eq!(b[0].line_height, Some(1500));
3520 assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3521 assert_eq!(b[0].non_breakable_lines, Some(true));
3522 assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3523 }
3524
3525 #[test]
3526 fn spacing_block_attributes_parse_into_block() {
3527 let b = blocks("{top_margin=24 text_indent=0}\nhello");
3531 assert_eq!(b.len(), 1);
3532 assert_eq!(b[0].top_margin, Some(24));
3533 assert_eq!(b[0].text_indent, Some(0));
3534 }
3535
3536 #[test]
3537 fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3538 let explicit = blocks("{text_indent=0}\nhello");
3542 let absent = blocks("hello");
3543 assert_eq!(explicit[0].text_indent, Some(0));
3544 assert_eq!(absent[0].text_indent, None);
3545 assert!(!explicit[0].is_inline_only());
3546 assert!(absent[0].is_inline_only());
3547 }
3548
3549 #[test]
3550 fn spacing_block_attributes_respect_import_options() {
3551 let src = "{top_margin=24 text_indent=0}\nhello";
3552 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3553 assert_eq!(b[0].top_margin, None);
3554 assert_eq!(b[0].text_indent, None);
3555 }
3556
3557 #[test]
3558 fn block_attributes_on_heading() {
3559 let b = blocks("{alignment=right}\n# Title");
3560 assert_eq!(b[0].heading_level, Some(1));
3561 assert_eq!(b[0].alignment, Some(Alignment::Right));
3562 }
3563
3564 #[test]
3565 fn block_attributes_respect_import_options() {
3566 let src = "{alignment=center line_height=1500}\nhello";
3569 let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3570 assert_eq!(b[0].alignment, None);
3571 assert_eq!(b[0].line_height, None);
3572 assert_eq!(
3573 b[0].spans
3574 .iter()
3575 .map(|s| s.text.as_str())
3576 .collect::<String>(),
3577 "hello"
3578 );
3579 }
3580
3581 #[test]
3582 fn list_item_block_attributes_are_dropped() {
3583 let b = blocks("{alignment=center}\n- item");
3586 assert!(b.iter().all(|blk| blk.alignment.is_none()));
3587 }
3588
3589 #[test]
3590 fn unknown_alignment_value_is_ignored() {
3591 let b = blocks("{alignment=sideways}\nhello");
3592 assert_eq!(b[0].alignment, None);
3593 }
3594}
3595
3596pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3608
3609fn span_prose(span: &ParsedSpan, out: &mut String) {
3617 if span.image.is_some() || span.footnote_ref.is_some() {
3618 out.push('\u{FFFC}');
3619 return;
3620 }
3621 out.push_str(&span.text);
3622}
3623
3624fn block_prose(block: &ParsedBlock) -> String {
3625 let mut prose = String::new();
3626 for span in &block.spans {
3627 span_prose(span, &mut prose);
3628 }
3629 prose
3630}
3631
3632fn cell_prose(cell: &ParsedTableCell) -> String {
3633 let mut prose = String::new();
3634 for span in &cell.spans {
3635 span_prose(span, &mut prose);
3636 }
3637 prose
3638}
3639
3640pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3689 let elements = parse_djot(djot, options);
3693
3694 let mut out = String::with_capacity(djot.len());
3697
3698 let mut first = true;
3703 let push = |text: &str, out: &mut String, first: &mut bool| {
3704 if *first {
3705 *first = false;
3706 } else {
3707 out.push('\n');
3708 }
3709 out.push_str(text);
3710 };
3711
3712 for element in &elements {
3713 match element {
3714 ParsedElement::Block(block) => {
3715 push(&block_prose(block), &mut out, &mut first);
3716 }
3717 ParsedElement::FootnoteDefinition { .. } => {}
3727 ParsedElement::Table(table) => {
3728 push(TABLE_ANCHOR, &mut out, &mut first);
3735 for row in &table.rows {
3736 for cell in row {
3737 push(&cell_prose(cell), &mut out, &mut first);
3738 }
3739 }
3740 }
3741 }
3742 }
3743 out
3744}
3745
3746#[cfg(test)]
3747mod html_footnote_tests {
3748 use super::*;
3749
3750 #[test]
3754 fn a_literal_reference_in_text_would_be_escaped_into_prose() {
3755 let blocks = parse_html("<p>The ferry.[^1]</p>");
3756 let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
3757 assert!(
3758 text.contains("[^1]"),
3759 "the parser keeps it as characters: {text:?}"
3760 );
3761 assert!(
3762 blocks[0].spans.iter().all(|s| s.footnote_ref.is_none()),
3763 "text is text — it must not become a reference by accident"
3764 );
3765 }
3766
3767 #[test]
3768 fn an_attributed_element_becomes_a_real_footnote_reference() {
3769 let blocks = parse_html(
3770 r#"<p>The ferry was late.<sup data-footnote-ref="3"></sup> She waited.</p>"#,
3771 );
3772 let refs: Vec<&str> = blocks
3773 .iter()
3774 .flat_map(|b| b.spans.iter())
3775 .filter_map(|s| s.footnote_ref.as_deref())
3776 .collect();
3777 assert_eq!(refs, vec!["3"]);
3778
3779 let marker = blocks[0]
3782 .spans
3783 .iter()
3784 .find(|s| s.footnote_ref.is_some())
3785 .expect("the reference span");
3786 assert!(marker.text.is_empty(), "{marker:?}");
3787 }
3788
3789 #[test]
3792 fn any_element_carrying_the_attribute_works() {
3793 for html in [
3794 r#"<p>a<sup data-footnote-ref="1"></sup></p>"#,
3795 r#"<p>a<span data-footnote-ref="1"></span></p>"#,
3796 r##"<p>a<a data-footnote-ref="1" href="#fn1"></a></p>"##,
3797 ] {
3798 let blocks = parse_html(html);
3799 assert_eq!(
3800 blocks
3801 .iter()
3802 .flat_map(|b| b.spans.iter())
3803 .filter_map(|s| s.footnote_ref.as_deref())
3804 .collect::<Vec<_>>(),
3805 vec!["1"],
3806 "failed for {html}"
3807 );
3808 }
3809 }
3810
3811 #[test]
3812 fn an_empty_or_absent_label_is_not_a_reference() {
3813 for html in [
3814 r#"<p>a<sup data-footnote-ref=""></sup></p>"#,
3815 r#"<p>a<sup></sup></p>"#,
3816 ] {
3817 let blocks = parse_html(html);
3818 assert!(
3819 blocks
3820 .iter()
3821 .flat_map(|b| b.spans.iter())
3822 .all(|s| s.footnote_ref.is_none()),
3823 "failed for {html}"
3824 );
3825 }
3826 }
3827}