Skip to main content

asciidoc_parser/parser/
inline_substitution_renderer.rs

1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{
6    Parser,
7    attributes::Attrlist,
8    parser::{DerivedReference, ResolvedReference, SafeMode, XrefSignifier, XrefStyle},
9};
10
11/// An implementation of `InlineSubstitutionRenderer` is used when converting
12/// the basic raw text of a simple block to the format which will ultimately be
13/// presented in the final converted output.
14///
15/// An implementation is provided for HTML output
16/// ([`HtmlSubstitutionRenderer`]); alternative implementations (not provided in
17/// this crate) could support other output formats.
18///
19/// ## Overriding only what differs
20///
21/// Every method has a default body, so an implementation only needs to override
22/// the substitutions whose output it wants to change and inherits the built-in
23/// HTML output for the rest. In particular, [`image_uri`](Self::image_uri) and
24/// [`render_image`](Self::render_image) inherit the crate's `data-uri`
25/// embedding and `opts=inline` SVG handling – which read bytes through the
26/// [`ImageFileHandler`](crate::parser::ImageFileHandler) and
27/// [`SvgFileHandler`](crate::parser::SvgFileHandler) registered on the
28/// [`Parser`] – so a downstream renderer gets that behavior without needing to
29/// reproduce it.
30///
31/// Note that these defaults delegate to the HTML renderer's *own* methods
32/// rather than routing back through `self`: an override of one method does not
33/// change what a still-defaulted sibling emits. For example, overriding
34/// [`image_uri`](Self::image_uri) alone does not change the URI that the
35/// inherited [`render_image`](Self::render_image) or
36/// [`render_icon`](Self::render_icon) produces – override that method too
37/// (calling `self.image_uri(…)`) if the two must agree. The sole exception is
38/// [`icon_uri`](Self::icon_uri): its default derives an icon path and then
39/// calls `self.image_uri(…)`, so it alone *does* reflect an `image_uri`
40/// override. A renderer that resolves asset URIs itself can reach the
41/// registered handlers through
42/// [`Parser::image_file_handler`](crate::Parser::image_file_handler) and
43/// [`Parser::svg_file_handler`](crate::Parser::svg_file_handler).
44pub trait InlineSubstitutionRenderer: Debug {
45    /// Renders the substitution for a special character.
46    ///
47    /// The renderer should write the appropriate rendering to `dest`.
48    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
49        DEFAULT_HTML_RENDERER.render_special_character(type_, dest);
50    }
51
52    /// Renders the content of a [quote substitution].
53    ///
54    /// The renderer should write the appropriate rendering to `dest`.
55    ///
56    /// [quote substitution]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
57    fn render_quoted_substitution(
58        &self,
59        type_: QuoteType,
60        scope: QuoteScope,
61        attrlist: Option<Attrlist<'_>>,
62        id: Option<String>,
63        body: &str,
64        dest: &mut String,
65    ) {
66        DEFAULT_HTML_RENDERER.render_quoted_substitution(type_, scope, attrlist, id, body, dest);
67    }
68
69    /// Renders the content of a [character replacement].
70    ///
71    /// The renderer should write the appropriate rendering to `dest`.
72    ///
73    /// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
74    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
75        DEFAULT_HTML_RENDERER.render_character_replacement(type_, dest);
76    }
77
78    /// Renders a line break.
79    ///
80    /// The renderer should write an appropriate rendering of line break to
81    /// `dest`.
82    ///
83    /// This is used in the implementation of [post-replacement substitutions].
84    ///
85    /// [post-replacement substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/post-replacements/
86    fn render_line_break(&self, dest: &mut String) {
87        DEFAULT_HTML_RENDERER.render_line_break(dest);
88    }
89
90    /// Renders an image.
91    ///
92    /// The renderer should write an appropriate rendering of the specified
93    /// image to `dest`.
94    fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
95        DEFAULT_HTML_RENDERER.render_image(params, dest);
96    }
97
98    /// Construct a URI reference or data URI to the target image.
99    ///
100    /// If the `target_image_path` is a URI reference, then leave it untouched.
101    ///
102    /// The `target_image_path` is resolved relative to the directory retrieved
103    /// from the specified document-scoped attribute key, if provided.
104    ///
105    /// If the `data-uri` attribute is set on the document and the safe mode is
106    /// below `SafeMode::Secure`, the image is embedded as a
107    /// `data:<mime>;base64,…` URI by reading its bytes through the
108    /// [`ImageFileHandler`](crate::parser::ImageFileHandler); otherwise (or
109    /// when no handler is registered) a normalized relative path (i.e.,
110    /// URL) is returned. A target that is itself a URI is never embedded.
111    ///
112    /// ## Parameters
113    ///
114    /// * `target_image_path`: path to the target image
115    /// * `parser`: Current document parser state
116    /// * `asset_dir_key`: If provided, the attribute key used to look up the
117    ///   directory where the image is located. If not provided, `imagesdir` is
118    ///   used.
119    ///
120    /// ## Return
121    ///
122    /// Returns a string reference or data URI for the target image that can be
123    /// safely used in an image tag.
124    fn image_uri(
125        &self,
126        target_image_path: &str,
127        parser: &Parser,
128        asset_dir_key: Option<&str>,
129    ) -> String {
130        DEFAULT_HTML_RENDERER.image_uri(target_image_path, parser, asset_dir_key)
131    }
132
133    /// Renders an icon.
134    ///
135    /// The renderer should write an appropriate rendering of the specified
136    /// icon to `dest`.
137    fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
138        DEFAULT_HTML_RENDERER.render_icon(params, dest);
139    }
140
141    /// Construct a reference or data URI to an icon image for the specified
142    /// icon name.
143    ///
144    /// The target image path is derived from the icon name. If the name already
145    /// carries a file extension, it is used verbatim; otherwise the value of
146    /// the `icontype` attribute (defaulting to `png`) is appended. In both
147    /// cases the path is resolved relative to the `iconsdir` attribute.
148    /// This mirrors the icon macro's image mode, where `icontype` is only
149    /// consulted when the icon type must be inferred (i.e. the target has
150    /// no file extension).
151    ///
152    /// The target image path is then passed through the `image_uri()` method.
153    /// If the `data-uri` attribute is set on the document, the image will be
154    /// safely converted to a data URI.
155    ///
156    /// The return value of this method can be safely used in an image tag.
157    fn icon_uri(&self, name: &str, _attrlist: &Attrlist, parser: &Parser) -> String {
158        let icon = if has_extname(name) {
159            name.to_owned()
160        } else {
161            let icontype = parser
162                .attribute_value("icontype")
163                .as_maybe_str()
164                .unwrap_or("png")
165                .to_owned();
166
167            format!("{name}.{icontype}")
168        };
169
170        self.image_uri(&icon, parser, Some("iconsdir"))
171    }
172
173    /// Renders a link.
174    ///
175    /// The renderer should write an appropriate rendering of the specified
176    /// link, to `dest`.
177    fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
178        DEFAULT_HTML_RENDERER.render_link(params, dest);
179    }
180
181    /// Renders an anchor.
182    ///
183    /// The rendered should write an appropriate rendering of the specified
184    /// anchor with ID and possible ref text (only used by some renderers).
185    fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String) {
186        DEFAULT_HTML_RENDERER.render_anchor(id, reftext, dest);
187    }
188
189    /// Renders a cross-reference.
190    ///
191    /// When [`XrefRenderParams::resolved`] is `Some`, the reference resolved to
192    /// a destination; the renderer should link to it. When it is `None`, the
193    /// reference could not be resolved and the renderer should emit a sensible
194    /// fallback (e.g. a link to the raw target with bracketed text).
195    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
196        DEFAULT_HTML_RENDERER.render_xref(params, dest);
197    }
198
199    /// Renders a [callout] number that annotates a line in a verbatim block.
200    ///
201    /// The renderer should write an appropriate rendering of the callout number
202    /// to `dest`. The rendering typically depends on whether font-based or
203    /// image-based icons are enabled (via the `icons` document attribute).
204    ///
205    /// [callout]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
206    fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
207        DEFAULT_HTML_RENDERER.render_callout(params, dest);
208    }
209
210    /// Renders an [index term].
211    ///
212    /// A *flow* (visible) index term ([`IndexTermRenderParams::visible_term`]
213    /// is `Some`) appears in the flow of text, so the renderer should write
214    /// the term text to `dest`. A *concealed* index term ([`visible_term`]
215    /// is `None`) does not appear in the rendered text, so the renderer
216    /// should typically write nothing.
217    ///
218    /// Note that the built-in HTML5 converter never builds an index catalog;
219    /// index terms only contribute markup in output formats (such as DocBook or
220    /// PDF) that generate an index.
221    ///
222    /// [index term]: https://docs.asciidoctor.org/asciidoc/latest/sections/user-index/
223    /// [`visible_term`]: IndexTermRenderParams::visible_term
224    fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
225        DEFAULT_HTML_RENDERER.render_index_term(params, dest);
226    }
227
228    /// Renders a [button] UI macro (`btn:[label]`).
229    ///
230    /// `text` is the already-normalized button label. The renderer should write
231    /// an appropriate rendering (e.g. `<b class="button">label</b>`) to `dest`.
232    ///
233    /// [button]: https://docs.asciidoctor.org/asciidoc/latest/macros/ui-macros/
234    fn render_button(&self, text: &str, dest: &mut String) {
235        DEFAULT_HTML_RENDERER.render_button(text, dest);
236    }
237
238    /// Renders a [keyboard] UI macro (`kbd:[keys]`).
239    ///
240    /// `keys` holds one entry per key in the shortcut. A single-element slice
241    /// is a lone key; multiple entries form a key sequence. The renderer
242    /// should write an appropriate rendering (e.g. a lone `<kbd>` element,
243    /// or a `<span class="keyseq">` wrapping several `<kbd>` elements) to
244    /// `dest`.
245    ///
246    /// [keyboard]: https://docs.asciidoctor.org/asciidoc/latest/macros/keyboard-macro/
247    fn render_keyboard(&self, keys: &[String], dest: &mut String) {
248        DEFAULT_HTML_RENDERER.render_keyboard(keys, dest);
249    }
250
251    /// Renders a [menu] UI macro (`menu:menu[submenu > … > item]`).
252    ///
253    /// The renderer should write an appropriate rendering to `dest`.
254    ///
255    /// [menu]: https://docs.asciidoctor.org/asciidoc/latest/macros/ui-macros/
256    fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
257        DEFAULT_HTML_RENDERER.render_menu(params, dest);
258    }
259
260    /// Renders the inline reference produced by a [`footnote`] macro.
261    ///
262    /// The footnote's *text* is not rendered here (it is extracted to the
263    /// document's footnote list); this method renders only the superscript
264    /// marker that appears in the flow of text and links to the footnote.
265    ///
266    /// See [`FootnoteRenderParams`] for the three cases the renderer must
267    /// handle (a defining occurrence, a reference to an earlier footnote, and
268    /// an unresolved reference).
269    ///
270    /// [`footnote`]: https://docs.asciidoctor.org/asciidoc/latest/macros/footnote/
271    fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
272        DEFAULT_HTML_RENDERER.render_footnote(params, dest);
273    }
274}
275
276/// Specifies which special character is being replaced in a call to
277/// [`InlineSubstitutionRenderer::render_special_character`].
278#[derive(Clone, Copy, Debug, Eq, PartialEq)]
279pub enum SpecialCharacter {
280    /// Replace `<` character.
281    Lt,
282
283    /// Replace `>` character.
284    Gt,
285
286    /// Replace `&` character.
287    Ampersand,
288}
289
290/// Specifies which [quote type] is being rendered.
291///
292/// [quote type]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
293#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
294pub enum QuoteType {
295    /// Strong (often bold) formatting.
296    Strong,
297
298    /// Word(s) surrounded by smart double quotes.
299    DoubleQuote,
300
301    /// Word(s) surrounded by smart single quotes.
302    SingleQuote,
303
304    /// Monospace (code) formatting.
305    Monospaced,
306
307    /// Emphasis (often italic) formatting.
308    Emphasis,
309
310    /// Text range (span) formatted with zero or more styles.
311    Mark,
312
313    /// Superscript formatting.
314    Superscript,
315
316    /// Subscript formatting.
317    Subscript,
318
319    /// Surrounds a block of text that may need a `<span>` or similar tag.
320    Unquoted,
321
322    /// Inline AsciiMath expression, surrounded by AsciiMath math delimiters.
323    AsciiMath,
324
325    /// Inline LaTeX math expression, surrounded by LaTeX inline math
326    /// delimiters.
327    LatexMath,
328}
329
330/// Specifies whether the block is aligned to word boundaries or not.
331#[derive(Clone, Copy, Debug, Eq, PartialEq)]
332pub enum QuoteScope {
333    /// The quoted section was aligned to word boundaries.
334    Constrained,
335
336    /// The quoted section may not have been aligned to word boundaries.
337    Unconstrained,
338}
339
340/// Specifies which [character replacement] is being rendered.
341///
342/// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
343#[derive(Clone, Debug, Eq, PartialEq)]
344pub enum CharacterReplacementType {
345    /// Copyright `(C)`.
346    Copyright,
347
348    /// Registered `(R)`.
349    Registered,
350
351    /// Trademark `(TM)`.
352    Trademark,
353
354    /// Em-dash surrounded by spaces ` -- `.
355    EmDashSurroundedBySpaces,
356
357    /// Em-dash without space `--`.
358    EmDashWithoutSpace,
359
360    /// Ellipsis `...`.
361    Ellipsis,
362
363    /// Single right arrow `->`.
364    SingleRightArrow,
365
366    /// Double right arrow `=>`.
367    DoubleRightArrow,
368
369    /// Single left arrow `<-`.
370    SingleLeftArrow,
371
372    /// Double left arrow `<=`.
373    DoubleLeftArrow,
374
375    /// Typographic apostrophe `'` within a word.
376    TypographicApostrophe,
377
378    /// Character reference `&___;`.
379    CharacterReference(String),
380}
381
382/// Provides parsed parameters for an image to be rendered.
383#[derive(Clone, Debug)]
384pub struct ImageRenderParams<'a> {
385    /// Target (the reference to the image).
386    pub target: &'a str,
387
388    /// Alt text (either explicitly set or defaulted).
389    pub alt: String,
390
391    /// Width. The data type is not checked; this may be any string.
392    pub width: Option<&'a str>,
393
394    /// Height. The data type is not checked; this may be any string.
395    pub height: Option<&'a str>,
396
397    /// Attribute list.
398    pub attrlist: &'a Attrlist<'a>,
399
400    /// Parser. The rendered may find document settings (such as an image
401    /// directory) in the parser's document attributes.
402    pub parser: &'a Parser,
403}
404
405/// Provides parsed parameters for an icon to be rendered.
406#[derive(Clone, Debug)]
407pub struct IconRenderParams<'a> {
408    /// Target (the reference to the image).
409    pub target: &'a str,
410
411    /// Alt text (either explicitly set or defaulted).
412    pub alt: String,
413
414    /// Size. The data type is not checked; this may be any string.
415    pub size: Option<&'a str>,
416
417    /// Attribute list.
418    pub attrlist: &'a Attrlist<'a>,
419
420    /// Parser. The rendered may find document settings (such as an image
421    /// directory) in the parser's document attributes.
422    pub parser: &'a Parser,
423}
424
425/// Provides parsed parameters for an icon to be rendered.
426#[derive(Clone, Debug)]
427pub struct LinkRenderParams<'a> {
428    /// Target (the target of this link).
429    pub target: String,
430
431    /// Link text.
432    pub link_text: String,
433
434    /// Roles (CSS classes) for this link not specified in the attrlist.
435    pub extra_roles: Vec<&'a str>,
436
437    /// Target window selection (passed through to `window` function in HTML).
438    pub window: Option<&'a str>,
439
440    /// Attribute list.
441    pub attrlist: &'a Attrlist<'a>,
442
443    /// Parser. The rendered may find document settings (such as an image
444    /// directory) in the parser's document attributes.
445    pub parser: &'a Parser,
446}
447
448/// Provides parameters for rendering a [callout] number.
449///
450/// [callout]: https://docs.asciidoctor.org/asciidoc/latest/verbatim/callouts/
451#[derive(Clone, Debug)]
452pub struct CalloutRenderParams<'a> {
453    /// The callout number to display. For automatically-numbered callouts
454    /// (`<.>`), this is the resolved sequential number.
455    pub number: &'a str,
456
457    /// The guard surrounding the callout in the source. This controls whether
458    /// (and how) the line-comment or XML-comment characters that hide the
459    /// callout in the raw source are preserved in the output when icons are not
460    /// enabled.
461    pub guard: CalloutGuard<'a>,
462
463    /// Parser. The renderer reads the `icons`, `iconsdir`, and `icontype`
464    /// document attributes to decide how to render the callout.
465    pub parser: &'a Parser,
466}
467
468/// Describes the characters that guard (hide) a callout number in verbatim
469/// source.
470#[derive(Clone, Debug, Eq, PartialEq)]
471pub enum CalloutGuard<'a> {
472    /// A line-comment (or absent) guard. Holds the line-comment prefix that
473    /// precedes the callout in the source (e.g. `# `), or an empty string when
474    /// the callout is not tucked behind a line comment. When icons are not
475    /// enabled, the prefix is preserved ahead of the rendered callout number.
476    LineComment(&'a str),
477
478    /// An XML comment guard (`<!--N-->`). When icons are not enabled, the XML
479    /// comment delimiters are preserved around the rendered callout number.
480    Xml,
481}
482
483/// Provides parameters for rendering a cross-reference.
484#[derive(Clone, Debug)]
485pub struct XrefRenderParams<'a> {
486    /// The raw, uninterpreted cross-reference target as written in the source.
487    pub target: &'a str,
488
489    /// Explicit link text supplied in the cross-reference, if any.
490    pub provided_text: Option<&'a str>,
491
492    /// Target window selection from a `window` attribute on the `xref:` macro
493    /// (e.g. `_blank`), or `None`. When `_blank`, the renderer also emits
494    /// `rel="noopener"`, mirroring the link macro.
495    pub window: Option<&'a str>,
496
497    /// Roles supplied via a `role` attribute on the `xref:` macro. Empty when
498    /// none were given.
499    pub roles: &'a [String],
500
501    /// The cross-reference text style in effect for this reference (from the
502    /// `xrefstyle=` macro attribute or the document-wide `xrefstyle`). `None`
503    /// when `xrefstyle` is unset, in which case the target's reference text is
504    /// used verbatim.
505    pub xrefstyle: Option<XrefStyle>,
506
507    /// The destination the parser derived from the target itself, for a
508    /// target that names a document; `None` for a reference to an element
509    /// within the current document.
510    ///
511    /// This is what the reference renders as when
512    /// [`resolved`](Self::resolved) is `None`: such a target is not
513    /// unresolved, it simply resolves without the catalog's help.
514    pub derived: Option<&'a DerivedReference>,
515
516    /// The resolved destination, or `None` if the reference is unresolved.
517    pub resolved: Option<&'a ResolvedReference>,
518}
519
520/// Provides parameters for rendering an [index term].
521///
522/// [index term]: https://docs.asciidoctor.org/asciidoc/latest/sections/user-index/
523#[derive(Clone, Debug)]
524pub struct IndexTermRenderParams<'a> {
525    /// For a *flow* (visible) index term (`((term))` or `indexterm2:[term]`),
526    /// the already-substituted primary term text to display in the flow of
527    /// text. `None` for a *concealed* index term (`(((p, s, t)))` or
528    /// `indexterm:[p, s, t]`), which produces no visible output.
529    pub visible_term: Option<&'a str>,
530}
531
532/// Provides parameters for rendering a [menu] UI macro.
533///
534/// [menu]: https://docs.asciidoctor.org/asciidoc/latest/macros/ui-macros/
535#[derive(Clone, Debug)]
536pub struct MenuRenderParams<'a> {
537    /// The top-level menu name.
538    pub menu: &'a str,
539
540    /// Zero or more intermediate submenu names, in order from outermost to
541    /// innermost.
542    pub submenus: &'a [String],
543
544    /// The final menu item, if any. `None` renders a bare menu reference (a
545    /// `menu:File[]` with no items).
546    pub menuitem: Option<&'a str>,
547
548    /// Parser, used to read the `icons` document attribute when choosing how to
549    /// render the caret between menu levels.
550    pub parser: &'a Parser,
551}
552
553/// Provides parameters for rendering the inline marker of a [`footnote`] macro.
554///
555/// There are three cases the renderer must distinguish:
556///
557/// * A *defining* occurrence (`index` is `Some`, `is_reference` is `false`):
558///   the footnote introduces new text. The marker carries the footnote number
559///   and, when the footnote was given an ID, an `id` of its own.
560/// * A *reference* to an earlier footnote (`index` is `Some`, `is_reference` is
561///   `true`): a later occurrence (`footnote:id[]`) that reuses an existing
562///   footnote's number.
563/// * An *unresolved* reference (`index` is `None`, `is_reference` is `true`): a
564///   reference whose ID was never defined; the renderer emits a visible error
565///   marker built from [`text`](Self::text).
566///
567/// [`footnote`]: https://docs.asciidoctor.org/asciidoc/latest/macros/footnote/
568#[derive(Clone, Debug)]
569pub struct FootnoteRenderParams<'a> {
570    /// The footnote's number, or `None` for an unresolved reference. Normally a
571    /// consecutive integer, but the `footnote-number` counter honors any seed
572    /// the document sets, so it is passed through as text.
573    pub index: Option<&'a str>,
574
575    /// The footnote's own ID, used only on a defining occurrence to produce the
576    /// `id="_footnote_<id>"` attribute on the marker.
577    pub id: Option<&'a str>,
578
579    /// `true` when this occurrence references an existing footnote (or fails to
580    /// resolve one); `false` for the defining occurrence.
581    pub is_reference: bool,
582
583    /// For an unresolved reference, the text to show inside the error marker
584    /// (the unresolved ID). Ignored in the other cases.
585    pub text: &'a str,
586}
587
588/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
589/// for common HTML-based applications.
590#[derive(Debug)]
591pub struct HtmlSubstitutionRenderer {}
592
593/// The shared HTML renderer to which [`InlineSubstitutionRenderer`]'s default
594/// method bodies delegate. It is stateless, so a single `const` instance serves
595/// every delegation.
596const DEFAULT_HTML_RENDERER: HtmlSubstitutionRenderer = HtmlSubstitutionRenderer {};
597
598impl HtmlSubstitutionRenderer {
599    /// Resolve an image target to a `src`/`data` reference, honoring a
600    /// macro-level `imagesdir` attribute.
601    ///
602    /// A named `imagesdir` attribute _on the image macro itself_ overrides the
603    /// document `imagesdir` for this one image (Asciidoctor 2.1+). When it is
604    /// absent, resolution falls back to [`image_uri`], which uses the document
605    /// `imagesdir`. As with the document attribute, an absolute-URL target
606    /// ignores the base entirely.
607    ///
608    /// [`image_uri`]: InlineSubstitutionRenderer::image_uri
609    fn image_src(&self, target: &str, attrlist: &Attrlist, parser: &Parser) -> String {
610        match attrlist.named_attribute("imagesdir") {
611            Some(imagesdir) => normalize_web_path(target, parser, Some(imagesdir.value()), true),
612            None => self.image_uri(target, parser, None),
613        }
614    }
615}
616
617impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
618    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
619        match type_ {
620            SpecialCharacter::Lt => {
621                dest.push_str("&lt;");
622            }
623            SpecialCharacter::Gt => {
624                dest.push_str("&gt;");
625            }
626            SpecialCharacter::Ampersand => {
627                dest.push_str("&amp;");
628            }
629        }
630    }
631
632    fn render_quoted_substitution(
633        &self,
634        type_: QuoteType,
635        _scope: QuoteScope,
636        attrlist: Option<Attrlist<'_>>,
637        mut id: Option<String>,
638        body: &str,
639        dest: &mut String,
640    ) {
641        let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
642
643        if let Some(block_style) = attrlist
644            .as_ref()
645            .and_then(|a| a.nth_attribute(1))
646            .and_then(|attr1| attr1.block_style())
647        {
648            roles.insert(0, block_style);
649        }
650
651        if id.is_none() {
652            id = attrlist
653                .as_ref()
654                .and_then(|a| a.nth_attribute(1))
655                .and_then(|attr1| attr1.id())
656                .map(|id| id.to_owned())
657        }
658
659        // A quote-delimited first positional attribute (e.g. `['role']`) carries
660        // no shorthand role or block style, so the derivation above leaves it
661        // out. Asciidoctor treats such a value as a verbatim role – quotes
662        // included – so recover it here rather than dropping the role.
663        if roles.is_empty()
664            && id.is_none()
665            && let Some(role) = attrlist
666                .as_ref()
667                .and_then(|a| a.quoted_text_fallback_role())
668        {
669            roles.push(role);
670        }
671
672        match type_ {
673            QuoteType::Strong => {
674                wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
675            }
676
677            QuoteType::DoubleQuote => {
678                dest.push_str("&#8220;");
679                dest.push_str(body);
680                dest.push_str("&#8221;");
681            }
682
683            QuoteType::SingleQuote => {
684                dest.push_str("&#8216;");
685                dest.push_str(body);
686                dest.push_str("&#8217;");
687            }
688
689            QuoteType::Monospaced => {
690                wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
691            }
692
693            QuoteType::Emphasis => {
694                wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
695            }
696
697            QuoteType::Mark => {
698                if roles.is_empty() && id.is_none() {
699                    wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
700                } else {
701                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
702                }
703            }
704
705            QuoteType::Superscript => {
706                wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
707            }
708
709            QuoteType::Subscript => {
710                wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
711            }
712
713            QuoteType::Unquoted => {
714                if roles.is_empty() && id.is_none() {
715                    dest.push_str(body);
716                } else {
717                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
718                }
719            }
720
721            QuoteType::AsciiMath => {
722                dest.push_str(r"\$");
723                dest.push_str(body);
724                dest.push_str(r"\$");
725            }
726
727            QuoteType::LatexMath => {
728                dest.push_str(r"\(");
729                dest.push_str(body);
730                dest.push_str(r"\)");
731            }
732        }
733    }
734
735    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
736        match type_ {
737            CharacterReplacementType::Copyright => {
738                dest.push_str("&#169;");
739            }
740
741            CharacterReplacementType::Registered => {
742                dest.push_str("&#174;");
743            }
744
745            CharacterReplacementType::Trademark => {
746                dest.push_str("&#8482;");
747            }
748
749            CharacterReplacementType::EmDashSurroundedBySpaces => {
750                dest.push_str("&#8201;&#8212;&#8201;");
751            }
752
753            CharacterReplacementType::EmDashWithoutSpace => {
754                dest.push_str("&#8212;&#8203;");
755            }
756
757            CharacterReplacementType::Ellipsis => {
758                dest.push_str("&#8230;&#8203;");
759            }
760
761            CharacterReplacementType::SingleLeftArrow => {
762                dest.push_str("&#8592;");
763            }
764
765            CharacterReplacementType::DoubleLeftArrow => {
766                dest.push_str("&#8656;");
767            }
768
769            CharacterReplacementType::SingleRightArrow => {
770                dest.push_str("&#8594;");
771            }
772
773            CharacterReplacementType::DoubleRightArrow => {
774                dest.push_str("&#8658;");
775            }
776
777            CharacterReplacementType::TypographicApostrophe => {
778                dest.push_str("&#8217;");
779            }
780
781            CharacterReplacementType::CharacterReference(name) => {
782                dest.push('&');
783                dest.push_str(&name);
784                dest.push(';');
785            }
786        }
787    }
788
789    fn render_line_break(&self, dest: &mut String) {
790        dest.push_str("<br>");
791    }
792
793    fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
794        let src = self.image_src(params.target, params.attrlist, params.parser);
795        let alt_encoded = encode_attribute_value(params.alt.clone());
796
797        // The dimension attributes (width, height, and title) are shared by the
798        // plain `<img>`, the interactive `<object>`, and the `<object>`'s image
799        // fallback. Each fragment carries its own leading space so the pieces
800        // concatenate cleanly after `src`/`alt` (or the `data` attribute).
801        let mut dimension_attrs = String::new();
802
803        if let Some(width) = params.width {
804            dimension_attrs.push_str(&format!(
805                r#" width="{width}""#,
806                width = encode_attribute_value(width.to_owned())
807            ));
808        }
809
810        if let Some(height) = params.height {
811            dimension_attrs.push_str(&format!(
812                r#" height="{height}""#,
813                height = encode_attribute_value(height.to_owned())
814            ));
815        }
816
817        if let Some(title) = params.attrlist.named_attribute("title") {
818            dimension_attrs.push_str(&format!(
819                r#" title="{title}""#,
820                title = encode_attribute_value(title.value().to_owned())
821            ));
822        }
823
824        let format = params
825            .attrlist
826            .named_attribute("format")
827            .map(|format| format.value());
828
829        // The `inline` and `interactive` SVG options are security-sensitive
830        // (they embed file contents or a live `<object>`), so they only take
831        // effect below the `Secure` safe mode. In `Secure` mode an SVG image
832        // renders as an ordinary `<img>`, matching Ruby Asciidoctor.
833        let svg_active = (format == Some("svg") || params.target.contains(".svg"))
834            && params.parser.safe_mode() < SafeMode::Secure;
835
836        // An inline SVG is embedded verbatim and has no meaningful `src`, so a
837        // `link=self` on it is left as the literal `self` rather than resolved
838        // to a URI (see `render_icon_or_image`). Every other image form does
839        // have a `src` (a data URI or web path) that `link=self` resolves to.
840        let inline_svg = svg_active && params.attrlist.has_option("inline");
841
842        let img = if inline_svg {
843            // Embed the SVG contents directly. When the contents cannot be read
844            // (no handler is registered, or it cannot find the file), fall back
845            // to the alt text, mirroring Ruby Asciidoctor.
846            read_svg_contents(&src, params.width, params.height, params.parser)
847                .unwrap_or_else(|| format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt))
848        } else if svg_active && params.attrlist.has_option("interactive") {
849            // Render an interactive SVG as an `<object>` element so its embedded
850            // scripting and links remain live. A `fallback` image (or, failing
851            // that, the alt text) is nested inside for user agents that can't
852            // display the object.
853            let fallback = if let Some(fallback) = params.attrlist.named_attribute("fallback") {
854                let fallback_src = self.image_src(fallback.value(), params.attrlist, params.parser);
855                format!(
856                    r#"<img src="{fallback_src}" alt="{alt_encoded}"{dimension_attrs}>"#,
857                    fallback_src = encode_attribute_value(fallback_src)
858                )
859            } else {
860                format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt)
861            };
862
863            format!(
864                r#"<object type="image/svg+xml" data="{src}"{dimension_attrs}>{fallback}</object>"#,
865                src = encode_attribute_value(src.clone())
866            )
867        } else {
868            format!(
869                r#"<img src="{src}" alt="{alt_encoded}"{dimension_attrs}>"#,
870                src = encode_attribute_value(src.clone())
871            )
872        };
873
874        let link_self_href = if inline_svg { None } else { Some(src.as_str()) };
875
876        // A URI-ish target passes through to `src` verbatim (it is never
877        // embedded), so a `link=self` resolving to it is author-supplied and
878        // subject to the stricter SVG-data-URI check; a plain path target is
879        // resolved to a web path or a trusted embedded `data-uri`.
880        let self_href_from_uri_target = is_uri_ish(params.target);
881
882        render_icon_or_image(
883            params.attrlist,
884            &img,
885            "image",
886            link_self_href,
887            self_href_from_uri_target,
888            dest,
889        );
890    }
891
892    fn image_uri(
893        &self,
894        target_image_path: &str,
895        parser: &Parser,
896        asset_dir_key: Option<&str>,
897    ) -> String {
898        let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
899
900        let asset_dir = parser
901            .attribute_value(asset_dir_key)
902            .as_maybe_str()
903            .map(|s| s.to_string());
904
905        let normalized = normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true);
906
907        // Asciidoctor embeds the image as a data URI when the `data-uri`
908        // attribute is set and the safe mode is below `SafeMode::Secure`. A
909        // target that is itself a URI is never embedded – there is no local
910        // file to read – so it passes through as an ordinary web path
911        // (Asciidoctor only fetches a remote target under `allow-uri-read`,
912        // which this crate does not implement). Otherwise the image's bytes are
913        // read through the `ImageFileHandler` and base64-encoded into a
914        // `data:<mime>;base64,…` URI.
915        //
916        // This crate never performs file I/O itself, so an absent handler (or
917        // one that cannot find the file) degrades silently to the web path,
918        // mirroring how a missing `SvgFileHandler` degrades an inline SVG.
919        if parser.safe_mode() < SafeMode::Secure
920            && parser.is_attribute_set("data-uri")
921            && !is_uri_ish(target_image_path)
922            && let Some(handler) = parser.image_file_handler.as_ref()
923            && let Some(bytes) = handler.resolve_image(&normalized, parser)
924        {
925            let mimetype = data_uri_mimetype(target_image_path);
926            let encoded = crate::internal::base64::strict_encode(&bytes);
927
928            return format!("data:{mimetype};base64,{encoded}");
929        }
930
931        normalized
932    }
933
934    fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
935        let src = self.icon_uri(params.target, params.attrlist, params.parser);
936
937        let img = if params.parser.is_attribute_set("icons") {
938            let icons = params.parser.attribute_value("icons");
939            if let Some(icons) = icons.as_maybe_str()
940                && icons == "font"
941            {
942                // Every fragment interpolated into the `class`/`title`
943                // attributes below is escaped for the `"` delimiter, mirroring
944                // the `alt` handling in the image branch. These values are
945                // already special-character-escaped (`< > &`) upstream, but a
946                // stray `"` in an author-supplied `target`, `size`, `flip`,
947                // `rotate`, or `title` would otherwise break out of its
948                // attribute.
949                let mut i_class_attrs: Vec<String> = vec![
950                    "fa".to_owned(),
951                    format!(
952                        "fa-{target}",
953                        target = encode_attribute_value(params.target.to_owned())
954                    ),
955                ];
956
957                if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
958                    i_class_attrs.push(format!(
959                        "fa-{size}",
960                        size = encode_attribute_value(size.value().to_owned())
961                    ));
962                }
963
964                if let Some(flip) = params.attrlist.named_attribute("flip") {
965                    i_class_attrs.push(format!(
966                        "fa-flip-{flip}",
967                        flip = encode_attribute_value(flip.value().to_owned())
968                    ));
969                } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
970                    i_class_attrs.push(format!(
971                        "fa-rotate-{rotate}",
972                        rotate = encode_attribute_value(rotate.value().to_owned())
973                    ));
974                }
975
976                format!(
977                    r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
978                    i_class_attr_val = i_class_attrs.join(" "),
979                    title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
980                        format!(
981                            r#" title="{title}""#,
982                            title = encode_attribute_value(title.value().to_owned())
983                        )
984                    } else {
985                        "".to_owned()
986                    }
987                )
988            } else {
989                let mut attrs: Vec<String> = vec![
990                    format!(r#"src="{src}""#, src = encode_attribute_value(src.clone())),
991                    format!(
992                        r#"alt="{alt}""#,
993                        alt = encode_attribute_value(params.alt.to_string())
994                    ),
995                ];
996
997                if let Some(width) = params.attrlist.named_attribute("width") {
998                    attrs.push(format!(
999                        r#"width="{width}""#,
1000                        width = encode_attribute_value(width.value().to_owned())
1001                    ));
1002                }
1003
1004                if let Some(height) = params.attrlist.named_attribute("height") {
1005                    attrs.push(format!(
1006                        r#"height="{height}""#,
1007                        height = encode_attribute_value(height.value().to_owned())
1008                    ));
1009                }
1010
1011                if let Some(title) = params.attrlist.named_attribute("title") {
1012                    attrs.push(format!(
1013                        r#"title="{title}""#,
1014                        title = encode_attribute_value(title.value().to_owned())
1015                    ));
1016                }
1017
1018                format!(
1019                    "<img {attrs}{void_element_slash}>",
1020                    attrs = attrs.join(" "),
1021                    void_element_slash = "",
1022                )
1023            }
1024        } else {
1025            format!("[{alt}&#93;", alt = params.alt)
1026        };
1027
1028        // `src` is only a real image URI in the image-icon branch (icons enabled
1029        // and not font-based); the font (`<i>`) and text (`[alt]`) branches have
1030        // no `src`, so a `link=self` on them stays literal (see
1031        // `render_icon_or_image`).
1032        let link_self_href = if params.parser.is_attribute_set("icons")
1033            && params.parser.attribute_value("icons").as_maybe_str() != Some("font")
1034        {
1035            Some(src.as_str())
1036        } else {
1037            None
1038        };
1039
1040        // As in `render_image`: a URI-ish target passes through to the icon
1041        // `src` verbatim, so a `link=self` resolving to it is author-supplied
1042        // and gets the stricter SVG-data-URI check.
1043        let self_href_from_uri_target = is_uri_ish(params.target);
1044
1045        render_icon_or_image(
1046            params.attrlist,
1047            &img,
1048            "icon",
1049            link_self_href,
1050            self_href_from_uri_target,
1051            dest,
1052        );
1053    }
1054
1055    fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
1056        let id = params.attrlist.id();
1057
1058        let mut roles = params.extra_roles.clone();
1059        let mut attrlist_roles = params.attrlist.roles().clone();
1060        roles.append(&mut attrlist_roles);
1061
1062        let link = format!(
1063            r##"<a href="{target}"{id}{class}{title}{link_constraint_attrs}>{link_text}</a>"##,
1064            // The target arrives here already special-character-escaped (`< > &`)
1065            // by the substitution pipeline, but that step leaves `"` intact. A
1066            // stray `"` in the target would otherwise close the `href` attribute
1067            // and let an author inject further attributes (e.g. an event
1068            // handler), so escape the quote delimiter here – mirroring the
1069            // image `alt`/`title` handling.
1070            target = encode_attribute_value(params.target.clone()),
1071            id = if let Some(id) = id {
1072                format!(r#" id="{id}""#)
1073            } else {
1074                "".to_owned()
1075            },
1076            class = if roles.is_empty() {
1077                "".to_owned()
1078            } else {
1079                format!(r#" class="{roles}""#, roles = roles.join(" "))
1080            },
1081            // Mirrors Asciidoctor's HTML5 converter: `title="#{node.attr 'title'}"`
1082            // is emitted (after the class) when the link carries a `title`
1083            // attribute.
1084            title = if let Some(title) = params.attrlist.named_attribute("title") {
1085                format!(
1086                    r#" title="{title}""#,
1087                    title = encode_attribute_value(title.value().to_owned())
1088                )
1089            } else {
1090                "".to_owned()
1091            },
1092            link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
1093            link_text = params.link_text,
1094        );
1095
1096        dest.push_str(&link);
1097    }
1098
1099    fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
1100        dest.push_str(&format!("<a id=\"{id}\"></a>"));
1101    }
1102
1103    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
1104        let class = if params.roles.is_empty() {
1105            String::new()
1106        } else {
1107            // Roles are author-supplied, so each is escaped before it is joined
1108            // into the `class` attribute (a stray `"` would otherwise break out
1109            // of the attribute).
1110            let roles = params
1111                .roles
1112                .iter()
1113                .map(|role| encode_html_attribute(role))
1114                .collect::<Vec<_>>()
1115                .join(" ");
1116            format!(r#" class="{roles}""#)
1117        };
1118
1119        let constraint_attrs = xref_constraint_attrs(params.window);
1120
1121        // Each `href` below is escaped for the `"` delimiter before it is
1122        // interpolated into the attribute. The destinations are already
1123        // special-character-escaped (`< > &`) upstream, but a stray `"` in a
1124        // crafted or unresolved target would otherwise break out of the `href`
1125        // attribute (see the `class`/roles escaping above).
1126        match (params.resolved, params.derived) {
1127            (Some(resolved), _) => {
1128                // Explicit link text always wins; otherwise use the target's
1129                // reference text, optionally reformatted by the `xrefstyle`.
1130                // Empty explicit text (`<<id,>>`) is treated as absent, matching
1131                // Asciidoctor's fallback to the target's reference text.
1132                let text = match params.provided_text {
1133                    Some(provided) if !provided.is_empty() => provided.to_string(),
1134                    _ => {
1135                        // The target's reference text becomes this reference's
1136                        // link text. When that reftext is itself a title
1137                        // containing a cross-reference (or an inline link), it
1138                        // carries a nested `<a>…</a>`; an anchor cannot legally
1139                        // nest inside another, so the inner anchor tags are
1140                        // dropped (keeping their text), mirroring Asciidoctor's
1141                        // `DropAnchorRx`. The bracketed fallback (`[id]`) has no
1142                        // anchors, so stripping only applies to a resolved
1143                        // reftext.
1144                        let base = resolved
1145                            .text
1146                            .as_deref()
1147                            .map(drop_anchor_tags)
1148                            .unwrap_or_else(|| format!("[{target}]", target = params.target));
1149                        apply_xrefstyle(params.xrefstyle, resolved.signifier.as_ref(), base)
1150                    }
1151                };
1152
1153                dest.push_str(&format!(
1154                    r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
1155                    href = encode_attribute_value(resolved.href.clone())
1156                ));
1157            }
1158
1159            // A target that named a document, which no resolver claimed: use
1160            // the destination derived from the target itself.
1161            (None, Some(derived)) => {
1162                let text = params
1163                    .provided_text
1164                    .map(str::to_string)
1165                    .unwrap_or_else(|| derived.text.clone());
1166
1167                dest.push_str(&format!(
1168                    r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
1169                    href = encode_attribute_value(derived.href.clone())
1170                ));
1171            }
1172
1173            (None, None) => {
1174                // Unresolved: link to the raw target and show bracketed text,
1175                // mirroring Asciidoctor's behavior for a missing reference.
1176                let text = params
1177                    .provided_text
1178                    .map(str::to_string)
1179                    .unwrap_or_else(|| format!("[{target}]", target = params.target));
1180
1181                dest.push_str(&format!(
1182                    r##"<a href="#{target}"{class}{constraint_attrs}>{text}</a>"##,
1183                    target = encode_attribute_value(params.target.to_owned())
1184                ));
1185            }
1186        }
1187    }
1188
1189    fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
1190        let n = params.number;
1191        let parser = params.parser;
1192
1193        if parser.attribute_value("icons").as_maybe_str() == Some("font") {
1194            dest.push_str(&format!(
1195                r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
1196            ));
1197        } else if parser.is_attribute_set("icons") {
1198            let icontype = parser
1199                .attribute_value("icontype")
1200                .as_maybe_str()
1201                .unwrap_or("png")
1202                .to_owned();
1203
1204            let icon = format!("callouts/{n}.{icontype}");
1205            let src = self.image_uri(&icon, parser, Some("iconsdir"));
1206
1207            dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
1208        } else {
1209            match params.guard {
1210                CalloutGuard::Xml => {
1211                    dest.push_str(&format!(r#"&lt;!--<b class="conum">({n})</b>--&gt;"#));
1212                }
1213
1214                CalloutGuard::LineComment(prefix) => {
1215                    dest.push_str(prefix);
1216                    dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
1217                }
1218            }
1219        }
1220    }
1221
1222    fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
1223        // The HTML5 converter does not generate an index, so a concealed index
1224        // term produces no output and a flow index term renders only its
1225        // (already-substituted) visible term text.
1226        if let Some(term) = params.visible_term {
1227            dest.push_str(term);
1228        }
1229    }
1230
1231    fn render_button(&self, text: &str, dest: &mut String) {
1232        dest.push_str(&format!(r#"<b class="button">{text}</b>"#));
1233    }
1234
1235    fn render_keyboard(&self, keys: &[String], dest: &mut String) {
1236        if let [key] = keys {
1237            dest.push_str(&format!("<kbd>{key}</kbd>"));
1238        } else {
1239            // The visual separator is always `+`, even when the source used a
1240            // comma delimiter (e.g. `kbd:[Ctrl,T]`). This matches Asciidoctor's
1241            // HTML5 output, where the delimiter only selects how keys are split,
1242            // not how the sequence is displayed.
1243            dest.push_str(&format!(
1244                r#"<span class="keyseq"><kbd>{keys}</kbd></span>"#,
1245                keys = keys.join("</kbd>+<kbd>")
1246            ));
1247        }
1248    }
1249
1250    fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
1251        let caret = if params.parser.attribute_value("icons").as_maybe_str() == Some("font") {
1252            r#"&#160;<i class="fa fa-angle-right caret"></i> "#
1253        } else {
1254            r#"&#160;<b class="caret">&#8250;</b> "#
1255        };
1256
1257        let menu = params.menu;
1258
1259        if params.submenus.is_empty() {
1260            if let Some(menuitem) = params.menuitem {
1261                dest.push_str(&format!(
1262                    r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#
1263                ));
1264            } else {
1265                dest.push_str(&format!(r#"<b class="menuref">{menu}</b>"#));
1266            }
1267        } else {
1268            let submenu_joiner = format!(r#"</b>{caret}<b class="submenu">"#);
1269            dest.push_str(&format!(
1270                r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="submenu">{submenus}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#,
1271                submenus = params.submenus.join(&submenu_joiner),
1272                menuitem = params.menuitem.unwrap_or_default(),
1273            ));
1274        }
1275    }
1276
1277    fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
1278        match params.index {
1279            Some(index) if params.is_reference => {
1280                // A reference to an already-defined footnote reuses its number
1281                // but gets no anchor of its own.
1282                dest.push_str(&format!(
1283                    r##"<sup class="footnoteref">[<a class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
1284                ));
1285            }
1286
1287            Some(index) => {
1288                // A defining occurrence. When the footnote carries an ID, the
1289                // marker is given a matching anchor so it can be linked to.
1290                let id_attr = params
1291                    .id
1292                    .map(|id| format!(r#" id="_footnote_{id}""#))
1293                    .unwrap_or_default();
1294
1295                dest.push_str(&format!(
1296                    r##"<sup class="footnote"{id_attr}>[<a id="_footnoteref_{index}" class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
1297                ));
1298            }
1299
1300            None => {
1301                // An unresolved reference: the ID was never defined.
1302                dest.push_str(&format!(
1303                    r#"<sup class="footnoteref red" title="Unresolved footnote reference.">[{text}]</sup>"#,
1304                    text = params.text
1305                ));
1306            }
1307        }
1308    }
1309}
1310
1311/// Writes an HTML attribute value to `dest`, escaping the double-quote
1312/// delimiter as `&quot;`.
1313///
1314/// A `"` left raw inside a `"…"`-delimited attribute value would terminate the
1315/// value early and let following text be parsed as additional attributes –
1316/// attribute injection. Role and id values can carry a literal `"` (for
1317/// example a single-quoted positional role such as `['a"b']`, or a
1318/// single-quoted `role='a"b'` named attribute), so escape it here. The `<`,
1319/// `>`, and `&` characters are left as-is: the surrounding content pipeline
1320/// already escapes them for the constrained/inline paths, and Asciidoctor
1321/// deliberately leaves them unescaped for a passthrough attribute list, so
1322/// re-escaping here would double-escape the former and diverge on the latter.
1323fn push_attribute_value(dest: &mut String, value: &str) {
1324    for ch in value.chars() {
1325        if ch == '"' {
1326            dest.push_str("&quot;");
1327        } else {
1328            dest.push(ch);
1329        }
1330    }
1331}
1332
1333fn wrap_body_in_html_tag(
1334    _attrlist: Option<&Attrlist<'_>>,
1335    tag: &'static str,
1336    id: Option<String>,
1337    roles: Vec<&str>,
1338    body: &str,
1339    dest: &mut String,
1340) {
1341    dest.push('<');
1342    dest.push_str(tag);
1343
1344    if let Some(id) = id.as_ref() {
1345        dest.push_str(" id=\"");
1346        push_attribute_value(dest, id);
1347        dest.push('"');
1348    }
1349
1350    if !roles.is_empty() {
1351        let roles = roles.join(" ");
1352        dest.push_str(" class=\"");
1353        push_attribute_value(dest, &roles);
1354        dest.push('"');
1355    }
1356
1357    dest.push('>');
1358    dest.push_str(body);
1359    dest.push_str("</");
1360    dest.push_str(tag);
1361    dest.push('>');
1362}
1363
1364fn render_icon_or_image(
1365    attrlist: &Attrlist,
1366    img: &str,
1367    type_: &'static str,
1368    link_self_href: Option<&str>,
1369    self_href_from_uri_target: bool,
1370    dest: &mut String,
1371) {
1372    let mut img = img.to_string();
1373
1374    // The `link` attribute value is used verbatim as the `href`, except that a
1375    // `link=self` resolves to the image's own `src` (its data URI or web path)
1376    // when one is available. An inline SVG (and a font/text icon) has no `src`
1377    // to resolve to, so `link_self_href` is `None` there and the literal `self`
1378    // is kept. (Ruby Asciidoctor, where `src` is undefined in those branches,
1379    // instead drops the anchor entirely; this crate keeps it with the literal
1380    // `self` target.)
1381    if let Some(link) = attrlist.named_attribute("link") {
1382        let is_self = link.value() == "self";
1383
1384        let href = if is_self {
1385            link_self_href.unwrap_or("self")
1386        } else {
1387            link.value()
1388        };
1389
1390        // An explicit `link=` destination whose scheme could execute script is
1391        // not turned into a live link; the image is rendered without the
1392        // wrapping anchor. Escaping the `"` delimiter alone would still leave a
1393        // live `javascript:` URI, so the destination is rejected outright – the
1394        // same policy the explicit `link:` macro applies, and the macro layer
1395        // records the accompanying warning. `link=self` resolves to the image's
1396        // own `src`, which may legitimately be a `data:image/*` URI, so it is
1397        // checked with the more permissive [`has_dangerous_self_href`] (a
1398        // `javascript:` or non-image `data:` src – or an author-supplied SVG
1399        // data URI target – still resolves to a live script URI here and is
1400        // rejected).
1401        let rejected = if is_self {
1402            has_dangerous_self_href(href, self_href_from_uri_target)
1403        } else {
1404            has_dangerous_scheme(link.value())
1405        };
1406
1407        if !rejected {
1408            img = format!(
1409                r#"<a class="image" href="{href}"{link_constraint_attrs}>{img}</a>"#,
1410                // Both sources of `href` – the image's own `src` (a resolved web
1411                // path that can carry a stray `"`) and an author-supplied
1412                // `link=` value – are escaped for the `"` delimiter so neither
1413                // can break out of the attribute.
1414                href = encode_attribute_value(href.to_owned()),
1415                link_constraint_attrs = link_constraint_attrs(attrlist, None)
1416            );
1417        }
1418    }
1419
1420    let mut roles: Vec<&str> = attrlist.roles();
1421
1422    if let Some(float) = attrlist.named_attribute("float") {
1423        roles.insert(0, float.value());
1424    }
1425
1426    roles.insert(0, type_);
1427
1428    dest.push_str(r#"<span class=""#);
1429    dest.push_str(&roles.join(" "));
1430    dest.push_str(r#"">"#);
1431    dest.push_str(&img);
1432    dest.push_str("</span>");
1433}
1434
1435fn encode_attribute_value(value: String) -> String {
1436    value.replace('"', "&quot;")
1437}
1438
1439/// Reports whether `target` begins with a URI scheme that can execute script
1440/// when placed in an `href` – `javascript:`, `data:`, or `vbscript:`.
1441///
1442/// Leading control and space characters are ignored first, because a browser
1443/// strips them before it parses the scheme (so `"\u{1}javascript:…"` is still
1444/// live). The comparison is ASCII-case-insensitive.
1445///
1446/// Escaping the quote delimiter (see [`encode_attribute_value`]) stops an
1447/// author from breaking out of an attribute, but not from placing a live
1448/// script URI in an `href`; that requires rejecting the scheme outright. This
1449/// guards the explicit `link:` macro and the `image:`/`icon:` `link=`
1450/// attribute. The auto-linker already restricts bare URLs to a safe scheme set
1451/// (`https?`/`file`/`ftp`/`irc`).
1452pub(crate) fn has_dangerous_scheme(target: &str) -> bool {
1453    let target = target.trim_start_matches(|c: char| c <= ' ');
1454
1455    const DANGEROUS_SCHEMES: [&str; 3] = ["javascript:", "data:", "vbscript:"];
1456
1457    DANGEROUS_SCHEMES.iter().any(|scheme| {
1458        target
1459            .get(..scheme.len())
1460            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme))
1461    })
1462}
1463
1464/// Reports whether `href` – the `src` a `link=self` image/icon resolves to –
1465/// would place a script-capable URI in the wrapping anchor's `href`.
1466///
1467/// `link=self` names the image's own `src`, so it cannot simply be run through
1468/// [`has_dangerous_scheme`]: an embedded image (`data-uri`) legitimately
1469/// resolves to a `data:image/*` URI, and there is an Asciidoctor-parity test
1470/// for exactly that. A `data:image/*` source is therefore exempt. Every other
1471/// dangerous scheme – `javascript:`, `vbscript:`, or a non-image `data:` such
1472/// as `data:text/html,…` – is never a valid image source, so promoting it into
1473/// an `href` is rejected: only the anchor is dropped (the harmless `<img src>`
1474/// is left intact), mirroring the `link=` policy above.
1475///
1476/// `from_uri_target` narrows that exemption. It is set when the `src` was
1477/// passed through verbatim from an author-supplied URI target, rather than
1478/// resolved from a local file path (a plain web path, or a crate-embedded
1479/// `data-uri`). A `data:image/svg+xml` document *executes* its embedded script
1480/// when it is navigated to – which following the `href` on click does – unlike
1481/// a raster image data URI, which merely displays. An embedded SVG comes from a
1482/// trusted local file (the parity case) and stays exempt; an author-supplied
1483/// `data:image/svg…` target is script-navigable and is rejected.
1484pub(crate) fn has_dangerous_self_href(href: &str, from_uri_target: bool) -> bool {
1485    if !has_dangerous_scheme(href) {
1486        return false;
1487    }
1488
1489    // A dangerous scheme that is not a `data:image/*` source (`javascript:`,
1490    // `vbscript:`, `data:text/html`, …) is always rejected.
1491    if !is_image_data_uri(href) {
1492        return true;
1493    }
1494
1495    // A `data:image/*` source is exempt, except an author-supplied SVG data URI,
1496    // whose script runs when the anchor is followed.
1497    from_uri_target && is_svg_data_uri(href)
1498}
1499
1500/// Reports whether `href` is a `data:image/*` URI (the leading control/space
1501/// characters are ignored and the comparison is ASCII-case-insensitive, as in
1502/// [`has_dangerous_scheme`]). This is the one `data:` form that is a legitimate
1503/// image source, so it is exempt from the `link=self` rejection above.
1504fn is_image_data_uri(href: &str) -> bool {
1505    let href = href.trim_start_matches(|c: char| c <= ' ');
1506
1507    const IMAGE_DATA_PREFIX: &str = "data:image/";
1508
1509    href.get(..IMAGE_DATA_PREFIX.len())
1510        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(IMAGE_DATA_PREFIX))
1511}
1512
1513/// Reports whether `href` is a `data:image/svg…` URI. SVG is the one image
1514/// media type that executes embedded script when the URI is opened as a
1515/// top-level document, so an author-supplied SVG data URI is not a safe
1516/// `link=self` target (see [`has_dangerous_self_href`]). Raster image data URIs
1517/// never begin with `svg`, so they are unaffected.
1518fn is_svg_data_uri(href: &str) -> bool {
1519    let href = href.trim_start_matches(|c: char| c <= ' ');
1520
1521    const SVG_DATA_PREFIX: &str = "data:image/svg";
1522
1523    href.get(..SVG_DATA_PREFIX.len())
1524        .is_some_and(|prefix| prefix.eq_ignore_ascii_case(SVG_DATA_PREFIX))
1525}
1526
1527/// Escapes a value for safe interpolation into an HTML attribute.
1528///
1529/// Unlike [`encode_attribute_value`] (which only guards the quote delimiter to
1530/// mirror Asciidoctor's image-alt handling), this escapes the full set of
1531/// characters that could break out of, or corrupt, an attribute value. It is
1532/// used for author-supplied `xref` `window`/`role` values, which – unlike the
1533/// hard-coded `window` strings the link macro passes – can contain arbitrary
1534/// text.
1535fn encode_html_attribute(value: &str) -> String {
1536    let mut out = String::with_capacity(value.len());
1537    for c in value.chars() {
1538        match c {
1539            '&' => out.push_str("&amp;"),
1540            '"' => out.push_str("&quot;"),
1541            '<' => out.push_str("&lt;"),
1542            '>' => out.push_str("&gt;"),
1543            _ => out.push(c),
1544        }
1545    }
1546    out
1547}
1548
1549fn normalize_web_path(
1550    target: &str,
1551    parser: &Parser,
1552    start: Option<&str>,
1553    preserve_uri_target: bool,
1554) -> String {
1555    if preserve_uri_target && is_uri_ish(target) {
1556        encode_spaces_in_uri(target)
1557    } else {
1558        parser.path_resolver.web_path(target, start)
1559    }
1560}
1561
1562pub(crate) fn is_uri_ish(path: &str) -> bool {
1563    path.contains(':') && URI_SNIFF.is_match(path)
1564}
1565
1566/// Returns the file extension (including the leading `.`) of the final path
1567/// segment of `path`, or `None` when that segment carries no extension (its `.`
1568/// is the first or last character of the segment, or there is no `.`). Mirrors
1569/// Asciidoctor's `Helpers.extname`.
1570fn extname(path: &str) -> Option<&str> {
1571    let segment = path.rsplit(['/', '\\']).next().unwrap_or(path);
1572    match segment.rfind('.') {
1573        Some(i) if i > 0 && i < segment.len() - 1 => Some(&segment[i..]),
1574        _ => None,
1575    }
1576}
1577
1578/// Reports whether the final path segment of `path` carries a file extension.
1579/// Mirrors Asciidoctor's `Helpers.extname?`, used by the icon macro to decide
1580/// whether the `icontype` attribute should be appended.
1581fn has_extname(path: &str) -> bool {
1582    extname(path).is_some()
1583}
1584
1585/// Determines the MIME type for a `data:` URI from the target image's file
1586/// extension, mirroring Asciidoctor's `generate_data_uri`: `.svg` maps to
1587/// `image/svg+xml`, any other extension maps to `image/<ext>`, and a target
1588/// with no extension maps to `application/octet-stream`.
1589///
1590/// The `image/<ext>` mapping is verbatim, matching Asciidoctor: `.jpg` yields
1591/// `image/jpg` (not the IANA-registered `image/jpeg`), while `.jpeg` yields
1592/// `image/jpeg`. This parity with Asciidoctor is deliberate.
1593fn data_uri_mimetype(target: &str) -> String {
1594    match extname(target) {
1595        Some(".svg") => "image/svg+xml".to_string(),
1596
1597        // `extname` always includes the leading `.`, which is dropped here.
1598        Some(ext) => format!("image/{ext}", ext = ext.strip_prefix('.').unwrap_or(ext)),
1599        None => "application/octet-stream".to_string(),
1600    }
1601}
1602
1603fn encode_spaces_in_uri(s: &str) -> String {
1604    s.replace(' ', "%20")
1605}
1606
1607/// Matches the opening `<svg …>` tag at the start of an SVG document.
1608///
1609/// Like Ruby Asciidoctor's equivalent (`/\A<svg[^>]*>/`), the `[^>]*` stops at
1610/// the first `>`, so a `>` appearing unencoded inside an attribute value would
1611/// truncate the match. That cannot happen in well-formed XML (where `>` must be
1612/// written as `&gt;`), so this only affects malformed input, and then only by
1613/// leaving the opening tag's dimensions unrewritten.
1614static SVG_START_TAG_RX: LazyLock<Regex> = LazyLock::new(|| {
1615    #[allow(clippy::unwrap_used)]
1616    Regex::new(r"\A<svg[^>]*>").unwrap()
1617});
1618
1619/// Matches a `width`, `height`, or `style` attribute (with its leading
1620/// whitespace) so they can be stripped from an SVG's opening tag.
1621static SVG_SNIFF_WIDTH_HEIGHT_RX: LazyLock<Regex> = LazyLock::new(|| {
1622    #[allow(clippy::unwrap_used)]
1623    Regex::new(r#"(?s)\s+(?:width|height|style)=(?:"[^"]*"|'[^']*')"#).unwrap()
1624});
1625
1626/// Reads and prepares the raw contents of an SVG file for inline embedding
1627/// (`image:target.svg[opts=inline]`).
1628///
1629/// The SVG contents are supplied by the parser's [`SvgFileHandler`]; when no
1630/// handler is registered (or it can't find the file) this returns `None` and
1631/// the caller falls back to rendering the alt text.
1632///
1633/// Before returning, the contents are prepared to match Ruby Asciidoctor:
1634///
1635/// * any XML preamble or doctype preceding the `<svg>` tag is removed, and
1636/// * if an explicit `width` and/or `height` was supplied on the macro, the
1637///   opening `<svg>` tag's own `width`, `height`, and `style` attributes are
1638///   dropped and the requested dimensions are appended in their place.
1639///
1640/// [`SvgFileHandler`]: crate::parser::SvgFileHandler
1641fn read_svg_contents(
1642    src: &str,
1643    width: Option<&str>,
1644    height: Option<&str>,
1645    parser: &Parser,
1646) -> Option<String> {
1647    let handler = parser.svg_file_handler.as_ref()?;
1648    let mut svg = handler.resolve_svg(src, parser)?;
1649
1650    // Strip anything that precedes the opening `<svg>` tag (e.g. `<?xml … ?>`).
1651    if svg.starts_with('<')
1652        && let Some(start) = svg.find("<svg")
1653        && start > 0
1654    {
1655        svg = svg[start..].to_string();
1656    }
1657
1658    // Rewrite the opening tag's dimensions only when at least one was supplied.
1659    if (width.is_some() || height.is_some())
1660        && let Some(start_tag) = SVG_START_TAG_RX.find(&svg).map(|m| m.as_str().to_string())
1661    {
1662        let rest = svg[start_tag.len()..].to_string();
1663
1664        // Attributes between `<svg` and the closing `>`, with any existing
1665        // width/height/style removed.
1666        let inner = &start_tag[4..start_tag.len() - 1];
1667        let mut new_tag = format!("<svg{}", SVG_SNIFF_WIDTH_HEIGHT_RX.replace_all(inner, ""));
1668
1669        if let Some(width) = width {
1670            new_tag.push_str(&format!(r#" width="{width}""#));
1671        }
1672
1673        if let Some(height) = height {
1674            new_tag.push_str(&format!(r#" height="{height}""#));
1675        }
1676
1677        new_tag.push('>');
1678        svg = format!("{new_tag}{rest}");
1679    }
1680
1681    Some(svg)
1682}
1683
1684/// Detects strings that resemble URIs.
1685///
1686/// ## Examples
1687///
1688/// * `http://domain`
1689/// * `https://domain`
1690/// * `file:///path`
1691/// * `data:info`
1692///
1693/// ## Counter-examples (do not match)
1694///
1695/// * `c:/sample.adoc`
1696/// * `c:\sample.adoc`
1697static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1698    #[allow(clippy::unwrap_used)]
1699    Regex::new(
1700        r#"(?x)
1701        \A                             # Anchor to start of string
1702        \p{Alphabetic}                 # First character must be a letter
1703        [\p{Alphabetic}\p{Nd}.+-]+     # Followed by one or more alphanum or . + -
1704        :                              # Literal colon
1705        /{0,2}                         # Zero to two slashes
1706    "#,
1707    )
1708    .unwrap()
1709});
1710
1711/// Removes the anchor (`<a …>` / `</a>`) tags from `text`, keeping everything
1712/// between them.
1713///
1714/// Used when a cross-reference's link text is drawn from its target's reference
1715/// text and that reftext itself contains an anchor – an inline link, or a
1716/// cross-reference embedded in the target's title. HTML forbids nesting an
1717/// `<a>` inside another, so the inner anchor tags are stripped, leaving their
1718/// text in place. Mirrors Asciidoctor's `DropAnchorRx = /<(?:a\b[^>]*|\/a)>/`.
1719fn drop_anchor_tags(text: &str) -> String {
1720    // The common case – a reftext with no anchor at all – allocates a plain
1721    // copy and does no scanning.
1722    if !text.contains("<a") {
1723        return text.to_string();
1724    }
1725
1726    #[allow(clippy::unwrap_used)]
1727    static DROP_ANCHOR_RX: LazyLock<Regex> =
1728        LazyLock::new(|| Regex::new(r"<(?:a\b[^>]*|/a)>").unwrap());
1729
1730    DROP_ANCHOR_RX.replace_all(text, "").into_owned()
1731}
1732
1733/// Builds the display text for a resolved cross-reference under the selected
1734/// [`XrefStyle`].
1735///
1736/// `base` is the target's reference text (its title, when the target has no
1737/// explicit reftext). Styling applies only when a style is selected *and* the
1738/// target carries an [`XrefSignifier`] (a numbered section or captioned block);
1739/// otherwise `base` is returned unchanged. The HTML conventions live here in
1740/// the HTML renderer: a title is wrapped in typographic quotes, except a
1741/// chapter or appendix title, which is emphasized with `<em>` (in every style).
1742fn apply_xrefstyle(
1743    style: Option<XrefStyle>,
1744    signifier: Option<&XrefSignifier>,
1745    base: String,
1746) -> String {
1747    let (Some(style), Some(signifier)) = (style, signifier) else {
1748        return base;
1749    };
1750
1751    match style {
1752        XrefStyle::Full if signifier.emphasize => {
1753            format!("{label}, <em>{base}</em>", label = signifier.label)
1754        }
1755        XrefStyle::Full => {
1756            format!("{label}, &#8220;{base}&#8221;", label = signifier.label)
1757        }
1758        XrefStyle::Short => signifier.label.clone(),
1759        XrefStyle::Basic if signifier.emphasize => format!("<em>{base}</em>"),
1760        XrefStyle::Basic => base,
1761    }
1762}
1763
1764/// Builds the `target`/`rel` attributes for a cross-reference whose `xref:`
1765/// macro carried a `window` attribute. Mirrors the link macro: a `_blank`
1766/// window automatically adds `rel="noopener"`.
1767fn xref_constraint_attrs(window: Option<&str>) -> String {
1768    let Some(window) = window else {
1769        return String::new();
1770    };
1771
1772    let rel_noopener = if window == "_blank" {
1773        r#" rel="noopener""#
1774    } else {
1775        ""
1776    };
1777
1778    // The `window` value is author-supplied, so it is escaped before being
1779    // interpolated into the `target` attribute. The `_blank` comparison above
1780    // runs on the raw value, which is correct for the well-formed inputs that
1781    // trigger `rel="noopener"`.
1782    format!(
1783        r#" target="{window}"{rel_noopener}"#,
1784        window = encode_html_attribute(window)
1785    )
1786}
1787
1788fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&str>) -> String {
1789    let rel = if attrlist.has_option("nofollow") {
1790        Some("nofollow")
1791    } else {
1792        None
1793    };
1794
1795    if let Some(window) = attrlist
1796        .named_attribute("window")
1797        .map(|a| a.value())
1798        .or(window)
1799    {
1800        let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
1801            if let Some(rel) = rel {
1802                format!(r#" rel="{rel} noopener""#)
1803            } else {
1804                r#" rel="noopener""#.to_owned()
1805            }
1806        } else {
1807            "".to_string()
1808        };
1809
1810        format!(r#" target="{window}"{rel_noopener}"#)
1811    } else if let Some(rel) = rel {
1812        format!(r#" rel="{rel}""#)
1813    } else {
1814        "".to_string()
1815    }
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820    use super::{data_uri_mimetype, drop_anchor_tags, encode_html_attribute, extname, has_extname};
1821
1822    #[test]
1823    fn extname_extracts_final_segment_extension() {
1824        // A normal extension on the final path segment.
1825        assert_eq!(extname("fixtures/dot.gif"), Some(".gif"));
1826        assert_eq!(extname("circle.svg"), Some(".svg"));
1827
1828        // A dot in an earlier segment does not count; only the final segment's
1829        // extension does.
1830        assert_eq!(extname("a.b/c"), None);
1831
1832        // A leading or trailing dot in the segment is not an extension.
1833        assert_eq!(extname(".hidden"), None);
1834        assert_eq!(extname("trailing."), None);
1835
1836        // No dot at all.
1837        assert_eq!(extname("plain"), None);
1838
1839        // `has_extname` is the boolean form.
1840        assert!(has_extname("a/b.png"));
1841        assert!(!has_extname("a.b/c"));
1842    }
1843
1844    #[test]
1845    fn data_uri_mimetype_maps_extension() {
1846        // `.svg` is special-cased; every other extension maps to `image/<ext>`.
1847        assert_eq!(data_uri_mimetype("circle.svg"), "image/svg+xml");
1848        assert_eq!(data_uri_mimetype("fixtures/dot.gif"), "image/gif");
1849        assert_eq!(data_uri_mimetype("photo.png"), "image/png");
1850
1851        // The extension is used verbatim (matching Asciidoctor), so `.jpg`
1852        // yields `image/jpg` rather than the registered `image/jpeg`, while
1853        // `.jpeg` yields `image/jpeg`.
1854        assert_eq!(data_uri_mimetype("photo.jpg"), "image/jpg");
1855        assert_eq!(data_uri_mimetype("photo.jpeg"), "image/jpeg");
1856
1857        // A target with no extension falls back to a generic binary type.
1858        assert_eq!(data_uri_mimetype("noext"), "application/octet-stream");
1859    }
1860
1861    #[test]
1862    fn encode_html_attribute_escapes_special_characters() {
1863        // Each of the four characters that could break out of or corrupt an
1864        // HTML attribute value is replaced with its entity; ordinary characters
1865        // pass through untouched.
1866        assert_eq!(
1867            encode_html_attribute(r#"a&b"c<d>e"#),
1868            "a&amp;b&quot;c&lt;d&gt;e"
1869        );
1870        assert_eq!(encode_html_attribute("plain"), "plain");
1871    }
1872
1873    #[test]
1874    fn drop_anchor_tags_strips_anchor_markup_keeping_text() {
1875        // Anchor-free text is returned unchanged.
1876        assert_eq!(drop_anchor_tags("plain text"), "plain text");
1877
1878        // A single anchor's tags are removed, keeping the link text.
1879        assert_eq!(
1880            drop_anchor_tags(r#"Consult <a href="https://google.com">Google</a>"#),
1881            "Consult Google"
1882        );
1883
1884        // A bracketed cross-reference fallback embedded in a reftext.
1885        assert_eq!(drop_anchor_tags(r##"B <a href="#a">[a]</a>"##), "B [a]");
1886
1887        // Multiple anchors are all stripped.
1888        assert_eq!(
1889            drop_anchor_tags(r##"<a href="#x">X</a> and <a href="#y">Y</a>"##),
1890            "X and Y"
1891        );
1892
1893        // A `<article>` tag is not an anchor and must be left intact (the `\b`
1894        // word boundary in the pattern keeps `<a` from matching `<article>`).
1895        assert_eq!(
1896            drop_anchor_tags("<article>text</article>"),
1897            "<article>text</article>"
1898        );
1899    }
1900}