use crate::substitution_text::Text;
use crate::AttributePart;
use std::ops::Range;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Media {
Image,
Video,
Audio,
}
impl Media {
pub fn posattrs(self) -> &'static [&'static str] {
match self {
Media::Image => &["alt", "width", "height"],
Media::Video => &["poster", "width", "height"],
Media::Audio => &[],
}
}
}
pub(crate) fn match_macro(line: &str) -> Option<(Media, Range<usize>, Range<usize>)> {
let (media, prefix) = [
(Media::Image, "image::"),
(Media::Video, "video::"),
(Media::Audio, "audio::"),
]
.into_iter()
.find(|(_, prefix)| line.starts_with(prefix))?;
let rest = &line[prefix.len()..];
let close = rest.strip_suffix(']')?.len();
if !rest.chars().next().is_some_and(|c| !is_blank(c)) {
return None;
}
let open = rest[..close]
.match_indices('[')
.map(|(at, _)| at)
.find(|&at| at > 0 && !rest[..at].chars().next_back().is_some_and(is_blank))?;
let base = prefix.len();
Some((media, base..base + open, base + open + 1..base + close))
}
fn is_blank(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
}
pub(crate) fn default_alt(target: &str) -> String {
let trimmed = target.trim_end_matches('/');
let base = if trimmed.is_empty() && !target.is_empty() {
"/"
} else {
trimmed.rsplit('/').next().unwrap_or(trimmed)
};
let ext = target
.rfind('.')
.filter(|&dot| !target[dot..].contains('/'))
.map_or("", |dot| &target[dot..]);
let base = match base.strip_suffix(ext) {
Some(stem) if !ext.is_empty() && !stem.is_empty() => stem,
_ => base,
};
base.replace(['_', '-'], " ")
}
pub(crate) fn has_extension(path: &str) -> bool {
path.rfind('.')
.is_some_and(|dot| !path[dot..].contains('/'))
}
pub(crate) fn icon_target(icon: Option<&str>, name: &str, icontype: Option<&str>) -> String {
let icontype = icontype.unwrap_or("png");
match icon {
Some(icon) if has_extension(icon) => icon.to_owned(),
Some(icon) => format!("{icon}.{icontype}"),
None => format!("{name}.{icontype}"),
}
}
pub(crate) fn alt_parts(alt: &str, default: bool) -> Vec<AttributePart> {
let parts = vec![AttributePart::Text(alt.to_owned())];
if default || alt.is_empty() {
parts
} else {
crate::replacements::apply_parts(parts)
}
}
pub(crate) fn scaled_width(value: &str) -> String {
if value.ends_with(|c: char| c.is_ascii_digit()) {
format!("{value}%")
} else {
value.to_owned()
}
}
pub(crate) fn uri_prefix(s: &str) -> Option<usize> {
let mut chars = s.char_indices();
if !chars.next()?.1.is_alphabetic() {
return None;
}
for (count, (at, c)) in chars.enumerate() {
if c == ':' && count > 0 {
let slashes = s[at + 1..]
.bytes()
.take(2)
.take_while(|&b| b == b'/')
.count();
return Some(at + 1 + slashes);
}
if !(c.is_alphanumeric() || matches!(c, '.' | '+' | '-')) {
return None;
}
}
None
}
pub(crate) fn web_path(target: &str, imagesdir: Option<&str>) -> String {
normalize_web_path(target, imagesdir, true)
}
pub fn normalize_web_path(target: &str, start: Option<&str>, preserve_uri_target: bool) -> String {
if preserve_uri_target && target.contains(':') && uri_prefix(target).is_some() {
return target.replace(' ', "%20");
}
let imagesdir = start;
let mut path = target.to_owned();
let mut prefix = "";
let joined;
if let Some(start) = imagesdir.filter(|start| !start.is_empty() && !target.starts_with('/')) {
let separator = if start.ends_with('/') { "" } else { "/" };
joined = format!("{start}{separator}{target}");
match uri_prefix(&joined).filter(|_| joined.contains(':')) {
Some(len) => {
prefix = &joined[..len];
path = joined[len..].to_owned();
}
None => path.clone_from(&joined),
}
}
let root = if path.starts_with('/') {
Some("/")
} else if path.starts_with("./") {
Some("./")
} else {
None
};
let rest = &path[root.map_or(0, str::len)..];
let mut segments: Vec<&str> = rest.split('/').collect();
while segments.last() == Some(&"") {
segments.pop();
}
let mut resolved: Vec<&str> = Vec::new();
for segment in segments.into_iter().filter(|s| *s != ".") {
if segment != ".." {
resolved.push(segment);
} else if resolved.is_empty() {
if matches!(root, None | Some("./")) {
resolved.push(segment);
}
} else if resolved.last() == Some(&"..") {
resolved.push(segment);
} else {
resolved.pop();
}
}
let path = format!("{}{}", root.unwrap_or(""), resolved.join("/")).replace(' ', "%20");
format!("{prefix}{path}")
}
pub(crate) fn target_text(target: &str, start: usize) -> Text {
let mut source = Text::default();
source.append_source(
target,
true,
0,
crate::Span::new(start, start + target.len()),
);
source
}
pub fn inline_svg(contents: &str, width: Option<&str>, height: Option<&str>) -> String {
let svg = if contents.starts_with("<svg") {
contents
} else {
let start = contents
.match_indices("<svg")
.map(|(at, _)| at)
.find(|&at| {
contents[at + 4..]
.chars()
.next()
.is_some_and(|c| c == '>' || ruby_space(c))
});
start.map_or(contents, |at| &contents[at..])
};
if width.is_none() && height.is_none() {
return svg.to_owned();
}
let Some(rest) = svg.strip_prefix("<svg") else {
return svg.to_owned();
};
let tag_end = match rest.chars().next() {
Some('>') => 0,
Some(c) if ruby_space(c) => match rest.find('>') {
Some(end) => end,
None => return svg.to_owned(),
},
_ => return svg.to_owned(),
};
let old = &svg[..4 + tag_end + 1];
let mut tag = strip_dimensions(old);
for (name, value) in [("width", width), ("height", height)] {
if let Some(value) = value {
tag.pop();
tag.push_str(&format!(" {name}=\"{value}\">"));
}
}
format!("{tag}{}", &svg[old.len()..])
}
fn ruby_space(c: char) -> bool {
matches!(c, ' ' | '\t'..='\r')
}
fn strip_dimensions(tag: &str) -> String {
let mut out = String::new();
let mut rest = tag;
'scan: while let Some((at, c)) = rest.char_indices().find(|&(_, c)| ruby_space(c)) {
let after = &rest[at + c.len_utf8()..];
for name in ["width", "height", "style"] {
let Some(value) = after.strip_prefix(name).and_then(|v| v.strip_prefix('=')) else {
continue;
};
let Some(quote) = value.chars().next().filter(|q| *q == '"' || *q == '\'') else {
continue;
};
if let Some(close) = value[1..].find(quote) {
out.push_str(&rest[..at]);
rest = &value[1 + close + 1..];
continue 'scan;
}
}
out.push_str(&rest[..at + c.len_utf8()]);
rest = after;
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_ruby_media_macro_lines() {
let line = "image::a b.png[x]y]";
let (media, target, list) = match_macro(line).unwrap();
assert_eq!(media, Media::Image);
assert_eq!((&line[target], &line[list]), ("a b.png", "x]y"));
let line = "image::a.png[]";
let (_, target, list) = match_macro(line).unwrap();
assert_eq!((&line[target], &line[list]), ("a.png", ""));
let line = "image::a [b][c]";
let (_, target, list) = match_macro(line).unwrap();
assert_eq!((&line[target], &line[list]), ("a [b]", "c"));
let line = "video::v.mp4[poster.png]";
let (media, target, list) = match_macro(line).unwrap();
assert_eq!(media, Media::Video);
assert_eq!((&line[target], &line[list]), ("v.mp4", "poster.png"));
let line = "audio::s.ogg[]";
let (media, target, _) = match_macro(line).unwrap();
assert_eq!((media, &line[target]), (Media::Audio, "s.ogg"));
for line in [
"image::f.png [x]",
"image:: f.png[x]",
"image::[x]",
"image::f.png[x] ",
"image:f.png[x]",
"text image::f.png[]",
"Video::v.mp4[]",
"audio:s.ogg[]",
] {
assert_eq!(match_macro(line), None, "{line}");
}
}
#[test]
fn default_alt_is_the_basename_without_extension() {
assert_eq!(default_alt("my_cool-pic.png"), "my cool pic");
assert_eq!(default_alt("dir/{nope}.png"), "{nope}");
assert_eq!(default_alt("dir.v2/file"), "file");
assert_eq!(default_alt("dir/sub/"), "sub");
assert_eq!(default_alt(".png"), ".png");
assert_eq!(default_alt("https://x.org/a.b.svg"), "a.b");
}
#[test]
fn alt_replacements_keep_ruby_entities_raw() {
assert_eq!(
alt_parts("(C) it's", false),
[
AttributePart::Raw("©".into()),
AttributePart::Text(" it".into()),
AttributePart::Raw("’".into()),
AttributePart::Text("s".into()),
]
);
assert_eq!(
alt_parts("a_(C)", true),
[AttributePart::Text("a_(C)".into())]
);
}
#[test]
fn web_paths_follow_the_path_resolver() {
assert_eq!(web_path("../a b.png", Some("img/")), "a%20b.png");
assert_eq!(web_path("a.png", Some("one")), "one/a.png");
assert_eq!(web_path("/abs/./x/../y.png", Some("img")), "/abs/y.png");
assert_eq!(
web_path("https://ex.com/a b.png", Some("img")),
"https://ex.com/a%20b.png"
);
assert_eq!(
web_path("a.png", Some("https://cdn.org/i/")),
"https://cdn.org/i/a.png"
);
assert_eq!(web_path("../../a.png", Some("./i")), "./../a.png");
assert_eq!(web_path("/../a.png", None), "/a.png");
assert_eq!(web_path("a//b/", None), "a//b");
assert_eq!(web_path("c:x.png", None), "c:x.png");
}
#[test]
fn scaled_width_defaults_to_percent() {
assert_eq!(scaled_width("50"), "50%");
assert_eq!(scaled_width("5cm"), "5cm");
}
}