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
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
220pub enum QuoteScope {
221 Constrained,
223
224 Unconstrained,
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
232pub enum CharacterReplacementType {
233 Copyright,
235
236 Registered,
238
239 Trademark,
241
242 EmDashSurroundedBySpaces,
244
245 EmDashWithoutSpace,
247
248 Ellipsis,
250
251 SingleRightArrow,
253
254 DoubleRightArrow,
256
257 SingleLeftArrow,
259
260 DoubleLeftArrow,
262
263 TypographicApostrophe,
265
266 CharacterReference(String),
268}
269
270#[derive(Clone, Debug)]
272pub struct ImageRenderParams<'a> {
273 pub target: &'a str,
275
276 pub alt: String,
278
279 pub width: Option<&'a str>,
281
282 pub height: Option<&'a str>,
284
285 pub attrlist: &'a Attrlist<'a>,
287
288 pub parser: &'a Parser,
291}
292
293#[derive(Clone, Debug)]
295pub struct IconRenderParams<'a> {
296 pub target: &'a str,
298
299 pub alt: String,
301
302 pub size: Option<&'a str>,
304
305 pub attrlist: &'a Attrlist<'a>,
307
308 pub parser: &'a Parser,
311}
312
313#[derive(Clone, Debug)]
315pub struct LinkRenderParams<'a> {
316 pub target: String,
318
319 pub link_text: String,
321
322 pub extra_roles: Vec<&'a str>,
324
325 pub window: Option<&'static str>,
327
328 pub type_: LinkRenderType,
330
331 pub attrlist: &'a Attrlist<'a>,
333
334 pub parser: &'a Parser,
337}
338
339#[derive(Clone, Debug)]
341pub enum LinkRenderType {
342 Link,
344}
345
346#[derive(Clone, Debug)]
350pub struct CalloutRenderParams<'a> {
351 pub number: &'a str,
354
355 pub guard: CalloutGuard<'a>,
360
361 pub parser: &'a Parser,
364}
365
366#[derive(Clone, Debug, Eq, PartialEq)]
369pub enum CalloutGuard<'a> {
370 LineComment(&'a str),
375
376 Xml,
379}
380
381#[derive(Clone, Debug)]
383pub struct XrefRenderParams<'a> {
384 pub target: &'a str,
386
387 pub provided_text: Option<&'a str>,
389
390 pub resolved: Option<&'a ResolvedReference>,
392}
393
394#[derive(Debug)]
397pub struct HtmlSubstitutionRenderer {}
398
399impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
400 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
401 match type_ {
402 SpecialCharacter::Lt => {
403 dest.push_str("<");
404 }
405 SpecialCharacter::Gt => {
406 dest.push_str(">");
407 }
408 SpecialCharacter::Ampersand => {
409 dest.push_str("&");
410 }
411 }
412 }
413
414 fn render_quoted_substitition(
415 &self,
416 type_: QuoteType,
417 _scope: QuoteScope,
418 attrlist: Option<Attrlist<'_>>,
419 mut id: Option<String>,
420 body: &str,
421 dest: &mut String,
422 ) {
423 let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
424
425 if let Some(block_style) = attrlist
426 .as_ref()
427 .and_then(|a| a.nth_attribute(1))
428 .and_then(|attr1| attr1.block_style())
429 {
430 roles.insert(0, block_style);
431 }
432
433 if id.is_none() {
434 id = attrlist
435 .as_ref()
436 .and_then(|a| a.nth_attribute(1))
437 .and_then(|attr1| attr1.id())
438 .map(|id| id.to_owned())
439 }
440
441 match type_ {
442 QuoteType::Strong => {
443 wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
444 }
445
446 QuoteType::DoubleQuote => {
447 dest.push_str("“");
448 dest.push_str(body);
449 dest.push_str("”");
450 }
451
452 QuoteType::SingleQuote => {
453 dest.push_str("‘");
454 dest.push_str(body);
455 dest.push_str("’");
456 }
457
458 QuoteType::Monospaced => {
459 wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
460 }
461
462 QuoteType::Emphasis => {
463 wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
464 }
465
466 QuoteType::Mark => {
467 if roles.is_empty() && id.is_none() {
468 wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
469 } else {
470 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
471 }
472 }
473
474 QuoteType::Superscript => {
475 wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
476 }
477
478 QuoteType::Subscript => {
479 wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
480 }
481
482 QuoteType::Unquoted => {
483 if roles.is_empty() && id.is_none() {
484 dest.push_str(body);
485 } else {
486 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
487 }
488 }
489 }
490 }
491
492 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
493 match type_ {
494 CharacterReplacementType::Copyright => {
495 dest.push_str("©");
496 }
497
498 CharacterReplacementType::Registered => {
499 dest.push_str("®");
500 }
501
502 CharacterReplacementType::Trademark => {
503 dest.push_str("™");
504 }
505
506 CharacterReplacementType::EmDashSurroundedBySpaces => {
507 dest.push_str(" — ");
508 }
509
510 CharacterReplacementType::EmDashWithoutSpace => {
511 dest.push_str("—​");
512 }
513
514 CharacterReplacementType::Ellipsis => {
515 dest.push_str("…​");
516 }
517
518 CharacterReplacementType::SingleLeftArrow => {
519 dest.push_str("←");
520 }
521
522 CharacterReplacementType::DoubleLeftArrow => {
523 dest.push_str("⇐");
524 }
525
526 CharacterReplacementType::SingleRightArrow => {
527 dest.push_str("→");
528 }
529
530 CharacterReplacementType::DoubleRightArrow => {
531 dest.push_str("⇒");
532 }
533
534 CharacterReplacementType::TypographicApostrophe => {
535 dest.push_str("’");
536 }
537
538 CharacterReplacementType::CharacterReference(name) => {
539 dest.push('&');
540 dest.push_str(&name);
541 dest.push(';');
542 }
543 }
544 }
545
546 fn render_line_break(&self, dest: &mut String) {
547 dest.push_str("<br>");
548 }
549
550 fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
551 let src = self.image_uri(params.target, params.parser, None);
552
553 let mut attrs: Vec<String> = vec![
554 format!(r#"src="{src}""#),
555 format!(
556 r#"alt="{alt}""#,
557 alt = encode_attribute_value(params.alt.to_string())
558 ),
559 ];
560
561 if let Some(width) = params.width {
562 attrs.push(format!(r#"width="{width}""#));
563 }
564
565 if let Some(height) = params.height {
566 attrs.push(format!(r#"height="{height}""#));
567 }
568
569 if let Some(title) = params.attrlist.named_attribute("title") {
570 attrs.push(format!(
571 r#"title="{title}""#,
572 title = encode_attribute_value(title.value().to_owned())
573 ));
574 }
575
576 let format = params
577 .attrlist
578 .named_attribute("format")
579 .map(|format| format.value());
580
581 let img = if format == Some("svg") || params.target.contains(".svg") {
586 if params.attrlist.has_option("inline") {
588 todo!(
589 "Port this: {}",
590 r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
591 NOTE: The attrs list calculated above may not be usable.
592 "#
593 );
594 } else if params.attrlist.has_option("interactive") {
595 todo!(
596 "Port this: {}",
597 r##"
598 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>)
599 img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
600 NOTE: The attrs list calculated above may not be usable.
601 "##
602 );
603 } else {
604 format!(
605 r#"<img {attrs}{void_element_slash}>"#,
606 attrs = attrs.join(" "),
607 void_element_slash = "",
608 )
609 }
610 } else {
611 format!(
612 r#"<img {attrs}{void_element_slash}>"#,
613 attrs = attrs.join(" "),
614 void_element_slash = "",
615 )
619 };
620
621 render_icon_or_image(params.attrlist, &img, &src, "image", dest);
622 }
623
624 fn image_uri(
625 &self,
626 target_image_path: &str,
627 parser: &Parser,
628 asset_dir_key: Option<&str>,
629 ) -> String {
630 let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
631
632 if false {
633 todo!(
634 "Port this when implementing safe modes: {}",
636 r#"
637 if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
638 if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
639 (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
640 (target_image = normalize_web_path target_image, images_base, false))
641 (doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
642 else
643 generate_data_uri target_image, asset_dir_key
644 end
645 else
646 normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
647 end
648 "#
649 );
650 } else {
651 let asset_dir = parser
652 .attribute_value(asset_dir_key)
653 .as_maybe_str()
654 .map(|s| s.to_string());
655
656 normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
657 }
658 }
659
660 fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
661 let src = self.icon_uri(params.target, params.attrlist, params.parser);
662
663 let img = if params.parser.has_attribute("icons") {
664 let icons = params.parser.attribute_value("icons");
665 if let Some(icons) = icons.as_maybe_str()
666 && icons == "font"
667 {
668 let mut i_class_attrs: Vec<String> = vec![
669 "fa".to_owned(),
670 format!("fa-{target}", target = params.target),
671 ];
672
673 if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
674 i_class_attrs.push(format!("fa-{size}", size = size.value()));
675 }
676
677 if let Some(flip) = params.attrlist.named_attribute("flip") {
678 i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
679 } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
680 i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
681 }
682
683 format!(
684 r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
685 i_class_attr_val = i_class_attrs.join(" "),
686 title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
687 format!(r#" title="{title}""#, title = title.value())
688 } else {
689 "".to_owned()
690 }
691 )
692 } else {
693 let mut attrs: Vec<String> = vec![
694 format!(r#"src="{src}""#),
695 format!(
696 r#"alt="{alt}""#,
697 alt = encode_attribute_value(params.alt.to_string())
698 ),
699 ];
700
701 if let Some(width) = params.attrlist.named_attribute("width") {
702 attrs.push(format!(r#"width="{width}""#, width = width.value()));
703 }
704
705 if let Some(height) = params.attrlist.named_attribute("height") {
706 attrs.push(format!(r#"height="{height}""#, height = height.value()));
707 }
708
709 if let Some(title) = params.attrlist.named_attribute("title") {
710 attrs.push(format!(r#"title="{title}""#, title = title.value()));
711 }
712
713 format!(
714 "<img {attrs}{void_element_slash}>",
715 attrs = attrs.join(" "),
716 void_element_slash = "",
717 )
718 }
719 } else {
720 format!("[{alt}]", alt = params.alt)
721 };
722
723 render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
724 }
725
726 fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
727 let id = params.attrlist.id();
728
729 let mut roles = params.extra_roles.clone();
730 let mut attrlist_roles = params.attrlist.roles().clone();
731 roles.append(&mut attrlist_roles);
732
733 let link = format!(
734 r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
735 target = params.target,
736 id = if let Some(id) = id {
737 format!(r#" id="{id}""#)
738 } else {
739 "".to_owned()
740 },
741 class = if roles.is_empty() {
742 "".to_owned()
743 } else {
744 format!(r#" class="{roles}""#, roles = roles.join(" "))
745 },
746 link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
749 link_text = params.link_text,
750 );
751
752 dest.push_str(&link);
753 }
754
755 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
756 dest.push_str(&format!("<a id=\"{id}\"></a>"));
757 }
758
759 fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
760 match params.resolved {
761 Some(resolved) => {
762 let text = params
763 .provided_text
764 .map(str::to_string)
765 .or_else(|| resolved.text.clone())
766 .unwrap_or_else(|| format!("[{target}]", target = params.target));
767
768 dest.push_str(&format!(
769 r#"<a href="{href}">{text}</a>"#,
770 href = resolved.href
771 ));
772 }
773
774 None => {
775 let text = params
778 .provided_text
779 .map(str::to_string)
780 .unwrap_or_else(|| format!("[{target}]", target = params.target));
781
782 dest.push_str(&format!(
783 r##"<a href="#{target}">{text}</a>"##,
784 target = params.target
785 ));
786 }
787 }
788 }
789
790 fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
791 let n = params.number;
792 let parser = params.parser;
793
794 if parser.attribute_value("icons").as_maybe_str() == Some("font") {
795 dest.push_str(&format!(
796 r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
797 ));
798 } else if parser.has_attribute("icons") {
799 let icontype = parser
800 .attribute_value("icontype")
801 .as_maybe_str()
802 .unwrap_or("png")
803 .to_owned();
804
805 let icon = format!("callouts/{n}.{icontype}");
806 let src = self.image_uri(&icon, parser, Some("iconsdir"));
807
808 dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
809 } else {
810 match params.guard {
811 CalloutGuard::Xml => {
812 dest.push_str(&format!(r#"<!--<b class="conum">({n})</b>-->"#));
813 }
814
815 CalloutGuard::LineComment(prefix) => {
816 dest.push_str(prefix);
817 dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
818 }
819 }
820 }
821 }
822}
823
824fn wrap_body_in_html_tag(
825 _attrlist: Option<&Attrlist<'_>>,
826 tag: &'static str,
827 id: Option<String>,
828 roles: Vec<&str>,
829 body: &str,
830 dest: &mut String,
831) {
832 dest.push('<');
833 dest.push_str(tag);
834
835 if let Some(id) = id.as_ref() {
836 dest.push_str(" id=\"");
837 dest.push_str(id);
838 dest.push('"');
839 }
840
841 if !roles.is_empty() {
842 let roles = roles.join(" ");
843 dest.push_str(" class=\"");
844 dest.push_str(&roles);
845 dest.push('"');
846 }
847
848 dest.push('>');
849 dest.push_str(body);
850 dest.push_str("</");
851 dest.push_str(tag);
852 dest.push('>');
853}
854
855fn render_icon_or_image(
856 attrlist: &Attrlist,
857 img: &str,
858 src: &str,
859 type_: &'static str,
860 dest: &mut String,
861) {
862 let mut img = img.to_string();
863
864 if let Some(link) = attrlist.named_attribute("link") {
865 let mut link = link.value();
866 if link == "self" {
867 link = src;
868 }
869
870 img = format!(
871 r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
872 link_constraint_attrs = link_constraint_attrs(attrlist, None)
873 );
874 }
875
876 let mut roles: Vec<&str> = attrlist.roles();
877
878 if let Some(float) = attrlist.named_attribute("float") {
879 roles.insert(0, float.value());
880 }
881
882 roles.insert(0, type_);
883
884 dest.push_str(r#"<span class=""#);
885 dest.push_str(&roles.join(" "));
886 dest.push_str(r#"">"#);
887 dest.push_str(&img);
888 dest.push_str("</span>");
889}
890
891fn encode_attribute_value(value: String) -> String {
892 value.replace('"', """)
893}
894
895fn normalize_web_path(
896 target: &str,
897 parser: &Parser,
898 start: Option<&str>,
899 preserve_uri_target: bool,
900) -> String {
901 if preserve_uri_target && is_uri_ish(target) {
902 encode_spaces_in_uri(target)
903 } else {
904 parser.path_resolver.web_path(target, start)
905 }
906}
907
908fn is_uri_ish(path: &str) -> bool {
909 path.contains(':') && URI_SNIFF.is_match(path)
910}
911
912fn encode_spaces_in_uri(s: &str) -> String {
913 s.replace(' ', "%20")
914}
915
916static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
930 #[allow(clippy::unwrap_used)]
931 Regex::new(
932 r#"(?x)
933 \A # Anchor to start of string
934 \p{Alphabetic} # First character must be a letter
935 [\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
936 : # Literal colon
937 /{0,2} # Zero to two slashes
938 "#,
939 )
940 .unwrap()
941});
942
943fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
944 let rel = if attrlist.has_option("nofollow") {
945 Some("nofollow")
946 } else {
947 None
948 };
949
950 if let Some(window) = attrlist
951 .named_attribute("window")
952 .map(|a| a.value())
953 .or(window)
954 {
955 let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
956 if let Some(rel) = rel {
957 format!(r#" rel="{rel}" noopener"#)
958 } else {
959 r#" rel="noopener""#.to_owned()
960 }
961 } else {
962 "".to_string()
963 };
964
965 format!(r#" target="{window}"{rel_noopener}"#)
966 } else if let Some(rel) = rel {
967 format!(r#" rel="{rel}""#)
968 } else {
969 "".to_string()
970 }
971}