use std::collections::HashMap;
use super::parser::ComponentValue;
use super::tokenizer::Token;
#[derive(Debug, Clone, PartialEq)]
pub enum Content {
Str(String),
Counter {
name: String,
style: ListStyle,
},
Counters {
name: String,
separator: String,
style: ListStyle,
},
Attr {
name: String,
},
OpenQuote,
CloseQuote,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListStyle {
Decimal,
DecimalLeadingZero,
LowerRoman,
UpperRoman,
LowerAlpha,
UpperAlpha,
LowerGreek,
Disc,
Circle,
Square,
None,
}
impl ListStyle {
pub fn render(self, n: i32) -> String {
if n <= 0 {
return n.to_string();
}
match self {
ListStyle::Decimal => n.to_string(),
ListStyle::DecimalLeadingZero => format!("{n:02}"),
ListStyle::LowerRoman => to_roman(n).to_lowercase(),
ListStyle::UpperRoman => to_roman(n),
ListStyle::LowerAlpha => to_alpha(n, 'a'),
ListStyle::UpperAlpha => to_alpha(n, 'A'),
ListStyle::LowerGreek => to_greek(n),
ListStyle::Disc => "•".to_string(),
ListStyle::Circle => "◦".to_string(),
ListStyle::Square => "▪".to_string(),
ListStyle::None => String::new(),
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"decimal" => Self::Decimal,
"decimal-leading-zero" => Self::DecimalLeadingZero,
"lower-roman" => Self::LowerRoman,
"upper-roman" => Self::UpperRoman,
"lower-alpha" | "lower-latin" => Self::LowerAlpha,
"upper-alpha" | "upper-latin" => Self::UpperAlpha,
"lower-greek" => Self::LowerGreek,
"disc" => Self::Disc,
"circle" => Self::Circle,
"square" => Self::Square,
"none" => Self::None,
_ => return None,
})
}
}
fn to_roman(mut n: i32) -> String {
const TABLE: &[(i32, &str)] = &[
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
let mut out = String::new();
for &(value, sym) in TABLE {
while n >= value {
out.push_str(sym);
n -= value;
}
}
out
}
fn to_alpha(n: i32, base: char) -> String {
let mut n = n;
let mut chars = Vec::new();
while n > 0 {
n -= 1;
chars.push(((n % 26) as u8 + base as u8) as char);
n /= 26;
}
chars.reverse();
chars.into_iter().collect()
}
fn to_greek(mut n: i32) -> String {
const GREEK: &[char] = &[
'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ',
'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
];
let mut out = String::new();
while n > 0 {
let idx = (n - 1) % GREEK.len() as i32;
out.insert(0, GREEK[idx as usize]);
n /= GREEK.len() as i32;
}
out
}
pub fn parse_content(value: &[ComponentValue<'_>]) -> Option<Vec<Content>> {
let trimmed = trim_ws(value);
if let [ComponentValue::Token(Token::Ident(s))] = trimmed {
if s.eq_ignore_ascii_case("none") || s.eq_ignore_ascii_case("normal") {
return None;
}
}
let mut out = Vec::new();
for cv in trimmed {
match cv {
ComponentValue::Token(Token::Whitespace) => {},
ComponentValue::Token(Token::String(s)) => out.push(Content::Str(s.to_string())),
ComponentValue::Token(Token::Ident(s)) => match s.to_ascii_lowercase().as_str() {
"open-quote" => out.push(Content::OpenQuote),
"close-quote" => out.push(Content::CloseQuote),
_ => {},
},
ComponentValue::Function { name, body } => {
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"counter" => {
let (n, style) = parse_counter_args(body);
out.push(Content::Counter {
name: n,
style: style.unwrap_or(ListStyle::Decimal),
});
},
"counters" => {
let (n, sep, style) = parse_counters_args(body);
out.push(Content::Counters {
name: n,
separator: sep,
style: style.unwrap_or(ListStyle::Decimal),
});
},
"attr" => {
if let Some(name) = body.iter().find_map(|c| match c {
ComponentValue::Token(Token::Ident(s)) => Some(s.to_string()),
_ => None,
}) {
out.push(Content::Attr { name });
}
},
_ => {},
}
},
_ => {},
}
}
Some(out)
}
fn parse_counter_args(body: &[ComponentValue<'_>]) -> (String, Option<ListStyle>) {
let mut iter = body.iter().filter(|cv| {
!matches!(
cv,
ComponentValue::Token(Token::Whitespace) | ComponentValue::Token(Token::Comma)
)
});
let name = match iter.next() {
Some(ComponentValue::Token(Token::Ident(s))) => s.to_string(),
_ => return (String::new(), None),
};
let style = match iter.next() {
Some(ComponentValue::Token(Token::Ident(s))) => ListStyle::parse(s),
_ => None,
};
(name, style)
}
fn parse_counters_args(body: &[ComponentValue<'_>]) -> (String, String, Option<ListStyle>) {
let mut iter = body.iter().filter(|cv| {
!matches!(
cv,
ComponentValue::Token(Token::Whitespace) | ComponentValue::Token(Token::Comma)
)
});
let name = match iter.next() {
Some(ComponentValue::Token(Token::Ident(s))) => s.to_string(),
_ => return (String::new(), String::new(), None),
};
let sep = match iter.next() {
Some(ComponentValue::Token(Token::String(s))) => s.to_string(),
_ => return (name, String::new(), None),
};
let style = match iter.next() {
Some(ComponentValue::Token(Token::Ident(s))) => ListStyle::parse(s),
_ => None,
};
(name, sep, style)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CounterOp {
pub name: String,
pub value: i32,
}
pub fn parse_counter_ops(value: &[ComponentValue<'_>], default: i32) -> Vec<CounterOp> {
let mut out = Vec::new();
let mut name: Option<String> = None;
for cv in value {
match cv {
ComponentValue::Token(Token::Whitespace) => continue,
ComponentValue::Token(Token::Ident(s)) => {
if let Some(prior) = name.take() {
out.push(CounterOp {
name: prior,
value: default,
});
}
if s.eq_ignore_ascii_case("none") {
return Vec::new();
}
name = Some(s.to_string());
},
ComponentValue::Token(Token::Number(n)) if n.is_integer => {
if let Some(prior) = name.take() {
out.push(CounterOp {
name: prior,
value: n.value as i32,
});
}
},
_ => {},
}
}
if let Some(prior) = name {
out.push(CounterOp {
name: prior,
value: default,
});
}
out
}
#[derive(Debug, Default, Clone)]
struct Scope {
counters: HashMap<String, i32>,
}
#[derive(Debug, Default, Clone)]
pub struct CounterState {
stack: Vec<Scope>,
}
impl CounterState {
pub fn new() -> Self {
Self {
stack: vec![Scope::default()],
}
}
pub fn enter(&mut self) {
self.stack.push(Scope::default());
}
pub fn leave(&mut self) {
if self.stack.len() > 1 {
self.stack.pop();
}
}
pub fn apply_reset(&mut self, op: &CounterOp) {
let top = self
.stack
.last_mut()
.expect("CounterState always has at least one scope");
top.counters.insert(op.name.clone(), op.value);
}
pub fn apply_increment(&mut self, op: &CounterOp) {
for scope in self.stack.iter_mut().rev() {
if let Some(v) = scope.counters.get_mut(&op.name) {
*v += op.value;
return;
}
}
let root = &mut self.stack[0];
root.counters.insert(op.name.clone(), op.value);
}
pub fn apply_set(&mut self, op: &CounterOp) {
for scope in self.stack.iter_mut().rev() {
if let Some(v) = scope.counters.get_mut(&op.name) {
*v = op.value;
return;
}
}
let top = self.stack.last_mut().unwrap();
top.counters.insert(op.name.clone(), op.value);
}
pub fn counter(&self, name: &str) -> i32 {
for scope in self.stack.iter().rev() {
if let Some(v) = scope.counters.get(name) {
return *v;
}
}
0
}
pub fn counters(&self, name: &str) -> Vec<i32> {
self.stack
.iter()
.filter_map(|s| s.counters.get(name).copied())
.collect()
}
}
pub fn evaluate_content(
content: &[Content],
state: &CounterState,
attr_lookup: impl Fn(&str) -> Option<String>,
) -> String {
let mut out = String::new();
for item in content {
match item {
Content::Str(s) => out.push_str(s),
Content::Counter { name, style } => {
let n = state.counter(name);
out.push_str(&style.render(n));
},
Content::Counters {
name,
separator,
style,
} => {
let parts: Vec<String> = state
.counters(name)
.into_iter()
.map(|n| style.render(n))
.collect();
out.push_str(&parts.join(separator));
},
Content::Attr { name } => {
if let Some(v) = attr_lookup(name) {
out.push_str(&v);
}
},
Content::OpenQuote => out.push('“'),
Content::CloseQuote => out.push('”'),
}
}
out
}
fn trim_ws<'a, 'i>(cvs: &'a [ComponentValue<'i>]) -> &'a [ComponentValue<'i>] {
let mut start = 0;
while start < cvs.len() && matches!(cvs[start], ComponentValue::Token(Token::Whitespace)) {
start += 1;
}
let mut end = cvs.len();
while end > start && matches!(cvs[end - 1], ComponentValue::Token(Token::Whitespace)) {
end -= 1;
}
&cvs[start..end]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::html_css::css::parser::{parse_stylesheet, Rule};
fn first_decl_value(css: &'static str, property: &'static str) -> Vec<ComponentValue<'static>> {
let ss: &'static _ = Box::leak(Box::new(parse_stylesheet(css).unwrap()));
let r = match &ss.rules[0] {
Rule::Qualified(q) => q,
_ => panic!(),
};
r.declarations
.iter()
.find(|d| d.name == property)
.unwrap()
.value
.clone()
}
#[test]
fn decimal_render() {
assert_eq!(ListStyle::Decimal.render(5), "5");
}
#[test]
fn decimal_leading_zero_pads() {
assert_eq!(ListStyle::DecimalLeadingZero.render(7), "07");
}
#[test]
fn lower_roman_render() {
assert_eq!(ListStyle::LowerRoman.render(4), "iv");
assert_eq!(ListStyle::LowerRoman.render(9), "ix");
assert_eq!(ListStyle::LowerRoman.render(40), "xl");
assert_eq!(ListStyle::LowerRoman.render(1994), "mcmxciv");
}
#[test]
fn lower_alpha_wraps() {
assert_eq!(ListStyle::LowerAlpha.render(1), "a");
assert_eq!(ListStyle::LowerAlpha.render(26), "z");
assert_eq!(ListStyle::LowerAlpha.render(27), "aa");
assert_eq!(ListStyle::LowerAlpha.render(28), "ab");
}
#[test]
fn upper_alpha_wraps() {
assert_eq!(ListStyle::UpperAlpha.render(1), "A");
assert_eq!(ListStyle::UpperAlpha.render(28), "AB");
}
#[test]
fn content_string_only() {
let v = first_decl_value(r#"p::before { content: "Section "; }"#, "content");
let c = parse_content(&v).unwrap();
assert_eq!(c, vec![Content::Str("Section ".into())]);
}
#[test]
fn content_none_returns_none() {
let v = first_decl_value("p::before { content: none; }", "content");
assert!(parse_content(&v).is_none());
}
#[test]
fn content_counter_default_decimal() {
let v = first_decl_value(r#"h2::before { content: counter(chapter) ". "; }"#, "content");
let c = parse_content(&v).unwrap();
assert_eq!(
c,
vec![
Content::Counter {
name: "chapter".into(),
style: ListStyle::Decimal,
},
Content::Str(". ".into()),
]
);
}
#[test]
fn content_counter_with_style() {
let v = first_decl_value(
r#"h2::before { content: counter(chapter, lower-roman); }"#,
"content",
);
let c = parse_content(&v).unwrap();
assert_eq!(
c,
vec![Content::Counter {
name: "chapter".into(),
style: ListStyle::LowerRoman,
}]
);
}
#[test]
fn content_counters_with_separator() {
let v = first_decl_value(r#"h3::before { content: counters(section, "."); }"#, "content");
let c = parse_content(&v).unwrap();
assert_eq!(
c,
vec![Content::Counters {
name: "section".into(),
separator: ".".into(),
style: ListStyle::Decimal,
}]
);
}
#[test]
fn content_attr() {
let v = first_decl_value(r#"a::after { content: attr(href); }"#, "content");
let c = parse_content(&v).unwrap();
assert_eq!(
c,
vec![Content::Attr {
name: "href".into()
}]
);
}
#[test]
fn content_quotes() {
let v = first_decl_value(r#"q::before { content: open-quote; }"#, "content");
let c = parse_content(&v).unwrap();
assert_eq!(c, vec![Content::OpenQuote]);
}
#[test]
fn parse_counter_reset_default_zero() {
let v = first_decl_value("body { counter-reset: chapter; }", "counter-reset");
let ops = parse_counter_ops(&v, 0);
assert_eq!(
ops,
vec![CounterOp {
name: "chapter".into(),
value: 0,
}]
);
}
#[test]
fn parse_counter_reset_with_value() {
let v = first_decl_value("body { counter-reset: chapter 5; }", "counter-reset");
let ops = parse_counter_ops(&v, 0);
assert_eq!(
ops,
vec![CounterOp {
name: "chapter".into(),
value: 5,
}]
);
}
#[test]
fn parse_counter_reset_multiple() {
let v = first_decl_value("body { counter-reset: chapter 0 section 1; }", "counter-reset");
let ops = parse_counter_ops(&v, 0);
assert_eq!(
ops,
vec![
CounterOp {
name: "chapter".into(),
value: 0,
},
CounterOp {
name: "section".into(),
value: 1,
},
]
);
}
#[test]
fn parse_counter_increment_default_one() {
let v = first_decl_value("h1 { counter-increment: chapter; }", "counter-increment");
let ops = parse_counter_ops(&v, 1);
assert_eq!(ops[0].value, 1);
}
#[test]
fn counter_state_basic_increment() {
let mut st = CounterState::new();
st.apply_reset(&CounterOp {
name: "n".into(),
value: 0,
});
assert_eq!(st.counter("n"), 0);
st.apply_increment(&CounterOp {
name: "n".into(),
value: 1,
});
assert_eq!(st.counter("n"), 1);
st.apply_increment(&CounterOp {
name: "n".into(),
value: 2,
});
assert_eq!(st.counter("n"), 3);
}
#[test]
fn counter_state_nested_scopes() {
let mut st = CounterState::new();
st.apply_reset(&CounterOp {
name: "x".into(),
value: 0,
});
st.apply_increment(&CounterOp {
name: "x".into(),
value: 1,
});
assert_eq!(st.counter("x"), 1);
st.enter();
st.apply_reset(&CounterOp {
name: "x".into(),
value: 100,
});
assert_eq!(st.counter("x"), 100);
assert_eq!(st.counters("x"), vec![1, 100]);
st.leave();
assert_eq!(st.counter("x"), 1);
}
#[test]
fn counter_state_set_overwrites() {
let mut st = CounterState::new();
st.apply_reset(&CounterOp {
name: "n".into(),
value: 0,
});
st.apply_set(&CounterOp {
name: "n".into(),
value: 42,
});
assert_eq!(st.counter("n"), 42);
}
#[test]
fn counter_state_unknown_returns_zero() {
let st = CounterState::new();
assert_eq!(st.counter("nope"), 0);
}
#[test]
fn evaluate_counter_in_content() {
let mut st = CounterState::new();
st.apply_reset(&CounterOp {
name: "chapter".into(),
value: 2,
});
let content = parse_content(&first_decl_value(
r#"h2::before { content: "Chapter " counter(chapter) ". "; }"#,
"content",
))
.unwrap();
let s = evaluate_content(&content, &st, |_| None);
assert_eq!(s, "Chapter 2. ");
}
#[test]
fn evaluate_counters_with_separator() {
let mut st = CounterState::new();
st.apply_reset(&CounterOp {
name: "section".into(),
value: 1,
});
st.enter();
st.apply_reset(&CounterOp {
name: "section".into(),
value: 2,
});
let content = parse_content(&first_decl_value(
r#"h3::before { content: counters(section, "."); }"#,
"content",
))
.unwrap();
let s = evaluate_content(&content, &st, |_| None);
assert_eq!(s, "1.2");
}
#[test]
fn evaluate_attr() {
let st = CounterState::new();
let content =
parse_content(&first_decl_value(r#"a::after { content: attr(href); }"#, "content"))
.unwrap();
let s = evaluate_content(&content, &st, |name| {
if name == "href" {
Some("https://example.com".to_string())
} else {
None
}
});
assert_eq!(s, "https://example.com");
}
}