use base64::Engine as _;
use crate::color;
use super::super::FormatEncodeError;
use super::raster::{edit, expanded_xml, memory, parse};
const COLOR_ATTRIBUTES: [&str; 5] = [
"fill",
"stroke",
"stop-color",
"flood-color",
"lighting-color",
];
const PNG_DATA_URL: &str = "data:image/png;base64,";
fn invalid(message: &str) -> FormatEncodeError {
FormatEncodeError::Encoding(message.to_owned())
}
pub(super) fn lower(tree: &usvg::Tree) -> Result<usvg::Tree, FormatEncodeError> {
let xml = expanded_xml(tree, &mut 0, None)?;
let document = roxmltree::Document::parse(&xml)
.map_err(|_| invalid("invalid normalized dark-mode nested SVG"))?;
let mut edits = Vec::new();
for node in document.descendants().filter(roxmltree::Node::is_element) {
if node
.ancestors()
.any(|ancestor| ancestor.has_tag_name("mask"))
{
continue;
}
for attribute in node.attributes() {
let replacement = if COLOR_ATTRIBUTES.contains(&attribute.name()) {
let Some(rgb) = parse_hex(attribute.value()) else {
continue;
};
let [red, green, blue] = color::theme_filter_rounded(rgb);
format!("{}=\"#{red:02x}{green:02x}{blue:02x}\"", attribute.name())
} else if node.has_tag_name("image") && attribute.name() == "href" {
let Some(encoded) = attribute.value().strip_prefix(PNG_DATA_URL) else {
continue;
};
let mut url = String::from("xlink:href=\"");
url.push_str(PNG_DATA_URL);
base64::engine::general_purpose::STANDARD
.encode_string(lower_png(encoded.trim())?, &mut url);
url.push('"');
url
} else {
continue;
};
edits.try_reserve(1).map_err(|_| memory())?;
edits.push((attribute.range(), replacement));
}
}
parse(&edit(&xml, edits)?)
}
fn parse_hex(value: &str) -> Option<[u8; 3]> {
let digits = value.strip_prefix('#')?;
if digits.len() != 6 {
return None;
}
let channel = |index: usize| u8::from_str_radix(&digits[index..index + 2], 16).ok();
Some([channel(0)?, channel(2)?, channel(4)?])
}
fn lower_png(encoded: &str) -> Result<Vec<u8>, FormatEncodeError> {
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| invalid("nested raster is not base64"))?;
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
let mut reader = decoder
.read_info()
.map_err(|_| invalid("nested raster is not a normalized PNG"))?;
let info = reader.info();
if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight {
return Err(invalid("nested raster is not normalized RGBA8"));
}
let (width, height) = (info.width, info.height);
let size = reader.output_buffer_size().ok_or_else(memory)?;
let mut rgba = Vec::new();
rgba.try_reserve_exact(size).map_err(|_| memory())?;
rgba.resize(size, 0);
reader
.next_frame(&mut rgba)
.map_err(|_| invalid("nested raster did not decode"))?;
for pixel in rgba.as_chunks_mut::<4>().0 {
if pixel[3] != 0 {
let [red, green, blue] = color::theme_filter_rounded([pixel[0], pixel[1], pixel[2]]);
pixel[..3].copy_from_slice(&[red, green, blue]);
}
}
super::super::asset::encode_normalized_png(width, height, &rgba)
.map_err(|_| invalid("lowered nested raster did not encode"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(svg: &str) -> usvg::Tree {
usvg::Tree::from_str(svg, &usvg::Options::default()).expect("fixture parses")
}
fn fill_of_first_path(group: &usvg::Group) -> Option<[u8; 3]> {
for node in group.children() {
match node {
usvg::Node::Path(path) => {
if let Some(usvg::Paint::Color(c)) = path.fill().map(usvg::Fill::paint) {
return Some([c.red, c.green, c.blue]);
}
}
usvg::Node::Group(group) => {
if let Some(found) = fill_of_first_path(group) {
return Some(found);
}
}
_ => {}
}
}
None
}
#[test]
fn paints_and_gradient_stops_take_the_rounded_theme_filter() {
let lowered = lower(&tree(concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">"##,
r##"<defs><linearGradient id="g"><stop offset="0" stop-color="#fffce8"/>"##,
r##"<stop offset="1" stop-color="#1e1e1e"/></linearGradient></defs>"##,
r##"<rect width="2" height="4" fill="#fffce8" stroke="#a5d8ff"/>"##,
r##"<rect x="2" width="2" height="4" fill="url(#g)"/></svg>"##
)))
.expect("lowers");
assert_eq!(fill_of_first_path(lowered.root()), Some([0x18, 0x16, 0x05]));
let stops: Vec<_> = lowered.linear_gradients()[0]
.stops()
.iter()
.map(|stop| [stop.color().red, stop.color().green, stop.color().blue])
.collect();
assert_eq!(stops, [[0x18, 0x16, 0x05], [0xd3, 0xd3, 0xd3]]);
}
#[test]
fn nested_raster_pixels_take_the_theme_filter_with_alpha_untouched() {
let png = super::super::super::asset::encode_normalized_png(
2,
1,
&[255, 255, 255, 255, 0, 0, 0, 128],
)
.expect("fixture encodes");
let href = base64::engine::general_purpose::STANDARD.encode(png);
let lowered = lower(&tree(&format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="1"><image width="2" height="1" href="data:image/png;base64,{href}"/></svg>"#
)))
.expect("lowers");
let xml = lowered.to_string(&usvg::WriteOptions::default());
let encoded = xml
.split("data:image/png;base64,")
.nth(1)
.and_then(|rest| rest.split('"').next())
.map(str::trim)
.expect("the raster stays a PNG data URL");
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("base64");
let mut reader = png::Decoder::new(std::io::Cursor::new(bytes))
.read_info()
.expect("PNG");
let mut rgba = vec![0; reader.output_buffer_size().expect("PNG buffer fits")];
reader.next_frame(&mut rgba).expect("decodes");
assert_eq!(rgba, [0x12, 0x12, 0x12, 255, 0xed, 0xed, 0xed, 128]);
}
#[test]
fn mask_content_keeps_its_light_mode_luminance() {
let lowered = lower(&tree(concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">"##,
r##"<mask id="m"><rect width="4" height="4" fill="#ffffff"/></mask>"##,
r##"<rect width="4" height="4" fill="#1e1e1e" mask="url(#m)"/></svg>"##
)))
.expect("lowers");
let xml = lowered.to_string(&usvg::WriteOptions::default());
assert!(xml.contains("fill=\"#ffffff\""), "{xml}");
assert!(xml.contains("fill=\"#d3d3d3\""), "{xml}");
}
}