use once_cell::sync::Lazy;
use regex::{Captures, Regex};
pub(crate) const TEXT_ALIGN: &[(&str, &str)] = &[
("END", "umd-end"),
("CENTER", "umd-center"),
("START", "umd-start"),
("JUSTIFY", "umd-justify"),
];
pub(crate) const VERTICAL_ALIGN: &[(&str, &str)] = &[
("V-START", "umd-v-start"),
("V-CENTER", "umd-v-center"),
("V-END", "umd-v-end"),
("BASELINE", "umd-v-baseline"),
];
fn align_keywords_pattern(table: &[(&str, &str)]) -> String {
let keywords: Vec<&str> = table.iter().map(|(keyword, _)| *keyword).collect();
format!("({}):", keywords.join("|"))
}
pub(crate) static ALIGN_EXTRACT: Lazy<Regex> =
Lazy::new(|| Regex::new(&align_keywords_pattern(TEXT_ALIGN)).unwrap());
pub(crate) static VALIGN_EXTRACT: Lazy<Regex> =
Lazy::new(|| Regex::new(&align_keywords_pattern(VERTICAL_ALIGN)).unwrap());
static BLOCK_PLACEMENT: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?m)^(START|CENTER|END|JUSTIFY):\n((?:\|[^\n]*\|(?:\n|$))+|@\w+(?:\([^)]*\))?\{[^}]*\})",
)
.unwrap()
});
fn lookup_align_class(table: &[(&str, &str)], value: &str, default: &str) -> String {
let upper = value.to_uppercase();
table
.iter()
.find(|(keyword, _)| *keyword == upper)
.map(|(_, class)| class.to_string())
.unwrap_or_else(|| default.to_string())
}
pub(crate) fn map_text_align(value: &str) -> String {
lookup_align_class(TEXT_ALIGN, value, "umd-start")
}
pub(crate) fn map_vertical_align(value: &str) -> String {
lookup_align_class(VERTICAL_ALIGN, value, "umd-v-baseline")
}
pub(crate) fn cell_alignment_prefixes() -> impl Iterator<Item = (&'static str, &'static str)> {
VERTICAL_ALIGN.iter().chain(TEXT_ALIGN.iter()).copied()
}
pub(crate) fn merge_class_into_tag(tag_html: &str, extra_classes: &str) -> String {
let class_re = Regex::new(r#"class=\"([^\"]*)\""#).unwrap();
if let Some(caps) = class_re.captures(tag_html) {
let existing = caps.get(1).map_or("", |m| m.as_str());
let mut merged: Vec<String> = if existing.trim().is_empty() {
Vec::new()
} else {
existing.split_whitespace().map(|s| s.to_string()).collect()
};
for class_name in extra_classes.split_whitespace() {
if !merged.iter().any(|c| c == class_name) {
merged.push(class_name.to_string());
}
}
class_re
.replace(tag_html, format!(r#"class="{}""#, merged.join(" ")))
.to_string()
} else {
tag_html.replacen('>', &format!(r#" class="{}">"#, extra_classes), 1)
}
}
fn placement_class_for_block(placement: &str) -> &'static str {
match placement {
"START" => "umd-block-auto",
"CENTER" => "umd-block-center",
"END" => "umd-block-auto umd-block-end",
"JUSTIFY" => "umd-block-justify",
_ => "",
}
}
static CODE_BLOCK_PLACEMENT: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"(?s)<p>\s*(START|CENTER|END|JUSTIFY):\s*</p>\s*(<!--CODE_BLOCK_\d+-->)"#).unwrap()
});
pub(crate) fn apply_pending_code_block_placement(html: &str) -> String {
static PENDING: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"(?s)<div data-umd-code-placement="(START|CENTER|END|JUSTIFY)">\s*(<figure\b[^>]*\bclass="[^"]*\bumd-code-block\b[^"]*"[^>]*>[\s\S]*?</figure>)\s*</div>"#,
)
.unwrap()
});
PENDING
.replace_all(html, |caps: &Captures| {
let placement = &caps[1];
let figure = &caps[2];
merge_class_into_tag(figure, placement_class_for_block(placement))
})
.to_string()
}
pub fn apply_block_placement(html: &str) -> String {
let with_code_block_placement = CODE_BLOCK_PLACEMENT
.replace_all(html, |caps: &Captures| {
format!(
r#"<div data-umd-code-placement="{}">{}</div>"#,
&caps[1], &caps[2]
)
})
.to_string();
let media_block_placement = Regex::new(
r#"(?s)<p>\s*(START|CENTER|END|JUSTIFY):\s*\n\s*(<picture[\s\S]*?</picture>|<video[\s\S]*?</video>|<audio[\s\S]*?</audio>|<a href="[^"]+" download class="download-link[^"]*"[^>]*>[\s\S]*?</a>)\s*</p>"#,
)
.unwrap();
let with_media_placement = media_block_placement
.replace_all(&with_code_block_placement, |caps: ®ex::Captures| {
let placement = &caps[1];
let media = &caps[2];
let wrapper_class = match placement {
"START" => "umd-block-start",
"CENTER" => "umd-inline-center",
"END" => "umd-block-end",
"JUSTIFY" => "umd-block-justify",
_ => "",
};
if wrapper_class.is_empty() {
format!("<figure>\n{}\n</figure>", media)
} else {
format!("<figure class=\"{}\">\n{}\n</figure>", wrapper_class, media)
}
})
.to_string();
let table_and_plugin_placement_in_paragraph = Regex::new(
r#"(?s)<p>\s*(START|CENTER|END|JUSTIFY):\s*\n\s*(<(?:table|template)\b[^>]*>[\s\S]*?</(?:table|template)>)\s*</p>"#,
)
.unwrap();
let with_table_and_plugin_placement_in_paragraph = table_and_plugin_placement_in_paragraph
.replace_all(&with_media_placement, |caps: ®ex::Captures| {
let placement = &caps[1];
let block = &caps[2];
let placement_class = placement_class_for_block(placement);
if block.starts_with("<table") {
return merge_class_into_tag(block, placement_class);
}
if block.starts_with("<template") && block.contains("umd-plugin") {
return merge_class_into_tag(block, placement_class);
}
block.to_string()
})
.to_string();
let table_and_plugin_placement = Regex::new(
r#"(?s)<p>\s*(START|CENTER|END|JUSTIFY):\s*</p>\s*(<(?:table|template)\b[^>]*>[\s\S]*?</(?:table|template)>)"#,
)
.unwrap();
let with_table_and_plugin_placement = table_and_plugin_placement
.replace_all(
&with_table_and_plugin_placement_in_paragraph,
|caps: ®ex::Captures| {
let placement = &caps[1];
let block = &caps[2];
let placement_class = placement_class_for_block(placement);
if block.starts_with("<table") {
return merge_class_into_tag(block, placement_class);
}
if block.starts_with("<template") && block.contains("umd-plugin") {
return merge_class_into_tag(block, placement_class);
}
block.to_string()
},
)
.to_string();
BLOCK_PLACEMENT
.replace_all(
&with_table_and_plugin_placement,
|caps: ®ex::Captures| {
let placement = &caps[1];
let content = &caps[2];
let wrapper_class = placement_class_for_block(placement);
if content.starts_with('|') {
format!("<div class=\"{}\">\n{}</div>", wrapper_class, content)
} else if content.starts_with('@') {
format!("<div class=\"{}\">\n{}</div>", wrapper_class, content)
} else {
content.to_string()
}
},
)
.to_string()
}
pub(crate) fn process_table_cell_alignment(html: &str) -> String {
let mut result = html.to_string();
let td_pattern = Regex::new(r"<td([^>]*)>(.*?)</td>").unwrap();
result = td_pattern
.replace_all(&result, |caps: &Captures| {
let existing_attrs = &caps[1];
let content = &caps[2];
process_cell_content("td", existing_attrs, content)
})
.to_string();
let th_pattern = Regex::new(r"<th([^>]*)>(.*?)</th>").unwrap();
result = th_pattern
.replace_all(&result, |caps: &Captures| {
let existing_attrs = &caps[1];
let content = &caps[2];
process_cell_content("th", existing_attrs, content)
})
.to_string();
result
}
fn process_cell_content(tag: &str, existing_attrs: &str, content: &str) -> String {
let trimmed = content.trim_start();
let matched = VERTICAL_ALIGN.iter().find_map(|(keyword, class)| {
trimmed
.strip_prefix(keyword)
.and_then(|rest| rest.strip_prefix(':'))
.map(|rest| (*class, rest.trim_start()))
});
match matched {
None => format!("<{}{}>{}</{}>", tag, existing_attrs, content, tag),
Some((align_class, remaining_content)) => {
if existing_attrs.contains("class=") {
let new_attrs =
existing_attrs.replace("class=\"", &format!("class=\"{} ", align_class));
format!("<{}{}>{}</{}>", tag, new_attrs, remaining_content, tag)
} else {
format!(
"<{} class=\"{}\"{}>{}</{}>",
tag, align_class, existing_attrs, remaining_content, tag
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::block_decoration::apply_block_decorations;
#[test]
fn test_text_align() {
let input = "CENTER: Centered text";
let output = apply_block_decorations(input);
assert!(output.contains("class=\"umd-center\""));
}
#[test]
fn test_text_align_start() {
let input = "START: Start-aligned text";
let output = apply_block_decorations(input);
assert!(output.contains("class=\"umd-start\""));
}
#[test]
fn test_text_align_end() {
let input = "END: End-aligned text";
let output = apply_block_decorations(input);
assert!(output.contains("class=\"umd-end\""));
}
#[test]
fn test_vertical_align() {
let input = "V-START: Top aligned";
let output = apply_block_decorations(input);
assert!(output.contains("class=\"umd-v-start\""));
}
#[test]
fn test_vertical_align_center_and_end() {
assert!(
apply_block_decorations("V-CENTER: Middle aligned").contains("class=\"umd-v-center\"")
);
assert!(apply_block_decorations("V-END: Bottom aligned").contains("class=\"umd-v-end\""));
}
#[test]
fn test_vertical_align_baseline() {
let input = "BASELINE: Baseline aligned";
let output = apply_block_decorations(input);
assert!(output.contains("class=\"umd-v-baseline\""));
}
#[test]
fn test_block_placement_start() {
let input = "START:\n|Header|\n|Cell|";
let output = apply_block_placement(input);
assert!(output.contains(r#"<div class="umd-block-auto">"#));
assert!(output.contains("|Header|"));
}
#[test]
fn test_block_placement_center() {
let input = "CENTER:\n|Header|\n|Cell|";
let output = apply_block_placement(input);
assert!(output.contains(r#"<div class="umd-block-center">"#));
}
#[test]
fn test_block_placement_end() {
let input = "END:\n|Header|\n|Cell|";
let output = apply_block_placement(input);
assert!(output.contains(r#"<div class="umd-block-auto umd-block-end">"#));
}
#[test]
fn test_block_placement_justify() {
let input = "JUSTIFY:\n|Header|\n|Cell|";
let output = apply_block_placement(input);
assert!(output.contains(r#"<div class="umd-block-justify">"#));
}
#[test]
fn test_block_placement_plugin() {
let input = "CENTER:\n@youtube{video_id}";
let output = apply_block_placement(input);
assert!(output.contains(r#"<div class="umd-block-center">"#));
assert!(output.contains("@youtube"));
}
#[test]
fn test_block_placement_end_media() {
let input = r#"<p>END:
<picture>
<img src="image.png" alt="alt" title="Title" />
</picture></p>"#;
let output = apply_block_placement(input);
assert!(output.contains(r#"<figure class="umd-block-end">"#));
assert!(output.contains("<picture>"));
assert!(!output.contains("END:"));
}
#[test]
fn test_table_cell_vertical_alignment() {
let input = r#"<table class="table"><tr><td>V-START: Cell1</td><td>V-CENTER: Cell2</td></tr></table>"#;
let output = process_table_cell_alignment(input);
assert!(output.contains(r#"class="umd-v-start""#));
assert!(output.contains("Cell1"));
assert!(output.contains(r#"class="umd-v-center""#));
assert!(output.contains("Cell2"));
}
#[test]
fn test_table_cell_multiple_alignments() {
let input = r#"<table><tr><th>BASELINE: Header</th><td>V-END: Data</td></tr></table>"#;
let output = process_table_cell_alignment(input);
assert!(output.contains(r#"class="umd-v-baseline""#));
assert!(output.contains(r#"class="umd-v-end""#));
}
#[test]
fn test_cell_alignment_prefixes_cover_all_logical_directions() {
let classes: Vec<&str> = cell_alignment_prefixes().map(|(_, c)| c).collect();
assert!(classes.contains(&"umd-center"));
assert!(classes.contains(&"umd-v-start"));
assert!(classes.contains(&"umd-v-baseline"));
}
}