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
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum SpecialCharacter {
166 Lt,
168
169 Gt,
171
172 Ampersand,
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum QuoteType {
181 Strong,
183
184 DoubleQuote,
186
187 SingleQuote,
189
190 Monospaced,
192
193 Emphasis,
195
196 Mark,
198
199 Superscript,
201
202 Subscript,
204
205 Unquoted,
207}
208
209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum QuoteScope {
212 Constrained,
214
215 Unconstrained,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
223pub enum CharacterReplacementType {
224 Copyright,
226
227 Registered,
229
230 Trademark,
232
233 EmDashSurroundedBySpaces,
235
236 EmDashWithoutSpace,
238
239 Ellipsis,
241
242 SingleRightArrow,
244
245 DoubleRightArrow,
247
248 SingleLeftArrow,
250
251 DoubleLeftArrow,
253
254 TypographicApostrophe,
256
257 CharacterReference(String),
259}
260
261#[derive(Clone, Debug)]
263pub struct ImageRenderParams<'a> {
264 pub target: &'a str,
266
267 pub alt: String,
269
270 pub width: Option<&'a str>,
272
273 pub height: Option<&'a str>,
275
276 pub attrlist: &'a Attrlist<'a>,
278
279 pub parser: &'a Parser,
282}
283
284#[derive(Clone, Debug)]
286pub struct IconRenderParams<'a> {
287 pub target: &'a str,
289
290 pub alt: String,
292
293 pub size: Option<&'a str>,
295
296 pub attrlist: &'a Attrlist<'a>,
298
299 pub parser: &'a Parser,
302}
303
304#[derive(Clone, Debug)]
306pub struct LinkRenderParams<'a> {
307 pub target: String,
309
310 pub link_text: String,
312
313 pub extra_roles: Vec<&'a str>,
315
316 pub window: Option<&'static str>,
318
319 pub type_: LinkRenderType,
321
322 pub attrlist: &'a Attrlist<'a>,
324
325 pub parser: &'a Parser,
328}
329
330#[derive(Clone, Debug)]
332pub enum LinkRenderType {
333 Link,
335}
336
337#[derive(Clone, Debug)]
339pub struct XrefRenderParams<'a> {
340 pub target: &'a str,
342
343 pub provided_text: Option<&'a str>,
345
346 pub resolved: Option<&'a ResolvedReference>,
348}
349
350#[derive(Debug)]
353pub struct HtmlSubstitutionRenderer {}
354
355impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
356 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
357 match type_ {
358 SpecialCharacter::Lt => {
359 dest.push_str("<");
360 }
361 SpecialCharacter::Gt => {
362 dest.push_str(">");
363 }
364 SpecialCharacter::Ampersand => {
365 dest.push_str("&");
366 }
367 }
368 }
369
370 fn render_quoted_substitition(
371 &self,
372 type_: QuoteType,
373 _scope: QuoteScope,
374 attrlist: Option<Attrlist<'_>>,
375 mut id: Option<String>,
376 body: &str,
377 dest: &mut String,
378 ) {
379 let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
380
381 if let Some(block_style) = attrlist
382 .as_ref()
383 .and_then(|a| a.nth_attribute(1))
384 .and_then(|attr1| attr1.block_style())
385 {
386 roles.insert(0, block_style);
387 }
388
389 if id.is_none() {
390 id = attrlist
391 .as_ref()
392 .and_then(|a| a.nth_attribute(1))
393 .and_then(|attr1| attr1.id())
394 .map(|id| id.to_owned())
395 }
396
397 match type_ {
398 QuoteType::Strong => {
399 wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
400 }
401
402 QuoteType::DoubleQuote => {
403 dest.push_str("“");
404 dest.push_str(body);
405 dest.push_str("”");
406 }
407
408 QuoteType::SingleQuote => {
409 dest.push_str("‘");
410 dest.push_str(body);
411 dest.push_str("’");
412 }
413
414 QuoteType::Monospaced => {
415 wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
416 }
417
418 QuoteType::Emphasis => {
419 wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
420 }
421
422 QuoteType::Mark => {
423 if roles.is_empty() && id.is_none() {
424 wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
425 } else {
426 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
427 }
428 }
429
430 QuoteType::Superscript => {
431 wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
432 }
433
434 QuoteType::Subscript => {
435 wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
436 }
437
438 QuoteType::Unquoted => {
439 if roles.is_empty() && id.is_none() {
440 dest.push_str(body);
441 } else {
442 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
443 }
444 }
445 }
446 }
447
448 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
449 match type_ {
450 CharacterReplacementType::Copyright => {
451 dest.push_str("©");
452 }
453
454 CharacterReplacementType::Registered => {
455 dest.push_str("®");
456 }
457
458 CharacterReplacementType::Trademark => {
459 dest.push_str("™");
460 }
461
462 CharacterReplacementType::EmDashSurroundedBySpaces => {
463 dest.push_str(" — ");
464 }
465
466 CharacterReplacementType::EmDashWithoutSpace => {
467 dest.push_str("—​");
468 }
469
470 CharacterReplacementType::Ellipsis => {
471 dest.push_str("…​");
472 }
473
474 CharacterReplacementType::SingleLeftArrow => {
475 dest.push_str("←");
476 }
477
478 CharacterReplacementType::DoubleLeftArrow => {
479 dest.push_str("⇐");
480 }
481
482 CharacterReplacementType::SingleRightArrow => {
483 dest.push_str("→");
484 }
485
486 CharacterReplacementType::DoubleRightArrow => {
487 dest.push_str("⇒");
488 }
489
490 CharacterReplacementType::TypographicApostrophe => {
491 dest.push_str("’");
492 }
493
494 CharacterReplacementType::CharacterReference(name) => {
495 dest.push('&');
496 dest.push_str(&name);
497 dest.push(';');
498 }
499 }
500 }
501
502 fn render_line_break(&self, dest: &mut String) {
503 dest.push_str("<br>");
504 }
505
506 fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
507 let src = self.image_uri(params.target, params.parser, None);
508
509 let mut attrs: Vec<String> = vec![
510 format!(r#"src="{src}""#),
511 format!(
512 r#"alt="{alt}""#,
513 alt = encode_attribute_value(params.alt.to_string())
514 ),
515 ];
516
517 if let Some(width) = params.width {
518 attrs.push(format!(r#"width="{width}""#));
519 }
520
521 if let Some(height) = params.height {
522 attrs.push(format!(r#"height="{height}""#));
523 }
524
525 if let Some(title) = params.attrlist.named_attribute("title") {
526 attrs.push(format!(
527 r#"title="{title}""#,
528 title = encode_attribute_value(title.value().to_owned())
529 ));
530 }
531
532 let format = params
533 .attrlist
534 .named_attribute("format")
535 .map(|format| format.value());
536
537 let img = if format == Some("svg") || params.target.contains(".svg") {
542 if params.attrlist.has_option("inline") {
544 todo!(
545 "Port this: {}",
546 r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
547 NOTE: The attrs list calculated above may not be usable.
548 "#
549 );
550 } else if params.attrlist.has_option("interactive") {
551 todo!(
552 "Port this: {}",
553 r##"
554 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>)
555 img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
556 NOTE: The attrs list calculated above may not be usable.
557 "##
558 );
559 } else {
560 format!(
561 r#"<img {attrs}{void_element_slash}>"#,
562 attrs = attrs.join(" "),
563 void_element_slash = "",
564 )
565 }
566 } else {
567 format!(
568 r#"<img {attrs}{void_element_slash}>"#,
569 attrs = attrs.join(" "),
570 void_element_slash = "",
571 )
575 };
576
577 render_icon_or_image(params.attrlist, &img, &src, "image", dest);
578 }
579
580 fn image_uri(
581 &self,
582 target_image_path: &str,
583 parser: &Parser,
584 asset_dir_key: Option<&str>,
585 ) -> String {
586 let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
587
588 if false {
589 todo!(
590 "Port this when implementing safe modes: {}",
592 r#"
593 if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
594 if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
595 (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
596 (target_image = normalize_web_path target_image, images_base, false))
597 (doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
598 else
599 generate_data_uri target_image, asset_dir_key
600 end
601 else
602 normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
603 end
604 "#
605 );
606 } else {
607 let asset_dir = parser
608 .attribute_value(asset_dir_key)
609 .as_maybe_str()
610 .map(|s| s.to_string());
611
612 normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
613 }
614 }
615
616 fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
617 let src = self.icon_uri(params.target, params.attrlist, params.parser);
618
619 let img = if params.parser.has_attribute("icons") {
620 let icons = params.parser.attribute_value("icons");
621 if let Some(icons) = icons.as_maybe_str()
622 && icons == "font"
623 {
624 let mut i_class_attrs: Vec<String> = vec![
625 "fa".to_owned(),
626 format!("fa-{target}", target = params.target),
627 ];
628
629 if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
630 i_class_attrs.push(format!("fa-{size}", size = size.value()));
631 }
632
633 if let Some(flip) = params.attrlist.named_attribute("flip") {
634 i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
635 } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
636 i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
637 }
638
639 format!(
640 r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
641 i_class_attr_val = i_class_attrs.join(" "),
642 title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
643 format!(r#" title="{title}""#, title = title.value())
644 } else {
645 "".to_owned()
646 }
647 )
648 } else {
649 let mut attrs: Vec<String> = vec![
650 format!(r#"src="{src}""#),
651 format!(
652 r#"alt="{alt}""#,
653 alt = encode_attribute_value(params.alt.to_string())
654 ),
655 ];
656
657 if let Some(width) = params.attrlist.named_attribute("width") {
658 attrs.push(format!(r#"width="{width}""#, width = width.value()));
659 }
660
661 if let Some(height) = params.attrlist.named_attribute("height") {
662 attrs.push(format!(r#"height="{height}""#, height = height.value()));
663 }
664
665 if let Some(title) = params.attrlist.named_attribute("title") {
666 attrs.push(format!(r#"title="{title}""#, title = title.value()));
667 }
668
669 format!(
670 "<img {attrs}{void_element_slash}>",
671 attrs = attrs.join(" "),
672 void_element_slash = "",
673 )
674 }
675 } else {
676 format!("[{alt}]", alt = params.alt)
677 };
678
679 render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
680 }
681
682 fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
683 let id = params.attrlist.id();
684
685 let mut roles = params.extra_roles.clone();
686 let mut attrlist_roles = params.attrlist.roles().clone();
687 roles.append(&mut attrlist_roles);
688
689 let link = format!(
690 r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
691 target = params.target,
692 id = if let Some(id) = id {
693 format!(r#" id="{id}""#)
694 } else {
695 "".to_owned()
696 },
697 class = if roles.is_empty() {
698 "".to_owned()
699 } else {
700 format!(r#" class="{roles}""#, roles = roles.join(" "))
701 },
702 link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
705 link_text = params.link_text,
706 );
707
708 dest.push_str(&link);
709 }
710
711 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
712 dest.push_str(&format!("<a id=\"{id}\"></a>"));
713 }
714
715 fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
716 match params.resolved {
717 Some(resolved) => {
718 let text = params
719 .provided_text
720 .map(str::to_string)
721 .or_else(|| resolved.text.clone())
722 .unwrap_or_else(|| format!("[{target}]", target = params.target));
723
724 dest.push_str(&format!(
725 r#"<a href="{href}">{text}</a>"#,
726 href = resolved.href
727 ));
728 }
729
730 None => {
731 let text = params
734 .provided_text
735 .map(str::to_string)
736 .unwrap_or_else(|| format!("[{target}]", target = params.target));
737
738 dest.push_str(&format!(
739 r##"<a href="#{target}">{text}</a>"##,
740 target = params.target
741 ));
742 }
743 }
744 }
745}
746
747fn wrap_body_in_html_tag(
748 _attrlist: Option<&Attrlist<'_>>,
749 tag: &'static str,
750 id: Option<String>,
751 roles: Vec<&str>,
752 body: &str,
753 dest: &mut String,
754) {
755 dest.push('<');
756 dest.push_str(tag);
757
758 if let Some(id) = id.as_ref() {
759 dest.push_str(" id=\"");
760 dest.push_str(id);
761 dest.push('"');
762 }
763
764 if !roles.is_empty() {
765 let roles = roles.join(" ");
766 dest.push_str(" class=\"");
767 dest.push_str(&roles);
768 dest.push('"');
769 }
770
771 dest.push('>');
772 dest.push_str(body);
773 dest.push_str("</");
774 dest.push_str(tag);
775 dest.push('>');
776}
777
778fn render_icon_or_image(
779 attrlist: &Attrlist,
780 img: &str,
781 src: &str,
782 type_: &'static str,
783 dest: &mut String,
784) {
785 let mut img = img.to_string();
786
787 if let Some(link) = attrlist.named_attribute("link") {
788 let mut link = link.value();
789 if link == "self" {
790 link = src;
791 }
792
793 img = format!(
794 r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
795 link_constraint_attrs = link_constraint_attrs(attrlist, None)
796 );
797 }
798
799 let mut roles: Vec<&str> = attrlist.roles();
800
801 if let Some(float) = attrlist.named_attribute("float") {
802 roles.insert(0, float.value());
803 }
804
805 roles.insert(0, type_);
806
807 dest.push_str(r#"<span class=""#);
808 dest.push_str(&roles.join(" "));
809 dest.push_str(r#"">"#);
810 dest.push_str(&img);
811 dest.push_str("</span>");
812}
813
814fn encode_attribute_value(value: String) -> String {
815 value.replace('"', """)
816}
817
818fn normalize_web_path(
819 target: &str,
820 parser: &Parser,
821 start: Option<&str>,
822 preserve_uri_target: bool,
823) -> String {
824 if preserve_uri_target && is_uri_ish(target) {
825 encode_spaces_in_uri(target)
826 } else {
827 parser.path_resolver.web_path(target, start)
828 }
829}
830
831fn is_uri_ish(path: &str) -> bool {
832 path.contains(':') && URI_SNIFF.is_match(path)
833}
834
835fn encode_spaces_in_uri(s: &str) -> String {
836 s.replace(' ', "%20")
837}
838
839static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
853 #[allow(clippy::unwrap_used)]
854 Regex::new(
855 r#"(?x)
856 \A # Anchor to start of string
857 \p{Alphabetic} # First character must be a letter
858 [\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
859 : # Literal colon
860 /{0,2} # Zero to two slashes
861 "#,
862 )
863 .unwrap()
864});
865
866fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
867 let rel = if attrlist.has_option("nofollow") {
868 Some("nofollow")
869 } else {
870 None
871 };
872
873 if let Some(window) = attrlist
874 .named_attribute("window")
875 .map(|a| a.value())
876 .or(window)
877 {
878 let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
879 if let Some(rel) = rel {
880 format!(r#" rel="{rel}" noopener"#)
881 } else {
882 r#" rel="noopener""#.to_owned()
883 }
884 } else {
885 "".to_string()
886 };
887
888 format!(r#" target="{window}"{rel_noopener}"#)
889 } else if let Some(rel) = rel {
890 format!(r#" rel="{rel}""#)
891 } else {
892 "".to_string()
893 }
894}