use std::collections::BTreeMap;
use crate::content_graph::ContentGraph;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fit {
Cover,
Contain,
Fill,
None,
ScaleDown,
}
impl Fit {
pub fn to_css_value(&self) -> &str {
match self {
Fit::Cover => "cover",
Fit::Contain => "contain",
Fit::Fill => "fill",
Fit::None => "none",
Fit::ScaleDown => "scale-down",
}
}
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"cover" => Some(Fit::Cover),
"contain" => Some(Fit::Contain),
"fill" => Some(Fit::Fill),
"none" => Some(Fit::None),
"scale-down" | "scaledown" => Some(Fit::ScaleDown),
_ => Option::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Position {
Center,
Left,
Right,
Top,
Bottom,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl Position {
pub fn to_css_value(&self) -> &str {
match self {
Position::Center => "center",
Position::Left => "left",
Position::Right => "right",
Position::Top => "top",
Position::Bottom => "bottom",
Position::TopLeft => "top left",
Position::TopRight => "top right",
Position::BottomLeft => "bottom left",
Position::BottomRight => "bottom right",
}
}
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"center" => Some(Position::Center),
"left" => Some(Position::Left),
"right" => Some(Position::Right),
"top" => Some(Position::Top),
"bottom" => Some(Position::Bottom),
"top-left" | "topleft" | "top left" => Some(Position::TopLeft),
"top-right" | "topright" | "top right" => Some(Position::TopRight),
"bottom-left" | "bottomleft" | "bottom left" => Some(Position::BottomLeft),
"bottom-right" | "bottomright" | "bottom right" => Some(Position::BottomRight),
_ => Option::None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AlignSide {
Left,
Right,
}
impl AlignSide {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"align-left" | "alignleft" | "left" => Some(AlignSide::Left),
"align-right" | "alignright" | "right" => Some(AlignSide::Right),
_ => None,
}
}
pub fn css_class(self) -> &'static str {
match self {
AlignSide::Left => "moss-align-left",
AlignSide::Right => "moss-align-right",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MediaAttrs {
pub fit: Option<Fit>,
pub position: Option<Position>,
pub align: Option<AlignSide>,
pub color: Option<String>,
pub class_names: Vec<String>,
pub extra_attrs: BTreeMap<String, String>,
}
impl MediaAttrs {
pub fn is_empty(&self) -> bool {
self.fit.is_none()
&& self.position.is_none()
&& self.align.is_none()
&& self.color.is_none()
&& self.class_names.is_empty()
&& self.extra_attrs.is_empty()
}
pub fn to_inline_style(&self) -> Option<String> {
if self.fit.is_none() && self.position.is_none() {
return None;
}
let mut parts = Vec::new();
if let Some(ref fit) = self.fit {
parts.push(format!("object-fit:{}", fit.to_css_value()));
}
if let Some(ref pos) = self.position {
parts.push(format!("object-position:{}", pos.to_css_value()));
}
Some(parts.join(";"))
}
pub fn css_class(&self) -> Option<&'static str> {
self.align.map(AlignSide::css_class)
}
pub fn class_attr(&self) -> Option<String> {
let moss_class = self.css_class();
if moss_class.is_none() && self.class_names.is_empty() {
return None;
}
let mut parts: Vec<&str> = Vec::new();
if let Some(c) = moss_class {
parts.push(c);
}
for c in &self.class_names {
parts.push(c.as_str());
}
Some(parts.join(" "))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedMedia {
pub path: String,
pub attrs: MediaAttrs,
}
pub fn strip_wikilink(raw: &str) -> &str {
let trimmed = raw.trim();
trimmed
.strip_prefix("[[")
.and_then(|s| s.strip_suffix("]]"))
.unwrap_or(trimmed)
}
pub fn split_pipe(raw: &str) -> (&str, &str) {
raw.split_once('|').unwrap_or((raw, ""))
}
pub fn parse_media_attrs(raw: &str) -> MediaAttrs {
let mut fit: Option<Fit> = None;
let mut position: Option<Position> = None;
let mut align: Option<AlignSide> = None;
let mut color: Option<String> = None;
let tokens: Vec<&str> = raw.split_whitespace().collect();
let mut i = 0;
while i < tokens.len() {
let token = tokens[i];
if i + 1 < tokens.len() {
let combined = format!("{} {}", token, tokens[i + 1]);
if let Some(pos) = Position::from_keyword(&combined) {
position = Some(pos);
i += 2;
continue;
}
}
if let Some(f) = Fit::from_keyword(token) {
fit = Some(f);
i += 1;
continue;
}
if let Some(pos) = Position::from_keyword(token) {
position = Some(pos);
i += 1;
continue;
}
if let Some(side) = AlignSide::from_keyword(token) {
align = Some(side);
i += 1;
continue;
}
if let Some(value) = token.strip_prefix("color=") {
if !value.is_empty() {
color = Some(value.to_string());
}
i += 1;
continue;
}
i += 1;
}
MediaAttrs {
fit,
position,
align,
color,
..Default::default()
}
}
pub fn match_width_token(s: &str) -> Option<&'static str> {
match s {
"body" => Some("body"),
"wide" => Some("wide"),
"page" => Some("page"),
"screen" | "full" => Some("screen"),
_ => None,
}
}
pub fn parse_image_width(seg: &str) -> Option<String> {
let s = seg.trim();
if s.is_empty() {
return None;
}
if let Some(named) = match_width_token(s) {
return Some(named.to_string());
}
if let Some(rest) = s.strip_suffix('%') {
let v: f64 = rest.trim().parse().ok()?;
if v <= 0.0 {
return None;
}
let clamped = v.min(100.0);
let text = if clamped.fract() == 0.0 {
format!("{}%", clamped as i64)
} else {
format!("{}%", clamped)
};
return Some(text);
}
None
}
pub fn split_alt_width(text: &str) -> (String, Option<String>) {
let mut width: Option<String> = None;
let mut remaining: Vec<&str> = Vec::new();
for seg in text.split('|') {
if width.is_none() {
if let Some(w) = parse_image_width(seg) {
width = Some(w);
continue;
}
}
remaining.push(seg);
}
(remaining.join("|"), width)
}
pub fn set_image_width(image_md: &str, width: Option<&str>) -> String {
let new_width: Option<String> = width.and_then(parse_image_width);
if let Some(inner) = image_md
.strip_prefix("![[")
.and_then(|s| s.strip_suffix("]]"))
{
let (path, pothole) = inner.split_once('|').unwrap_or((inner, ""));
let (rest, _old) = split_alt_width(pothole);
let segments: Vec<&str> = rest.split('|').filter(|s| !s.is_empty()).collect();
let mut parts: Vec<String> = segments.iter().map(|s| s.to_string()).collect();
if let Some(w) = new_width {
parts.push(w);
}
return if parts.is_empty() {
format!("![[{}]]", path)
} else {
format!("![[{}|{}]]", path, parts.join("|"))
};
}
if image_md.starts_with(") {
{
let (rest_alt, _old) = split_alt_width(alt_raw);
let alt_out = match new_width {
Some(w) => format!("{}|{}", rest_alt, w),
None => rest_alt,
};
return format!("", alt_out, url);
}
}
}
image_md.to_string()
}
pub fn extract_width_from_alias(alias: &str) -> (Option<&'static str>, String) {
let segments: Vec<&str> = alias.split('|').collect();
let mut width: Option<&'static str> = None;
let mut remaining: Vec<&str> = Vec::with_capacity(segments.len());
for seg in &segments {
let trimmed = seg.trim();
if width.is_none() {
if let Some(canonical) = match_width_token(trimmed) {
width = Some(canonical);
continue;
}
}
remaining.push(seg);
}
(width, remaining.join("|"))
}
pub fn is_all_display_keywords(text: &str) -> bool {
let tokens: Vec<&str> = text.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut i = 0;
while i < tokens.len() {
if i + 1 < tokens.len() {
let combined = format!("{} {}", tokens[i], tokens[i + 1]);
if Position::from_keyword(&combined).is_some() {
i += 2;
continue;
}
}
if Fit::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
if Position::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
if AlignSide::from_keyword(tokens[i]).is_some() {
i += 1;
continue;
}
return false;
}
true
}
pub(crate) fn is_structural_alias(alias: &str) -> bool {
if is_all_display_keywords(alias) {
return true;
}
let tokens: Vec<&str> = alias.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let mut i = 0;
while i < tokens.len() {
if match_width_token(tokens[i]).is_some() {
i += 1;
continue;
}
if i + 1 < tokens.len() {
let combined = format!("{} {}", tokens[i], tokens[i + 1]);
if Position::from_keyword(&combined).is_some() {
i += 2;
continue;
}
}
if Fit::from_keyword(tokens[i]).is_some()
|| Position::from_keyword(tokens[i]).is_some()
|| AlignSide::from_keyword(tokens[i]).is_some()
{
i += 1;
continue;
}
return false;
}
true
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImageAliasClass {
pub display_keywords: Option<String>,
pub caption: Option<String>,
}
pub(crate) fn classify_image_alias(alias: Option<&str>) -> ImageAliasClass {
match alias {
Some(a) if a.is_empty() => ImageAliasClass {
display_keywords: None,
caption: None,
},
Some(a) if is_structural_alias(a) => ImageAliasClass {
display_keywords: Some(a.to_string()),
caption: None,
},
Some(other) => ImageAliasClass {
display_keywords: None,
caption: Some(other.to_string()),
},
None => ImageAliasClass {
display_keywords: None,
caption: None,
},
}
}
pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn is_external(path: &str) -> bool {
path.starts_with("http://")
|| path.starts_with("https://")
|| path.starts_with("//")
|| path.starts_with("data:")
}
pub(crate) fn resolve_media_ref(raw: &str, source_path: &str, graph: &ContentGraph) -> ResolvedMedia {
let inner = strip_wikilink(raw);
let (path_part, attrs_str) = split_pipe(inner);
let path_trimmed = path_part.trim();
let attrs = parse_media_attrs(attrs_str);
let resolved_path = if is_external(path_trimmed) {
path_trimmed.to_string()
} else if let Some(stripped) = path_trimmed.strip_prefix('/') {
stripped.to_string()
} else {
graph
.resolve_path(path_trimmed, source_path)
.unwrap_or_else(|| path_trimmed.to_string())
};
ResolvedMedia {
path: resolved_path,
attrs,
}
}
#[cfg(test)]
#[path = "media_tests.rs"]
mod tests;