diff --git a/.changeset/shield.io-badges-in-markdown.md b/.changeset/shield.io-badges-in-markdown.md new file mode 100644 index 00000000..b2895f8f --- /dev/null +++ b/.changeset/shield.io-badges-in-markdown.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Fixed shield.io badges not rendering properly in markdown viewers \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 635026f1..da9d6039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4918,6 +4918,7 @@ version = "2.7.0" dependencies = [ "anyhow", "arboard", + "base64 0.23.1", "bytes 1.12.1", "cargo-packager-updater", "chrono", diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 3577359d..df8c60a6 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -62,6 +62,8 @@ freya-skia-safe.workspace = true freya.workspace = true freya-core.workspace = true pulldown-cmark.workspace = true +# inlines svg logos embedded in markdown badges as data urls +base64.workspace = true [dev-dependencies] freya-testing.workspace = true diff --git a/packages/oneclient_app/src/components/markdown/image.rs b/packages/oneclient_app/src/components/markdown/image.rs index 1599ddd9..ff92dc4c 100644 --- a/packages/oneclient_app/src/components/markdown/image.rs +++ b/packages/oneclient_app/src/components/markdown/image.rs @@ -3,12 +3,13 @@ use std::borrow::Cow; use std::rc::Rc; use freya::elements::image::ImageHandle; -use freya::engine::prelude::{ClipOp, Paint, SkRect}; +use freya::engine::prelude::{ClipOp, FontMgr, Paint, Picture, PictureRecorder, SkRect, svg}; use freya::prelude::*; use freya_core::element::{ClipContext, ElementExt, LayoutContext}; use freya_core::tree::DiffModifies; use super::super::local_image::decode; +use super::svg_compat::prepare_svg; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; @@ -34,24 +35,24 @@ impl Component for MarkdownImage { let query = use_cached_image(Some(self.url.clone()), MAX_EDGE); let loaded = loaded_image(Some(&self.url), &query); - let mut cache = use_state(|| None::<(usize, ImageHandle)>); - let holder = loaded.and_then(|(_, bytes)| { + let mut cache = use_state(|| None::<(usize, ImageContent)>); + let content = loaded.and_then(|(_, bytes)| { let ptr = bytes.as_ptr() as usize; - if let Some((cached_ptr, holder)) = cache.read().clone() + if let Some((cached_ptr, content)) = cache.read().clone() && cached_ptr == ptr { - return Some(holder); + return Some(content); } - let holder = decode(&bytes)?; - cache.set(Some((ptr, holder.clone()))); + let content = decode_any(&bytes)?; + cache.set(Some((ptr, content.clone()))); - Some(holder) + Some(content) }); - match holder { - Some(holder) => scaled_image(holder) + match content { + Some(content) => scaled_content(content) .a11y_role(AccessibilityRole::Image) .a11y_alt(self.alt.clone()) .into_element(), @@ -64,6 +65,90 @@ impl Component for MarkdownImage { } } +#[derive(Clone, PartialEq)] +enum ImageContent { + Bitmap(ImageHandle), + Vector(SvgPicture), +} + +/// An svg recorded as drawing commands, so it is drawn straight onto the +/// window's pixel grid instead of being resampled from a bitmap +#[derive(Clone)] +struct SvgPicture { + picture: Picture, + /// Logical size, scaled to physical pixels at layout like any `Size::px` + size: Size2D, +} + +impl PartialEq for SvgPicture { + fn eq(&self, other: &Self) -> bool { + self.picture.unique_id() == other.picture.unique_id() && self.size == other.size + } +} + +fn decode_any(bytes: &Bytes) -> Option { + if let Some(handle) = decode(bytes) { + return Some(ImageContent::Bitmap(handle)); + } + + // Skia's codecs can't read svg, which is what shields.io badges are + if !looks_like_svg(bytes) { + return None; + } + + record_svg(bytes).map(ImageContent::Vector) +} + +fn looks_like_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(1024)]; + let head = head.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(head); + let start = head + .iter() + .position(|b| !b.is_ascii_whitespace()) + .unwrap_or(head.len()); + let head = &head[start..]; + + head.starts_with(b" Option { + let intrinsic = root.intrinsic_size(); + let size = if intrinsic.width > 0. && intrinsic.height > 0. { + Size2D::new(intrinsic.width, intrinsic.height) + } else { + let view_box = root.view_box()?; + Size2D::new(view_box.width(), view_box.height()) + }; + + let valid = size.width.is_finite() && size.height.is_finite(); + (valid && size.width > 0. && size.height > 0.).then_some(size) +} + +/// Badges are mostly text, so the dom gets the system fonts rather than an empty manager +fn record_svg(bytes: &[u8]) -> Option { + let mut dom = svg::Dom::from_bytes(&prepare_svg(bytes), FontMgr::default()).ok()?; + let declared = svg_size(&dom.root())?; + dom.set_container_size((declared.width, declared.height)); + + // the dom always draws at its declared size, so an oversized svg is scaled down while recording + let shrink = (MAX_EDGE as f32 / declared.width.max(declared.height)).min(1.); + let size = declared * shrink; + + let bounds = SkRect::from_wh(size.width, size.height); + let mut recorder = PictureRecorder::new(); + let canvas = recorder.begin_recording(bounds, false); + canvas.clip_rect(bounds, None, None); + canvas.scale((shrink, shrink)); + dom.render(canvas); + let picture = recorder.finish_recording_as_picture(Some(&bounds))?; + + Some(SvgPicture { picture, size }) +} + #[derive(Clone)] pub struct ScaledImage { key: DiffKey, @@ -74,18 +159,18 @@ pub struct ScaledImage { pub struct ScaledImageElement { accessibility: AccessibilityData, layout: LayoutData, - image_handle: ImageHandle, + content: ImageContent, sampling_mode: SamplingMode, corner_radius: CornerRadius, } -pub fn scaled_image(image_handle: ImageHandle) -> ScaledImage { +fn scaled_content(content: ImageContent) -> ScaledImage { ScaledImage { key: DiffKey::None, element: ScaledImageElement { accessibility: AccessibilityData::default(), layout: LayoutData::default(), - image_handle, + content, sampling_mode: SamplingMode::default(), corner_radius: CornerRadius::default(), }, @@ -134,6 +219,18 @@ impl From for Element { } } +impl ImageContent { + /// Size in layout (physical) pixels; bitmaps keep their pixel dimensions + fn size(&self, scale_factor: f32) -> Size2D { + match self { + Self::Bitmap(handle) => { + Size2D::new(handle.image.width() as f32, handle.image.height() as f32) + } + Self::Vector(svg) => svg.size * scale_factor, + } + } +} + impl ElementExt for ScaledImageElement { fn changed(&self, other: &Rc) -> bool { let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { @@ -157,10 +254,10 @@ impl ElementExt for ScaledImageElement { diff.insert(DiffModifies::LAYOUT); } - if self.image_handle != other.image_handle { + if self.content != other.content { diff.insert(DiffModifies::STYLE); - if self.image_handle.image.dimensions() != other.image_handle.image.dimensions() { + if self.content.size(1.) != other.content.size(1.) { diff.insert(DiffModifies::LAYOUT); } } @@ -192,8 +289,7 @@ impl ElementExt for ScaledImageElement { } fn measure(&self, context: LayoutContext) -> Option<(Size2D, Rc)> { - let image = &self.image_handle.image; - let natural = Size2D::new(image.width() as f32, image.height() as f32); + let natural = self.content.size(context.scale_factor as f32); let available = (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); @@ -238,16 +334,28 @@ impl ElementExt for ScaledImageElement { .canvas .clip_rrect(clip_rrect, ClipOp::Intersect, true); - let mut paint = Paint::default(); - paint.set_anti_alias(true); + match &self.content { + ImageContent::Bitmap(handle) => { + let mut paint = Paint::default(); + paint.set_anti_alias(true); - context.canvas.draw_image_rect_with_sampling_options( - &self.image_handle.image, - None, - rect, - self.sampling_mode.sampling_options(), - &paint, - ); + context.canvas.draw_image_rect_with_sampling_options( + &handle.image, + None, + rect, + self.sampling_mode.sampling_options(), + &paint, + ); + } + ImageContent::Vector(svg) => { + context.canvas.translate((rect.left, rect.top)); + context.canvas.scale(( + rect.width() / svg.size.width, + rect.height() / svg.size.height, + )); + context.canvas.draw_picture(&svg.picture, None, None); + } + } context.canvas.restore(); } @@ -271,7 +379,9 @@ mod tests { rect() .width(Size::px(panel.0)) .height(Size::px(panel.1)) - .child(scaled_image(handle(image.0, image.1))) + .child(scaled_content(ImageContent::Bitmap(handle( + image.0, image.1, + )))) }; let test = launch_test(app); @@ -291,7 +401,7 @@ mod tests { rect().width(Size::px(300.)).height(Size::px(400.)).child( paragraph() .span(Span::new("before ")) - .child(scaled_image(handle(600, 200))) + .child(scaled_content(ImageContent::Bitmap(handle(600, 200)))) .span(Span::new(" after")), ) }; @@ -324,4 +434,175 @@ mod tests { fn short_panel_does_not_shrink_the_width() { assert_eq!(measured((300., 60.), (600, 200)), Size2D::new(300., 100.)); } + + const BADGE: &str = r##"v3.0.1"##; + + #[test] + fn sniffs_svg_documents() { + assert!(looks_like_svg(BADGE.as_bytes())); + assert!(looks_like_svg( + b"\xEF\xBB\xBF \n" + )); + assert!(!looks_like_svg(b"\x89PNG\r\n\x1a\n")); + assert!(!looks_like_svg(b"not found")); + } + + #[test] + fn records_a_badge_at_its_declared_size() { + let svg = record_svg(BADGE.as_bytes()).expect("badge records"); + assert_eq!(svg.size, Size2D::new(88., 20.)); + } + + #[test] + fn falls_back_to_the_view_box() { + let svg = record_svg( + br#""#, + ) + .expect("view box svg records"); + + assert_eq!(svg.size, Size2D::new(40., 10.)); + } + + fn vector_size(scale_factor: f64) -> Size2D { + let svg = record_svg(BADGE.as_bytes()).expect("badge records"); + let app = move || { + rect() + .width(Size::px(300.)) + .height(Size::px(400.)) + .child(scaled_content(ImageContent::Vector(svg.clone()))) + }; + + let (test, _) = TestingRunner::new(app, Size2D::new(500., 500.), |_| {}, scale_factor); + test.find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size + } + + #[test] + fn a_vector_follows_the_scale_factor_like_text() { + assert_eq!(vector_size(1.), Size2D::new(88., 20.)); + assert_eq!(vector_size(1.5), Size2D::new(132., 30.)); + } + + #[test] + fn a_vector_at_a_fractional_offset_is_not_resampled() { + // grey ends at 39.5, so after the 10.5 offset the colour edge lands on pixel 50 + let svg = record_svg( + br##""##, + ) + .expect("svg records"); + let app = move || { + rect() + .horizontal() + .child(rect().width(Size::px(10.5)).height(Size::px(1.))) + .child(scaled_content(ImageContent::Vector(svg.clone()))) + }; + + let mut test = launch_test(app); + let png = test.render(); + let snapshot = image::load_from_memory(png.as_bytes()) + .expect("snapshot decodes") + .to_rgb8(); + let rgb = |x: u32| { + let [r, g, b] = snapshot.get_pixel(x, 10).0; + (r, g, b) + }; + + assert_eq!(rgb(49), (0x55, 0x55, 0x55)); + assert_eq!(rgb(50), (0x00, 0x7e, 0xc6)); + } + + /// A grey shields-style badge with a 14px logo slot, rendered at the origin + fn logo_pixel(href: &str) -> (u8, u8, u8) { + let badge = format!( + r##""## + ); + let svg = record_svg(badge.as_bytes()).expect("badge records"); + let app = move || rect().child(scaled_content(ImageContent::Vector(svg.clone()))); + + let mut test = launch_test(app); + let png = test.render(); + let snapshot = image::load_from_memory(png.as_bytes()) + .expect("snapshot decodes") + .to_rgb8(); + let [r, g, b] = snapshot.get_pixel(12, 10).0; + (r, g, b) + } + + fn data_url(mime: &str, bytes: &[u8]) -> String { + use base64::Engine; + format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + } + + #[test] + fn draws_a_png_logo_given_by_href() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([255, 0, 0])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("png encodes"); + + assert_eq!(logo_pixel(&data_url("image/png", &png)), (255, 0, 0)); + } + + #[test] + fn draws_an_svg_logo_given_as_a_data_url() { + let logo = br##""##; + + assert_eq!( + logo_pixel(&data_url("image/svg+xml", logo)), + (0x58, 0x65, 0xf2) + ); + } + + /// Replays a recorded svg at its own size on white and reads one pixel + fn picture_pixel(svg: &SvgPicture, x: u32, y: u32) -> (u8, u8, u8) { + use freya::engine::prelude::{EncodedImageFormat, raster_n32_premul}; + + let size = (svg.size.width.ceil() as i32, svg.size.height.ceil() as i32); + let mut surface = raster_n32_premul(size).expect("surface"); + surface.canvas().clear(Color::WHITE); + surface.canvas().draw_picture(&svg.picture, None, None); + let png = surface + .image_snapshot() + .encode(None, EncodedImageFormat::PNG, None) + .expect("png encodes"); + let [r, g, b] = image::load_from_memory(png.as_bytes()) + .expect("png decodes") + .to_rgb8() + .get_pixel(x, y) + .0; + (r, g, b) + } + + #[test] + fn an_oversized_svg_is_scaled_down_rather_than_cropped() { + let svg = record_svg( + br##""##, + ) + .expect("svg records"); + + assert_eq!(svg.size, Size2D::new(1600., 50.)); + assert_eq!(picture_pixel(&svg, 10, 40), (255, 0, 0)); + assert_eq!(picture_pixel(&svg, 1590, 40), (0, 0, 255)); + } + + #[test] + fn a_logo_reusing_the_badges_ids_leaves_the_badge_intact() { + let logo = br##""##; + let badge = format!( + r##""##, + data_url("image/svg+xml", logo) + ); + let svg = record_svg(badge.as_bytes()).expect("badge records"); + + assert_eq!(picture_pixel(&svg, 60, 10), (0x55, 0x55, 0x55)); + } } diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs index e927ce6e..ea80aeae 100644 --- a/packages/oneclient_app/src/components/markdown/mod.rs +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -6,6 +6,7 @@ use pulldown_cmark::HeadingLevel; mod image; mod parse; mod render; +mod svg_compat; #[derive(Clone, PartialEq)] pub struct MarkdownStyle { diff --git a/packages/oneclient_app/src/components/markdown/svg_compat.rs b/packages/oneclient_app/src/components/markdown/svg_compat.rs new file mode 100644 index 00000000..4a0aabbb --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/svg_compat.rs @@ -0,0 +1,434 @@ +//! Rewrites the parts of an svg Skia's svg module can't read. +//! +//! shields.io embeds badge logos as ``. Skia only reads +//! the SVG 1.1 `xlink:href`, and its `` only decodes bitmaps, so svg logos +//! (`logo=discord`, `logo=modrinth`, ...) are inlined as nested `` elements instead. + +use std::borrow::Cow; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; + +/// Logos are a few kilobytes, anything bigger is left for Skia to skip +const MAX_INLINED_SVG: usize = 256 * 1024; +const MAX_INLINED_IMAGES: usize = 16; + +/// The `` attributes that position the picture, carried onto the inlined `` +const PLACEMENT: [&str; 5] = ["x", "y", "width", "height", "preserveAspectRatio"]; + +pub(super) fn prepare_svg(bytes: &[u8]) -> Cow<'_, [u8]> { + let Ok(text) = std::str::from_utf8(bytes) else { + return Cow::Borrowed(bytes); + }; + + match rewrite_images(text) { + Some(rewritten) => Cow::Owned(rewritten.into_bytes()), + None => Cow::Borrowed(bytes), + } +} + +fn rewrite_images(text: &str) -> Option { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut changed = false; + let mut uses_xlink = false; + let mut inlined = 0; + + while let Some(at) = find_tag(rest, "image") { + out.push_str(&rest[..at]); + let from_tag = &rest[at..]; + + let Some(tag) = Tag::parse(from_tag, "image") else { + out.push_str(from_tag); + rest = ""; + break; + }; + rest = &from_tag[tag.len..]; + + if inlined < MAX_INLINED_IMAGES + && let Some(nested) = inline_svg_image(&tag, &mut rest, inlined) + { + out.push_str(&nested); + inlined += 1; + changed = true; + continue; + } + + if tag.attr("href").is_some() && tag.attr("xlink:href").is_none() { + out.push_str(&tag.renamed("href", "xlink:href").to_source()); + uses_xlink = true; + changed = true; + } else { + out.push_str(&from_tag[..tag.len]); + } + } + out.push_str(rest); + + if !changed { + return None; + } + + if uses_xlink { + declare_xlink(&mut out); + } + + Some(out) +} + +/// Replaces an `` holding an svg data url with that svg, placed where the image was +fn inline_svg_image(tag: &Tag<'_>, rest: &mut &str, index: usize) -> Option { + let href = tag.attr("href").or_else(|| tag.attr("xlink:href"))?; + // the logo's ids now share the badge's document, where shields.io already uses `r`, `s` and `blur` + let document = prefix_ids(&decode_svg_data_url(href)?, &format!("logo{index}-")); + + let start = find_tag(&document, "svg")?; + let inner = Tag::parse(&document[start..], "svg")?; + let body = &document[start + inner.len..]; + + // `` has a closing tag the nested svg must swallow + if !tag.self_closing { + let trimmed = rest.trim_start(); + *rest = trimmed.strip_prefix("")?; + } + + let mut nested = inner.without(&PLACEMENT); + for name in PLACEMENT { + if let Some(value) = tag.attr(name) { + nested.attrs.push((name, value, '"')); + } + } + + let mut out = nested.to_source(); + out.push_str(body); + + Some(match tag.attr("transform") { + Some(transform) => format!("{out}"), + None => out, + }) +} + +fn decode_svg_data_url(href: &str) -> Option { + let payload = href.trim().strip_prefix("data:image/svg+xml")?; + let (params, data) = payload.split_once(',')?; + if !params.split(';').any(|param| param == "base64") { + return None; + } + + let data: String = data.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + // base64 grows by a third, so the encoded length bounds the decoded one + if data.len() / 4 * 3 > MAX_INLINED_SVG { + return None; + } + + let decoded = String::from_utf8(STANDARD.decode(data).ok()?).ok()?; + // one level of nesting, an inlined logo can't pull in further svgs + (!decoded.contains("data:image/svg+xml")).then_some(decoded) +} + +/// Prefixes every `id` attribute and the `url(#id)` / `href="#id"` references to it +fn prefix_ids(svg: &str, prefix: &str) -> String { + let mut ids = Vec::new(); + let mut out = String::with_capacity(svg.len()); + let mut rest = svg; + + while let Some(at) = rest.find("id=") { + let attr_start = at > 0 && rest[..at].ends_with(|c: char| c.is_ascii_whitespace()); + let (before, after) = rest.split_at(at + "id=".len()); + out.push_str(before); + rest = after; + + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + let Some(len) = rest[1..].find(quote) else { + continue; + }; + if !attr_start || len == 0 { + continue; + } + + let id = &rest[1..1 + len]; + out.push(quote); + out.push_str(prefix); + out.push_str(id); + out.push(quote); + ids.push(id); + rest = &rest[len + 2..]; + } + out.push_str(rest); + + for id in ids { + for (from, to) in [ + ("url(#", ")"), + ("url('#", "')"), + ("url(\"#", "\")"), + ("href=\"#", "\""), + ("href='#", "'"), + ] { + out = out.replace( + &format!("{from}{id}{to}"), + &format!("{from}{prefix}{id}{to}"), + ); + } + } + + out +} + +fn declare_xlink(svg: &mut String) { + let Some(root) = find_tag(svg, "svg") else { + return; + }; + let Some(tag) = Tag::parse(&svg[root..], "svg") else { + return; + }; + if tag.attr("xmlns:xlink").is_some() { + return; + } + + svg.insert_str(root + " Option { + let open = format!("<{name}"); + let mut from = 0; + + while let Some(found) = text[from..].find(&open) { + let at = from + found; + let next = text[at + open.len()..].chars().next(); + if matches!(next, Some(c) if c.is_ascii_whitespace() || c == '/' || c == '>') { + return Some(at); + } + from = at + open.len(); + } + + None +} + +/// A start tag, values kept as written so re-emitting them needs no escaping +#[derive(Debug, PartialEq)] +struct Tag<'a> { + name: &'a str, + attrs: Vec<(&'a str, &'a str, char)>, + self_closing: bool, + /// Bytes of the source the tag spans, `>` included + len: usize, +} + +impl<'a> Tag<'a> { + fn parse(source: &'a str, name: &'a str) -> Option { + let mut at = source.strip_prefix('<')?.strip_prefix(name)?.len(); + at = source.len() - at; + let mut attrs = Vec::new(); + + loop { + at += whitespace(&source[at..]); + let rest = &source[at..]; + + if let Some(after) = rest.strip_prefix("/>") { + let len = source.len() - after.len(); + return Some(Self { + name, + attrs, + self_closing: true, + len, + }); + } + if rest.starts_with('>') { + return Some(Self { + name, + attrs, + self_closing: false, + len: at + 1, + }); + } + + let name_len = + rest.find(|c: char| c == '=' || c.is_ascii_whitespace() || c == '>' || c == '/')?; + if name_len == 0 { + return None; + } + let attr = &rest[..name_len]; + at += name_len; + at += whitespace(&source[at..]); + source[at..].strip_prefix('=')?; + at += 1; + at += whitespace(&source[at..]); + + let quote = source[at..] + .chars() + .next() + .filter(|c| *c == '"' || *c == '\'')?; + at += 1; + let value_len = source[at..].find(quote)?; + attrs.push((attr, &source[at..at + value_len], quote)); + at += value_len + 1; + } + } + + fn attr(&self, name: &str) -> Option<&'a str> { + self.attrs + .iter() + .find(|(attr, _, _)| *attr == name) + .map(|(_, value, _)| *value) + } + + fn renamed(mut self, from: &str, to: &'a str) -> Self { + for attr in &mut self.attrs { + if attr.0 == from { + attr.0 = to; + } + } + self + } + + fn without(&self, names: &[&str]) -> Self { + Self { + name: self.name, + attrs: self + .attrs + .iter() + .filter(|(attr, _, _)| !names.contains(attr)) + .copied() + .collect(), + self_closing: self.self_closing, + len: self.len, + } + } + + fn to_source(&self) -> String { + let mut out = format!("<{}", self.name); + for (attr, value, quote) in &self.attrs { + out.push_str(&format!(" {attr}={quote}{value}{quote}")); + } + out.push_str(if self.self_closing { "/>" } else { ">" }); + out + } +} + +fn whitespace(text: &str) -> usize { + text.len() + - text + .trim_start_matches(|c: char| c.is_ascii_whitespace()) + .len() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prepared(svg: &str) -> String { + String::from_utf8(prepare_svg(svg.as_bytes()).into_owned()).expect("utf8") + } + + fn svg_data_url(svg: &str) -> String { + format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg)) + } + + #[test] + fn leaves_an_svg_without_images_untouched() { + let svg = r#""#; + assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_))); + } + + #[test] + fn renames_href_and_declares_xlink_once() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn keeps_an_existing_xlink_declaration() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn inlines_an_svg_logo_where_the_image_was() { + let logo = r##""##; + let svg = format!( + r#"Discord"#, + svg_data_url(logo) + ); + + assert_eq!( + prepared(&svg), + r##"Discord"## + ); + } + + #[test] + fn swallows_the_closing_tag_of_an_open_image() { + let logo = r#""#; + let svg = format!( + r#" "#, + svg_data_url(logo) + ); + + assert_eq!( + prepared(&svg), + r#""# + ); + } + + #[test] + fn malformed_base64_falls_back_to_the_xlink_rename() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn does_not_inline_a_logo_that_nests_another_svg() { + let inner = svg_data_url(r#""#); + let logo = format!(r#""#); + let svg = format!(r#""#, svg_data_url(&logo)); + + assert!(prepared(&svg).contains(""##; + let svg = format!(r#""#, svg_data_url(logo)); + + assert_eq!( + prepared(&svg), + r##""## + ); + } + + #[test] + fn each_inlined_logo_gets_its_own_prefix() { + let logo = svg_data_url(r#""#); + let svg = format!(r#""#); + + assert_eq!( + prepared(&svg), + r#""# + ); + } + + #[test] + fn ignores_tags_that_only_share_the_prefix() { + let svg = r#""#; + assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_))); + } + + #[test] + fn unterminated_tag_is_left_alone() { + let svg = r#"