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    /// Inline AsciiMath expression, surrounded by AsciiMath math delimiters.
218    AsciiMath,
219
220    /// Inline LaTeX math expression, surrounded by LaTeX inline math
221    /// delimiters.
222    LatexMath,
223}
224
225/// Specifies whether the block is aligned to word boundaries or not.
226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
227pub enum QuoteScope {
228    /// The quoted section was aligned to word boundaries.
229    Constrained,
230
231    /// The quoted section may not have been aligned to word boundaries.
232    Unconstrained,
233}
234
235/// Specifies which [character replacement] is being rendered.
236///
237/// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub enum CharacterReplacementType {
240    /// Copyright `(C)`.
241    Copyright,
242
243    /// Registered `(R)`.
244    Registered,
245
246    /// Trademark `(TM)`.
247    Trademark,
248
249    /// Em-dash surrounded by spaces ` -- `.
250    EmDashSurroundedBySpaces,
251
252    /// Em-dash without space `--`.
253    EmDashWithoutSpace,
254
255    /// Ellipsis `...`.
256    Ellipsis,
257
258    /// Single right arrow `->`.
259    SingleRightArrow,
260
261    /// Double right arrow `=>`.
262    DoubleRightArrow,
263
264    /// Single left arrow `<-`.
265    SingleLeftArrow,
266
267    /// Double left arrow `<=`.
268    DoubleLeftArrow,
269
270    /// Typographic apostrophe `'` within a word.
271    TypographicApostrophe,
272
273    /// Character reference `&___;`.
274    CharacterReference(String),
275}
276
277/// Provides parsed parameters for an image to be rendered.
278#[derive(Clone, Debug)]
279pub struct ImageRenderParams<'a> {
280    /// Target (the reference to the image).
281    pub target: &'a str,
282
283    /// Alt text (either explicitly set or defaulted).
284    pub alt: String,
285
286    /// Width. The data type is not checked; this may be any string.
287    pub width: Option<&'a str>,
288
289    /// Height. The data type is not checked; this may be any string.
290    pub height: Option<&'a str>,
291
292    /// Attribute list.
293    pub attrlist: &'a Attrlist<'a>,
294
295    /// Parser. The rendered may find document settings (such as an image
296    /// directory) in the parser's document attributes.
297    pub parser: &'a Parser,
298}
299
300/// Provides parsed parameters for an icon to be rendered.
301#[derive(Clone, Debug)]
302pub struct IconRenderParams<'a> {
303    /// Target (the reference to the image).
304    pub target: &'a str,
305
306    /// Alt text (either explicitly set or defaulted).
307    pub alt: String,
308
309    /// Size. The data type is not checked; this may be any string.
310    pub size: Option<&'a str>,
311
312    /// Attribute list.
313    pub attrlist: &'a Attrlist<'a>,
314
315    /// Parser. The rendered may find document settings (such as an image
316    /// directory) in the parser's document attributes.
317    pub parser: &'a Parser,
318}
319
320/// Provides parsed parameters for an icon to be rendered.
321#[derive(Clone, Debug)]
322pub struct LinkRenderParams<'a> {
323    /// Target (the target of this link).
324    pub target: String,
325
326    /// Link text.
327    pub link_text: String,
328
329    /// Roles (CSS classes) for this link not specified in the attrlist.
330    pub extra_roles: Vec<&'a str>,
331
332    /// Target window selection (passed through to `window` function in HTML).
333    pub window: Option<&'static str>,
334
335    /// What type of link is being rendered?
336    pub type_: LinkRenderType,
337
338    /// Attribute list.
339    pub attrlist: &'a Attrlist<'a>,
340
341    /// Parser. The rendered may find document settings (such as an image
342    /// directory) in the parser's document attributes.
343    pub parser: &'a Parser,
344}
345
346/// What type of link is being rendered?
347#[derive(Clone, Debug)]
348pub enum LinkRenderType {
349    /// TEMPORARY: I don't know the different types of links yet.
350    Link,
351}
352
353/// Provides parameters for rendering a [callout] number.
354///
355/// [callout]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
356#[derive(Clone, Debug)]
357pub struct CalloutRenderParams<'a> {
358    /// The callout number to display. For automatically-numbered callouts
359    /// (`<.>`), this is the resolved sequential number.
360    pub number: &'a str,
361
362    /// The guard surrounding the callout in the source. This controls whether
363    /// (and how) the line-comment or XML-comment characters that hide the
364    /// callout in the raw source are preserved in the output when icons are not
365    /// enabled.
366    pub guard: CalloutGuard<'a>,
367
368    /// Parser. The renderer reads the `icons`, `iconsdir`, and `icontype`
369    /// document attributes to decide how to render the callout.
370    pub parser: &'a Parser,
371}
372
373/// Describes the characters that guard (hide) a callout number in verbatim
374/// source.
375#[derive(Clone, Debug, Eq, PartialEq)]
376pub enum CalloutGuard<'a> {
377    /// A line-comment (or absent) guard. Holds the line-comment prefix that
378    /// precedes the callout in the source (e.g. `# `), or an empty string when
379    /// the callout is not tucked behind a line comment. When icons are not
380    /// enabled, the prefix is preserved ahead of the rendered callout number.
381    LineComment(&'a str),
382
383    /// An XML comment guard (`<!--N-->`). When icons are not enabled, the XML
384    /// comment delimiters are preserved around the rendered callout number.
385    Xml,
386}
387
388/// Provides parameters for rendering a cross-reference.
389#[derive(Clone, Debug)]
390pub struct XrefRenderParams<'a> {
391    /// The raw, uninterpreted cross-reference target as written in the source.
392    pub target: &'a str,
393
394    /// Explicit link text supplied in the cross-reference, if any.
395    pub provided_text: Option<&'a str>,
396
397    /// The resolved destination, or `None` if the reference is unresolved.
398    pub resolved: Option<&'a ResolvedReference>,
399}
400
401/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
402/// for common HTML-based applications.
403#[derive(Debug)]
404pub struct HtmlSubstitutionRenderer {}
405
406impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
407    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
408        match type_ {
409            SpecialCharacter::Lt => {
410                dest.push_str("&lt;");
411            }
412            SpecialCharacter::Gt => {
413                dest.push_str("&gt;");
414            }
415            SpecialCharacter::Ampersand => {
416                dest.push_str("&amp;");
417            }
418        }
419    }
420
421    fn render_quoted_substitition(
422        &self,
423        type_: QuoteType,
424        _scope: QuoteScope,
425        attrlist: Option<Attrlist<'_>>,
426        mut id: Option<String>,
427        body: &str,
428        dest: &mut String,
429    ) {
430        let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
431
432        if let Some(block_style) = attrlist
433            .as_ref()
434            .and_then(|a| a.nth_attribute(1))
435            .and_then(|attr1| attr1.block_style())
436        {
437            roles.insert(0, block_style);
438        }
439
440        if id.is_none() {
441            id = attrlist
442                .as_ref()
443                .and_then(|a| a.nth_attribute(1))
444                .and_then(|attr1| attr1.id())
445                .map(|id| id.to_owned())
446        }
447
448        match type_ {
449            QuoteType::Strong => {
450                wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
451            }
452
453            QuoteType::DoubleQuote => {
454                dest.push_str("&#8220;");
455                dest.push_str(body);
456                dest.push_str("&#8221;");
457            }
458
459            QuoteType::SingleQuote => {
460                dest.push_str("&#8216;");
461                dest.push_str(body);
462                dest.push_str("&#8217;");
463            }
464
465            QuoteType::Monospaced => {
466                wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
467            }
468
469            QuoteType::Emphasis => {
470                wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
471            }
472
473            QuoteType::Mark => {
474                if roles.is_empty() && id.is_none() {
475                    wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
476                } else {
477                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
478                }
479            }
480
481            QuoteType::Superscript => {
482                wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
483            }
484
485            QuoteType::Subscript => {
486                wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
487            }
488
489            QuoteType::Unquoted => {
490                if roles.is_empty() && id.is_none() {
491                    dest.push_str(body);
492                } else {
493                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
494                }
495            }
496
497            QuoteType::AsciiMath => {
498                dest.push_str(r"\$");
499                dest.push_str(body);
500                dest.push_str(r"\$");
501            }
502
503            QuoteType::LatexMath => {
504                dest.push_str(r"\(");
505                dest.push_str(body);
506                dest.push_str(r"\)");
507            }
508        }
509    }
510
511    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
512        match type_ {
513            CharacterReplacementType::Copyright => {
514                dest.push_str("&#169;");
515            }
516
517            CharacterReplacementType::Registered => {
518                dest.push_str("&#174;");
519            }
520
521            CharacterReplacementType::Trademark => {
522                dest.push_str("&#8482;");
523            }
524
525            CharacterReplacementType::EmDashSurroundedBySpaces => {
526                dest.push_str("&#8201;&#8212;&#8201;");
527            }
528
529            CharacterReplacementType::EmDashWithoutSpace => {
530                dest.push_str("&#8212;&#8203;");
531            }
532
533            CharacterReplacementType::Ellipsis => {
534                dest.push_str("&#8230;&#8203;");
535            }
536
537            CharacterReplacementType::SingleLeftArrow => {
538                dest.push_str("&#8592;");
539            }
540
541            CharacterReplacementType::DoubleLeftArrow => {
542                dest.push_str("&#8656;");
543            }
544
545            CharacterReplacementType::SingleRightArrow => {
546                dest.push_str("&#8594;");
547            }
548
549            CharacterReplacementType::DoubleRightArrow => {
550                dest.push_str("&#8658;");
551            }
552
553            CharacterReplacementType::TypographicApostrophe => {
554                dest.push_str("&#8217;");
555            }
556
557            CharacterReplacementType::CharacterReference(name) => {
558                dest.push('&');
559                dest.push_str(&name);
560                dest.push(';');
561            }
562        }
563    }
564
565    fn render_line_break(&self, dest: &mut String) {
566        dest.push_str("<br>");
567    }
568
569    fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
570        let src = self.image_uri(params.target, params.parser, None);
571
572        let mut attrs: Vec<String> = vec![
573            format!(r#"src="{src}""#),
574            format!(
575                r#"alt="{alt}""#,
576                alt = encode_attribute_value(params.alt.to_string())
577            ),
578        ];
579
580        if let Some(width) = params.width {
581            attrs.push(format!(r#"width="{width}""#));
582        }
583
584        if let Some(height) = params.height {
585            attrs.push(format!(r#"height="{height}""#));
586        }
587
588        if let Some(title) = params.attrlist.named_attribute("title") {
589            attrs.push(format!(
590                r#"title="{title}""#,
591                title = encode_attribute_value(title.value().to_owned())
592            ));
593        }
594
595        let format = params
596            .attrlist
597            .named_attribute("format")
598            .map(|format| format.value());
599
600        // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
601        // Enforce non-safe mode. Add this contraint to following `if` clause:
602        // `&& node.document.safe < SafeMode::SECURE`
603
604        let img = if format == Some("svg") || params.target.contains(".svg") {
605            // NOTE: In the SVG case we may have to ignore the attrs list.
606            if params.attrlist.has_option("inline") {
607                todo!(
608                    "Port this: {}",
609                    r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
610                    NOTE: The attrs list calculated above may not be usable.
611                    "#
612                );
613            } else if params.attrlist.has_option("interactive") {
614                todo!(
615                    "Port this: {}",
616                    r##"
617                        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>)
618                        img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
619                        NOTE: The attrs list calculated above may not be usable.
620                    "##
621                );
622            } else {
623                format!(
624                    r#"<img {attrs}{void_element_slash}>"#,
625                    attrs = attrs.join(" "),
626                    void_element_slash = "",
627                )
628            }
629        } else {
630            format!(
631                r#"<img {attrs}{void_element_slash}>"#,
632                attrs = attrs.join(" "),
633                void_element_slash = "",
634                // img = %(<img src="#{src = node.image_uri target}"
635                // alt="#{encode_attribute_value node.alt}"#{attrs}#{@
636                // void_element_slash}>)
637            )
638        };
639
640        render_icon_or_image(params.attrlist, &img, &src, "image", dest);
641    }
642
643    fn image_uri(
644        &self,
645        target_image_path: &str,
646        parser: &Parser,
647        asset_dir_key: Option<&str>,
648    ) -> String {
649        let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
650
651        if false {
652            todo!(
653                // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
654                "Port this when implementing safe modes: {}",
655                r#"
656				if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
657				  if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
658					  (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
659					  (target_image = normalize_web_path target_image, images_base, false))
660					(doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
661				  else
662					generate_data_uri target_image, asset_dir_key
663				  end
664				else
665				  normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
666				end
667            "#
668            );
669        } else {
670            let asset_dir = parser
671                .attribute_value(asset_dir_key)
672                .as_maybe_str()
673                .map(|s| s.to_string());
674
675            normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
676        }
677    }
678
679    fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
680        let src = self.icon_uri(params.target, params.attrlist, params.parser);
681
682        let img = if params.parser.is_attribute_set("icons") {
683            let icons = params.parser.attribute_value("icons");
684            if let Some(icons) = icons.as_maybe_str()
685                && icons == "font"
686            {
687                let mut i_class_attrs: Vec<String> = vec![
688                    "fa".to_owned(),
689                    format!("fa-{target}", target = params.target),
690                ];
691
692                if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
693                    i_class_attrs.push(format!("fa-{size}", size = size.value()));
694                }
695
696                if let Some(flip) = params.attrlist.named_attribute("flip") {
697                    i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
698                } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
699                    i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
700                }
701
702                format!(
703                    r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
704                    i_class_attr_val = i_class_attrs.join(" "),
705                    title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
706                        format!(r#" title="{title}""#, title = title.value())
707                    } else {
708                        "".to_owned()
709                    }
710                )
711            } else {
712                let mut attrs: Vec<String> = vec![
713                    format!(r#"src="{src}""#),
714                    format!(
715                        r#"alt="{alt}""#,
716                        alt = encode_attribute_value(params.alt.to_string())
717                    ),
718                ];
719
720                if let Some(width) = params.attrlist.named_attribute("width") {
721                    attrs.push(format!(r#"width="{width}""#, width = width.value()));
722                }
723
724                if let Some(height) = params.attrlist.named_attribute("height") {
725                    attrs.push(format!(r#"height="{height}""#, height = height.value()));
726                }
727
728                if let Some(title) = params.attrlist.named_attribute("title") {
729                    attrs.push(format!(r#"title="{title}""#, title = title.value()));
730                }
731
732                format!(
733                    "<img {attrs}{void_element_slash}>",
734                    attrs = attrs.join(" "),
735                    void_element_slash = "",
736                )
737            }
738        } else {
739            format!("[{alt}&#93;", alt = params.alt)
740        };
741
742        render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
743    }
744
745    fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
746        let id = params.attrlist.id();
747
748        let mut roles = params.extra_roles.clone();
749        let mut attrlist_roles = params.attrlist.roles().clone();
750        roles.append(&mut attrlist_roles);
751
752        let link = format!(
753            r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
754            target = params.target,
755            id = if let Some(id) = id {
756                format!(r#" id="{id}""#)
757            } else {
758                "".to_owned()
759            },
760            class = if roles.is_empty() {
761                "".to_owned()
762            } else {
763                format!(r#" class="{roles}""#, roles = roles.join(" "))
764            },
765            // title = %( title="#{node.attr 'title'}") if node.attr? 'title'
766            // Haven't seen this in the wild yet.
767            link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
768            link_text = params.link_text,
769        );
770
771        dest.push_str(&link);
772    }
773
774    fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
775        dest.push_str(&format!("<a id=\"{id}\"></a>"));
776    }
777
778    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
779        match params.resolved {
780            Some(resolved) => {
781                let text = params
782                    .provided_text
783                    .map(str::to_string)
784                    .or_else(|| resolved.text.clone())
785                    .unwrap_or_else(|| format!("[{target}]", target = params.target));
786
787                dest.push_str(&format!(
788                    r#"<a href="{href}">{text}</a>"#,
789                    href = resolved.href
790                ));
791            }
792
793            None => {
794                // Unresolved: link to the raw target and show bracketed text,
795                // mirroring Asciidoctor's behavior for a missing reference.
796                let text = params
797                    .provided_text
798                    .map(str::to_string)
799                    .unwrap_or_else(|| format!("[{target}]", target = params.target));
800
801                dest.push_str(&format!(
802                    r##"<a href="#{target}">{text}</a>"##,
803                    target = params.target
804                ));
805            }
806        }
807    }
808
809    fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
810        let n = params.number;
811        let parser = params.parser;
812
813        if parser.attribute_value("icons").as_maybe_str() == Some("font") {
814            dest.push_str(&format!(
815                r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
816            ));
817        } else if parser.is_attribute_set("icons") {
818            let icontype = parser
819                .attribute_value("icontype")
820                .as_maybe_str()
821                .unwrap_or("png")
822                .to_owned();
823
824            let icon = format!("callouts/{n}.{icontype}");
825            let src = self.image_uri(&icon, parser, Some("iconsdir"));
826
827            dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
828        } else {
829            match params.guard {
830                CalloutGuard::Xml => {
831                    dest.push_str(&format!(r#"&lt;!--<b class="conum">({n})</b>--&gt;"#));
832                }
833
834                CalloutGuard::LineComment(prefix) => {
835                    dest.push_str(prefix);
836                    dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
837                }
838            }
839        }
840    }
841}
842
843fn wrap_body_in_html_tag(
844    _attrlist: Option<&Attrlist<'_>>,
845    tag: &'static str,
846    id: Option<String>,
847    roles: Vec<&str>,
848    body: &str,
849    dest: &mut String,
850) {
851    dest.push('<');
852    dest.push_str(tag);
853
854    if let Some(id) = id.as_ref() {
855        dest.push_str(" id=\"");
856        dest.push_str(id);
857        dest.push('"');
858    }
859
860    if !roles.is_empty() {
861        let roles = roles.join(" ");
862        dest.push_str(" class=\"");
863        dest.push_str(&roles);
864        dest.push('"');
865    }
866
867    dest.push('>');
868    dest.push_str(body);
869    dest.push_str("</");
870    dest.push_str(tag);
871    dest.push('>');
872}
873
874fn render_icon_or_image(
875    attrlist: &Attrlist,
876    img: &str,
877    src: &str,
878    type_: &'static str,
879    dest: &mut String,
880) {
881    let mut img = img.to_string();
882
883    if let Some(link) = attrlist.named_attribute("link") {
884        let mut link = link.value();
885        if link == "self" {
886            link = src;
887        }
888
889        img = format!(
890            r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
891            link_constraint_attrs = link_constraint_attrs(attrlist, None)
892        );
893    }
894
895    let mut roles: Vec<&str> = attrlist.roles();
896
897    if let Some(float) = attrlist.named_attribute("float") {
898        roles.insert(0, float.value());
899    }
900
901    roles.insert(0, type_);
902
903    dest.push_str(r#"<span class=""#);
904    dest.push_str(&roles.join(" "));
905    dest.push_str(r#"">"#);
906    dest.push_str(&img);
907    dest.push_str("</span>");
908}
909
910fn encode_attribute_value(value: String) -> String {
911    value.replace('"', "&quot;")
912}
913
914fn normalize_web_path(
915    target: &str,
916    parser: &Parser,
917    start: Option<&str>,
918    preserve_uri_target: bool,
919) -> String {
920    if preserve_uri_target && is_uri_ish(target) {
921        encode_spaces_in_uri(target)
922    } else {
923        parser.path_resolver.web_path(target, start)
924    }
925}
926
927fn is_uri_ish(path: &str) -> bool {
928    path.contains(':') && URI_SNIFF.is_match(path)
929}
930
931fn encode_spaces_in_uri(s: &str) -> String {
932    s.replace(' ', "%20")
933}
934
935/// Detects strings that resemble URIs.
936///
937/// ## Examples
938///
939/// * `http://domain`
940/// * `https://domain`
941/// * `file:///path`
942/// * `data:info`
943///
944/// ## Counter-examples (do not match)
945///
946/// * `c:/sample.adoc`
947/// * `c:\sample.adoc`
948static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
949    #[allow(clippy::unwrap_used)]
950    Regex::new(
951        r#"(?x)
952        \A                             # Anchor to start of string
953        \p{Alphabetic}                 # First character must be a letter
954        [\p{Alphabetic}\p{Nd}.+-]+     # Followed by one or more alphanum or . + -
955        :                              # Literal colon
956        /{0,2}                         # Zero to two slashes
957    "#,
958    )
959    .unwrap()
960});
961
962fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
963    let rel = if attrlist.has_option("nofollow") {
964        Some("nofollow")
965    } else {
966        None
967    };
968
969    if let Some(window) = attrlist
970        .named_attribute("window")
971        .map(|a| a.value())
972        .or(window)
973    {
974        let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
975            if let Some(rel) = rel {
976                format!(r#" rel="{rel}" noopener"#)
977            } else {
978                r#" rel="noopener""#.to_owned()
979            }
980        } else {
981            "".to_string()
982        };
983
984        format!(r#" target="{window}"{rel_noopener}"#)
985    } else if let Some(rel) = rel {
986        format!(r#" rel="{rel}""#)
987    } else {
988        "".to_string()
989    }
990}