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