1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{Parser, attributes::Attrlist};
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
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub enum SpecialCharacter {
158 Lt,
160
161 Gt,
163
164 Ampersand,
166}
167
168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum QuoteType {
173 Strong,
175
176 DoubleQuote,
178
179 SingleQuote,
181
182 Monospaced,
184
185 Emphasis,
187
188 Mark,
190
191 Superscript,
193
194 Subscript,
196
197 Unquoted,
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum QuoteScope {
204 Constrained,
206
207 Unconstrained,
209}
210
211#[derive(Clone, Debug, Eq, PartialEq)]
215pub enum CharacterReplacementType {
216 Copyright,
218
219 Registered,
221
222 Trademark,
224
225 EmDashSurroundedBySpaces,
227
228 EmDashWithoutSpace,
230
231 Ellipsis,
233
234 SingleRightArrow,
236
237 DoubleRightArrow,
239
240 SingleLeftArrow,
242
243 DoubleLeftArrow,
245
246 TypographicApostrophe,
248
249 CharacterReference(String),
251}
252
253#[derive(Clone, Debug)]
255pub struct ImageRenderParams<'a> {
256 pub target: &'a str,
258
259 pub alt: String,
261
262 pub width: Option<&'a str>,
264
265 pub height: Option<&'a str>,
267
268 pub attrlist: &'a Attrlist<'a>,
270
271 pub parser: &'a Parser,
274}
275
276#[derive(Clone, Debug)]
278pub struct IconRenderParams<'a> {
279 pub target: &'a str,
281
282 pub alt: String,
284
285 pub size: Option<&'a str>,
287
288 pub attrlist: &'a Attrlist<'a>,
290
291 pub parser: &'a Parser,
294}
295
296#[derive(Clone, Debug)]
298pub struct LinkRenderParams<'a> {
299 pub target: String,
301
302 pub link_text: String,
304
305 pub extra_roles: Vec<&'a str>,
307
308 pub window: Option<&'static str>,
310
311 pub type_: LinkRenderType,
313
314 pub attrlist: &'a Attrlist<'a>,
316
317 pub parser: &'a Parser,
320}
321
322#[derive(Clone, Debug)]
324pub enum LinkRenderType {
325 Link,
327}
328
329#[derive(Debug)]
332pub struct HtmlSubstitutionRenderer {}
333
334impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
335 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
336 match type_ {
337 SpecialCharacter::Lt => {
338 dest.push_str("<");
339 }
340 SpecialCharacter::Gt => {
341 dest.push_str(">");
342 }
343 SpecialCharacter::Ampersand => {
344 dest.push_str("&");
345 }
346 }
347 }
348
349 fn render_quoted_substitition(
350 &self,
351 type_: QuoteType,
352 _scope: QuoteScope,
353 attrlist: Option<Attrlist<'_>>,
354 mut id: Option<String>,
355 body: &str,
356 dest: &mut String,
357 ) {
358 let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
359
360 if let Some(block_style) = attrlist
361 .as_ref()
362 .and_then(|a| a.nth_attribute(1))
363 .and_then(|attr1| attr1.block_style())
364 {
365 roles.insert(0, block_style);
366 }
367
368 if id.is_none() {
369 id = attrlist
370 .as_ref()
371 .and_then(|a| a.nth_attribute(1))
372 .and_then(|attr1| attr1.id())
373 .map(|id| id.to_owned())
374 }
375
376 match type_ {
377 QuoteType::Strong => {
378 wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
379 }
380
381 QuoteType::DoubleQuote => {
382 dest.push_str("“");
383 dest.push_str(body);
384 dest.push_str("”");
385 }
386
387 QuoteType::SingleQuote => {
388 dest.push_str("‘");
389 dest.push_str(body);
390 dest.push_str("’");
391 }
392
393 QuoteType::Monospaced => {
394 wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
395 }
396
397 QuoteType::Emphasis => {
398 wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
399 }
400
401 QuoteType::Mark => {
402 if roles.is_empty() && id.is_none() {
403 wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
404 } else {
405 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
406 }
407 }
408
409 QuoteType::Superscript => {
410 wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
411 }
412
413 QuoteType::Subscript => {
414 wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
415 }
416
417 QuoteType::Unquoted => {
418 if roles.is_empty() && id.is_none() {
419 dest.push_str(body);
420 } else {
421 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
422 }
423 }
424 }
425 }
426
427 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
428 match type_ {
429 CharacterReplacementType::Copyright => {
430 dest.push_str("©");
431 }
432
433 CharacterReplacementType::Registered => {
434 dest.push_str("®");
435 }
436
437 CharacterReplacementType::Trademark => {
438 dest.push_str("™");
439 }
440
441 CharacterReplacementType::EmDashSurroundedBySpaces => {
442 dest.push_str(" — ");
443 }
444
445 CharacterReplacementType::EmDashWithoutSpace => {
446 dest.push_str("—​");
447 }
448
449 CharacterReplacementType::Ellipsis => {
450 dest.push_str("…​");
451 }
452
453 CharacterReplacementType::SingleLeftArrow => {
454 dest.push_str("←");
455 }
456
457 CharacterReplacementType::DoubleLeftArrow => {
458 dest.push_str("⇐");
459 }
460
461 CharacterReplacementType::SingleRightArrow => {
462 dest.push_str("→");
463 }
464
465 CharacterReplacementType::DoubleRightArrow => {
466 dest.push_str("⇒");
467 }
468
469 CharacterReplacementType::TypographicApostrophe => {
470 dest.push_str("’");
471 }
472
473 CharacterReplacementType::CharacterReference(name) => {
474 dest.push('&');
475 dest.push_str(&name);
476 dest.push(';');
477 }
478 }
479 }
480
481 fn render_line_break(&self, dest: &mut String) {
482 dest.push_str("<br>");
483 }
484
485 fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
486 let src = self.image_uri(params.target, params.parser, None);
487
488 let mut attrs: Vec<String> = vec![
489 format!(r#"src="{src}""#),
490 format!(
491 r#"alt="{alt}""#,
492 alt = encode_attribute_value(params.alt.to_string())
493 ),
494 ];
495
496 if let Some(width) = params.width {
497 attrs.push(format!(r#"width="{width}""#));
498 }
499
500 if let Some(height) = params.height {
501 attrs.push(format!(r#"height="{height}""#));
502 }
503
504 if let Some(title) = params.attrlist.named_attribute("title") {
505 attrs.push(format!(
506 r#"title="{title}""#,
507 title = encode_attribute_value(title.value().to_owned())
508 ));
509 }
510
511 let format = params
512 .attrlist
513 .named_attribute("format")
514 .map(|format| format.value());
515
516 let img = if format == Some("svg") || params.target.contains(".svg") {
521 if params.attrlist.has_option("inline") {
523 todo!(
524 "Port this: {}",
525 r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
526 NOTE: The attrs list calculated above may not be usable.
527 "#
528 );
529 } else if params.attrlist.has_option("interactive") {
530 todo!(
531 "Port this: {}",
532 r##"
533 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>)
534 img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
535 NOTE: The attrs list calculated above may not be usable.
536 "##
537 );
538 } else {
539 format!(
540 r#"<img {attrs}{void_element_slash}>"#,
541 attrs = attrs.join(" "),
542 void_element_slash = "",
543 )
544 }
545 } else {
546 format!(
547 r#"<img {attrs}{void_element_slash}>"#,
548 attrs = attrs.join(" "),
549 void_element_slash = "",
550 )
554 };
555
556 render_icon_or_image(params.attrlist, &img, &src, "image", dest);
557 }
558
559 fn image_uri(
560 &self,
561 target_image_path: &str,
562 parser: &Parser,
563 asset_dir_key: Option<&str>,
564 ) -> String {
565 let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
566
567 if false {
568 todo!(
569 "Port this when implementing safe modes: {}",
571 r#"
572 if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
573 if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
574 (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
575 (target_image = normalize_web_path target_image, images_base, false))
576 (doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
577 else
578 generate_data_uri target_image, asset_dir_key
579 end
580 else
581 normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
582 end
583 "#
584 );
585 } else {
586 let asset_dir = parser
587 .attribute_value(asset_dir_key)
588 .as_maybe_str()
589 .map(|s| s.to_string());
590
591 normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
592 }
593 }
594
595 fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
596 let src = self.icon_uri(params.target, params.attrlist, params.parser);
597
598 let img = if params.parser.has_attribute("icons") {
599 let icons = params.parser.attribute_value("icons");
600 if let Some(icons) = icons.as_maybe_str()
601 && icons == "font"
602 {
603 let mut i_class_attrs: Vec<String> = vec![
604 "fa".to_owned(),
605 format!("fa-{target}", target = params.target),
606 ];
607
608 if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
609 i_class_attrs.push(format!("fa-{size}", size = size.value()));
610 }
611
612 if let Some(flip) = params.attrlist.named_attribute("flip") {
613 i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
614 } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
615 i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
616 }
617
618 format!(
619 r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
620 i_class_attr_val = i_class_attrs.join(" "),
621 title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
622 format!(r#" title="{title}""#, title = title.value())
623 } else {
624 "".to_owned()
625 }
626 )
627 } else {
628 let mut attrs: Vec<String> = vec![
629 format!(r#"src="{src}""#),
630 format!(
631 r#"alt="{alt}""#,
632 alt = encode_attribute_value(params.alt.to_string())
633 ),
634 ];
635
636 if let Some(width) = params.attrlist.named_attribute("width") {
637 attrs.push(format!(r#"width="{width}""#, width = width.value()));
638 }
639
640 if let Some(height) = params.attrlist.named_attribute("height") {
641 attrs.push(format!(r#"height="{height}""#, height = height.value()));
642 }
643
644 if let Some(title) = params.attrlist.named_attribute("title") {
645 attrs.push(format!(r#"title="{title}""#, title = title.value()));
646 }
647
648 format!(
649 "<img {attrs}{void_element_slash}>",
650 attrs = attrs.join(" "),
651 void_element_slash = "",
652 )
653 }
654 } else {
655 format!("[{alt}]", alt = params.alt)
656 };
657
658 render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
659 }
660
661 fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
662 let id = params.attrlist.id();
663
664 let mut roles = params.extra_roles.clone();
665 let mut attrlist_roles = params.attrlist.roles().clone();
666 roles.append(&mut attrlist_roles);
667
668 let link = format!(
669 r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
670 target = params.target,
671 id = if let Some(id) = id {
672 format!(r#" id="{id}""#)
673 } else {
674 "".to_owned()
675 },
676 class = if roles.is_empty() {
677 "".to_owned()
678 } else {
679 format!(r#" class="{roles}""#, roles = roles.join(" "))
680 },
681 link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
684 link_text = params.link_text,
685 );
686
687 dest.push_str(&link);
688 }
689
690 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
691 dest.push_str(&format!("<a id=\"{id}\"></a>"));
692 }
693}
694
695fn wrap_body_in_html_tag(
696 _attrlist: Option<&Attrlist<'_>>,
697 tag: &'static str,
698 id: Option<String>,
699 roles: Vec<&str>,
700 body: &str,
701 dest: &mut String,
702) {
703 dest.push('<');
704 dest.push_str(tag);
705
706 if let Some(id) = id.as_ref() {
707 dest.push_str(" id=\"");
708 dest.push_str(id);
709 dest.push('"');
710 }
711
712 if !roles.is_empty() {
713 let roles = roles.join(" ");
714 dest.push_str(" class=\"");
715 dest.push_str(&roles);
716 dest.push('"');
717 }
718
719 dest.push('>');
720 dest.push_str(body);
721 dest.push_str("</");
722 dest.push_str(tag);
723 dest.push('>');
724}
725
726fn render_icon_or_image(
727 attrlist: &Attrlist,
728 img: &str,
729 src: &str,
730 type_: &'static str,
731 dest: &mut String,
732) {
733 let mut img = img.to_string();
734
735 if let Some(link) = attrlist.named_attribute("link") {
736 let mut link = link.value();
737 if link == "self" {
738 link = src;
739 }
740
741 img = format!(
742 r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
743 link_constraint_attrs = link_constraint_attrs(attrlist, None)
744 );
745 }
746
747 let mut roles: Vec<&str> = attrlist.roles();
748
749 if let Some(float) = attrlist.named_attribute("float") {
750 roles.insert(0, float.value());
751 }
752
753 roles.insert(0, type_);
754
755 dest.push_str(r#"<span class=""#);
756 dest.push_str(&roles.join(" "));
757 dest.push_str(r#"">"#);
758 dest.push_str(&img);
759 dest.push_str("</span>");
760}
761
762fn encode_attribute_value(value: String) -> String {
763 value.replace('"', """)
764}
765
766fn normalize_web_path(
767 target: &str,
768 parser: &Parser,
769 start: Option<&str>,
770 preserve_uri_target: bool,
771) -> String {
772 if preserve_uri_target && is_uri_ish(target) {
773 encode_spaces_in_uri(target)
774 } else {
775 parser.path_resolver.web_path(target, start)
776 }
777}
778
779fn is_uri_ish(path: &str) -> bool {
780 path.contains(':') && URI_SNIFF.is_match(path)
781}
782
783fn encode_spaces_in_uri(s: &str) -> String {
784 s.replace(' ', "%20")
785}
786
787static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
801 #[allow(clippy::unwrap_used)]
802 Regex::new(
803 r#"(?x)
804 \A # Anchor to start of string
805 \p{Alphabetic} # First character must be a letter
806 [\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
807 : # Literal colon
808 /{0,2} # Zero to two slashes
809 "#,
810 )
811 .unwrap()
812});
813
814fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
815 let rel = if attrlist.has_option("nofollow") {
816 Some("nofollow")
817 } else {
818 None
819 };
820
821 if let Some(window) = attrlist
822 .named_attribute("window")
823 .map(|a| a.value())
824 .or(window)
825 {
826 let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
827 if let Some(rel) = rel {
828 format!(r#" rel="{rel}" noopener"#)
829 } else {
830 r#" rel="noopener""#.to_owned()
831 }
832 } else {
833 "".to_string()
834 };
835
836 format!(r#" target="{window}"{rel_noopener}"#)
837 } else if let Some(rel) = rel {
838 format!(r#" rel="{rel}""#)
839 } else {
840 "".to_string()
841 }
842}