use std::{fmt::Debug, sync::LazyLock};
use regex::Regex;
use crate::{
Parser,
attributes::Attrlist,
parser::{DerivedReference, ResolvedReference, SafeMode, XrefSignifier, XrefStyle},
};
pub trait InlineSubstitutionRenderer: Debug {
fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_special_character(type_, dest);
}
fn render_quoted_substitution(
&self,
type_: QuoteType,
scope: QuoteScope,
attrlist: Option<Attrlist<'_>>,
id: Option<String>,
body: &str,
dest: &mut String,
) {
DEFAULT_HTML_RENDERER.render_quoted_substitution(type_, scope, attrlist, id, body, dest);
}
fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_character_replacement(type_, dest);
}
fn render_line_break(&self, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_line_break(dest);
}
fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_image(params, dest);
}
fn image_uri(
&self,
target_image_path: &str,
parser: &Parser,
asset_dir_key: Option<&str>,
) -> String {
DEFAULT_HTML_RENDERER.image_uri(target_image_path, parser, asset_dir_key)
}
fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_icon(params, dest);
}
fn icon_uri(&self, name: &str, _attrlist: &Attrlist, parser: &Parser) -> String {
let icon = if has_extname(name) {
name.to_owned()
} else {
let icontype = parser
.attribute_value("icontype")
.as_maybe_str()
.unwrap_or("png")
.to_owned();
format!("{name}.{icontype}")
};
self.image_uri(&icon, parser, Some("iconsdir"))
}
fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_link(params, dest);
}
fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_anchor(id, reftext, dest);
}
fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_xref(params, dest);
}
fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_callout(params, dest);
}
fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_index_term(params, dest);
}
fn render_button(&self, text: &str, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_button(text, dest);
}
fn render_keyboard(&self, keys: &[String], dest: &mut String) {
DEFAULT_HTML_RENDERER.render_keyboard(keys, dest);
}
fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_menu(params, dest);
}
fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
DEFAULT_HTML_RENDERER.render_footnote(params, dest);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SpecialCharacter {
Lt,
Gt,
Ampersand,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuoteType {
Strong,
DoubleQuote,
SingleQuote,
Monospaced,
Emphasis,
Mark,
Superscript,
Subscript,
Unquoted,
AsciiMath,
LatexMath,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuoteScope {
Constrained,
Unconstrained,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CharacterReplacementType {
Copyright,
Registered,
Trademark,
EmDashSurroundedBySpaces,
EmDashWithoutSpace,
Ellipsis,
SingleRightArrow,
DoubleRightArrow,
SingleLeftArrow,
DoubleLeftArrow,
TypographicApostrophe,
CharacterReference(String),
}
#[derive(Clone, Debug)]
pub struct ImageRenderParams<'a> {
pub target: &'a str,
pub alt: String,
pub width: Option<&'a str>,
pub height: Option<&'a str>,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct IconRenderParams<'a> {
pub target: &'a str,
pub alt: String,
pub size: Option<&'a str>,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct LinkRenderParams<'a> {
pub target: String,
pub link_text: String,
pub extra_roles: Vec<&'a str>,
pub window: Option<&'a str>,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct CalloutRenderParams<'a> {
pub number: &'a str,
pub guard: CalloutGuard<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CalloutGuard<'a> {
LineComment(&'a str),
Xml,
}
#[derive(Clone, Debug)]
pub struct XrefRenderParams<'a> {
pub target: &'a str,
pub provided_text: Option<&'a str>,
pub window: Option<&'a str>,
pub roles: &'a [String],
pub xrefstyle: Option<XrefStyle>,
pub derived: Option<&'a DerivedReference>,
pub resolved: Option<&'a ResolvedReference>,
}
#[derive(Clone, Debug)]
pub struct IndexTermRenderParams<'a> {
pub visible_term: Option<&'a str>,
}
#[derive(Clone, Debug)]
pub struct MenuRenderParams<'a> {
pub menu: &'a str,
pub submenus: &'a [String],
pub menuitem: Option<&'a str>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct FootnoteRenderParams<'a> {
pub index: Option<&'a str>,
pub id: Option<&'a str>,
pub is_reference: bool,
pub text: &'a str,
}
#[derive(Debug)]
pub struct HtmlSubstitutionRenderer {}
const DEFAULT_HTML_RENDERER: HtmlSubstitutionRenderer = HtmlSubstitutionRenderer {};
impl HtmlSubstitutionRenderer {
fn image_src(&self, target: &str, attrlist: &Attrlist, parser: &Parser) -> String {
match attrlist.named_attribute("imagesdir") {
Some(imagesdir) => normalize_web_path(target, parser, Some(imagesdir.value()), true),
None => self.image_uri(target, parser, None),
}
}
}
impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
match type_ {
SpecialCharacter::Lt => {
dest.push_str("<");
}
SpecialCharacter::Gt => {
dest.push_str(">");
}
SpecialCharacter::Ampersand => {
dest.push_str("&");
}
}
}
fn render_quoted_substitution(
&self,
type_: QuoteType,
_scope: QuoteScope,
attrlist: Option<Attrlist<'_>>,
mut id: Option<String>,
body: &str,
dest: &mut String,
) {
let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
if let Some(block_style) = attrlist
.as_ref()
.and_then(|a| a.nth_attribute(1))
.and_then(|attr1| attr1.block_style())
{
roles.insert(0, block_style);
}
if id.is_none() {
id = attrlist
.as_ref()
.and_then(|a| a.nth_attribute(1))
.and_then(|attr1| attr1.id())
.map(|id| id.to_owned())
}
if roles.is_empty()
&& id.is_none()
&& let Some(role) = attrlist
.as_ref()
.and_then(|a| a.quoted_text_fallback_role())
{
roles.push(role);
}
match type_ {
QuoteType::Strong => {
wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
}
QuoteType::DoubleQuote => {
dest.push_str("“");
dest.push_str(body);
dest.push_str("”");
}
QuoteType::SingleQuote => {
dest.push_str("‘");
dest.push_str(body);
dest.push_str("’");
}
QuoteType::Monospaced => {
wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
}
QuoteType::Emphasis => {
wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
}
QuoteType::Mark => {
if roles.is_empty() && id.is_none() {
wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
} else {
wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
}
}
QuoteType::Superscript => {
wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
}
QuoteType::Subscript => {
wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
}
QuoteType::Unquoted => {
if roles.is_empty() && id.is_none() {
dest.push_str(body);
} else {
wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
}
}
QuoteType::AsciiMath => {
dest.push_str(r"\$");
dest.push_str(body);
dest.push_str(r"\$");
}
QuoteType::LatexMath => {
dest.push_str(r"\(");
dest.push_str(body);
dest.push_str(r"\)");
}
}
}
fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
match type_ {
CharacterReplacementType::Copyright => {
dest.push_str("©");
}
CharacterReplacementType::Registered => {
dest.push_str("®");
}
CharacterReplacementType::Trademark => {
dest.push_str("™");
}
CharacterReplacementType::EmDashSurroundedBySpaces => {
dest.push_str(" — ");
}
CharacterReplacementType::EmDashWithoutSpace => {
dest.push_str("—​");
}
CharacterReplacementType::Ellipsis => {
dest.push_str("…​");
}
CharacterReplacementType::SingleLeftArrow => {
dest.push_str("←");
}
CharacterReplacementType::DoubleLeftArrow => {
dest.push_str("⇐");
}
CharacterReplacementType::SingleRightArrow => {
dest.push_str("→");
}
CharacterReplacementType::DoubleRightArrow => {
dest.push_str("⇒");
}
CharacterReplacementType::TypographicApostrophe => {
dest.push_str("’");
}
CharacterReplacementType::CharacterReference(name) => {
dest.push('&');
dest.push_str(&name);
dest.push(';');
}
}
}
fn render_line_break(&self, dest: &mut String) {
dest.push_str("<br>");
}
fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
let src = self.image_src(params.target, params.attrlist, params.parser);
let alt_encoded = encode_attribute_value(params.alt.clone());
let mut dimension_attrs = String::new();
if let Some(width) = params.width {
dimension_attrs.push_str(&format!(
r#" width="{width}""#,
width = encode_attribute_value(width.to_owned())
));
}
if let Some(height) = params.height {
dimension_attrs.push_str(&format!(
r#" height="{height}""#,
height = encode_attribute_value(height.to_owned())
));
}
if let Some(title) = params.attrlist.named_attribute("title") {
dimension_attrs.push_str(&format!(
r#" title="{title}""#,
title = encode_attribute_value(title.value().to_owned())
));
}
let format = params
.attrlist
.named_attribute("format")
.map(|format| format.value());
let svg_active = (format == Some("svg") || params.target.contains(".svg"))
&& params.parser.safe_mode() < SafeMode::Secure;
let inline_svg = svg_active && params.attrlist.has_option("inline");
let img = if inline_svg {
read_svg_contents(&src, params.width, params.height, params.parser)
.unwrap_or_else(|| format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt))
} else if svg_active && params.attrlist.has_option("interactive") {
let fallback = if let Some(fallback) = params.attrlist.named_attribute("fallback") {
let fallback_src = self.image_src(fallback.value(), params.attrlist, params.parser);
format!(
r#"<img src="{fallback_src}" alt="{alt_encoded}"{dimension_attrs}>"#,
fallback_src = encode_attribute_value(fallback_src)
)
} else {
format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt)
};
format!(
r#"<object type="image/svg+xml" data="{src}"{dimension_attrs}>{fallback}</object>"#,
src = encode_attribute_value(src.clone())
)
} else {
format!(
r#"<img src="{src}" alt="{alt_encoded}"{dimension_attrs}>"#,
src = encode_attribute_value(src.clone())
)
};
let link_self_href = if inline_svg { None } else { Some(src.as_str()) };
let self_href_from_uri_target = is_uri_ish(params.target);
render_icon_or_image(
params.attrlist,
&img,
"image",
link_self_href,
self_href_from_uri_target,
dest,
);
}
fn image_uri(
&self,
target_image_path: &str,
parser: &Parser,
asset_dir_key: Option<&str>,
) -> String {
let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
let asset_dir = parser
.attribute_value(asset_dir_key)
.as_maybe_str()
.map(|s| s.to_string());
let normalized = normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true);
if parser.safe_mode() < SafeMode::Secure
&& parser.is_attribute_set("data-uri")
&& !is_uri_ish(target_image_path)
&& let Some(handler) = parser.image_file_handler.as_ref()
&& let Some(bytes) = handler.resolve_image(&normalized, parser)
{
let mimetype = data_uri_mimetype(target_image_path);
let encoded = crate::internal::base64::strict_encode(&bytes);
return format!("data:{mimetype};base64,{encoded}");
}
normalized
}
fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
let src = self.icon_uri(params.target, params.attrlist, params.parser);
let img = if params.parser.is_attribute_set("icons") {
let icons = params.parser.attribute_value("icons");
if let Some(icons) = icons.as_maybe_str()
&& icons == "font"
{
let mut i_class_attrs: Vec<String> = vec![
"fa".to_owned(),
format!(
"fa-{target}",
target = encode_attribute_value(params.target.to_owned())
),
];
if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
i_class_attrs.push(format!(
"fa-{size}",
size = encode_attribute_value(size.value().to_owned())
));
}
if let Some(flip) = params.attrlist.named_attribute("flip") {
i_class_attrs.push(format!(
"fa-flip-{flip}",
flip = encode_attribute_value(flip.value().to_owned())
));
} else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
i_class_attrs.push(format!(
"fa-rotate-{rotate}",
rotate = encode_attribute_value(rotate.value().to_owned())
));
}
format!(
r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
i_class_attr_val = i_class_attrs.join(" "),
title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
format!(
r#" title="{title}""#,
title = encode_attribute_value(title.value().to_owned())
)
} else {
"".to_owned()
}
)
} else {
let mut attrs: Vec<String> = vec![
format!(r#"src="{src}""#, src = encode_attribute_value(src.clone())),
format!(
r#"alt="{alt}""#,
alt = encode_attribute_value(params.alt.to_string())
),
];
if let Some(width) = params.attrlist.named_attribute("width") {
attrs.push(format!(
r#"width="{width}""#,
width = encode_attribute_value(width.value().to_owned())
));
}
if let Some(height) = params.attrlist.named_attribute("height") {
attrs.push(format!(
r#"height="{height}""#,
height = encode_attribute_value(height.value().to_owned())
));
}
if let Some(title) = params.attrlist.named_attribute("title") {
attrs.push(format!(
r#"title="{title}""#,
title = encode_attribute_value(title.value().to_owned())
));
}
format!(
"<img {attrs}{void_element_slash}>",
attrs = attrs.join(" "),
void_element_slash = "",
)
}
} else {
format!("[{alt}]", alt = params.alt)
};
let link_self_href = if params.parser.is_attribute_set("icons")
&& params.parser.attribute_value("icons").as_maybe_str() != Some("font")
{
Some(src.as_str())
} else {
None
};
let self_href_from_uri_target = is_uri_ish(params.target);
render_icon_or_image(
params.attrlist,
&img,
"icon",
link_self_href,
self_href_from_uri_target,
dest,
);
}
fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
let id = params.attrlist.id();
let mut roles = params.extra_roles.clone();
let mut attrlist_roles = params.attrlist.roles().clone();
roles.append(&mut attrlist_roles);
let link = format!(
r##"<a href="{target}"{id}{class}{title}{link_constraint_attrs}>{link_text}</a>"##,
target = encode_attribute_value(params.target.clone()),
id = if let Some(id) = id {
format!(r#" id="{id}""#)
} else {
"".to_owned()
},
class = if roles.is_empty() {
"".to_owned()
} else {
format!(r#" class="{roles}""#, roles = roles.join(" "))
},
title = if let Some(title) = params.attrlist.named_attribute("title") {
format!(
r#" title="{title}""#,
title = encode_attribute_value(title.value().to_owned())
)
} else {
"".to_owned()
},
link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
link_text = params.link_text,
);
dest.push_str(&link);
}
fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
dest.push_str(&format!("<a id=\"{id}\"></a>"));
}
fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
let class = if params.roles.is_empty() {
String::new()
} else {
let roles = params
.roles
.iter()
.map(|role| encode_html_attribute(role))
.collect::<Vec<_>>()
.join(" ");
format!(r#" class="{roles}""#)
};
let constraint_attrs = xref_constraint_attrs(params.window);
match (params.resolved, params.derived) {
(Some(resolved), _) => {
let text = match params.provided_text {
Some(provided) if !provided.is_empty() => provided.to_string(),
_ => {
let base = resolved
.text
.as_deref()
.map(drop_anchor_tags)
.unwrap_or_else(|| format!("[{target}]", target = params.target));
apply_xrefstyle(params.xrefstyle, resolved.signifier.as_ref(), base)
}
};
dest.push_str(&format!(
r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
href = encode_attribute_value(resolved.href.clone())
));
}
(None, Some(derived)) => {
let text = params
.provided_text
.map(str::to_string)
.unwrap_or_else(|| derived.text.clone());
dest.push_str(&format!(
r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
href = encode_attribute_value(derived.href.clone())
));
}
(None, None) => {
let text = params
.provided_text
.map(str::to_string)
.unwrap_or_else(|| format!("[{target}]", target = params.target));
dest.push_str(&format!(
r##"<a href="#{target}"{class}{constraint_attrs}>{text}</a>"##,
target = encode_attribute_value(params.target.to_owned())
));
}
}
}
fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
let n = params.number;
let parser = params.parser;
if parser.attribute_value("icons").as_maybe_str() == Some("font") {
dest.push_str(&format!(
r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
));
} else if parser.is_attribute_set("icons") {
let icontype = parser
.attribute_value("icontype")
.as_maybe_str()
.unwrap_or("png")
.to_owned();
let icon = format!("callouts/{n}.{icontype}");
let src = self.image_uri(&icon, parser, Some("iconsdir"));
dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
} else {
match params.guard {
CalloutGuard::Xml => {
dest.push_str(&format!(r#"<!--<b class="conum">({n})</b>-->"#));
}
CalloutGuard::LineComment(prefix) => {
dest.push_str(prefix);
dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
}
}
}
}
fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
if let Some(term) = params.visible_term {
dest.push_str(term);
}
}
fn render_button(&self, text: &str, dest: &mut String) {
dest.push_str(&format!(r#"<b class="button">{text}</b>"#));
}
fn render_keyboard(&self, keys: &[String], dest: &mut String) {
if let [key] = keys {
dest.push_str(&format!("<kbd>{key}</kbd>"));
} else {
dest.push_str(&format!(
r#"<span class="keyseq"><kbd>{keys}</kbd></span>"#,
keys = keys.join("</kbd>+<kbd>")
));
}
}
fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
let caret = if params.parser.attribute_value("icons").as_maybe_str() == Some("font") {
r#" <i class="fa fa-angle-right caret"></i> "#
} else {
r#" <b class="caret">›</b> "#
};
let menu = params.menu;
if params.submenus.is_empty() {
if let Some(menuitem) = params.menuitem {
dest.push_str(&format!(
r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#
));
} else {
dest.push_str(&format!(r#"<b class="menuref">{menu}</b>"#));
}
} else {
let submenu_joiner = format!(r#"</b>{caret}<b class="submenu">"#);
dest.push_str(&format!(
r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="submenu">{submenus}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#,
submenus = params.submenus.join(&submenu_joiner),
menuitem = params.menuitem.unwrap_or_default(),
));
}
}
fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
match params.index {
Some(index) if params.is_reference => {
dest.push_str(&format!(
r##"<sup class="footnoteref">[<a class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
));
}
Some(index) => {
let id_attr = params
.id
.map(|id| format!(r#" id="_footnote_{id}""#))
.unwrap_or_default();
dest.push_str(&format!(
r##"<sup class="footnote"{id_attr}>[<a id="_footnoteref_{index}" class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
));
}
None => {
dest.push_str(&format!(
r#"<sup class="footnoteref red" title="Unresolved footnote reference.">[{text}]</sup>"#,
text = params.text
));
}
}
}
}
fn push_attribute_value(dest: &mut String, value: &str) {
for ch in value.chars() {
if ch == '"' {
dest.push_str(""");
} else {
dest.push(ch);
}
}
}
fn wrap_body_in_html_tag(
_attrlist: Option<&Attrlist<'_>>,
tag: &'static str,
id: Option<String>,
roles: Vec<&str>,
body: &str,
dest: &mut String,
) {
dest.push('<');
dest.push_str(tag);
if let Some(id) = id.as_ref() {
dest.push_str(" id=\"");
push_attribute_value(dest, id);
dest.push('"');
}
if !roles.is_empty() {
let roles = roles.join(" ");
dest.push_str(" class=\"");
push_attribute_value(dest, &roles);
dest.push('"');
}
dest.push('>');
dest.push_str(body);
dest.push_str("</");
dest.push_str(tag);
dest.push('>');
}
fn render_icon_or_image(
attrlist: &Attrlist,
img: &str,
type_: &'static str,
link_self_href: Option<&str>,
self_href_from_uri_target: bool,
dest: &mut String,
) {
let mut img = img.to_string();
if let Some(link) = attrlist.named_attribute("link") {
let is_self = link.value() == "self";
let href = if is_self {
link_self_href.unwrap_or("self")
} else {
link.value()
};
let rejected = if is_self {
has_dangerous_self_href(href, self_href_from_uri_target)
} else {
has_dangerous_scheme(link.value())
};
if !rejected {
img = format!(
r#"<a class="image" href="{href}"{link_constraint_attrs}>{img}</a>"#,
href = encode_attribute_value(href.to_owned()),
link_constraint_attrs = link_constraint_attrs(attrlist, None)
);
}
}
let mut roles: Vec<&str> = attrlist.roles();
if let Some(float) = attrlist.named_attribute("float") {
roles.insert(0, float.value());
}
roles.insert(0, type_);
dest.push_str(r#"<span class=""#);
dest.push_str(&roles.join(" "));
dest.push_str(r#"">"#);
dest.push_str(&img);
dest.push_str("</span>");
}
fn encode_attribute_value(value: String) -> String {
value.replace('"', """)
}
pub(crate) fn has_dangerous_scheme(target: &str) -> bool {
let target = target.trim_start_matches(|c: char| c <= ' ');
const DANGEROUS_SCHEMES: [&str; 3] = ["javascript:", "data:", "vbscript:"];
DANGEROUS_SCHEMES.iter().any(|scheme| {
target
.get(..scheme.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme))
})
}
pub(crate) fn has_dangerous_self_href(href: &str, from_uri_target: bool) -> bool {
if !has_dangerous_scheme(href) {
return false;
}
if !is_image_data_uri(href) {
return true;
}
from_uri_target && is_svg_data_uri(href)
}
fn is_image_data_uri(href: &str) -> bool {
let href = href.trim_start_matches(|c: char| c <= ' ');
const IMAGE_DATA_PREFIX: &str = "data:image/";
href.get(..IMAGE_DATA_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(IMAGE_DATA_PREFIX))
}
fn is_svg_data_uri(href: &str) -> bool {
let href = href.trim_start_matches(|c: char| c <= ' ');
const SVG_DATA_PREFIX: &str = "data:image/svg";
href.get(..SVG_DATA_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(SVG_DATA_PREFIX))
}
fn encode_html_attribute(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn normalize_web_path(
target: &str,
parser: &Parser,
start: Option<&str>,
preserve_uri_target: bool,
) -> String {
if preserve_uri_target && is_uri_ish(target) {
encode_spaces_in_uri(target)
} else {
parser.path_resolver.web_path(target, start)
}
}
pub(crate) fn is_uri_ish(path: &str) -> bool {
path.contains(':') && URI_SNIFF.is_match(path)
}
fn extname(path: &str) -> Option<&str> {
let segment = path.rsplit(['/', '\\']).next().unwrap_or(path);
match segment.rfind('.') {
Some(i) if i > 0 && i < segment.len() - 1 => Some(&segment[i..]),
_ => None,
}
}
fn has_extname(path: &str) -> bool {
extname(path).is_some()
}
fn data_uri_mimetype(target: &str) -> String {
match extname(target) {
Some(".svg") => "image/svg+xml".to_string(),
Some(ext) => format!("image/{ext}", ext = ext.strip_prefix('.').unwrap_or(ext)),
None => "application/octet-stream".to_string(),
}
}
fn encode_spaces_in_uri(s: &str) -> String {
s.replace(' ', "%20")
}
static SVG_START_TAG_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"\A<svg[^>]*>").unwrap()
});
static SVG_SNIFF_WIDTH_HEIGHT_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"(?s)\s+(?:width|height|style)=(?:"[^"]*"|'[^']*')"#).unwrap()
});
fn read_svg_contents(
src: &str,
width: Option<&str>,
height: Option<&str>,
parser: &Parser,
) -> Option<String> {
let handler = parser.svg_file_handler.as_ref()?;
let mut svg = handler.resolve_svg(src, parser)?;
if svg.starts_with('<')
&& let Some(start) = svg.find("<svg")
&& start > 0
{
svg = svg[start..].to_string();
}
if (width.is_some() || height.is_some())
&& let Some(start_tag) = SVG_START_TAG_RX.find(&svg).map(|m| m.as_str().to_string())
{
let rest = svg[start_tag.len()..].to_string();
let inner = &start_tag[4..start_tag.len() - 1];
let mut new_tag = format!("<svg{}", SVG_SNIFF_WIDTH_HEIGHT_RX.replace_all(inner, ""));
if let Some(width) = width {
new_tag.push_str(&format!(r#" width="{width}""#));
}
if let Some(height) = height {
new_tag.push_str(&format!(r#" height="{height}""#));
}
new_tag.push('>');
svg = format!("{new_tag}{rest}");
}
Some(svg)
}
static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x)
\A # Anchor to start of string
\p{Alphabetic} # First character must be a letter
[\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
: # Literal colon
/{0,2} # Zero to two slashes
"#,
)
.unwrap()
});
fn drop_anchor_tags(text: &str) -> String {
if !text.contains("<a") {
return text.to_string();
}
#[allow(clippy::unwrap_used)]
static DROP_ANCHOR_RX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<(?:a\b[^>]*|/a)>").unwrap());
DROP_ANCHOR_RX.replace_all(text, "").into_owned()
}
fn apply_xrefstyle(
style: Option<XrefStyle>,
signifier: Option<&XrefSignifier>,
base: String,
) -> String {
let (Some(style), Some(signifier)) = (style, signifier) else {
return base;
};
match style {
XrefStyle::Full if signifier.emphasize => {
format!("{label}, <em>{base}</em>", label = signifier.label)
}
XrefStyle::Full => {
format!("{label}, “{base}”", label = signifier.label)
}
XrefStyle::Short => signifier.label.clone(),
XrefStyle::Basic if signifier.emphasize => format!("<em>{base}</em>"),
XrefStyle::Basic => base,
}
}
fn xref_constraint_attrs(window: Option<&str>) -> String {
let Some(window) = window else {
return String::new();
};
let rel_noopener = if window == "_blank" {
r#" rel="noopener""#
} else {
""
};
format!(
r#" target="{window}"{rel_noopener}"#,
window = encode_html_attribute(window)
)
}
fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&str>) -> String {
let rel = if attrlist.has_option("nofollow") {
Some("nofollow")
} else {
None
};
if let Some(window) = attrlist
.named_attribute("window")
.map(|a| a.value())
.or(window)
{
let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
if let Some(rel) = rel {
format!(r#" rel="{rel} noopener""#)
} else {
r#" rel="noopener""#.to_owned()
}
} else {
"".to_string()
};
format!(r#" target="{window}"{rel_noopener}"#)
} else if let Some(rel) = rel {
format!(r#" rel="{rel}""#)
} else {
"".to_string()
}
}
#[cfg(test)]
mod tests {
use super::{data_uri_mimetype, drop_anchor_tags, encode_html_attribute, extname, has_extname};
#[test]
fn extname_extracts_final_segment_extension() {
assert_eq!(extname("fixtures/dot.gif"), Some(".gif"));
assert_eq!(extname("circle.svg"), Some(".svg"));
assert_eq!(extname("a.b/c"), None);
assert_eq!(extname(".hidden"), None);
assert_eq!(extname("trailing."), None);
assert_eq!(extname("plain"), None);
assert!(has_extname("a/b.png"));
assert!(!has_extname("a.b/c"));
}
#[test]
fn data_uri_mimetype_maps_extension() {
assert_eq!(data_uri_mimetype("circle.svg"), "image/svg+xml");
assert_eq!(data_uri_mimetype("fixtures/dot.gif"), "image/gif");
assert_eq!(data_uri_mimetype("photo.png"), "image/png");
assert_eq!(data_uri_mimetype("photo.jpg"), "image/jpg");
assert_eq!(data_uri_mimetype("photo.jpeg"), "image/jpeg");
assert_eq!(data_uri_mimetype("noext"), "application/octet-stream");
}
#[test]
fn encode_html_attribute_escapes_special_characters() {
assert_eq!(
encode_html_attribute(r#"a&b"c<d>e"#),
"a&b"c<d>e"
);
assert_eq!(encode_html_attribute("plain"), "plain");
}
#[test]
fn drop_anchor_tags_strips_anchor_markup_keeping_text() {
assert_eq!(drop_anchor_tags("plain text"), "plain text");
assert_eq!(
drop_anchor_tags(r#"Consult <a href="https://google.com">Google</a>"#),
"Consult Google"
);
assert_eq!(drop_anchor_tags(r##"B <a href="#a">[a]</a>"##), "B [a]");
assert_eq!(
drop_anchor_tags(r##"<a href="#x">X</a> and <a href="#y">Y</a>"##),
"X and Y"
);
assert_eq!(
drop_anchor_tags("<article>text</article>"),
"<article>text</article>"
);
}
}