1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{Parser, attributes::Attrlist, parser::ResolvedReference};
6
7pub trait InlineSubstitutionRenderer: Debug {
14 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String);
18
19 fn render_quoted_substitition(
25 &self,
26 type_: QuoteType,
27 scope: QuoteScope,
28 attrlist: Option<Attrlist<'_>>,
29 id: Option<String>,
30 body: &str,
31 dest: &mut String,
32 );
33
34 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String);
40
41 fn render_line_break(&self, dest: &mut String);
50
51 fn render_image(&self, params: &ImageRenderParams, dest: &mut String);
56
57 fn image_uri(
84 &self,
85 target_image_path: &str,
86 parser: &Parser,
87 asset_dir_key: Option<&str>,
88 ) -> String;
89
90 fn render_icon(&self, params: &IconRenderParams, dest: &mut String);
95
96 fn icon_uri(&self, name: &str, _attrlist: &Attrlist, parser: &Parser) -> String {
111 let icontype = parser
112 .attribute_value("icontype")
113 .as_maybe_str()
114 .unwrap_or("png")
115 .to_owned();
116
117 if false {
118 todo!(
119 "Enable this when doing block-related icon attributes: {}",
120 r#"
121 let icon = if let Some(icon) = attrlist.named_attribute("icon") {
122 let icon_str = icon.value();
123 if has_extname(icon_str) {
124 icon_str.to_string()
125 } else {
126 format!("{icon_str}.{icontype}")
127 }
128 } else {
129 // This part is defaulted for now.
130 format!("{name}.{icontype}")
131 };
132 "#
133 );
134 }
135
136 let icon = format!("{name}.{icontype}");
137
138 self.image_uri(&icon, parser, Some("iconsdir"))
139 }
140
141 fn render_link(&self, params: &LinkRenderParams, dest: &mut String);
146
147 fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String);
152
153 fn render_xref(&self, params: &XrefRenderParams, dest: &mut String);
160
161 fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String);
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum SpecialCharacter {
175 Lt,
177
178 Gt,
180
181 Ampersand,
183}
184
185#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189pub enum QuoteType {
190 Strong,
192
193 DoubleQuote,
195
196 SingleQuote,
198
199 Monospaced,
201
202 Emphasis,
204
205 Mark,
207
208 Superscript,
210
211 Subscript,
213
214 Unquoted,
216
217 AsciiMath,
219
220 LatexMath,
223}
224
225#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum QuoteScope {
228 Constrained,
230
231 Unconstrained,
233}
234
235#[derive(Clone, Debug, Eq, PartialEq)]
239pub enum CharacterReplacementType {
240 Copyright,
242
243 Registered,
245
246 Trademark,
248
249 EmDashSurroundedBySpaces,
251
252 EmDashWithoutSpace,
254
255 Ellipsis,
257
258 SingleRightArrow,
260
261 DoubleRightArrow,
263
264 SingleLeftArrow,
266
267 DoubleLeftArrow,
269
270 TypographicApostrophe,
272
273 CharacterReference(String),
275}
276
277#[derive(Clone, Debug)]
279pub struct ImageRenderParams<'a> {
280 pub target: &'a str,
282
283 pub alt: String,
285
286 pub width: Option<&'a str>,
288
289 pub height: Option<&'a str>,
291
292 pub attrlist: &'a Attrlist<'a>,
294
295 pub parser: &'a Parser,
298}
299
300#[derive(Clone, Debug)]
302pub struct IconRenderParams<'a> {
303 pub target: &'a str,
305
306 pub alt: String,
308
309 pub size: Option<&'a str>,
311
312 pub attrlist: &'a Attrlist<'a>,
314
315 pub parser: &'a Parser,
318}
319
320#[derive(Clone, Debug)]
322pub struct LinkRenderParams<'a> {
323 pub target: String,
325
326 pub link_text: String,
328
329 pub extra_roles: Vec<&'a str>,
331
332 pub window: Option<&'static str>,
334
335 pub type_: LinkRenderType,
337
338 pub attrlist: &'a Attrlist<'a>,
340
341 pub parser: &'a Parser,
344}
345
346#[derive(Clone, Debug)]
348pub enum LinkRenderType {
349 Link,
351}
352
353#[derive(Clone, Debug)]
357pub struct CalloutRenderParams<'a> {
358 pub number: &'a str,
361
362 pub guard: CalloutGuard<'a>,
367
368 pub parser: &'a Parser,
371}
372
373#[derive(Clone, Debug, Eq, PartialEq)]
376pub enum CalloutGuard<'a> {
377 LineComment(&'a str),
382
383 Xml,
386}
387
388#[derive(Clone, Debug)]
390pub struct XrefRenderParams<'a> {
391 pub target: &'a str,
393
394 pub provided_text: Option<&'a str>,
396
397 pub resolved: Option<&'a ResolvedReference>,
399}
400
401#[derive(Debug)]
404pub struct HtmlSubstitutionRenderer {}
405
406impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
407 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
408 match type_ {
409 SpecialCharacter::Lt => {
410 dest.push_str("<");
411 }
412 SpecialCharacter::Gt => {
413 dest.push_str(">");
414 }
415 SpecialCharacter::Ampersand => {
416 dest.push_str("&");
417 }
418 }
419 }
420
421 fn render_quoted_substitition(
422 &self,
423 type_: QuoteType,
424 _scope: QuoteScope,
425 attrlist: Option<Attrlist<'_>>,
426 mut id: Option<String>,
427 body: &str,
428 dest: &mut String,
429 ) {
430 let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
431
432 if let Some(block_style) = attrlist
433 .as_ref()
434 .and_then(|a| a.nth_attribute(1))
435 .and_then(|attr1| attr1.block_style())
436 {
437 roles.insert(0, block_style);
438 }
439
440 if id.is_none() {
441 id = attrlist
442 .as_ref()
443 .and_then(|a| a.nth_attribute(1))
444 .and_then(|attr1| attr1.id())
445 .map(|id| id.to_owned())
446 }
447
448 match type_ {
449 QuoteType::Strong => {
450 wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
451 }
452
453 QuoteType::DoubleQuote => {
454 dest.push_str("“");
455 dest.push_str(body);
456 dest.push_str("”");
457 }
458
459 QuoteType::SingleQuote => {
460 dest.push_str("‘");
461 dest.push_str(body);
462 dest.push_str("’");
463 }
464
465 QuoteType::Monospaced => {
466 wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
467 }
468
469 QuoteType::Emphasis => {
470 wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
471 }
472
473 QuoteType::Mark => {
474 if roles.is_empty() && id.is_none() {
475 wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
476 } else {
477 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
478 }
479 }
480
481 QuoteType::Superscript => {
482 wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
483 }
484
485 QuoteType::Subscript => {
486 wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
487 }
488
489 QuoteType::Unquoted => {
490 if roles.is_empty() && id.is_none() {
491 dest.push_str(body);
492 } else {
493 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
494 }
495 }
496
497 QuoteType::AsciiMath => {
498 dest.push_str(r"\$");
499 dest.push_str(body);
500 dest.push_str(r"\$");
501 }
502
503 QuoteType::LatexMath => {
504 dest.push_str(r"\(");
505 dest.push_str(body);
506 dest.push_str(r"\)");
507 }
508 }
509 }
510
511 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
512 match type_ {
513 CharacterReplacementType::Copyright => {
514 dest.push_str("©");
515 }
516
517 CharacterReplacementType::Registered => {
518 dest.push_str("®");
519 }
520
521 CharacterReplacementType::Trademark => {
522 dest.push_str("™");
523 }
524
525 CharacterReplacementType::EmDashSurroundedBySpaces => {
526 dest.push_str(" — ");
527 }
528
529 CharacterReplacementType::EmDashWithoutSpace => {
530 dest.push_str("—​");
531 }
532
533 CharacterReplacementType::Ellipsis => {
534 dest.push_str("…​");
535 }
536
537 CharacterReplacementType::SingleLeftArrow => {
538 dest.push_str("←");
539 }
540
541 CharacterReplacementType::DoubleLeftArrow => {
542 dest.push_str("⇐");
543 }
544
545 CharacterReplacementType::SingleRightArrow => {
546 dest.push_str("→");
547 }
548
549 CharacterReplacementType::DoubleRightArrow => {
550 dest.push_str("⇒");
551 }
552
553 CharacterReplacementType::TypographicApostrophe => {
554 dest.push_str("’");
555 }
556
557 CharacterReplacementType::CharacterReference(name) => {
558 dest.push('&');
559 dest.push_str(&name);
560 dest.push(';');
561 }
562 }
563 }
564
565 fn render_line_break(&self, dest: &mut String) {
566 dest.push_str("<br>");
567 }
568
569 fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
570 let src = self.image_uri(params.target, params.parser, None);
571
572 let mut attrs: Vec<String> = vec![
573 format!(r#"src="{src}""#),
574 format!(
575 r#"alt="{alt}""#,
576 alt = encode_attribute_value(params.alt.to_string())
577 ),
578 ];
579
580 if let Some(width) = params.width {
581 attrs.push(format!(r#"width="{width}""#));
582 }
583
584 if let Some(height) = params.height {
585 attrs.push(format!(r#"height="{height}""#));
586 }
587
588 if let Some(title) = params.attrlist.named_attribute("title") {
589 attrs.push(format!(
590 r#"title="{title}""#,
591 title = encode_attribute_value(title.value().to_owned())
592 ));
593 }
594
595 let format = params
596 .attrlist
597 .named_attribute("format")
598 .map(|format| format.value());
599
600 let img = if format == Some("svg") || params.target.contains(".svg") {
605 if params.attrlist.has_option("inline") {
607 todo!(
608 "Port this: {}",
609 r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
610 NOTE: The attrs list calculated above may not be usable.
611 "#
612 );
613 } else if params.attrlist.has_option("interactive") {
614 todo!(
615 "Port this: {}",
616 r##"
617 fallback = (node.attr? 'fallback') ? %(<img src="#{node.image_uri node.attr 'fallback'}" alt="#{encode_attribute_value node.alt}"#{attrs}#{@void_element_slash}>) : %(<span class="alt">#{node.alt}</span>)
618 img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
619 NOTE: The attrs list calculated above may not be usable.
620 "##
621 );
622 } else {
623 format!(
624 r#"<img {attrs}{void_element_slash}>"#,
625 attrs = attrs.join(" "),
626 void_element_slash = "",
627 )
628 }
629 } else {
630 format!(
631 r#"<img {attrs}{void_element_slash}>"#,
632 attrs = attrs.join(" "),
633 void_element_slash = "",
634 )
638 };
639
640 render_icon_or_image(params.attrlist, &img, &src, "image", dest);
641 }
642
643 fn image_uri(
644 &self,
645 target_image_path: &str,
646 parser: &Parser,
647 asset_dir_key: Option<&str>,
648 ) -> String {
649 let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
650
651 if false {
652 todo!(
653 "Port this when implementing safe modes: {}",
655 r#"
656 if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
657 if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
658 (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
659 (target_image = normalize_web_path target_image, images_base, false))
660 (doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
661 else
662 generate_data_uri target_image, asset_dir_key
663 end
664 else
665 normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
666 end
667 "#
668 );
669 } else {
670 let asset_dir = parser
671 .attribute_value(asset_dir_key)
672 .as_maybe_str()
673 .map(|s| s.to_string());
674
675 normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
676 }
677 }
678
679 fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
680 let src = self.icon_uri(params.target, params.attrlist, params.parser);
681
682 let img = if params.parser.is_attribute_set("icons") {
683 let icons = params.parser.attribute_value("icons");
684 if let Some(icons) = icons.as_maybe_str()
685 && icons == "font"
686 {
687 let mut i_class_attrs: Vec<String> = vec![
688 "fa".to_owned(),
689 format!("fa-{target}", target = params.target),
690 ];
691
692 if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
693 i_class_attrs.push(format!("fa-{size}", size = size.value()));
694 }
695
696 if let Some(flip) = params.attrlist.named_attribute("flip") {
697 i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
698 } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
699 i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
700 }
701
702 format!(
703 r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
704 i_class_attr_val = i_class_attrs.join(" "),
705 title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
706 format!(r#" title="{title}""#, title = title.value())
707 } else {
708 "".to_owned()
709 }
710 )
711 } else {
712 let mut attrs: Vec<String> = vec![
713 format!(r#"src="{src}""#),
714 format!(
715 r#"alt="{alt}""#,
716 alt = encode_attribute_value(params.alt.to_string())
717 ),
718 ];
719
720 if let Some(width) = params.attrlist.named_attribute("width") {
721 attrs.push(format!(r#"width="{width}""#, width = width.value()));
722 }
723
724 if let Some(height) = params.attrlist.named_attribute("height") {
725 attrs.push(format!(r#"height="{height}""#, height = height.value()));
726 }
727
728 if let Some(title) = params.attrlist.named_attribute("title") {
729 attrs.push(format!(r#"title="{title}""#, title = title.value()));
730 }
731
732 format!(
733 "<img {attrs}{void_element_slash}>",
734 attrs = attrs.join(" "),
735 void_element_slash = "",
736 )
737 }
738 } else {
739 format!("[{alt}]", alt = params.alt)
740 };
741
742 render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
743 }
744
745 fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
746 let id = params.attrlist.id();
747
748 let mut roles = params.extra_roles.clone();
749 let mut attrlist_roles = params.attrlist.roles().clone();
750 roles.append(&mut attrlist_roles);
751
752 let link = format!(
753 r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
754 target = params.target,
755 id = if let Some(id) = id {
756 format!(r#" id="{id}""#)
757 } else {
758 "".to_owned()
759 },
760 class = if roles.is_empty() {
761 "".to_owned()
762 } else {
763 format!(r#" class="{roles}""#, roles = roles.join(" "))
764 },
765 link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
768 link_text = params.link_text,
769 );
770
771 dest.push_str(&link);
772 }
773
774 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
775 dest.push_str(&format!("<a id=\"{id}\"></a>"));
776 }
777
778 fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
779 match params.resolved {
780 Some(resolved) => {
781 let text = params
782 .provided_text
783 .map(str::to_string)
784 .or_else(|| resolved.text.clone())
785 .unwrap_or_else(|| format!("[{target}]", target = params.target));
786
787 dest.push_str(&format!(
788 r#"<a href="{href}">{text}</a>"#,
789 href = resolved.href
790 ));
791 }
792
793 None => {
794 let text = params
797 .provided_text
798 .map(str::to_string)
799 .unwrap_or_else(|| format!("[{target}]", target = params.target));
800
801 dest.push_str(&format!(
802 r##"<a href="#{target}">{text}</a>"##,
803 target = params.target
804 ));
805 }
806 }
807 }
808
809 fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
810 let n = params.number;
811 let parser = params.parser;
812
813 if parser.attribute_value("icons").as_maybe_str() == Some("font") {
814 dest.push_str(&format!(
815 r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
816 ));
817 } else if parser.is_attribute_set("icons") {
818 let icontype = parser
819 .attribute_value("icontype")
820 .as_maybe_str()
821 .unwrap_or("png")
822 .to_owned();
823
824 let icon = format!("callouts/{n}.{icontype}");
825 let src = self.image_uri(&icon, parser, Some("iconsdir"));
826
827 dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
828 } else {
829 match params.guard {
830 CalloutGuard::Xml => {
831 dest.push_str(&format!(r#"<!--<b class="conum">({n})</b>-->"#));
832 }
833
834 CalloutGuard::LineComment(prefix) => {
835 dest.push_str(prefix);
836 dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
837 }
838 }
839 }
840 }
841}
842
843fn wrap_body_in_html_tag(
844 _attrlist: Option<&Attrlist<'_>>,
845 tag: &'static str,
846 id: Option<String>,
847 roles: Vec<&str>,
848 body: &str,
849 dest: &mut String,
850) {
851 dest.push('<');
852 dest.push_str(tag);
853
854 if let Some(id) = id.as_ref() {
855 dest.push_str(" id=\"");
856 dest.push_str(id);
857 dest.push('"');
858 }
859
860 if !roles.is_empty() {
861 let roles = roles.join(" ");
862 dest.push_str(" class=\"");
863 dest.push_str(&roles);
864 dest.push('"');
865 }
866
867 dest.push('>');
868 dest.push_str(body);
869 dest.push_str("</");
870 dest.push_str(tag);
871 dest.push('>');
872}
873
874fn render_icon_or_image(
875 attrlist: &Attrlist,
876 img: &str,
877 src: &str,
878 type_: &'static str,
879 dest: &mut String,
880) {
881 let mut img = img.to_string();
882
883 if let Some(link) = attrlist.named_attribute("link") {
884 let mut link = link.value();
885 if link == "self" {
886 link = src;
887 }
888
889 img = format!(
890 r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
891 link_constraint_attrs = link_constraint_attrs(attrlist, None)
892 );
893 }
894
895 let mut roles: Vec<&str> = attrlist.roles();
896
897 if let Some(float) = attrlist.named_attribute("float") {
898 roles.insert(0, float.value());
899 }
900
901 roles.insert(0, type_);
902
903 dest.push_str(r#"<span class=""#);
904 dest.push_str(&roles.join(" "));
905 dest.push_str(r#"">"#);
906 dest.push_str(&img);
907 dest.push_str("</span>");
908}
909
910fn encode_attribute_value(value: String) -> String {
911 value.replace('"', """)
912}
913
914fn normalize_web_path(
915 target: &str,
916 parser: &Parser,
917 start: Option<&str>,
918 preserve_uri_target: bool,
919) -> String {
920 if preserve_uri_target && is_uri_ish(target) {
921 encode_spaces_in_uri(target)
922 } else {
923 parser.path_resolver.web_path(target, start)
924 }
925}
926
927fn is_uri_ish(path: &str) -> bool {
928 path.contains(':') && URI_SNIFF.is_match(path)
929}
930
931fn encode_spaces_in_uri(s: &str) -> String {
932 s.replace(' ', "%20")
933}
934
935static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
949 #[allow(clippy::unwrap_used)]
950 Regex::new(
951 r#"(?x)
952 \A # Anchor to start of string
953 \p{Alphabetic} # First character must be a letter
954 [\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
955 : # Literal colon
956 /{0,2} # Zero to two slashes
957 "#,
958 )
959 .unwrap()
960});
961
962fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
963 let rel = if attrlist.has_option("nofollow") {
964 Some("nofollow")
965 } else {
966 None
967 };
968
969 if let Some(window) = attrlist
970 .named_attribute("window")
971 .map(|a| a.value())
972 .or(window)
973 {
974 let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
975 if let Some(rel) = rel {
976 format!(r#" rel="{rel}" noopener"#)
977 } else {
978 r#" rel="noopener""#.to_owned()
979 }
980 } else {
981 "".to_string()
982 };
983
984 format!(r#" target="{window}"{rel_noopener}"#)
985 } else if let Some(rel) = rel {
986 format!(r#" rel="{rel}""#)
987 } else {
988 "".to_string()
989 }
990}