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