Skip to main content

asciidoc_parser/parser/
inline_substitution_renderer.rs

1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{Parser, attributes::Attrlist, parser::ResolvedReference};
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    /// Renders a cross-reference.
154    ///
155    /// When [`XrefRenderParams::resolved`] is `Some`, the reference resolved to
156    /// a destination; the renderer should link to it. When it is `None`, the
157    /// reference could not be resolved and the renderer should emit a sensible
158    /// fallback (e.g. a link to the raw target with bracketed text).
159    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String);
160
161    /// Renders a [callout] number that annotates a line in a verbatim block.
162    ///
163    /// The renderer should write an appropriate rendering of the callout number
164    /// to `dest`. The rendering typically depends on whether font-based or
165    /// image-based icons are enabled (via the `icons` document attribute).
166    ///
167    /// [callout]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
168    fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String);
169}
170
171/// Specifies which special character is being replaced in a call to
172/// [`InlineSubstitutionRenderer::render_special_character`].
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum SpecialCharacter {
175    /// Replace `<` character.
176    Lt,
177
178    /// Replace `>` character.
179    Gt,
180
181    /// Replace `&` character.
182    Ampersand,
183}
184
185/// Specifies which [quote type] is being rendered.
186///
187/// [quote type]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189pub enum QuoteType {
190    /// Strong (often bold) formatting.
191    Strong,
192
193    /// Word(s) surrounded by smart double quotes.
194    DoubleQuote,
195
196    /// Word(s) surrounded by smart single quotes.
197    SingleQuote,
198
199    /// Monospace (code) formatting.
200    Monospaced,
201
202    /// Emphasis (often italic) formatting.
203    Emphasis,
204
205    /// Text range (span) formatted with zero or more styles.
206    Mark,
207
208    /// Superscript formatting.
209    Superscript,
210
211    /// Subscript formatting.
212    Subscript,
213
214    /// Surrounds a block of text that may need a `<span>` or similar tag.
215    Unquoted,
216}
217
218/// Specifies whether the block is aligned to word boundaries or not.
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
220pub enum QuoteScope {
221    /// The quoted section was aligned to word boundaries.
222    Constrained,
223
224    /// The quoted section may not have been aligned to word boundaries.
225    Unconstrained,
226}
227
228/// Specifies which [character replacement] is being rendered.
229///
230/// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub enum CharacterReplacementType {
233    /// Copyright `(C)`.
234    Copyright,
235
236    /// Registered `(R)`.
237    Registered,
238
239    /// Trademark `(TM)`.
240    Trademark,
241
242    /// Em-dash surrounded by spaces ` -- `.
243    EmDashSurroundedBySpaces,
244
245    /// Em-dash without space `--`.
246    EmDashWithoutSpace,
247
248    /// Ellipsis `...`.
249    Ellipsis,
250
251    /// Single right arrow `->`.
252    SingleRightArrow,
253
254    /// Double right arrow `=>`.
255    DoubleRightArrow,
256
257    /// Single left arrow `<-`.
258    SingleLeftArrow,
259
260    /// Double left arrow `<=`.
261    DoubleLeftArrow,
262
263    /// Typographic apostrophe `'` within a word.
264    TypographicApostrophe,
265
266    /// Character reference `&___;`.
267    CharacterReference(String),
268}
269
270/// Provides parsed parameters for an image to be rendered.
271#[derive(Clone, Debug)]
272pub struct ImageRenderParams<'a> {
273    /// Target (the reference to the image).
274    pub target: &'a str,
275
276    /// Alt text (either explicitly set or defaulted).
277    pub alt: String,
278
279    /// Width. The data type is not checked; this may be any string.
280    pub width: Option<&'a str>,
281
282    /// Height. The data type is not checked; this may be any string.
283    pub height: Option<&'a str>,
284
285    /// Attribute list.
286    pub attrlist: &'a Attrlist<'a>,
287
288    /// Parser. The rendered may find document settings (such as an image
289    /// directory) in the parser's document attributes.
290    pub parser: &'a Parser,
291}
292
293/// Provides parsed parameters for an icon to be rendered.
294#[derive(Clone, Debug)]
295pub struct IconRenderParams<'a> {
296    /// Target (the reference to the image).
297    pub target: &'a str,
298
299    /// Alt text (either explicitly set or defaulted).
300    pub alt: String,
301
302    /// Size. The data type is not checked; this may be any string.
303    pub size: Option<&'a str>,
304
305    /// Attribute list.
306    pub attrlist: &'a Attrlist<'a>,
307
308    /// Parser. The rendered may find document settings (such as an image
309    /// directory) in the parser's document attributes.
310    pub parser: &'a Parser,
311}
312
313/// Provides parsed parameters for an icon to be rendered.
314#[derive(Clone, Debug)]
315pub struct LinkRenderParams<'a> {
316    /// Target (the target of this link).
317    pub target: String,
318
319    /// Link text.
320    pub link_text: String,
321
322    /// Roles (CSS classes) for this link not specified in the attrlist.
323    pub extra_roles: Vec<&'a str>,
324
325    /// Target window selection (passed through to `window` function in HTML).
326    pub window: Option<&'static str>,
327
328    /// What type of link is being rendered?
329    pub type_: LinkRenderType,
330
331    /// Attribute list.
332    pub attrlist: &'a Attrlist<'a>,
333
334    /// Parser. The rendered may find document settings (such as an image
335    /// directory) in the parser's document attributes.
336    pub parser: &'a Parser,
337}
338
339/// What type of link is being rendered?
340#[derive(Clone, Debug)]
341pub enum LinkRenderType {
342    /// TEMPORARY: I don't know the different types of links yet.
343    Link,
344}
345
346/// Provides parameters for rendering a [callout] number.
347///
348/// [callout]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
349#[derive(Clone, Debug)]
350pub struct CalloutRenderParams<'a> {
351    /// The callout number to display. For automatically-numbered callouts
352    /// (`<.>`), this is the resolved sequential number.
353    pub number: &'a str,
354
355    /// The guard surrounding the callout in the source. This controls whether
356    /// (and how) the line-comment or XML-comment characters that hide the
357    /// callout in the raw source are preserved in the output when icons are not
358    /// enabled.
359    pub guard: CalloutGuard<'a>,
360
361    /// Parser. The renderer reads the `icons`, `iconsdir`, and `icontype`
362    /// document attributes to decide how to render the callout.
363    pub parser: &'a Parser,
364}
365
366/// Describes the characters that guard (hide) a callout number in verbatim
367/// source.
368#[derive(Clone, Debug, Eq, PartialEq)]
369pub enum CalloutGuard<'a> {
370    /// A line-comment (or absent) guard. Holds the line-comment prefix that
371    /// precedes the callout in the source (e.g. `# `), or an empty string when
372    /// the callout is not tucked behind a line comment. When icons are not
373    /// enabled, the prefix is preserved ahead of the rendered callout number.
374    LineComment(&'a str),
375
376    /// An XML comment guard (`<!--N-->`). When icons are not enabled, the XML
377    /// comment delimiters are preserved around the rendered callout number.
378    Xml,
379}
380
381/// Provides parameters for rendering a cross-reference.
382#[derive(Clone, Debug)]
383pub struct XrefRenderParams<'a> {
384    /// The raw, uninterpreted cross-reference target as written in the source.
385    pub target: &'a str,
386
387    /// Explicit link text supplied in the cross-reference, if any.
388    pub provided_text: Option<&'a str>,
389
390    /// The resolved destination, or `None` if the reference is unresolved.
391    pub resolved: Option<&'a ResolvedReference>,
392}
393
394/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
395/// for common HTML-based applications.
396#[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("&lt;");
404            }
405            SpecialCharacter::Gt => {
406                dest.push_str("&gt;");
407            }
408            SpecialCharacter::Ampersand => {
409                dest.push_str("&amp;");
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("&#8220;");
448                dest.push_str(body);
449                dest.push_str("&#8221;");
450            }
451
452            QuoteType::SingleQuote => {
453                dest.push_str("&#8216;");
454                dest.push_str(body);
455                dest.push_str("&#8217;");
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("&#169;");
496            }
497
498            CharacterReplacementType::Registered => {
499                dest.push_str("&#174;");
500            }
501
502            CharacterReplacementType::Trademark => {
503                dest.push_str("&#8482;");
504            }
505
506            CharacterReplacementType::EmDashSurroundedBySpaces => {
507                dest.push_str("&#8201;&#8212;&#8201;");
508            }
509
510            CharacterReplacementType::EmDashWithoutSpace => {
511                dest.push_str("&#8212;&#8203;");
512            }
513
514            CharacterReplacementType::Ellipsis => {
515                dest.push_str("&#8230;&#8203;");
516            }
517
518            CharacterReplacementType::SingleLeftArrow => {
519                dest.push_str("&#8592;");
520            }
521
522            CharacterReplacementType::DoubleLeftArrow => {
523                dest.push_str("&#8656;");
524            }
525
526            CharacterReplacementType::SingleRightArrow => {
527                dest.push_str("&#8594;");
528            }
529
530            CharacterReplacementType::DoubleRightArrow => {
531                dest.push_str("&#8658;");
532            }
533
534            CharacterReplacementType::TypographicApostrophe => {
535                dest.push_str("&#8217;");
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        // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
582        // Enforce non-safe mode. Add this contraint to following `if` clause:
583        // `&& node.document.safe < SafeMode::SECURE`
584
585        let img = if format == Some("svg") || params.target.contains(".svg") {
586            // NOTE: In the SVG case we may have to ignore the attrs list.
587            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                // img = %(<img src="#{src = node.image_uri target}"
616                // alt="#{encode_attribute_value node.alt}"#{attrs}#{@
617                // void_element_slash}>)
618            )
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                // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
635                "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}&#93;", 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            // title = %( title="#{node.attr 'title'}") if node.attr? 'title'
747            // Haven't seen this in the wild yet.
748            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                // Unresolved: link to the raw target and show bracketed text,
776                // mirroring Asciidoctor's behavior for a missing reference.
777                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#"&lt;!--<b class="conum">({n})</b>--&gt;"#));
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('"', "&quot;")
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
916/// Detects strings that resemble URIs.
917///
918/// ## Examples
919///
920/// * `http://domain`
921/// * `https://domain`
922/// * `file:///path`
923/// * `data:info`
924///
925/// ## Counter-examples (do not match)
926///
927/// * `c:/sample.adoc`
928/// * `c:\sample.adoc`
929static 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}