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 /// NOT YET IMPLEMENTED:
69 /// If the `data-uri` attribute is set on the document, and the safe mode
70 /// level is less than `SafeMode::SECURE`, the image will be safely
71 /// converted to a data URI by reading it from the same directory. If
72 /// neither of these conditions are satisfied, a relative path (i.e., URL)
73 /// will be returned.
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 resolved destination, or `None` if the reference is unresolved.
459 pub resolved: Option<&'a ResolvedReference>,
460}
461
462/// Provides parameters for rendering an [index term].
463///
464/// [index term]: https://docs.asciidoctor.org/asciidoc/latest/sections/user-index/
465#[derive(Clone, Debug)]
466pub struct IndexTermRenderParams<'a> {
467 /// For a *flow* (visible) index term (`((term))` or `indexterm2:[term]`),
468 /// the already-substituted primary term text to display in the flow of
469 /// text. `None` for a *concealed* index term (`(((p, s, t)))` or
470 /// `indexterm:[p, s, t]`), which produces no visible output.
471 pub visible_term: Option<&'a str>,
472}
473
474/// Provides parameters for rendering a [menu] UI macro.
475///
476/// [menu]: https://docs.asciidoctor.org/asciidoc/latest/macros/ui-macros/
477#[derive(Clone, Debug)]
478pub struct MenuRenderParams<'a> {
479 /// The top-level menu name.
480 pub menu: &'a str,
481
482 /// Zero or more intermediate submenu names, in order from outermost to
483 /// innermost.
484 pub submenus: &'a [String],
485
486 /// The final menu item, if any. `None` renders a bare menu reference (a
487 /// `menu:File[]` with no items).
488 pub menuitem: Option<&'a str>,
489
490 /// Parser, used to read the `icons` document attribute when choosing how to
491 /// render the caret between menu levels.
492 pub parser: &'a Parser,
493}
494
495/// Provides parameters for rendering the inline marker of a [`footnote`] macro.
496///
497/// There are three cases the renderer must distinguish:
498///
499/// * A *defining* occurrence (`index` is `Some`, `is_reference` is `false`):
500/// the footnote introduces new text. The marker carries the footnote number
501/// and, when the footnote was given an ID, an `id` of its own.
502/// * A *reference* to an earlier footnote (`index` is `Some`, `is_reference` is
503/// `true`): a later occurrence (`footnote:id[]`) that reuses an existing
504/// footnote's number.
505/// * An *unresolved* reference (`index` is `None`, `is_reference` is `true`): a
506/// reference whose ID was never defined; the renderer emits a visible error
507/// marker built from [`text`](Self::text).
508///
509/// [`footnote`]: https://docs.asciidoctor.org/asciidoc/latest/macros/footnote/
510#[derive(Clone, Debug)]
511pub struct FootnoteRenderParams<'a> {
512 /// The footnote's number, or `None` for an unresolved reference. Normally a
513 /// consecutive integer, but the `footnote-number` counter honors any seed
514 /// the document sets, so it is passed through as text.
515 pub index: Option<&'a str>,
516
517 /// The footnote's own ID, used only on a defining occurrence to produce the
518 /// `id="_footnote_<id>"` attribute on the marker.
519 pub id: Option<&'a str>,
520
521 /// `true` when this occurrence references an existing footnote (or fails to
522 /// resolve one); `false` for the defining occurrence.
523 pub is_reference: bool,
524
525 /// For an unresolved reference, the text to show inside the error marker
526 /// (the unresolved ID). Ignored in the other cases.
527 pub text: &'a str,
528}
529
530/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
531/// for common HTML-based applications.
532#[derive(Debug)]
533pub struct HtmlSubstitutionRenderer {}
534
535impl HtmlSubstitutionRenderer {
536 /// Resolve an image target to a `src`/`data` reference, honoring a
537 /// macro-level `imagesdir` attribute.
538 ///
539 /// A named `imagesdir` attribute _on the image macro itself_ overrides the
540 /// document `imagesdir` for this one image (Asciidoctor 2.1+). When it is
541 /// absent, resolution falls back to [`image_uri`], which uses the document
542 /// `imagesdir`. As with the document attribute, an absolute-URL target
543 /// ignores the base entirely.
544 ///
545 /// [`image_uri`]: InlineSubstitutionRenderer::image_uri
546 fn image_src(&self, target: &str, attrlist: &Attrlist, parser: &Parser) -> String {
547 match attrlist.named_attribute("imagesdir") {
548 Some(imagesdir) => normalize_web_path(target, parser, Some(imagesdir.value()), true),
549 None => self.image_uri(target, parser, None),
550 }
551 }
552}
553
554impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
555 fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
556 match type_ {
557 SpecialCharacter::Lt => {
558 dest.push_str("<");
559 }
560 SpecialCharacter::Gt => {
561 dest.push_str(">");
562 }
563 SpecialCharacter::Ampersand => {
564 dest.push_str("&");
565 }
566 }
567 }
568
569 fn render_quoted_substitition(
570 &self,
571 type_: QuoteType,
572 _scope: QuoteScope,
573 attrlist: Option<Attrlist<'_>>,
574 mut id: Option<String>,
575 body: &str,
576 dest: &mut String,
577 ) {
578 let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
579
580 if let Some(block_style) = attrlist
581 .as_ref()
582 .and_then(|a| a.nth_attribute(1))
583 .and_then(|attr1| attr1.block_style())
584 {
585 roles.insert(0, block_style);
586 }
587
588 if id.is_none() {
589 id = attrlist
590 .as_ref()
591 .and_then(|a| a.nth_attribute(1))
592 .and_then(|attr1| attr1.id())
593 .map(|id| id.to_owned())
594 }
595
596 match type_ {
597 QuoteType::Strong => {
598 wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
599 }
600
601 QuoteType::DoubleQuote => {
602 dest.push_str("“");
603 dest.push_str(body);
604 dest.push_str("”");
605 }
606
607 QuoteType::SingleQuote => {
608 dest.push_str("‘");
609 dest.push_str(body);
610 dest.push_str("’");
611 }
612
613 QuoteType::Monospaced => {
614 wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
615 }
616
617 QuoteType::Emphasis => {
618 wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
619 }
620
621 QuoteType::Mark => {
622 if roles.is_empty() && id.is_none() {
623 wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
624 } else {
625 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
626 }
627 }
628
629 QuoteType::Superscript => {
630 wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
631 }
632
633 QuoteType::Subscript => {
634 wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
635 }
636
637 QuoteType::Unquoted => {
638 if roles.is_empty() && id.is_none() {
639 dest.push_str(body);
640 } else {
641 wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
642 }
643 }
644
645 QuoteType::AsciiMath => {
646 dest.push_str(r"\$");
647 dest.push_str(body);
648 dest.push_str(r"\$");
649 }
650
651 QuoteType::LatexMath => {
652 dest.push_str(r"\(");
653 dest.push_str(body);
654 dest.push_str(r"\)");
655 }
656 }
657 }
658
659 fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
660 match type_ {
661 CharacterReplacementType::Copyright => {
662 dest.push_str("©");
663 }
664
665 CharacterReplacementType::Registered => {
666 dest.push_str("®");
667 }
668
669 CharacterReplacementType::Trademark => {
670 dest.push_str("™");
671 }
672
673 CharacterReplacementType::EmDashSurroundedBySpaces => {
674 dest.push_str(" — ");
675 }
676
677 CharacterReplacementType::EmDashWithoutSpace => {
678 dest.push_str("—​");
679 }
680
681 CharacterReplacementType::Ellipsis => {
682 dest.push_str("…​");
683 }
684
685 CharacterReplacementType::SingleLeftArrow => {
686 dest.push_str("←");
687 }
688
689 CharacterReplacementType::DoubleLeftArrow => {
690 dest.push_str("⇐");
691 }
692
693 CharacterReplacementType::SingleRightArrow => {
694 dest.push_str("→");
695 }
696
697 CharacterReplacementType::DoubleRightArrow => {
698 dest.push_str("⇒");
699 }
700
701 CharacterReplacementType::TypographicApostrophe => {
702 dest.push_str("’");
703 }
704
705 CharacterReplacementType::CharacterReference(name) => {
706 dest.push('&');
707 dest.push_str(&name);
708 dest.push(';');
709 }
710 }
711 }
712
713 fn render_line_break(&self, dest: &mut String) {
714 dest.push_str("<br>");
715 }
716
717 fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
718 let src = self.image_src(params.target, params.attrlist, params.parser);
719 let alt_encoded = encode_attribute_value(params.alt.clone());
720
721 // The dimension attributes (width, height, and title) are shared by the
722 // plain `<img>`, the interactive `<object>`, and the `<object>`'s image
723 // fallback. Each fragment carries its own leading space so the pieces
724 // concatenate cleanly after `src`/`alt` (or the `data` attribute).
725 let mut dimension_attrs = String::new();
726
727 if let Some(width) = params.width {
728 dimension_attrs.push_str(&format!(r#" width="{width}""#));
729 }
730
731 if let Some(height) = params.height {
732 dimension_attrs.push_str(&format!(r#" height="{height}""#));
733 }
734
735 if let Some(title) = params.attrlist.named_attribute("title") {
736 dimension_attrs.push_str(&format!(
737 r#" title="{title}""#,
738 title = encode_attribute_value(title.value().to_owned())
739 ));
740 }
741
742 let format = params
743 .attrlist
744 .named_attribute("format")
745 .map(|format| format.value());
746
747 // The `inline` and `interactive` SVG options are security-sensitive
748 // (they embed file contents or a live `<object>`), so they only take
749 // effect below the `Secure` safe mode. In `Secure` mode an SVG image
750 // renders as an ordinary `<img>`, matching Ruby Asciidoctor.
751 let svg_active = (format == Some("svg") || params.target.contains(".svg"))
752 && params.parser.safe_mode() < SafeMode::Secure;
753
754 let img = if svg_active && params.attrlist.has_option("inline") {
755 // Embed the SVG contents directly. When the contents cannot be read
756 // (no handler is registered, or it cannot find the file), fall back
757 // to the alt text, mirroring Ruby Asciidoctor.
758 read_svg_contents(&src, params.width, params.height, params.parser)
759 .unwrap_or_else(|| format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt))
760 } else if svg_active && params.attrlist.has_option("interactive") {
761 // Render an interactive SVG as an `<object>` element so its embedded
762 // scripting and links remain live. A `fallback` image (or, failing
763 // that, the alt text) is nested inside for user agents that can't
764 // display the object.
765 let fallback = if let Some(fallback) = params.attrlist.named_attribute("fallback") {
766 let fallback_src = self.image_src(fallback.value(), params.attrlist, params.parser);
767 format!(r#"<img src="{fallback_src}" alt="{alt_encoded}"{dimension_attrs}>"#)
768 } else {
769 format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt)
770 };
771
772 format!(
773 r#"<object type="image/svg+xml" data="{src}"{dimension_attrs}>{fallback}</object>"#
774 )
775 } else {
776 format!(r#"<img src="{src}" alt="{alt_encoded}"{dimension_attrs}>"#)
777 };
778
779 render_icon_or_image(params.attrlist, &img, "image", dest);
780 }
781
782 fn image_uri(
783 &self,
784 target_image_path: &str,
785 parser: &Parser,
786 asset_dir_key: Option<&str>,
787 ) -> String {
788 let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
789
790 // Asciidoctor embeds the image as a data URI when the `data-uri`
791 // attribute is set and the safe mode is below `SafeMode::Secure`. That
792 // requires reading the image's bytes, which this crate leaves to the
793 // caller rather than performing file/network access itself; the
794 // `data-uri` attribute is therefore not implemented and the image is
795 // always emitted as a normalized web path. Because data-uri embedding is
796 // absent, there is no safe-mode-sensitive behavior to gate here.
797 let asset_dir = parser
798 .attribute_value(asset_dir_key)
799 .as_maybe_str()
800 .map(|s| s.to_string());
801
802 normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
803 }
804
805 fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
806 let src = self.icon_uri(params.target, params.attrlist, params.parser);
807
808 let img = if params.parser.is_attribute_set("icons") {
809 let icons = params.parser.attribute_value("icons");
810 if let Some(icons) = icons.as_maybe_str()
811 && icons == "font"
812 {
813 let mut i_class_attrs: Vec<String> = vec![
814 "fa".to_owned(),
815 format!("fa-{target}", target = params.target),
816 ];
817
818 if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
819 i_class_attrs.push(format!("fa-{size}", size = size.value()));
820 }
821
822 if let Some(flip) = params.attrlist.named_attribute("flip") {
823 i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
824 } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
825 i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
826 }
827
828 format!(
829 r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
830 i_class_attr_val = i_class_attrs.join(" "),
831 title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
832 format!(r#" title="{title}""#, title = title.value())
833 } else {
834 "".to_owned()
835 }
836 )
837 } else {
838 let mut attrs: Vec<String> = vec![
839 format!(r#"src="{src}""#),
840 format!(
841 r#"alt="{alt}""#,
842 alt = encode_attribute_value(params.alt.to_string())
843 ),
844 ];
845
846 if let Some(width) = params.attrlist.named_attribute("width") {
847 attrs.push(format!(r#"width="{width}""#, width = width.value()));
848 }
849
850 if let Some(height) = params.attrlist.named_attribute("height") {
851 attrs.push(format!(r#"height="{height}""#, height = height.value()));
852 }
853
854 if let Some(title) = params.attrlist.named_attribute("title") {
855 attrs.push(format!(r#"title="{title}""#, title = title.value()));
856 }
857
858 format!(
859 "<img {attrs}{void_element_slash}>",
860 attrs = attrs.join(" "),
861 void_element_slash = "",
862 )
863 }
864 } else {
865 format!("[{alt}]", alt = params.alt)
866 };
867
868 render_icon_or_image(params.attrlist, &img, "icon", dest);
869 }
870
871 fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
872 let id = params.attrlist.id();
873
874 let mut roles = params.extra_roles.clone();
875 let mut attrlist_roles = params.attrlist.roles().clone();
876 roles.append(&mut attrlist_roles);
877
878 let link = format!(
879 r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
880 target = params.target,
881 id = if let Some(id) = id {
882 format!(r#" id="{id}""#)
883 } else {
884 "".to_owned()
885 },
886 class = if roles.is_empty() {
887 "".to_owned()
888 } else {
889 format!(r#" class="{roles}""#, roles = roles.join(" "))
890 },
891 // title = %( title="#{node.attr 'title'}") if node.attr? 'title'
892 // Haven't seen this in the wild yet.
893 link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
894 link_text = params.link_text,
895 );
896
897 dest.push_str(&link);
898 }
899
900 fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
901 dest.push_str(&format!("<a id=\"{id}\"></a>"));
902 }
903
904 fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
905 let class = if params.roles.is_empty() {
906 String::new()
907 } else {
908 // Roles are author-supplied, so each is escaped before it is joined
909 // into the `class` attribute (a stray `"` would otherwise break out
910 // of the attribute).
911 let roles = params
912 .roles
913 .iter()
914 .map(|role| encode_html_attribute(role))
915 .collect::<Vec<_>>()
916 .join(" ");
917 format!(r#" class="{roles}""#)
918 };
919
920 let constraint_attrs = xref_constraint_attrs(params.window);
921
922 match params.resolved {
923 Some(resolved) => {
924 // Explicit link text always wins; otherwise use the target's
925 // reference text, optionally reformatted by the `xrefstyle`.
926 let text = match params.provided_text {
927 Some(provided) => provided.to_string(),
928 None => {
929 let base = resolved
930 .text
931 .clone()
932 .unwrap_or_else(|| format!("[{target}]", target = params.target));
933 apply_xrefstyle(params.xrefstyle, resolved.signifier.as_ref(), base)
934 }
935 };
936
937 dest.push_str(&format!(
938 r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
939 href = resolved.href
940 ));
941 }
942
943 None => {
944 // Unresolved: link to the raw target and show bracketed text,
945 // mirroring Asciidoctor's behavior for a missing reference.
946 let text = params
947 .provided_text
948 .map(str::to_string)
949 .unwrap_or_else(|| format!("[{target}]", target = params.target));
950
951 dest.push_str(&format!(
952 r##"<a href="#{target}"{class}{constraint_attrs}>{text}</a>"##,
953 target = params.target
954 ));
955 }
956 }
957 }
958
959 fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
960 let n = params.number;
961 let parser = params.parser;
962
963 if parser.attribute_value("icons").as_maybe_str() == Some("font") {
964 dest.push_str(&format!(
965 r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
966 ));
967 } else if parser.is_attribute_set("icons") {
968 let icontype = parser
969 .attribute_value("icontype")
970 .as_maybe_str()
971 .unwrap_or("png")
972 .to_owned();
973
974 let icon = format!("callouts/{n}.{icontype}");
975 let src = self.image_uri(&icon, parser, Some("iconsdir"));
976
977 dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
978 } else {
979 match params.guard {
980 CalloutGuard::Xml => {
981 dest.push_str(&format!(r#"<!--<b class="conum">({n})</b>-->"#));
982 }
983
984 CalloutGuard::LineComment(prefix) => {
985 dest.push_str(prefix);
986 dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
987 }
988 }
989 }
990 }
991
992 fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
993 // The HTML5 converter does not generate an index, so a concealed index
994 // term produces no output and a flow index term renders only its
995 // (already-substituted) visible term text.
996 if let Some(term) = params.visible_term {
997 dest.push_str(term);
998 }
999 }
1000
1001 fn render_button(&self, text: &str, dest: &mut String) {
1002 dest.push_str(&format!(r#"<b class="button">{text}</b>"#));
1003 }
1004
1005 fn render_keyboard(&self, keys: &[String], dest: &mut String) {
1006 if let [key] = keys {
1007 dest.push_str(&format!("<kbd>{key}</kbd>"));
1008 } else {
1009 // The visual separator is always `+`, even when the source used a
1010 // comma delimiter (e.g. `kbd:[Ctrl,T]`). This matches Asciidoctor's
1011 // HTML5 output, where the delimiter only selects how keys are split,
1012 // not how the sequence is displayed.
1013 dest.push_str(&format!(
1014 r#"<span class="keyseq"><kbd>{keys}</kbd></span>"#,
1015 keys = keys.join("</kbd>+<kbd>")
1016 ));
1017 }
1018 }
1019
1020 fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
1021 let caret = if params.parser.attribute_value("icons").as_maybe_str() == Some("font") {
1022 r#" <i class="fa fa-angle-right caret"></i> "#
1023 } else {
1024 r#" <b class="caret">›</b> "#
1025 };
1026
1027 let menu = params.menu;
1028
1029 if params.submenus.is_empty() {
1030 if let Some(menuitem) = params.menuitem {
1031 dest.push_str(&format!(
1032 r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#
1033 ));
1034 } else {
1035 dest.push_str(&format!(r#"<b class="menuref">{menu}</b>"#));
1036 }
1037 } else {
1038 let submenu_joiner = format!(r#"</b>{caret}<b class="submenu">"#);
1039 dest.push_str(&format!(
1040 r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="submenu">{submenus}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#,
1041 submenus = params.submenus.join(&submenu_joiner),
1042 menuitem = params.menuitem.unwrap_or_default(),
1043 ));
1044 }
1045 }
1046
1047 fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
1048 match params.index {
1049 Some(index) if params.is_reference => {
1050 // A reference to an already-defined footnote reuses its number
1051 // but gets no anchor of its own.
1052 dest.push_str(&format!(
1053 r##"<sup class="footnoteref">[<a class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
1054 ));
1055 }
1056
1057 Some(index) => {
1058 // A defining occurrence. When the footnote carries an ID, the
1059 // marker is given a matching anchor so it can be linked to.
1060 let id_attr = params
1061 .id
1062 .map(|id| format!(r#" id="_footnote_{id}""#))
1063 .unwrap_or_default();
1064
1065 dest.push_str(&format!(
1066 r##"<sup class="footnote"{id_attr}>[<a id="_footnoteref_{index}" class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
1067 ));
1068 }
1069
1070 None => {
1071 // An unresolved reference: the ID was never defined.
1072 dest.push_str(&format!(
1073 r#"<sup class="footnoteref red" title="Unresolved footnote reference.">[{text}]</sup>"#,
1074 text = params.text
1075 ));
1076 }
1077 }
1078 }
1079}
1080
1081fn wrap_body_in_html_tag(
1082 _attrlist: Option<&Attrlist<'_>>,
1083 tag: &'static str,
1084 id: Option<String>,
1085 roles: Vec<&str>,
1086 body: &str,
1087 dest: &mut String,
1088) {
1089 dest.push('<');
1090 dest.push_str(tag);
1091
1092 if let Some(id) = id.as_ref() {
1093 dest.push_str(" id=\"");
1094 dest.push_str(id);
1095 dest.push('"');
1096 }
1097
1098 if !roles.is_empty() {
1099 let roles = roles.join(" ");
1100 dest.push_str(" class=\"");
1101 dest.push_str(&roles);
1102 dest.push('"');
1103 }
1104
1105 dest.push('>');
1106 dest.push_str(body);
1107 dest.push_str("</");
1108 dest.push_str(tag);
1109 dest.push('>');
1110}
1111
1112fn render_icon_or_image(attrlist: &Attrlist, img: &str, type_: &'static str, dest: &mut String) {
1113 let mut img = img.to_string();
1114
1115 // The `link` attribute value is used verbatim as the `href` (matching Ruby
1116 // Asciidoctor, which does not special-case `link=self`). This applies to
1117 // every image, including an inline SVG embedded in the flow of text.
1118 if let Some(link) = attrlist.named_attribute("link") {
1119 img = format!(
1120 r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
1121 link = link.value(),
1122 link_constraint_attrs = link_constraint_attrs(attrlist, None)
1123 );
1124 }
1125
1126 let mut roles: Vec<&str> = attrlist.roles();
1127
1128 if let Some(float) = attrlist.named_attribute("float") {
1129 roles.insert(0, float.value());
1130 }
1131
1132 roles.insert(0, type_);
1133
1134 dest.push_str(r#"<span class=""#);
1135 dest.push_str(&roles.join(" "));
1136 dest.push_str(r#"">"#);
1137 dest.push_str(&img);
1138 dest.push_str("</span>");
1139}
1140
1141fn encode_attribute_value(value: String) -> String {
1142 value.replace('"', """)
1143}
1144
1145/// Escapes a value for safe interpolation into an HTML attribute.
1146///
1147/// Unlike [`encode_attribute_value`] (which only guards the quote delimiter to
1148/// mirror Asciidoctor's image-alt handling), this escapes the full set of
1149/// characters that could break out of, or corrupt, an attribute value. It is
1150/// used for author-supplied `xref` `window`/`role` values, which — unlike the
1151/// hard-coded `window` strings the link macro passes — can contain arbitrary
1152/// text.
1153fn encode_html_attribute(value: &str) -> String {
1154 let mut out = String::with_capacity(value.len());
1155 for c in value.chars() {
1156 match c {
1157 '&' => out.push_str("&"),
1158 '"' => out.push_str("""),
1159 '<' => out.push_str("<"),
1160 '>' => out.push_str(">"),
1161 _ => out.push(c),
1162 }
1163 }
1164 out
1165}
1166
1167fn normalize_web_path(
1168 target: &str,
1169 parser: &Parser,
1170 start: Option<&str>,
1171 preserve_uri_target: bool,
1172) -> String {
1173 if preserve_uri_target && is_uri_ish(target) {
1174 encode_spaces_in_uri(target)
1175 } else {
1176 parser.path_resolver.web_path(target, start)
1177 }
1178}
1179
1180fn is_uri_ish(path: &str) -> bool {
1181 path.contains(':') && URI_SNIFF.is_match(path)
1182}
1183
1184/// Reports whether the final path segment of `path` carries a file extension,
1185/// i.e. it contains a `.` that is neither the first nor the last character of
1186/// the segment. Mirrors Asciidoctor's `Helpers.extname?`, used by the icon
1187/// macro to decide whether the `icontype` attribute should be appended.
1188fn has_extname(path: &str) -> bool {
1189 let segment = path.rsplit(['/', '\\']).next().unwrap_or(path);
1190 match segment.rfind('.') {
1191 Some(i) => i > 0 && i < segment.len() - 1,
1192 None => false,
1193 }
1194}
1195
1196fn encode_spaces_in_uri(s: &str) -> String {
1197 s.replace(' ', "%20")
1198}
1199
1200/// Matches the opening `<svg …>` tag at the start of an SVG document.
1201///
1202/// Like Ruby Asciidoctor's equivalent (`/\A<svg[^>]*>/`), the `[^>]*` stops at
1203/// the first `>`, so a `>` appearing unencoded inside an attribute value would
1204/// truncate the match. That cannot happen in well-formed XML (where `>` must be
1205/// written as `>`), so this only affects malformed input, and then only by
1206/// leaving the opening tag's dimensions unrewritten.
1207static SVG_START_TAG_RX: LazyLock<Regex> = LazyLock::new(|| {
1208 #[allow(clippy::unwrap_used)]
1209 Regex::new(r"\A<svg[^>]*>").unwrap()
1210});
1211
1212/// Matches a `width`, `height`, or `style` attribute (with its leading
1213/// whitespace) so they can be stripped from an SVG's opening tag.
1214static SVG_SNIFF_WIDTH_HEIGHT_RX: LazyLock<Regex> = LazyLock::new(|| {
1215 #[allow(clippy::unwrap_used)]
1216 Regex::new(r#"(?s)\s+(?:width|height|style)=(?:"[^"]*"|'[^']*')"#).unwrap()
1217});
1218
1219/// Reads and prepares the raw contents of an SVG file for inline embedding
1220/// (`image:target.svg[opts=inline]`).
1221///
1222/// The SVG contents are supplied by the parser's [`SvgFileHandler`]; when no
1223/// handler is registered (or it can't find the file) this returns `None` and
1224/// the caller falls back to rendering the alt text.
1225///
1226/// Before returning, the contents are prepared to match Ruby Asciidoctor:
1227///
1228/// * any XML preamble or doctype preceding the `<svg>` tag is removed, and
1229/// * if an explicit `width` and/or `height` was supplied on the macro, the
1230/// opening `<svg>` tag's own `width`, `height`, and `style` attributes are
1231/// dropped and the requested dimensions are appended in their place.
1232///
1233/// [`SvgFileHandler`]: crate::parser::SvgFileHandler
1234fn read_svg_contents(
1235 src: &str,
1236 width: Option<&str>,
1237 height: Option<&str>,
1238 parser: &Parser,
1239) -> Option<String> {
1240 let handler = parser.svg_file_handler.as_ref()?;
1241 let mut svg = handler.resolve_svg(src, parser)?;
1242
1243 // Strip anything that precedes the opening `<svg>` tag (e.g. `<?xml … ?>`).
1244 if svg.starts_with('<')
1245 && let Some(start) = svg.find("<svg")
1246 && start > 0
1247 {
1248 svg = svg[start..].to_string();
1249 }
1250
1251 // Rewrite the opening tag's dimensions only when at least one was supplied.
1252 if (width.is_some() || height.is_some())
1253 && let Some(start_tag) = SVG_START_TAG_RX.find(&svg).map(|m| m.as_str().to_string())
1254 {
1255 let rest = svg[start_tag.len()..].to_string();
1256
1257 // Attributes between `<svg` and the closing `>`, with any existing
1258 // width/height/style removed.
1259 let inner = &start_tag[4..start_tag.len() - 1];
1260 let mut new_tag = format!("<svg{}", SVG_SNIFF_WIDTH_HEIGHT_RX.replace_all(inner, ""));
1261
1262 if let Some(width) = width {
1263 new_tag.push_str(&format!(r#" width="{width}""#));
1264 }
1265
1266 if let Some(height) = height {
1267 new_tag.push_str(&format!(r#" height="{height}""#));
1268 }
1269
1270 new_tag.push('>');
1271 svg = format!("{new_tag}{rest}");
1272 }
1273
1274 Some(svg)
1275}
1276
1277/// Detects strings that resemble URIs.
1278///
1279/// ## Examples
1280///
1281/// * `http://domain`
1282/// * `https://domain`
1283/// * `file:///path`
1284/// * `data:info`
1285///
1286/// ## Counter-examples (do not match)
1287///
1288/// * `c:/sample.adoc`
1289/// * `c:\sample.adoc`
1290static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1291 #[allow(clippy::unwrap_used)]
1292 Regex::new(
1293 r#"(?x)
1294 \A # Anchor to start of string
1295 \p{Alphabetic} # First character must be a letter
1296 [\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
1297 : # Literal colon
1298 /{0,2} # Zero to two slashes
1299 "#,
1300 )
1301 .unwrap()
1302});
1303
1304/// Builds the display text for a resolved cross-reference under the selected
1305/// [`XrefStyle`].
1306///
1307/// `base` is the target's reference text (its title, when the target has no
1308/// explicit reftext). Styling applies only when a style is selected *and* the
1309/// target carries an [`XrefSignifier`] (a numbered section or captioned block);
1310/// otherwise `base` is returned unchanged. The HTML conventions live here in
1311/// the HTML renderer: a title is wrapped in typographic quotes, except a
1312/// chapter or appendix title, which is emphasized with `<em>` (in every style).
1313fn apply_xrefstyle(
1314 style: Option<XrefStyle>,
1315 signifier: Option<&XrefSignifier>,
1316 base: String,
1317) -> String {
1318 let (Some(style), Some(signifier)) = (style, signifier) else {
1319 return base;
1320 };
1321
1322 match style {
1323 XrefStyle::Full if signifier.emphasize => {
1324 format!("{label}, <em>{base}</em>", label = signifier.label)
1325 }
1326 XrefStyle::Full => {
1327 format!("{label}, “{base}”", label = signifier.label)
1328 }
1329 XrefStyle::Short => signifier.label.clone(),
1330 XrefStyle::Basic if signifier.emphasize => format!("<em>{base}</em>"),
1331 XrefStyle::Basic => base,
1332 }
1333}
1334
1335/// Builds the `target`/`rel` attributes for a cross-reference whose `xref:`
1336/// macro carried a `window` attribute. Mirrors the link macro: a `_blank`
1337/// window automatically adds `rel="noopener"`.
1338fn xref_constraint_attrs(window: Option<&str>) -> String {
1339 let Some(window) = window else {
1340 return String::new();
1341 };
1342
1343 let rel_noopener = if window == "_blank" {
1344 r#" rel="noopener""#
1345 } else {
1346 ""
1347 };
1348
1349 // The `window` value is author-supplied, so it is escaped before being
1350 // interpolated into the `target` attribute. The `_blank` comparison above
1351 // runs on the raw value, which is correct for the well-formed inputs that
1352 // trigger `rel="noopener"`.
1353 format!(
1354 r#" target="{window}"{rel_noopener}"#,
1355 window = encode_html_attribute(window)
1356 )
1357}
1358
1359fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
1360 let rel = if attrlist.has_option("nofollow") {
1361 Some("nofollow")
1362 } else {
1363 None
1364 };
1365
1366 if let Some(window) = attrlist
1367 .named_attribute("window")
1368 .map(|a| a.value())
1369 .or(window)
1370 {
1371 let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
1372 if let Some(rel) = rel {
1373 format!(r#" rel="{rel}" noopener"#)
1374 } else {
1375 r#" rel="noopener""#.to_owned()
1376 }
1377 } else {
1378 "".to_string()
1379 };
1380
1381 format!(r#" target="{window}"{rel_noopener}"#)
1382 } else if let Some(rel) = rel {
1383 format!(r#" rel="{rel}""#)
1384 } else {
1385 "".to_string()
1386 }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391 use super::encode_html_attribute;
1392
1393 #[test]
1394 fn encode_html_attribute_escapes_special_characters() {
1395 // Each of the four characters that could break out of or corrupt an
1396 // HTML attribute value is replaced with its entity; ordinary characters
1397 // pass through untouched.
1398 assert_eq!(
1399 encode_html_attribute(r#"a&b"c<d>e"#),
1400 "a&b"c<d>e"
1401 );
1402 assert_eq!(encode_html_attribute("plain"), "plain");
1403 }
1404}