asciidoc_parser/parser/
inline_substitution_renderer.rs

1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{Parser, attributes::Attrlist};
6
7/// An implementation of `InlineSubstitutionRenderer` is used when converting
8/// the basic raw text of a simple block to the format which will ultimately be
9/// presented in the final converted output.
10///
11/// An implementation is provided for HTML output; alternative implementations
12/// (not provided in this crate) could support other output formats.
13pub trait InlineSubstitutionRenderer: Debug {
14    /// Renders the substitution for a special character.
15    ///
16    /// The renderer should write the appropriate rendering to `dest`.
17    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String);
18
19    /// Renders the content of a [quote substitution].
20    ///
21    /// The renderer should write the appropriate rendering to `dest`.
22    ///
23    /// [quote substitution]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
24    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    /// Renders the content of a [character replacement].
35    ///
36    /// The renderer should write the appropriate rendering to `dest`.
37    ///
38    /// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
39    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String);
40
41    /// Renders a line break.
42    ///
43    /// The renderer should write an appropriate rendering of line break to
44    /// `dest`.
45    ///
46    /// This is used in the implementation of [post-replacement substitutions].
47    ///
48    /// [post-replacement substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/post-replacements/
49    fn render_line_break(&self, dest: &mut String);
50
51    /// Renders an image.
52    ///
53    /// The renderer should write an appropriate rendering of the specified
54    /// image to `dest`.
55    fn render_image(&self, params: &ImageRenderParams, dest: &mut String);
56
57    /// Construct a URI reference or data URI to the target image.
58    ///
59    /// If the `target_image_path` is a URI reference, then leave it untouched.
60    ///
61    /// The `target_image_path` is resolved relative to the directory retrieved
62    /// from the specified document-scoped attribute key, if provided.
63    ///
64    /// NOT YET IMPLEMENTED:
65    /// If the `data-uri` attribute is set on the document, and the safe mode
66    /// level is less than `SafeMode::SECURE`, the image will be safely
67    /// converted to a data URI by reading it from the same directory. If
68    /// neither of these conditions are satisfied, a relative path (i.e., URL)
69    /// will be returned.
70    ///
71    /// ## Parameters
72    ///
73    /// * `target_image_path`: path to the target image
74    /// * `parser`: Current document parser state
75    /// * `asset_dir_key`: If provided, the attribute key used to look up the
76    ///   directory where the image is located. If not provided, `imagesdir` is
77    ///   used.
78    ///
79    /// ## Return
80    ///
81    /// Returns a string reference or data URI for the target image that can be
82    /// safely used in an image tag.
83    fn image_uri(
84        &self,
85        target_image_path: &str,
86        parser: &Parser,
87        asset_dir_key: Option<&str>,
88    ) -> String;
89
90    /// Renders an icon.
91    ///
92    /// The renderer should write an appropriate rendering of the specified
93    /// icon to `dest`.
94    fn render_icon(&self, params: &IconRenderParams, dest: &mut String);
95
96    /// Construct a reference or data URI to an icon image for the specified
97    /// icon name.
98    ///
99    /// If the `icon` attribute is set on this block, the name is ignored and
100    /// the value of this attribute is used as the target image path. Otherwise,
101    /// construct a target image path by concatenating the value of the
102    /// `iconsdir` attribute, the icon name, and the value of the `icontype`
103    /// attribute (defaulting to `png`).
104    ///
105    /// The target image path is then passed through the `image_uri()` method.
106    /// If the `data-uri` attribute is set on the document, the image will be
107    /// safely converted to a data URI.
108    ///
109    /// The return value of this method can be safely used in an image tag.
110    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    /// Renders a link.
142    ///
143    /// The renderer should write an appropriate rendering of the specified
144    /// link, to `dest`.
145    fn render_link(&self, params: &LinkRenderParams, dest: &mut String);
146
147    /// Renders an anchor.
148    ///
149    /// The rendered should write an appropriate rendering of the specified
150    /// anchor with ID and possible ref text (only used by some renderers).
151    fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String);
152}
153
154/// Specifies which special character is being replaced in a call to
155/// [`InlineSubstitutionRenderer::render_special_character`].
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub enum SpecialCharacter {
158    /// Replace `<` character.
159    Lt,
160
161    /// Replace `>` character.
162    Gt,
163
164    /// Replace `&` character.
165    Ampersand,
166}
167
168/// Specifies which [quote type] is being rendered.
169///
170/// [quote type]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
172pub enum QuoteType {
173    /// Strong (often bold) formatting.
174    Strong,
175
176    /// Word(s) surrounded by smart double quotes.
177    DoubleQuote,
178
179    /// Word(s) surrounded by smart single quotes.
180    SingleQuote,
181
182    /// Monospace (code) formatting.
183    Monospaced,
184
185    /// Emphasis (often italic) formatting.
186    Emphasis,
187
188    /// Text range (span) formatted with zero or more styles.
189    Mark,
190
191    /// Superscript formatting.
192    Superscript,
193
194    /// Subscript formatting.
195    Subscript,
196
197    /// Surrounds a block of text that may need a `<span>` or similar tag.
198    Unquoted,
199}
200
201/// Specifies whether the block is aligned to word boundaries or not.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203pub enum QuoteScope {
204    /// The quoted section was aligned to word boundaries.
205    Constrained,
206
207    /// The quoted section may not have been aligned to word boundaries.
208    Unconstrained,
209}
210
211/// Specifies which [character replacement] is being rendered.
212///
213/// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub enum CharacterReplacementType {
216    /// Copyright `(C)`.
217    Copyright,
218
219    /// Registered `(R)`.
220    Registered,
221
222    /// Trademark `(TM)`.
223    Trademark,
224
225    /// Em-dash surrounded by spaces ` -- `.
226    EmDashSurroundedBySpaces,
227
228    /// Em-dash without space `--`.
229    EmDashWithoutSpace,
230
231    /// Ellipsis `...`.
232    Ellipsis,
233
234    /// Single right arrow `->`.
235    SingleRightArrow,
236
237    /// Double right arrow `=>`.
238    DoubleRightArrow,
239
240    /// Single left arrow `<-`.
241    SingleLeftArrow,
242
243    /// Double left arrow `<=`.
244    DoubleLeftArrow,
245
246    /// Typographic apostrophe `'` within a word.
247    TypographicApostrophe,
248
249    /// Character reference `&___;`.
250    CharacterReference(String),
251}
252
253/// Provides parsed parameters for an image to be rendered.
254#[derive(Clone, Debug)]
255pub struct ImageRenderParams<'a> {
256    /// Target (the reference to the image).
257    pub target: &'a str,
258
259    /// Alt text (either explicitly set or defaulted).
260    pub alt: String,
261
262    /// Width. The data type is not checked; this may be any string.
263    pub width: Option<&'a str>,
264
265    /// Height. The data type is not checked; this may be any string.
266    pub height: Option<&'a str>,
267
268    /// Attribute list.
269    pub attrlist: &'a Attrlist<'a>,
270
271    /// Parser. The rendered may find document settings (such as an image
272    /// directory) in the parser's document attributes.
273    pub parser: &'a Parser,
274}
275
276/// Provides parsed parameters for an icon to be rendered.
277#[derive(Clone, Debug)]
278pub struct IconRenderParams<'a> {
279    /// Target (the reference to the image).
280    pub target: &'a str,
281
282    /// Alt text (either explicitly set or defaulted).
283    pub alt: String,
284
285    /// Size. The data type is not checked; this may be any string.
286    pub size: Option<&'a str>,
287
288    /// Attribute list.
289    pub attrlist: &'a Attrlist<'a>,
290
291    /// Parser. The rendered may find document settings (such as an image
292    /// directory) in the parser's document attributes.
293    pub parser: &'a Parser,
294}
295
296/// Provides parsed parameters for an icon to be rendered.
297#[derive(Clone, Debug)]
298pub struct LinkRenderParams<'a> {
299    /// Target (the target of this link).
300    pub target: String,
301
302    /// Link text.
303    pub link_text: String,
304
305    /// Roles (CSS classes) for this link not specified in the attrlist.
306    pub extra_roles: Vec<&'a str>,
307
308    /// Target window selection (passed through to `window` function in HTML).
309    pub window: Option<&'static str>,
310
311    /// What type of link is being rendered?
312    pub type_: LinkRenderType,
313
314    /// Attribute list.
315    pub attrlist: &'a Attrlist<'a>,
316
317    /// Parser. The rendered may find document settings (such as an image
318    /// directory) in the parser's document attributes.
319    pub parser: &'a Parser,
320}
321
322/// What type of link is being rendered?
323#[derive(Clone, Debug)]
324pub enum LinkRenderType {
325    /// TEMPORARY: I don't know the different types of links yet.
326    Link,
327}
328
329/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
330/// for common HTML-based applications.
331#[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("&lt;");
339            }
340            SpecialCharacter::Gt => {
341                dest.push_str("&gt;");
342            }
343            SpecialCharacter::Ampersand => {
344                dest.push_str("&amp;");
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("&#8220;");
383                dest.push_str(body);
384                dest.push_str("&#8221;");
385            }
386
387            QuoteType::SingleQuote => {
388                dest.push_str("&#8216;");
389                dest.push_str(body);
390                dest.push_str("&#8217;");
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("&#169;");
431            }
432
433            CharacterReplacementType::Registered => {
434                dest.push_str("&#174;");
435            }
436
437            CharacterReplacementType::Trademark => {
438                dest.push_str("&#8482;");
439            }
440
441            CharacterReplacementType::EmDashSurroundedBySpaces => {
442                dest.push_str("&#8201;&#8212;&#8201;");
443            }
444
445            CharacterReplacementType::EmDashWithoutSpace => {
446                dest.push_str("&#8212;&#8203;");
447            }
448
449            CharacterReplacementType::Ellipsis => {
450                dest.push_str("&#8230;&#8203;");
451            }
452
453            CharacterReplacementType::SingleLeftArrow => {
454                dest.push_str("&#8592;");
455            }
456
457            CharacterReplacementType::DoubleLeftArrow => {
458                dest.push_str("&#8656;");
459            }
460
461            CharacterReplacementType::SingleRightArrow => {
462                dest.push_str("&#8594;");
463            }
464
465            CharacterReplacementType::DoubleRightArrow => {
466                dest.push_str("&#8658;");
467            }
468
469            CharacterReplacementType::TypographicApostrophe => {
470                dest.push_str("&#8217;");
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        // TO DO (https://github.com/scouten/asciidoc-parser/issues/277):
517        // Enforce non-safe mode. Add this contraint to following `if` clause:
518        // `&& node.document.safe < SafeMode::SECURE`
519
520        let img = if format == Some("svg") || params.target.contains(".svg") {
521            // NOTE: In the SVG case we may have to ignore the attrs list.
522            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                // img = %(<img src="#{src = node.image_uri target}"
551                // alt="#{encode_attribute_value node.alt}"#{attrs}#{@
552                // void_element_slash}>)
553            )
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                // TO DO (https://github.com/scouten/asciidoc-parser/issues/277):
570                "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}&#93;", 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            // title = %( title="#{node.attr 'title'}") if node.attr? 'title'
682            // Haven't seen this in the wild yet.
683            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('"', "&quot;")
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
787/// Detects strings that resemble URIs.
788///
789/// ## Examples
790///
791/// * `http://domain`
792/// * `https://domain`
793/// * `file:///path`
794/// * `data:info`
795///
796/// ## Counter-examples (do not match)
797///
798/// * `c:/sample.adoc`
799/// * `c:\sample.adoc`
800static 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}