#[cfg(test)]
mod tests;
use crate::js::chars::{is_space_char, to_lower, trim, trim_start};
use crate::js::math::{max, min};
use crate::js::number;
use crate::scene::style::{Cap, Color, Join, Paint, Style};
const SHAPE_PROPS: &[&str] = &[
"fill",
"fill-opacity",
"stroke",
"stroke-width",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-linecap",
"stroke-linejoin",
"stroke-opacity",
"opacity",
"rx",
"ry",
];
const TEXT_PROPS: &[&str] = &[
"color",
"font-size",
"font-family",
"font-weight",
"font-style",
"text-decoration",
"letter-spacing",
"word-spacing",
"opacity",
];
#[derive(Default)]
struct Kept {
shape: Vec<(String, String)>,
text: Vec<(String, String)>,
}
impl Kept {
fn shape(&self, prop: &str) -> Option<&str> {
get(&self.shape, prop)
}
fn text(&self, prop: &str) -> Option<&str> {
get(&self.text, prop)
}
}
fn get<'a>(map: &'a [(String, String)], prop: &str) -> Option<&'a str> {
map.iter().find(|(p, _)| p == prop).map(|(_, v)| v.as_str())
}
fn set(map: &mut Vec<(String, String)>, prop: &str, value: &str) {
match map.iter_mut().find(|(p, _)| p == prop) {
Some((_, v)) => *v = value.to_string(),
None => map.push((prop.to_string(), value.to_string())),
}
}
fn keep(decls: &[String]) -> Kept {
let mut kept = Kept::default();
for decl in decls {
let Some(at) = decl.find(':') else {
continue;
};
if at == 0 {
continue;
}
let prop = to_lower(trim(&decl[..at]));
let without = drop_important(&decl[at + 1..]);
let value = trim(&without);
if value.is_empty() || unsafe_value(value) {
continue;
}
if SHAPE_PROPS.contains(&prop.as_str()) {
set(&mut kept.shape, &prop, value);
}
if TEXT_PROPS.contains(&prop.as_str()) {
let prop = if prop == "color" { "fill" } else { &prop };
set(&mut kept.text, prop, value);
}
}
kept
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Resolved {
pub(crate) shape: String,
pub(crate) line: String,
pub(crate) text: String,
pub(crate) stroke: Option<String>,
pub(crate) bold: bool,
pub(crate) italic: bool,
pub(crate) font_size: Option<f64>,
}
pub(crate) fn resolve_style(decls: &[String]) -> Resolved {
let kept = keep(decls);
let mut r = Resolved::default();
for (prop, value) in &kept.shape {
write(&mut r.shape, prop, value);
if prop != "fill" && prop != "fill-opacity" {
write(&mut r.line, prop, value);
}
}
for (prop, value) in &kept.text {
write(&mut r.text, prop, value);
}
r.stroke = kept.shape("stroke").map(str::to_string);
(r.bold, r.italic, r.font_size) = font(&kept);
r
}
fn write(css: &mut String, prop: &str, value: &str) {
css.push_str(prop);
css.push(':');
for c in value.chars() {
match c {
'&' => css.push_str("&"),
'"' => css.push_str("""),
_ => css.push(c),
}
}
css.push(';');
}
fn font(kept: &Kept) -> (bool, bool, Option<f64>) {
let weight = kept.text("font-weight");
let bold = weight.is_some_and(|w| w == "bold" || w == "bolder" || number::number(w) >= 600.0);
let italic = kept.text("font-style") == Some("italic");
let size = kept.text("font-size").and_then(font_pixels);
(bold, italic, size.filter(|&px| px > 0.0 && px <= 200.0))
}
fn font_pixels(value: &str) -> Option<f64> {
let body = value.strip_suffix("px").unwrap_or(value);
if body.is_empty() || !body.bytes().all(|c| c.is_ascii_digit() || c == b'.') {
return None;
}
let body = match body
.find('.')
.and_then(|i| body[i + 1..].find('.').map(|j| i + 1 + j))
{
Some(second) => &body[..second],
None => body,
};
body.parse().ok()
}
impl Resolved {
pub(crate) fn fill_declared(&self) -> bool {
declares(&self.shape, "fill")
}
pub(crate) fn font_family_declared(&self) -> bool {
declares(&self.text, "font-family")
}
pub(crate) fn line_declared(&self) -> bool {
!self.line.is_empty()
}
pub(crate) fn paints(&self) -> bool {
paints(&self.shape)
}
pub(crate) fn inks(&self) -> bool {
inks(&self.shape, "")
}
pub(crate) fn inks_text(&self, text: &Resolved) -> bool {
inks(&self.shape, &text.text)
}
}
fn values_at<'a>(css: &'a str, prop: &'a str) -> impl Iterator<Item = usize> + 'a {
std::iter::once(0)
.chain(css.match_indices(';').map(|(i, _)| i + 1))
.filter(move |&i| css[i..].starts_with(prop) && css[i + prop.len()..].starts_with(':'))
.map(move |i| i + prop.len() + 1)
}
pub(crate) fn declares(css: &str, prop: &str) -> bool {
values_at(css, prop).next().is_some()
}
pub(crate) fn declared<'a>(css: &'a str, prop: &str) -> Option<&'a str> {
values_at(css, prop).find_map(|i| {
let end = css[i..].find([';', '"']).map(|n| i + n)?;
(end > i && css[end..].starts_with(';')).then(|| trim(&css[i..end]))
})
}
fn fill_value(css: &str) -> Option<&str> {
let i = values_at(css, "fill").next()?;
let end = css[i..].find([';', '"']).map_or(css.len(), |n| i + n);
Some(&css[i..end])
}
fn opacity(css: &str) -> f64 {
let mut product = 1.0;
let mut starts: Vec<usize> = values_at(css, "opacity")
.chain(values_at(css, "fill-opacity"))
.collect();
starts.sort_unstable();
for i in starts {
let end = css[i..].find([';', '"']).map_or(css.len(), |n| i + n);
product *= amount(trim(&css[i..end]), 1.0);
}
product
}
pub(crate) fn inks(shape: &str, text: &str) -> bool {
let Some(fill) = fill_value(shape) else {
return false;
};
if declares(text, "fill") {
return false;
}
color_alpha(fill).is_some_and(|alpha| alpha * opacity(shape) >= 0.5)
}
pub(crate) fn paints(shape: &str) -> bool {
let Some(fill) = fill_value(shape) else {
return false;
};
let fill = to_lower(trim(fill));
if fill.is_empty() || fill == "none" || fill == "transparent" {
return false;
}
let scale = opacity(shape);
color_alpha(&fill).unwrap_or(1.0) * (if scale.is_nan() { 1.0 } else { scale }) > 0.0
}
fn color_alpha(raw: &str) -> Option<f64> {
let value = to_lower(trim(raw));
if !value.contains(['#', '(']) {
let name = value.replacen("grey", "gray", 1);
return (value != "transparent" && Color::parse(&name).is_some()).then_some(1.0);
}
if let Some(h) = value.strip_prefix('#') {
if !matches!(h.len(), 3 | 4 | 6 | 8) || !h.bytes().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let alpha = match h.len() {
4 => h[3..4].repeat(2),
8 => h[6..8].to_string(),
_ => return Some(1.0),
};
return Some(f64::from(u8::from_str_radix(&alpha, 16).ok()?) / 255.0);
}
let open = value.find('(')?;
let (name, rest) = (&value[..open], &value[open + 1..]);
let body = rest.strip_suffix(')')?;
if !matches!(name, "rgb" | "rgba" | "hsl" | "hsla") || body.contains(['(', ')']) {
return None;
}
let hsl = name.starts_with("hsl");
let list = args(body, hsl)?;
let alpha = if list.len() == 4 {
amount(list[3], 1.0)
} else {
1.0
};
let unread = if hsl {
let h = (hue(list[0]) % 360.0 + 360.0) % 360.0;
h.is_nan() || amount(list[1], 100.0).is_nan() || amount(list[2], 100.0).is_nan()
} else {
list[..3].iter().any(|c| amount(c, 255.0).is_nan())
};
(!unread && !alpha.is_nan()).then_some(alpha)
}
fn args(body: &str, hsl: bool) -> Option<Vec<&str>> {
let legacy = body.contains(',');
let (main, alpha) = if legacy {
(body, None)
} else {
let mut parts = body.split('/');
let main = parts.next().unwrap_or("");
let alpha = parts.next();
if parts.next().is_some() {
return None;
}
(main, alpha)
};
let mut list: Vec<&str> = if legacy {
trim(main).split(',').map(trim).collect()
} else {
split_spaces(trim(main))
};
let counted = if legacy {
(3..=4).contains(&list.len())
} else {
list.len() == 3
};
if !counted {
return None;
}
if let Some(alpha) = alpha {
list.push(trim(alpha));
}
if !legacy {
return Some(
list.into_iter()
.map(|c| if c == "none" { "0" } else { c })
.collect(),
);
}
let (from, want) = if hsl { (1, 2) } else { (0, 3) };
let percent = list[from..3].iter().filter(|c| c.ends_with('%')).count();
(percent == want || !hsl && percent == 0).then_some(list)
}
fn split_spaces(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = s;
while let Some(i) = rest.find(is_space_char) {
out.push(&rest[..i]);
rest = trim_start(&rest[i..]);
}
out.push(rest);
out
}
fn amount(raw: &str, full: f64) -> f64 {
match css_number(raw) {
Some((f, "")) => min(max(f / full, 0.0), 1.0),
Some((f, "%")) => min(max(f / 100.0, 0.0), 1.0),
_ => f64::NAN,
}
}
fn hue(raw: &str) -> f64 {
match css_number(raw) {
None | Some((_, "%")) => f64::NAN,
Some((f, "turn")) => f * 360.0,
Some((f, "rad")) => (f * 180.0) / std::f64::consts::PI,
Some((f, "grad")) => f * 0.9,
Some((f, _)) => f,
}
}
fn css_number(raw: &str) -> Option<(f64, &'static str)> {
let b = raw.as_bytes();
let digits = |mut i: usize| {
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
i
};
let start = usize::from(matches!(b.first(), Some(b'+' | b'-')));
let mut i = digits(start);
if i > start {
if b.get(i) == Some(&b'.') && digits(i + 1) > i + 1 {
i = digits(i + 1);
}
} else if b.get(i) == Some(&b'.') && digits(i + 1) > i + 1 {
i = digits(i + 1);
} else {
return None;
}
if matches!(b.get(i), Some(b'e' | b'E')) {
let j = i + 1 + usize::from(matches!(b.get(i + 1), Some(b'+' | b'-')));
if digits(j) > j {
i = digits(j);
}
}
let unit = ["", "%", "deg", "grad", "rad", "turn"]
.into_iter()
.find(|u| raw[i..].eq_ignore_ascii_case(u))?;
Some((raw[..i].parse().ok()?, unit))
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct Author {
pub(crate) style: Style,
pub(crate) bold: bool,
pub(crate) italic: bool,
pub(crate) font_size: Option<f64>,
}
pub(crate) fn declarations(decls: &[String]) -> Author {
let kept = keep(decls);
let mut s = Style {
text: kept.text("fill").and_then(paint),
..Style::default()
};
for (prop, value) in &kept.shape {
let value = value.as_str();
match prop.as_str() {
"fill" => s.fill = paint(value),
"stroke" => s.stroke = paint(value),
"fill-opacity" => s.fill_opacity = opacity_of(value),
"stroke-opacity" => s.stroke_opacity = opacity_of(value),
"opacity" => s.opacity = opacity_of(value),
"stroke-width" => s.stroke_width = length(value).filter(|&f| f >= 0.0),
"stroke-dasharray" => s.dash = dashes(value),
"stroke-linecap" => s.cap = cap(value),
"stroke-linejoin" => s.join = join(value),
_ => {}
}
}
let radius = |prop| kept.shape(prop).and_then(length).filter(|&f| f >= 0.0);
s.radius = radius("rx").or_else(|| radius("ry"));
let (bold, italic, font_size) = font(&kept);
Author {
style: s,
bold,
italic,
font_size,
}
}
fn paint(value: &str) -> Option<Paint> {
if value.eq_ignore_ascii_case("none") {
return Some(Paint::NONE);
}
let c = Color::parse(value)?;
Some(if c.a == 0 { Paint::NONE } else { Paint::own(c) })
}
fn opacity_of(value: &str) -> Option<f64> {
let (f, unit) = css_number(value)?;
let f = match unit {
"" => f,
"%" => f / 100.0,
_ => return None,
};
(!f.is_infinite()).then(|| min(max(f, 0.0), 1.0))
}
fn length(value: &str) -> Option<f64> {
let v = css_trim(value);
let v = match v.len().checked_sub(2) {
Some(n) if v.is_char_boundary(n) && v[n..].eq_ignore_ascii_case("px") => &v[..n],
_ => v,
};
match css_number(v)? {
(f, "") if !f.is_infinite() => Some(f),
_ => None,
}
}
fn css_trim(s: &str) -> &str {
s.trim_matches([' ', '\t', '\n', '\r', '\x0c'])
}
fn dashes(value: &str) -> Option<Vec<f64>> {
if value.eq_ignore_ascii_case("none") {
return Some(Vec::new());
}
let out: Vec<f64> = value
.split([',', ' ', '\t', '\n', '\r', '\x0c'])
.filter(|f| !f.is_empty())
.map(|f| length(f).filter(|&n| n >= 0.0))
.collect::<Option<_>>()?;
(!out.is_empty()).then_some(out)
}
fn cap(value: &str) -> Option<Cap> {
[
("butt", Cap::Butt),
("round", Cap::Round),
("square", Cap::Square),
("initial", Cap::Butt),
]
.into_iter()
.find(|(k, _)| value.eq_ignore_ascii_case(k))
.map(|(_, c)| c)
}
fn join(value: &str) -> Option<Join> {
[
("miter", Join::Miter),
("round", Join::Round),
("bevel", Join::Bevel),
("initial", Join::Miter),
]
.into_iter()
.find(|(k, _)| value.eq_ignore_ascii_case(k))
.map(|(_, j)| j)
}
fn unsafe_value(value: &str) -> bool {
if value.contains(['<', '>', '{', '}', ';', '\\']) {
return true;
}
let invalid = |c: char| {
c < ' ' && !matches!(c, '\t' | '\n' | '\r') || matches!(c, '\x7f' | '\u{fffe}' | '\u{ffff}')
};
if value.contains(invalid) {
return true;
}
let v = value.to_ascii_lowercase();
if ["javascript:", "@import", "image-set", "/*"]
.iter()
.any(|bad| v.contains(bad))
{
return true;
}
["url", "expression"].iter().any(|f| {
v.match_indices(f)
.any(|(i, _)| trim_start(&v[i + f.len()..]).starts_with('('))
})
}
fn drop_important(s: &str) -> String {
const OLD: &[u8] = b"!important";
let mut out = String::new();
let (mut last, mut i) = (0, 0);
while let Some(n) = s[i..].find('!') {
let at = i + n;
if s.as_bytes()[at..].len() >= OLD.len()
&& s.as_bytes()[at..at + OLD.len()].eq_ignore_ascii_case(OLD)
{
out.push_str(&s[last..at]);
last = at + OLD.len();
i = last;
} else {
i = at + 1;
}
}
out.push_str(&s[last..]);
out
}