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